diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 16c2b9c53..9dbec1d66 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -29,6 +29,14 @@ updates: interval: "weekly" day: "monday" open-pull-requests-limit: 5 + # devDependencies batch into one PR instead of one per package — the CLI's + # Windows job alone runs ~8 min, and an unrelated bump should not pay for it once + # per dependency. + groups: + npm-dev-dependencies: + dependency-type: "development" + patterns: + - "*" commit-message: prefix: "chore" include: "scope" @@ -42,6 +50,29 @@ updates: interval: "weekly" day: "monday" open-pull-requests-limit: 5 + groups: + npm-dev-dependencies: + dependency-type: "development" + patterns: + - "*" + commit-message: + prefix: "chore" + include: "scope" + labels: + - "dependencies" + + - package-ecosystem: "npm" + directory: "kanban" + target-branch: "next" + schedule: + interval: "weekly" + day: "monday" + open-pull-requests-limit: 5 + groups: + npm-dev-dependencies: + dependency-type: "development" + patterns: + - "*" commit-message: prefix: "chore" include: "scope" diff --git a/.github/rulesets/main.json b/.github/rulesets/main.json index 6df743e1c..72de2e226 100644 --- a/.github/rulesets/main.json +++ b/.github/rulesets/main.json @@ -20,7 +20,8 @@ "dismiss_stale_reviews_on_push": false, "require_code_owner_review": true, "require_last_push_approval": false, - "required_review_thread_resolution": true + "required_review_thread_resolution": true, + "require_extra_approval_for_unattributed_changes": true } }, { @@ -29,7 +30,8 @@ "strict_required_status_checks_policy": false, "required_status_checks": [ {"context": "lefthook (framework-local checks)"}, - {"context": "Commitlint"} + {"context": "Commitlint"}, + {"context": "cli / gate"} ] } }, diff --git a/.github/rulesets/next.json b/.github/rulesets/next.json index 146c93a0a..6af9a2318 100644 --- a/.github/rulesets/next.json +++ b/.github/rulesets/next.json @@ -20,7 +20,8 @@ "dismiss_stale_reviews_on_push": false, "require_code_owner_review": false, "require_last_push_approval": false, - "required_review_thread_resolution": false + "required_review_thread_resolution": false, + "require_extra_approval_for_unattributed_changes": true } }, { @@ -29,7 +30,8 @@ "strict_required_status_checks_policy": false, "required_status_checks": [ {"context": "lefthook (framework-local checks)"}, - {"context": "Commitlint"} + {"context": "Commitlint"}, + {"context": "cli / gate"} ] } }, diff --git a/.github/workflows/back-merge.yml b/.github/workflows/back-merge.yml index c993cb4a1..f36b63be2 100644 --- a/.github/workflows/back-merge.yml +++ b/.github/workflows/back-merge.yml @@ -1,16 +1,12 @@ name: Back-merge -# After a release on main, fold main's changelog, manifest and version bumps -# back into next so the two branches do not drift. +# After a release on main, fold main's changelog, manifest and version bumps back into next. # -# This stays a plain merge because promote.yml lands as a merge commit: main and -# next share a real merge base, so the only thing left to reconcile here is the -# release commit itself. Conflict -> open a PR for a human. Any other failure -> -# open a tracking issue so the drift is never silent. +# A plain merge, because promote.yml lands as a merge commit: main and next share a real +# merge base, so only the release commit is left to reconcile. Conflict opens a PR instead. # -# A release can publish several tags at once (umbrella, plugins, cli), firing -# this workflow once per tag. The first run folds main in; the rest find main -# already merged and exit, instead of each opening its own duplicate PR. +# A release can publish several tags at once, firing this once per tag: the first run folds +# main in, the rest find it already merged rather than each opening a duplicate PR. on: release: @@ -56,10 +52,8 @@ jobs: exit 0 fi - # `ci:` on purpose. The type is absent from release-please's - # changelog-sections, so this plumbing commit never surfaces in a - # released changelog, while commitlint still accepts it as the tip of - # a later promote PR. + # `ci:` on purpose: the type is absent from release-please's changelog-sections, + # so this plumbing commit never surfaces in a changelog, and commitlint accepts it. if git merge --no-ff -m "ci: back-merge main into next" origin/main; then git push origin next else @@ -72,8 +66,7 @@ jobs: --body "Automated back-merge hit conflicts. Resolve manually, then **merge with a merge commit, not a squash**: the second parent is what keeps a shared merge base between \`main\` and \`next\`, and a squash puts the next back-merge back into conflict." fi - # Never let a back-merge fail silently: a rejected push or any other error - # opens a tracking issue so a human resyncs main into next. + # A rejected push, or any other error, must not leave next drifting silently. - name: Open tracking issue on failure if: failure() env: diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6585d8b8a..25b6d331f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -9,11 +9,9 @@ concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: ${{ github.ref != 'refs/heads/main' }} -permissions: - contents: write - pull-requests: write - packages: write - id-token: write +# No default grant: a job added later starts with nothing rather than inheriting write +# access it never asked for. +permissions: {} jobs: commitlint: @@ -32,6 +30,21 @@ jobs: configFile: commitlint.config.cjs commitDepth: 1 + # `commitDepth: 1` lints the head commit, not the subject a squash merge gives `next`, + # which GitHub defaults to the PR title. The title goes through the same config here. + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + if: github.event_name == 'pull_request' + with: + node-version: "22" + - name: Lint the PR title (the subject a squash merge will actually use) + if: github.event_name == 'pull_request' + env: + PR_TITLE: ${{ github.event.pull_request.title }} + run: | + corepack enable + pnpm install --frozen-lockfile --ignore-scripts + printf '%s' "$PR_TITLE" | pnpm exec commitlint --config commitlint.config.cjs + release-please: name: Release Please runs-on: ubuntu-latest @@ -45,24 +58,6 @@ jobs: tag_name: ${{ steps.release.outputs.tag_name }} version: ${{ steps.release.outputs.version }} paths_released: ${{ steps.release.outputs.paths_released }} - aidd_context_release_created: ${{ steps.release.outputs['plugins/aidd-context--release_created'] }} - aidd_context_version: ${{ steps.release.outputs['plugins/aidd-context--version'] }} - aidd_context_tag_name: ${{ steps.release.outputs['plugins/aidd-context--tag_name'] }} - aidd_dev_release_created: ${{ steps.release.outputs['plugins/aidd-dev--release_created'] }} - aidd_dev_version: ${{ steps.release.outputs['plugins/aidd-dev--version'] }} - aidd_dev_tag_name: ${{ steps.release.outputs['plugins/aidd-dev--tag_name'] }} - aidd_vcs_release_created: ${{ steps.release.outputs['plugins/aidd-vcs--release_created'] }} - aidd_vcs_version: ${{ steps.release.outputs['plugins/aidd-vcs--version'] }} - aidd_vcs_tag_name: ${{ steps.release.outputs['plugins/aidd-vcs--tag_name'] }} - aidd_pm_release_created: ${{ steps.release.outputs['plugins/aidd-pm--release_created'] }} - aidd_pm_version: ${{ steps.release.outputs['plugins/aidd-pm--version'] }} - aidd_pm_tag_name: ${{ steps.release.outputs['plugins/aidd-pm--tag_name'] }} - aidd_orchestrator_release_created: ${{ steps.release.outputs['plugins/aidd-orchestrator--release_created'] }} - aidd_orchestrator_version: ${{ steps.release.outputs['plugins/aidd-orchestrator--version'] }} - aidd_orchestrator_tag_name: ${{ steps.release.outputs['plugins/aidd-orchestrator--tag_name'] }} - aidd_refine_release_created: ${{ steps.release.outputs['plugins/aidd-refine--release_created'] }} - aidd_refine_version: ${{ steps.release.outputs['plugins/aidd-refine--version'] }} - aidd_refine_tag_name: ${{ steps.release.outputs['plugins/aidd-refine--tag_name'] }} steps: - uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 id: app-token @@ -76,30 +71,20 @@ jobs: config-file: release-please-config.json manifest-file: .release-please-manifest.json - # Auto-merge the Release PR release-please just opened/updated, with the - # App token. `--admin` is required: a plain `gh pr merge` is refused by the - # branch policy ("base branch policy prohibits the merge") even for a - # bypass actor (verified in a sandbox). `--admin` performs the override - # merge that the ruleset's bypass_actors entry permits for this App. - # An App-token merge also re-fires the `push: main` and `release: published` - # workflows that a GITHUB_TOKEN merge would not. - # Guard on prs_created (PR opened), not releases_created (fires on merge). + # `--admin` is required: the branch policy refuses a plain `gh pr merge` even for a + # bypass actor. The App token also re-fires the `push: main` and `release: published` + # workflows a GITHUB_TOKEN merge would not. Guarded on prs_created, since + # releases_created only fires on merge. - name: Auto-merge the Release PR if: ${{ steps.release.outputs.prs_created == 'true' }} env: GH_TOKEN: ${{ steps.app-token.outputs.token }} run: gh pr merge "${{ fromJSON(steps.release.outputs.pr).number }}" --squash --admin --repo "${{ github.repository }}" - # Pin GitHub's "Latest" badge to the umbrella marketplace release. - # release-please creates the umbrella + every plugin release in one run - # with no ordering guarantee, and GitHub marks whichever release is created - # last as "Latest" (the create API defaults make_latest to true). That - # lets a plugin release (e.g. aidd-dev-v2.3.0) outrank the marketplace - # version whenever a cycle ships plugins alongside the umbrella. Bare - # `tag_name` is the root "." package tag (the umbrella `v*`, the tag that - # carries the marketplace bundle); forcing it latest auto-unsets the plugin - # release that held the badge. The umbrella bumps every cycle, so a - # release run always has a root tag to pin. + # GitHub marks whichever release is created last as "Latest", and release-please + # creates the umbrella and every plugin release in one unordered run — so a plugin + # release can outrank the marketplace version. Bare `tag_name` is the root tag, the one + # carrying the marketplace bundle, and it bumps every cycle. - name: Pin umbrella release as latest if: ${{ steps.release.outputs.release_created == 'true' }} env: @@ -111,14 +96,15 @@ jobs: needs: [release-please] if: needs.release-please.outputs.release_created == 'true' runs-on: ubuntu-latest + # `gh release upload` writes a release asset, which GitHub treats as a contents change. + permissions: + contents: write steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Build clean marketplace bundle - # A self-contained marketplace a user can extract and register with - # `/plugin marketplace add ./aidd-framework-marketplace-`. - # Contains only what the marketplace needs: the catalog manifest and - # the plugins. No framework-internal files (aidd_docs/, scripts/, etc.). + # Self-contained, for `/plugin marketplace add ./aidd-framework-marketplace-`: + # the catalog manifest and the plugins, and no framework-internal file. run: | VERSION="${{ needs.release-please.outputs.version }}" STAGE_PARENT="$(mktemp -d)" @@ -143,21 +129,24 @@ jobs: needs: [release-please] if: needs.release-please.outputs.release_created == 'true' runs-on: ubuntu-latest + # contents: write — `gh release upload` writes a release asset. + permissions: + contents: write strategy: fail-fast: false - # 9-cell matrix: 4 marketplace (claude/cursor/copilot/codex) + 5 flat - # (+opencode, which is flat-only). Mirrors the CLI golden snapshot matrix. + # Four marketplace plus five flat — opencode is flat-only. Mirrors the CLI golden + # snapshot matrix. matrix: include: - - { tool: claude, mode: marketplace, flag: "" } - - { tool: cursor, mode: marketplace, flag: "" } - - { tool: copilot, mode: marketplace, flag: "" } - - { tool: codex, mode: marketplace, flag: "" } - - { tool: claude, mode: flat, flag: "--flat" } - - { tool: cursor, mode: flat, flag: "--flat" } - - { tool: copilot, mode: flat, flag: "--flat" } - - { tool: codex, mode: flat, flag: "--flat" } - - { tool: opencode, mode: flat, flag: "--flat" } + - { tool: claude, mode: marketplace } + - { tool: cursor, mode: marketplace } + - { tool: copilot, mode: marketplace } + - { tool: codex, mode: marketplace } + - { tool: claude, mode: flat } + - { tool: cursor, mode: flat } + - { tool: copilot, mode: flat } + - { tool: codex, mode: flat } + - { tool: opencode, mode: flat } steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 @@ -165,18 +154,25 @@ jobs: with: node-version: "22" + - name: Install pnpm + run: corepack enable + + # The exact CLI this release is publishing, built from this run's own checkout: a + # published-version pin would need bumping by hand and can name a command that no + # longer exists. + - run: cd cli && pnpm install --frozen-lockfile + - run: cd cli && pnpm build + - name: Build the target-native distribution - # Pin the CLI version: a future CLI release must not silently change - # framework dist output. Bump deliberately alongside a framework change. run: | VERSION="${{ needs.release-please.outputs.version }}" NAME="aidd-framework-${{ matrix.tool }}-${{ matrix.mode }}-${VERSION}" - # --out must live outside --source: the CLI refuses to build when one - # path contains the other. Stage under RUNNER_TEMP, not the repo tree. + # --out must live outside --source: the CLI refuses to build when one path + # contains the other, so stage under RUNNER_TEMP rather than the repo tree. OUT="${RUNNER_TEMP}/dist/${{ matrix.tool }}/${{ matrix.mode }}" mkdir -p "$OUT" - npx --yes @ai-driven-dev/cli@5.1.1 framework build \ - --source . --target ${{ matrix.tool }} --out "$OUT" ${{ matrix.flag }} + node cli/dist/cli.js translate . \ + --to ${{ matrix.tool }} --out "$OUT" --as ${{ matrix.mode }} STAGE="$(mktemp -d)/${NAME}" mkdir -p "$STAGE" cp -R "$OUT"/. "$STAGE"/ @@ -196,10 +192,26 @@ jobs: needs: [release-please] if: needs.release-please.outputs.paths_released != '' && needs.release-please.outputs.paths_released != '[]' runs-on: ubuntu-latest + # contents: write — `gh release upload` writes a release asset. + permissions: + contents: write strategy: fail-fast: false matrix: - plugin: [aidd-context, aidd-dev, aidd-vcs, aidd-pm, aidd-orchestrator, aidd-refine] + # Every plugin `marketplace.json` lists, and nothing else — asserted by + # scripts/__tests__/release-covers-every-plugin.test.js, since a plugin missing here + # is tagged with no archive and nothing else notices. + plugin: + [ + aidd-context, + aidd-dev, + aidd-vcs, + aidd-pm, + aidd-orchestrator, + aidd-refine, + aidd-ui, + aidd-telemetry, + ] steps: - name: Check if this plugin was released id: check @@ -223,8 +235,7 @@ jobs: - name: Build plugin bundle if: steps.check.outputs.released == 'true' - # Clean, self-contained plugin directory. A user extracts it and - # installs the plugin locally. No framework-internal files. + # Self-contained: a user extracts it and installs the plugin locally. run: | PLUGIN="${{ matrix.plugin }}" VERSION="${{ steps.version.outputs.version }}" @@ -247,17 +258,20 @@ jobs: needs: [release-please] if: needs.release-please.outputs.paths_released != '' && contains(fromJSON(needs.release-please.outputs.paths_released), 'cli') runs-on: ubuntu-latest + # id-token for npm's OIDC trusted publish, packages for the best-effort mirror. + permissions: + contents: read + id-token: write + packages: write steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Install pnpm - run: | - corepack enable + run: corepack enable - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: node-version: "22" - # kanban/ is bundled into the CLI from source, so its dependencies must be - # installed before the build can resolve them. - - run: cd kanban && pnpm install --frozen-lockfile + # No kanban install: `cli/src/` names kanban nowhere and the bundle carries none of + # its symbols. - run: cd cli && pnpm install --frozen-lockfile - run: cd cli && pnpm build @@ -270,19 +284,18 @@ jobs: env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - # Real OIDC trusted publishing (npm CLI >= 11.5.1, Node >= 22.14.0), not a - # token — matches the npmjs.com trusted-publisher entry for - # ai-driven-dev/framework, workflow ci.yml. No NPM_TOKEN secret needed. - # `npm publish`, not `pnpm publish`: pnpm's OIDC support is unverified/ - # buggy as of this writing (pnpm/pnpm#9812, #11513) — npm's own CLI is - # the reference implementation for its own feature. + # OIDC trusted publishing (npm CLI >= 11.5.1, Node >= 22.14.0), not a token, so no + # NPM_TOKEN secret is needed. `npm publish` and not `pnpm publish`: pnpm's OIDC support + # is unreliable, and npm's own CLI is the reference implementation for its own feature. - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: node-version: "22" registry-url: "https://registry.npmjs.org" - name: Publish to npm working-directory: cli + # Pinned, not @latest: this is the release path, so an npm release breaking here + # breaks publishing. Bump by hand. run: | rm -f .npmrc - npm install -g npm@latest + npm install -g npm@11.19.1 npm publish --access public diff --git a/.github/workflows/cli-ci.yml b/.github/workflows/cli-ci.yml index 18584c91e..b560a5670 100644 --- a/.github/workflows/cli-ci.yml +++ b/.github/workflows/cli-ci.yml @@ -1,70 +1,20 @@ name: cli CI -# Native path-filtered isolation: this whole workflow is skipped when nothing -# under cli/ or kanban/ changed — no third-party paths-filter action needed. +# No `paths:` filter, deliberately: a filtered workflow that is not triggered still leaves +# its required checks "expected" on GitHub's side, blocking merge on a PR outside its scope. +# The `changes` job decides in bash instead, every other job is gated on its `relevant` +# output, and `gate` reports green when nothing was relevant. # -# kanban/ is a source folder the CLI type-checks and bundles, not a published -# package. Any job that runs a TypeScript-aware tool over the CLI's program must -# install kanban's dependencies too, or module resolution fails from its sources. +# kanban/ has its own job and its own dependencies; no CLI job needs its node_modules. on: push: branches: [main, next] - paths: - - "cli/**" - - "kanban/**" - # This file. A change to how the suite runs - a worker pool, an exclusion, a step - # order - is a change nothing else in the filter matches, so without this the - # workflow that decides whether the suite passes never runs on the commit that - # changed it, and lands unverified. - - ".github/workflows/cli-ci.yml" - # The plugin ships the hooks and skills these jobs exercise, and the Windows job - # exists for them in particular - without this, changing any of it runs nothing. - - "plugins/aidd-telemetry/**" - - "scripts/__tests__/**" - # Except its prose. The plugin's markdown is asserted - the README's coverage - # table, where its scripts say they live - but only by `scripts/__tests__`, - # which `validate.yml` already runs over the whole tree on every push and - # pull request, unfiltered and required. Re-running it here costs a Windows - # runner for a paragraph, and proves nothing that job did not. - # - # `**.md`, not `**/*.md`: GitHub's `**` matches any character including a - # slash, so the second form needs a slash after it and would miss the - # top-level README.md that this exists for. - # - # Bounded on purpose: on `pull_request` GitHub matches the whole diff, not - # the last push, so a branch that also touches cli/ runs everything however - # small its newest commit is. This saves the run on a push to next or main, - # and on a pull request whose diff is prose and nothing else. - - "!plugins/aidd-telemetry/**.md" pull_request: - paths: - - "cli/**" - - "kanban/**" - # This file. A change to how the suite runs - a worker pool, an exclusion, a step - # order - is a change nothing else in the filter matches, so without this the - # workflow that decides whether the suite passes never runs on the commit that - # changed it, and lands unverified. - - ".github/workflows/cli-ci.yml" - # The plugin ships the hooks and skills these jobs exercise, and the Windows job - # exists for them in particular - without this, changing any of it runs nothing. - - "plugins/aidd-telemetry/**" - - "scripts/__tests__/**" - # Except its prose. The plugin's markdown is asserted - the README's coverage - # table, where its scripts say they live - but only by `scripts/__tests__`, - # which `validate.yml` already runs over the whole tree on every push and - # pull request, unfiltered and required. Re-running it here costs a Windows - # runner for a paragraph, and proves nothing that job did not. - # - # `**.md`, not `**/*.md`: GitHub's `**` matches any character including a - # slash, so the second form needs a slash after it and would miss the - # top-level README.md that this exists for. - # - # Bounded on purpose: on `pull_request` GitHub matches the whole diff, not - # the last push, so a branch that also touches cli/ runs everything however - # small its newest commit is. This saves the run on a push to next or main, - # and on a pull request whose diff is prose and nothing else. - - "!plugins/aidd-telemetry/**.md" + schedule: + # Weekly, every mutant of every scope from scratch: the incremental files PRs restore + # only replay what a change touched, so drift through a dependency is caught here. + - cron: "0 3 * * 1" concurrency: group: ${{ github.workflow }}-${{ github.ref }} @@ -74,134 +24,311 @@ permissions: contents: read jobs: + changes: + name: cli / changes + runs-on: ubuntu-latest + outputs: + relevant: ${{ steps.filter.outputs.relevant }} + mutation_scopes: ${{ steps.mutation.outputs.scopes }} + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 0 + + - name: Decide whether this workflow's scope changed + id: filter + run: | + set -euo pipefail + # The scope the jobs below actually exercise. The telemetry plugin's own prose is + # excluded because validate.yml already asserts it on every push and pull request. + if [[ "${{ github.event_name }}" == "pull_request" ]]; then + BASE="${{ github.event.pull_request.base.sha }}" + HEAD="${{ github.event.pull_request.head.sha }}" + else + BASE="${{ github.event.before }}" + HEAD="${{ github.sha }}" + fi + + # A new branch or a force-push gives an all-zero `before`: with no base to diff + # against, `git diff` would error into a skip reading as "nothing changed". + if [[ -z "$BASE" || "$BASE" =~ ^0+$ ]]; then + echo "no usable base SHA (new branch or force-push) - treating as relevant" + echo "relevant=true" >> "$GITHUB_OUTPUT" + exit 0 + fi + + relevant=false + while IFS= read -r f; do + [[ -z "$f" ]] && continue + case "$f" in + cli/*|kanban/*|scripts/__tests__/*|README.md) + relevant=true + ;; + # This file: a change to how the suite runs matches nothing else here, so the + # workflow deciding whether the suite passes would land unverified. + .github/workflows/cli-ci.yml) + relevant=true + ;; + plugins/aidd-telemetry/*) + case "$f" in + *.md) ;; # prose only, covered by validate.yml instead + *) relevant=true ;; + esac + ;; + esac + [[ "$relevant" == "true" ]] && break + done < <(git diff --name-only "$BASE" "$HEAD") + + echo "relevant=$relevant" >> "$GITHUB_OUTPUT" + + - name: Decide which mutation scopes a change can move + id: mutation + run: | + set -euo pipefail + if [[ "${{ github.event_name }}" == "pull_request" ]]; then + BASE="${{ github.event.pull_request.base.sha }}" + HEAD="${{ github.event.pull_request.head.sha }}" + else + BASE="${{ github.event.before }}" + HEAD="${{ github.sha }}" + fi + if [[ -z "$BASE" || "$BASE" =~ ^0+$ ]]; then + changed="" + all=true + else + changed="$(git diff --name-only "$BASE" "$HEAD")" + all=false + fi + scopes="$(ALL="$all" CHANGED="$changed" node cli/scripts/mutation-scopes-to-run.mjs)" + echo "mutation scopes: $scopes" + echo "scopes=$scopes" >> "$GITHUB_OUTPUT" + cli-typecheck: name: cli / Typecheck + needs: [changes] + if: needs.changes.outputs.relevant == 'true' runs-on: ubuntu-latest steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Install pnpm - run: | - corepack enable + run: corepack enable - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: node-version: "22" - - run: cd kanban && pnpm install --frozen-lockfile + - name: Cache pnpm store + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: ~/.local/share/pnpm/store + key: pnpm-${{ runner.os }}-${{ hashFiles('cli/pnpm-lock.yaml') }} + restore-keys: pnpm-${{ runner.os }}- - run: cd cli && pnpm install --frozen-lockfile - run: cd cli && pnpm typecheck cli-lint: name: cli / Lint + needs: [changes] + if: needs.changes.outputs.relevant == 'true' runs-on: ubuntu-latest steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Install pnpm - run: | - corepack enable + run: corepack enable - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: node-version: "22" + - name: Cache pnpm store + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: ~/.local/share/pnpm/store + key: pnpm-${{ runner.os }}-${{ hashFiles('cli/pnpm-lock.yaml') }} + restore-keys: pnpm-${{ runner.os }}- - run: cd cli && pnpm install --frozen-lockfile - run: cd cli && pnpm lint - cli-test: - name: cli / Test + cli-architecture: + name: cli / Architecture invariants + needs: [changes] + if: needs.changes.outputs.relevant == 'true' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - name: Install pnpm + run: corepack enable + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: "22" + - name: Cache pnpm store + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: ~/.local/share/pnpm/store + key: pnpm-${{ runner.os }}-${{ hashFiles('cli/pnpm-lock.yaml') }} + restore-keys: pnpm-${{ runner.os }}- + - run: cd cli && pnpm install --frozen-lockfile + - run: cd cli && pnpm test:arch + + # Every tier once, instrumented: coverage is the test run, and a second uninstrumented + # pass would prove nothing this one does not. + cli-coverage: + name: cli / Test & Coverage thresholds + needs: [changes] + if: needs.changes.outputs.relevant == 'true' runs-on: ubuntu-latest steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Install expect (TTY persona tests) run: sudo apt-get update && sudo apt-get install -y expect - name: Install pnpm - run: | - corepack enable + run: corepack enable - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: node-version: "22" - - run: cd kanban && pnpm install --frozen-lockfile + - name: Cache pnpm store + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: ~/.local/share/pnpm/store + key: pnpm-${{ runner.os }}-${{ hashFiles('cli/pnpm-lock.yaml') }} + restore-keys: pnpm-${{ runner.os }}- - run: cd cli && pnpm install --frozen-lockfile - - run: cd cli && pnpm test + - run: cd cli && pnpm test:coverage + + cli-smoke: + name: cli / Smoke + needs: [changes] + if: needs.changes.outputs.relevant == 'true' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - name: Install pnpm + run: corepack enable + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: "22" + - name: Cache pnpm store + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: ~/.local/share/pnpm/store + key: pnpm-${{ runner.os }}-${{ hashFiles('cli/pnpm-lock.yaml') }} + restore-keys: pnpm-${{ runner.os }}- + - run: cd cli && pnpm install --frozen-lockfile + # Hermetic: every setup uses the local framework fixture, so this needs no + # token and no network. `smoke:full` adds the remote-fetch section on demand. + - run: cd cli && pnpm smoke cli-build: name: cli / Build & Bundle Budget + needs: [changes] + if: needs.changes.outputs.relevant == 'true' runs-on: ubuntu-latest steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Install pnpm - run: | - corepack enable + run: corepack enable - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: node-version: "22" - - run: cd kanban && pnpm install --frozen-lockfile + - name: Cache pnpm store + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: ~/.local/share/pnpm/store + key: pnpm-${{ runner.os }}-${{ hashFiles('cli/pnpm-lock.yaml') }} + restore-keys: pnpm-${{ runner.os }}- - run: cd cli && pnpm install --frozen-lockfile - run: cd cli && pnpm build cli-knip: name: cli / Knip (dead code) + needs: [changes] + if: needs.changes.outputs.relevant == 'true' runs-on: ubuntu-latest - continue-on-error: true steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Install pnpm - run: | - corepack enable + run: corepack enable - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: node-version: "22" - - run: cd kanban && pnpm install --frozen-lockfile + - name: Cache pnpm store + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: ~/.local/share/pnpm/store + key: pnpm-${{ runner.os }}-${{ hashFiles('cli/pnpm-lock.yaml') }} + restore-keys: pnpm-${{ runner.os }}- - run: cd cli && pnpm install --frozen-lockfile - - run: cd cli && pnpm knip:production + - run: cd cli && pnpm knip identifier-join: name: cli / Identifier join (Claude Code) + needs: [changes] + if: needs.changes.outputs.relevant == 'true' runs-on: ubuntu-latest steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Install pnpm - run: | - corepack enable + run: corepack enable - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: node-version: "22" + - name: Cache pnpm store + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: ~/.local/share/pnpm/store + key: pnpm-${{ runner.os }}-${{ hashFiles('cli/pnpm-lock.yaml') }} + restore-keys: pnpm-${{ runner.os }}- - run: cd cli && pnpm install --frozen-lockfile - run: cd cli && pnpm build # The probe drives the real tool, so the real tool has to be here. No credentials are - # configured and none are needed: it points Claude Code at a dead address with a fake - # key, and the session id is minted before any of that matters. + # needed: it points Claude Code at a dead address, and the session id is minted first. - name: Install Claude Code - run: npm install -g @anthropic-ai/claude-code - # Re-checks what #632 measured once, by running a session: that the identifier a hook - # receives is the one the export carries. Everything in this layer joins on that, and a - # tool update can break it with nothing else turning red. Exit 2 means the probe could - # not form an opinion and says so rather than blaming the tool. + run: npm install -g @anthropic-ai/claude-code # floats on purpose, declared in scripts/__tests__/workflows-pin-global-installs.test.js + # Everything in this layer joins on the identifier a hook receives being the one the + # export carries, and a tool update can break it with nothing else turning red. Exit 2 + # means the probe could not form an opinion, rather than blaming the tool. - name: Probe the identifier join run: node scripts/probe-identifier-join.cjs + # The golden snapshots pin what a claude build contains; only the host can say it loads. + # Its `plugin validate` exits 0 either way, so the script reads the verdict from its text. + - name: Claude Code accepts the translated marketplace + run: node scripts/check-claude-accepts-build.cjs cli-jscpd: name: cli / JSCPD (duplication) + needs: [changes] + if: needs.changes.outputs.relevant == 'true' runs-on: ubuntu-latest - continue-on-error: true steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Install pnpm - run: | - corepack enable + run: corepack enable - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: node-version: "22" + - name: Cache pnpm store + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: ~/.local/share/pnpm/store + key: pnpm-${{ runner.os }}-${{ hashFiles('cli/pnpm-lock.yaml') }} + restore-keys: pnpm-${{ runner.os }}- - run: cd cli && pnpm install --frozen-lockfile - run: cd cli && pnpm jscpd kanban-checks: name: kanban / Typecheck, Lint & Test + needs: [changes] + if: needs.changes.outputs.relevant == 'true' runs-on: ubuntu-latest steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Install pnpm - run: | - corepack enable + run: corepack enable - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: node-version: "22" + - name: Cache pnpm store + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: ~/.local/share/pnpm/store + key: pnpm-${{ runner.os }}-${{ hashFiles('kanban/pnpm-lock.yaml') }} + restore-keys: pnpm-${{ runner.os }}- - run: cd kanban && pnpm install --frozen-lockfile - run: cd kanban && pnpm typecheck - run: cd kanban && pnpm lint @@ -209,8 +336,11 @@ jobs: windows: # Runs under bash (Git Bash, bundled on windows-latest) so every command is the one the - # Linux jobs run, not a PowerShell rewrite of it. + # Linux jobs run, not a PowerShell rewrite of it. No pnpm store cache: the store path in + # the Linux jobs' cache key is POSIX, not where a native Windows pnpm resolves its store. name: cli / Windows + needs: [changes] + if: needs.changes.outputs.relevant == 'true' runs-on: windows-latest defaults: run: @@ -218,96 +348,173 @@ jobs: steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Install pnpm - run: | - corepack enable + run: corepack enable - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: node-version: "22" # The WRITE path, on the platform where a home directory, a line ending and an absolute - # path all resolve differently than they do on the Linux jobs above. It runs before the - # CLI is built, on purpose: recording is supposed to need nothing installed, and this is - # where that claim is exercised. Reading is the CLI's now, and the e2e project below - # covers it on this same runner. + # path all resolve differently. It runs before the CLI is built on purpose: recording is + # supposed to need nothing installed, and this is where that claim is exercised. - name: Chain - allow measurement, with nothing installed run: | - # Written directly rather than through a command: the switch is a file, the hooks - # read it fresh at every write, and no binary has to exist for that to work. + # Written directly rather than through a command: the switch is a file the hooks + # read fresh at every write, so no binary has to exist for it to work. mkdir -p .aidd node -e "require('fs').writeFileSync('.aidd/config.json', JSON.stringify({ telemetry: { enabled: true } }, null, 2) + '\n');" - name: Chain - journal a captured payload run: | - # The fixture's own cwd is a path from whatever machine captured it - rewritten to - # this checkout's real path so getRepoRoot resolves a repository that exists here. + # The fixture's cwd came off whatever machine captured it: rewritten so getRepoRoot + # resolves a repository that exists here. node -e "const p=require('./scripts/__tests__/fixtures/claude-code-session-start.json'); p.cwd=process.cwd(); require('fs').writeFileSync('payload.json', JSON.stringify(p));" node plugins/aidd-telemetry/hooks/journal.cjs session-start < payload.json node plugins/aidd-telemetry/hooks/journal.cjs turn-end < payload.json + # `telemetry check` fails on `sessionFound: false`, and nothing in this chain places a + # transcript for the session just journalled. Copied from a local-cost fixture with its + # session id rewritten, under `$HOME` — which is what the read path itself resolves + # first, and which Git Bash sets on this runner. + - name: Chain - place the transcript where claude keeps it + run: | + node -e "const fs=require('fs'),os=require('os'),path=require('path');const homeDir=process.env.HOME||os.homedir();const sessionId='ffde6fda-14a8-4b32-8110-be1f1d13eebf';const src='cli/tests/fixtures/local-cost/.claude/projects/fake-project/22222222-2222-4222-8222-222222222222.jsonl';const dir=path.join(homeDir,'.claude','projects','fake-project');fs.mkdirSync(dir,{recursive:true});const content=fs.readFileSync(src,'utf8').split('22222222-2222-4222-8222-222222222222').join(sessionId);fs.writeFileSync(path.join(dir,sessionId+'.jsonl'),content);" - name: Chain - the journal stays private and git add -A still works run: | git add -A git status --porcelain >/dev/null + # Wrapped: a suite writing into this repository's own .git/hooks passes every assertion + # and destroys an install nothing can restore. The suite reads YAML through the root + # devDependencies, so the install has to precede it — and the chain steps above stay + # ahead of any install on purpose. + - run: pnpm install --frozen-lockfile - name: Plugin suite - run: node --test "scripts/__tests__/*.test.js" + run: node scripts/check-tests-leave-git-alone.js -- node --test 'scripts/__tests__/**/*.test.js' - - run: cd kanban && pnpm install --frozen-lockfile - run: cd cli && pnpm install --frozen-lockfile - # A real global install, not `node dist/cli.js` by path: build, pack, and - # `npm install -g` the tarball — the same shim generation a person's own - # `npm install -g @ai-driven-dev/cli` produces. Every suite below invokes the built - # file directly and proves nothing about whether `aidd` actually resolves on this - # platform's PATH — Windows is where that kind of assumption has broken silently - # before (`os.homedir()` never reading `$HOME` here). Built here, ahead of the - # unit/integration/e2e suites below, so this is the only build this job needs. + # A real global install, not `node dist/cli.js` by path: nothing else proves `aidd` + # resolves on this platform's PATH. Built here, ahead of the suites below, so this is + # the only build the job needs. # - # Inlined rather than `pnpm run install:local` (the equivalent script this package - # already ships): a package.json script runs through pnpm's own configured shell, - # `cmd.exe` on Windows unless `script-shell` says otherwise, which this repository - # never sets — its `$(node -p ...)` command substitution is bash syntax and would - # not survive that. This step's own `run:` block is guaranteed bash by the job's - # `defaults.run.shell` above, so the same two commands run here directly instead, - # with a glob standing in for the version substitution. + # Inlined rather than `pnpm run install:local`: a package.json script runs through + # pnpm's configured shell, `cmd.exe` on Windows, where that script's `$(node -p ...)` + # is not valid syntax. This block is bash by the job's `defaults.run.shell`. - name: Install the built CLI globally, the way a person actually would run: | cd cli pnpm build pnpm pack --pack-destination ./dist npm install -g ./dist/ai-driven-dev-cli-*.tgz --force - # `02-check` diagnoses the chain above through this exact command, lifted from its - # own markdown — not a script beside it, the same move `00-init`'s own chain step - # made for the switch. `aidd --version` is the same command every skill's own locate - # step runs first; failing here means the CLI could not be resolved on the PATH. + # The exact command `02-check`'s own markdown names, not a script beside it. Failing + # `aidd --version` means the CLI could not be resolved on the PATH. - name: Chain - diagnose, through the command every skill's own markdown names run: | aidd --version - aidd telemetry check + # A gated run judges nothing and exits 0, so the one row the chain exists to make + # true is asserted separately: the transcript placed for the session was read. + aidd telemetry check | tee telemetry-check.out + grep -E "tool files readable +ok" telemetry-check.out - run: cd cli && pnpm test:unit - run: cd cli && pnpm test:integration - # Every e2e but the two named below — and one of them is why this step matters most: - # `telemetry-commit-trailer.e2e.test.ts` makes real commits and reads their messages - # back, which is the only thing that proves the `prepare-commit-msg` hook this build - # installs is reachable by the shell Git for Windows ships. A path written with - # backslashes is not, and nothing but this catches it. Exclude it only for a reason as - # concrete as the two below. + # Every e2e but the two named below. `telemetry-commit-trailer.e2e.test.ts` is why the + # step matters most: it is the only thing proving the `prepare-commit-msg` hook is + # reachable by the shell Git for Windows ships, which a backslash path is not. - name: cli e2e - # `--max-workers=2`, and only here. Vitest transforms modules on its own main - # thread while every worker calls back into it (`onTaskUpdate` after each test), - # and that call has a fixed 60 s timeout no configuration exposes. On this runner - # - a slower filesystem than the Linux jobs, and an e2e test that spawns `node - # dist/cli.js` inside every worker - the default pool (one fork per CPU but one) - # queued that main thread past 60 s, and the step failed with `[vitest-worker]: - # Timeout calling "onTaskUpdate"` while every test passed: 39 files, 279 tests, 0 - # failures, exit 1. Measured on five runs between 2026-09-02 and 2026-09-04, - # including three plain merges to `next` and one re-run of the same commit, so it - # is neither a branch nor a one-off. A fixed pool of two caps the transform - # requests competing for that thread whatever the runner's core count - a figure - # the job log does not state, so nothing here assumes one. The Linux jobs keep the - # default: they have never produced this, and slowing them down would buy nothing. + # `--max-workers=2`, and only here. Vitest transforms modules on its own main thread + # while every worker calls back into it, and that call has a fixed 60 s timeout no + # configuration exposes: on this runner the default pool queued it past 60 s and + # failed with `Timeout calling "onTaskUpdate"` while every test passed. A fixed pool + # of two caps the competing requests whatever the runner's core count. run: | cd cli pnpm exec vitest run --project=e2e --max-workers=2 \ --exclude "tests/e2e/persona.e2e.test.ts" \ --exclude "tests/e2e/telemetry-multi-tool.e2e.test.ts" - # persona.e2e.test.ts hardcodes /usr/bin/expect for TTY emulation, which windows-latest - # does not carry. telemetry-multi-tool.e2e.test.ts puts a `#!/bin/sh` stand-in binary - # with no extension on PATH; Windows resolves an executable by PATHEXT, never a shebang. + # persona.e2e.test.ts needs /usr/bin/expect, which windows-latest does not carry; + # telemetry-multi-tool.e2e.test.ts relies on a shebang, and Windows resolves an + # executable by PATHEXT. + + # Fan-in: every job above either ran and passed, or was skipped because `changes` found + # nothing relevant. The one check the branch rulesets require — without it a pull request + # merges with the real jobs red, because nothing names them. + cli-mutation: + name: cli / Mutation (${{ matrix.scope }}) + needs: [changes] + if: needs.changes.outputs.mutation_scopes != '[]' + runs-on: ubuntu-latest + timeout-minutes: 90 + strategy: + fail-fast: false + matrix: + scope: ${{ fromJSON(needs.changes.outputs.mutation_scopes) }} + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - name: Install pnpm + run: corepack enable + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: "22" + - name: Cache pnpm store + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: ~/.local/share/pnpm/store + key: pnpm-${{ runner.os }}-${{ hashFiles('cli/pnpm-lock.yaml') }} + restore-keys: pnpm-${{ runner.os }}- + - run: cd cli && pnpm install --frozen-lockfile + # The incremental file is what keeps a run to the mutants a change can move; the newest + # one this branch or its base saved is restored, and this run's is saved whatever the score. + - name: Restore this scope's incremental file + uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: cli/reports/mutation/${{ matrix.scope }}/incremental.json + key: mutation-${{ matrix.scope }}-${{ github.run_id }} + restore-keys: mutation-${{ matrix.scope }}- + - run: cd cli && node scripts/run-mutation.mjs "${{ matrix.scope }}" ${{ github.event_name == 'schedule' && '--force' || '' }} + - name: Save this scope's incremental file + if: always() + uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: cli/reports/mutation/${{ matrix.scope }}/incremental.json + key: mutation-${{ matrix.scope }}-${{ github.run_id }} + + gate: + name: cli / gate + runs-on: ubuntu-latest + if: always() + needs: + - changes + - cli-typecheck + - cli-lint + - cli-architecture + - cli-coverage + - cli-smoke + - cli-build + - cli-knip + - identifier-join + - cli-jscpd + - cli-mutation + - kanban-checks + - windows + steps: + - name: Fail unless changes decided cleanly and every gated job passed or was skipped + run: | + if [[ "${{ needs.changes.result }}" != "success" ]]; then + echo "::error::the changes job did not succeed (${{ needs.changes.result }}) — whether this workflow had anything to do could not be decided, so every job below it reads as skipped without proving it should have been" + exit 1 + fi + for result in \ + "${{ needs.cli-typecheck.result }}" \ + "${{ needs.cli-lint.result }}" \ + "${{ needs.cli-architecture.result }}" \ + "${{ needs.cli-coverage.result }}" \ + "${{ needs.cli-smoke.result }}" \ + "${{ needs.cli-build.result }}" \ + "${{ needs.cli-knip.result }}" \ + "${{ needs.identifier-join.result }}" \ + "${{ needs.cli-jscpd.result }}" \ + "${{ needs.cli-mutation.result }}" \ + "${{ needs.kanban-checks.result }}" \ + "${{ needs.windows.result }}"; do + if [[ "$result" == "failure" || "$result" == "cancelled" ]]; then + echo "::error::a required job did not pass (result: $result)" + exit 1 + fi + done diff --git a/.github/workflows/close-finished-milestones.yml b/.github/workflows/close-finished-milestones.yml index 692bfc721..73735e086 100644 --- a/.github/workflows/close-finished-milestones.yml +++ b/.github/workflows/close-finished-milestones.yml @@ -1,15 +1,8 @@ name: Close finished milestones -# A milestone whose last issue just closed should close too, so its due date -# stops reading as "overdue" and the roadmap view drops the finished group. -# GitHub has no native rule for this. This closes any OPEN milestone that has -# at least one closed issue and zero open ones. An empty milestone (no issues -# at all) is left untouched — it is a freshly created bucket, not finished work. -# -# Runs when an issue closes (the moment a milestone can become finished) and on -# a weekly safety net for anything the event missed. Reopening a milestone by -# hand is safe: this only ever acts on milestones that have become empty of open -# issues, so it will not re-close one you deliberately reopened to add work. +# GitHub has no native rule for closing a milestone whose last issue closed, so its due date +# keeps reading as "overdue". An empty milestone is left alone: a fresh bucket, not finished +# work. Reopening one by hand is safe, since only a milestone with no open issue is touched. on: issues: @@ -38,7 +31,7 @@ jobs: set -euo pipefail gh api --paginate "repos/$REPO/milestones?state=open&per_page=100" \ --jq '.[] | select(.closed_issues > 0 and .open_issues == 0) | "\(.number)\t\(.title)"' \ - > finished.tsv || true + > finished.tsv if [ ! -s finished.tsv ]; then echo "No finished milestone to close." diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 43a8f086f..600a5211d 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -4,7 +4,7 @@ on: push: branches: [main] pull_request: - branches: [main] + branches: [main, next] schedule: # Weekly Monday 03:00 UTC; matches dependabot cadence. - cron: "0 3 * * 1" @@ -17,9 +17,8 @@ permissions: jobs: analyze: name: Analyze (${{ matrix.language }}) - # CodeQL requires GitHub Advanced Security, which is free on public repos - # and paid on private ones. Skip while the repo is private to avoid - # spamming the run history with "Advanced Security must be enabled" failures. + # CodeQL needs Advanced Security, free only on a public repository: while this one is + # private every run would fail with "Advanced Security must be enabled". if: github.event.repository.visibility == 'public' runs-on: ubuntu-latest strategy: diff --git a/.github/workflows/dependabot-auto-merge.yml b/.github/workflows/dependabot-auto-merge.yml index 02dde8596..421fbb2c4 100644 --- a/.github/workflows/dependabot-auto-merge.yml +++ b/.github/workflows/dependabot-auto-merge.yml @@ -1,10 +1,11 @@ name: Dependabot auto-merge -# Enables auto-merge on Dependabot PRs for non-major updates (patch + minor). -# Major bumps are left for a human to review. The PR still merges only once its -# required status checks pass (and any required review is satisfied). +# Auto-merge for patch and minor Dependabot PRs; a major bump waits for a human. The PR +# still merges only once its required checks pass. -on: pull_request_target +on: + pull_request_target: + types: [opened, synchronize, reopened] permissions: contents: write @@ -26,9 +27,9 @@ jobs: uses: dependabot/fetch-metadata@25dd0e34f4fe68f24cc83900b1fe3fe149efef98 # v3.1.0 - name: Approve non-major updates - # Required-review rulesets (e.g. on `next`) block auto-merge until a - # review approves. Dependabot never gets one, so the app approves here. - # The app identity differs from the PR author, so the approval counts. + # A required-review ruleset blocks auto-merge until a review approves, and + # Dependabot never gets one. The app identity differs from the pull request author, + # so its approval counts. if: steps.meta.outputs.update-type != 'version-update:semver-major' run: gh pr review --approve "${{ github.event.pull_request.html_url }}" env: diff --git a/.github/workflows/promote.yml b/.github/workflows/promote.yml index 38de5e7b8..347c6371c 100644 --- a/.github/workflows/promote.yml +++ b/.github/workflows/promote.yml @@ -1,21 +1,13 @@ name: Promote next to main -# One intuitive button to ship `next` to `main` the RIGHT way: a merge commit. +# Ships `next` to `main` as a merge commit, run from the Actions tab: it snapshots `next`, +# opens a PR and enables auto-merge, and CI gates it. # -# The merge commit does two jobs. It carries every conventional commit onto -# `main` untouched, so commitlint passes and release-please reads each scope to -# bump the right plugin. And its two parents give `main` and `next` a real merge -# base, which is the only thing that keeps the back-merge after each release -# conflict-free. -# -# Neither alternative does the second job. A squash collapses the batch into one -# subject and hides the scopes. A rebase recopies the commits under new hashes, -# so git never learns the branches were reconciled: the recorded merge base goes -# stale, and every later back-merge conflicts on the release metadata that -# release-please rewrites each time. That is the failure this workflow prevents. -# -# Run it from the Actions tab (Run workflow). It snapshots `next`, opens a PR to -# main, and enables merge auto-merge. CI gates it, then it merges itself. +# The merge commit does two jobs. It carries every conventional commit onto `main` untouched, +# so release-please attributes each one to a package by the path it touched — a squash hides +# that. And its two parents give the branches a real merge base, which a rebase never +# records: without one the base goes stale, and every later back-merge conflicts on the +# release metadata release-please rewrites each cycle. on: workflow_dispatch: @@ -52,24 +44,18 @@ jobs: git config user.name "aidd-bot[bot]" git config user.email "aidd-bot[bot]@users.noreply.github.com" - git remote set-url origin "https://x-access-token:${GH_TOKEN}@github.com/${REPO}.git" git fetch origin main next --prune - # Compare trees, not ancestry. After a back-merge, next's tip is a - # merge commit main does not carry, so next is never an ancestor of - # main even when the two hold identical content. Going by ancestry - # would open an empty promote PR, and merging it is a push to main, - # which runs release-please for nothing. + # Trees, not ancestry: after a back-merge, next's tip is a merge commit main does + # not carry, so identical content still reads as unmerged and opens an empty PR. if [ "$(git rev-parse origin/next^{tree})" = "$(git rev-parse origin/main^{tree})" ]; then echo "next and main already hold the same tree; nothing to promote." exit 0 fi - # Promote a snapshot of next, never next itself. A PR headed by the - # live branch silently widens as work merges into next during review, - # so what ships stops matching what was approved. The snapshot points - # at next's exact tip, so merging it still makes that commit an - # ancestor of main and still refreshes the merge base. + # A snapshot of next, never next itself: a PR headed by the live branch widens as + # work merges in during review. The snapshot is next's exact tip, so the merge base + # still refreshes. BRANCH="promote/next-to-main-${RUN_ID}" git push origin "origin/next:refs/heads/${BRANCH}" @@ -79,15 +65,13 @@ jobs: | grep -oE '[0-9]+$') echo "Promote PR: #$PR" - # `ci:` on purpose. The type is absent from release-please's - # changelog-sections, so this plumbing commit never surfaces in a - # released changelog, while commitlint still accepts it. + # `ci:` on purpose: the type is absent from release-please's changelog-sections, + # so this plumbing commit never surfaces in a changelog, and commitlint accepts it. gh pr merge "$PR" --repo "$REPO" --merge --auto --delete-branch \ --subject "ci: promote next to main (#${PR})" - # Without a recorded subject GitHub generates "Merge pull request #N - # from ...", which is not conventional. Commitlint lints main's tip on - # push, so fail here rather than on main. + # Without a recorded subject GitHub generates one that is not conventional, and + # commitlint lints main's tip on push. gh pr view "$PR" --repo "$REPO" --json autoMergeRequest \ --jq '.autoMergeRequest.commitHeadline // empty' | grep -q . || { echo "Auto-merge did not record the commit subject; refusing to leave main's tip to chance." diff --git a/.github/workflows/star-history.yml b/.github/workflows/star-history.yml index 0184d8835..883672033 100644 --- a/.github/workflows/star-history.yml +++ b/.github/workflows/star-history.yml @@ -1,13 +1,10 @@ name: Star history -# Regenerates the star history chart the README embeds. GitHub restricted the -# stargazers API to a repository's own admins and collaborators on June 30 2026, -# so no third-party service can draw this chart any more — only the repository -# itself can, with its own token. +# Regenerates the star history chart the README embeds. The stargazers API answers only a +# repository's own admins and collaborators, so no third-party service can draw it. # -# The SVG is published on the `star-history` orphan branch rather than committed -# to main: main requires a pull request, and a data-only branch keeps generated -# bytes out of the source history while staying servable over raw.githubusercontent. +# Published on the `star-history` orphan branch: main requires a pull request, and a +# data-only branch keeps generated bytes out of the source history while staying servable. on: schedule: diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml index 6f3bd3566..85eb94443 100644 --- a/.github/workflows/validate.yml +++ b/.github/workflows/validate.yml @@ -1,8 +1,8 @@ name: Validate -# Runs the framework's pre-commit hooks against the full tree on every push -# and pull request, so contributions that bypass lefthook locally -# (--no-verify, fork PRs, web edits) are still gated by the same logic. +# Runs the pre-commit hooks against the full tree on every push and pull request, so work +# that bypassed lefthook locally is gated by the same logic. The generator hooks `git add` +# what they regenerate instead of failing on drift, so a step below re-checks against HEAD. on: push: @@ -20,6 +20,12 @@ jobs: env: LEFTHOOK_CHILD: "1" CI: "true" + # cli-ci.yml owns these three unconditionally, so running them again buys no coverage. + # cli-type-honesty is deliberately NOT excluded: no cli-ci.yml job runs it, making this + # its only CI coverage. The exclusion is also why no `cli/` install runs below — the + # three hooks needing cli/node_modules are the three dropped, and every hook that stays + # reads `cli/` as text from the repository root. + LEFTHOOK_EXCLUDE: "cli-biome,cli-architecture,cli-typecheck" steps: - name: Checkout uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 @@ -30,25 +36,27 @@ jobs: node-version: "22" - name: Install pnpm - run: | - corepack enable + run: corepack enable - name: Cache pnpm store uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: ~/.local/share/pnpm/store - key: pnpm-${{ runner.os }}-${{ hashFiles('package.json') }} + key: pnpm-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }} restore-keys: pnpm-${{ runner.os }}- - name: Install dependencies - run: pnpm install --no-frozen-lockfile --ignore-scripts - - - name: Install cli dependencies - # cli/ is a self-contained project (own lockfile, not a pnpm workspace - # member) — the root install above never touches it, but the - # cli-biome/cli-typecheck pre-commit hooks below need cli/node_modules - # to exist or they fail outright ("biome: not found"). - run: cd cli && pnpm install --frozen-lockfile + run: pnpm install --frozen-lockfile --ignore-scripts - name: Run pre-commit hooks against the full tree run: pnpm exec lefthook run pre-commit --all-files --force + + # The generator hooks `git add` their output and never fail on drift, so a stale + # CATALOG.md, prompts doc or README count passes the step above in silence. Against + # HEAD, not the index: the generators already staged, so a plain `git diff` is empty. + - name: Fail if a generated file drifted from what pre-commit regenerated + run: | + if ! git diff --exit-code HEAD -- 'plugins/*/CATALOG.md' docs/prompts-documentation.md README.md; then + echo "::error::A generated file is stale. Run 'pnpm exec lefthook run pre-commit --all-files --force' locally and commit the result." + exit 1 + fi diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index ef455db66..8e1b4da66 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -46,11 +46,11 @@ make setup # deps, git hooks, registers the marketplace, installs plugins into - **Follow the [Principles](#-principles).** - **Test locally** — run `make reload`, restart your session(s). Test in Claude *and* one other tool (e.g. Codex). -- **Commit** — `(): description`, one scope per commit → [convention](aidd_docs/memory/vcs.md#commit-convention). +- **Commit** — `(): description`, one scope per commit → [convention](aidd_docs/memory/vcs.md#commits). ## 🔀 Open a pull request -- **Branch off `next`, target `next`** → [routing table](aidd_docs/memory/vcs.md#types). +- **Branch off `next`, target `next`** → [routing table](aidd_docs/memory/vcs.md#branches). - **Fill the [PR template](.github/PULL_REQUEST_TEMPLATE.md)** — what changed, how you solved it. - **A Maintainer review gates every merge** → [`GOVERNANCE.md`](./GOVERNANCE.md#-code-decisions). diff --git a/README.md b/README.md index ecee0e4b9..7507f3b57 100644 --- a/README.md +++ b/README.md @@ -38,7 +38,7 @@ Why not just write your own commands? → [FAQ](docs/FAQ.md#-why-aidd-instead-of ## ✅ Prerequisites - **An AI coding tool** — Claude Code (native), or Cursor / Copilot / Codex / OpenCode (see [Compatibility](#-compatibility)). -- **[Node](https://nodejs.org)** on your `PATH` — for the plugin that ships hooks ([what they do](docs/ARCHITECTURE.md#-bundled-hooks)). +- **[Node](https://nodejs.org) 22 or later** on your `PATH`, only for the plugin that ships hooks ([what they do](docs/ARCHITECTURE.md#-bundled-hooks)); the workflows themselves are markdown and need nothing. ## 🔌 Compatibility @@ -69,7 +69,6 @@ Installs the 6 stable plugins (`aidd-ui` is 🚧 alpha and `aidd-telemetry` 🧪 /plugin install aidd-vcs@aidd-framework /plugin install aidd-pm@aidd-framework /plugin install aidd-orchestrator@aidd-framework -/plugin install aidd-ui@aidd-framework # 🚧 alpha, install separately ``` @@ -84,7 +83,6 @@ claude plugin install aidd-dev@aidd-framework claude plugin install aidd-vcs@aidd-framework claude plugin install aidd-pm@aidd-framework claude plugin install aidd-orchestrator@aidd-framework -claude plugin install aidd-ui@aidd-framework # 🚧 alpha, install separately ``` =22.12`, pnpm. `cli/` (the `aidd` binary) and `kanban/` (bundled into it from source). | -| Manifest | `.claude-plugin/marketplace.json`, 7 plugins, versioned per plugin. | +| Delivery | Node `>=22.12`, pnpm. `cli/` is the `aidd` binary; `kanban/` is a private package. | +| Manifest | `.claude-plugin/marketplace.json`, the plugin manifest: 8 plugins, no version among them. Versions are release-please's, in `deployment.md`. | ## How it fits together ```mermaid flowchart LR - Manifest[".claude-plugin/marketplace.json"] -->|lists| Plugins["plugins/ · 7"] + Manifest[".claude-plugin/marketplace.json"] -->|lists| Plugins["plugins/ · 8"] Plugins -->|ships| Surfaces["skills · agents · commands · hooks · rules"] CLI["cli/ · aidd"] -->|reads| Manifest CLI -->|installs| Target["a project's AI tool dir"] - Kanban["kanban/"] -->|bundled from source| CLI Editor["AI coding tool"] -->|invokes| Surfaces ``` -`cli/` and `kanban/` are workspaces of this repo, not outside consumers: type-checked, tested and released here. +`cli/` and `kanban/` are self-contained projects with their own lockfiles, not pnpm workspace members — `pnpm-workspace.yaml` declares no `packages:` list on purpose, because membership would change how their dependencies resolve. Each installs on its own, and both are type-checked, linted and tested by this repository's CI. Only `cli/` is published. ## Key decisions @@ -34,13 +33,13 @@ flowchart LR | Concern decides placement, not existence | a missing capability goes to the plugin whose concern owns it; the caller delegates | | A skill is a router | `SKILL.md` dispatches to actions or protocols; the only place a capability is addressed by name | | Recipe skills discover providers at runtime | by description matching. Only agent permission lists and orchestration references name a provider | -| `kanban/` never imports `cli/` | the host injects through `KanbanCommandDeps` (`kanban/src/presentation/kanban-deps.ts`) | +| Observation is its own layer | `aidd-telemetry` journals what a session did; it never reads or writes application source | +| A launcher runs an external binary, never embeds it | `kanban` broke that and was unwired. Detail in the CLI bank | The concern-to-plugin taxonomy is canonical in [`docs/ARCHITECTURE.md`](../../docs/ARCHITECTURE.md). ## Gotchas -- A plugin never contains its own tests — `hooks/` ships recursively into user projects. +- 8 plugins ship, 2 off the curated install path: `aidd-ui` is alpha, `aidd-telemetry` beta and opt-in. - A skill never links outside itself: the tree ships both flat and as a marketplace, so no relative path survives both. -- Bundled hooks run Node. No `node` on `PATH`, no memory refresh. -- `cli/` reaches into `kanban/src/` by relative path, so `kanban/`'s deps must be installed before any `cli` typecheck, test or build. +- Bundled hooks run Node. No `node` on `PATH`, no memory refresh and no run journal. diff --git a/aidd_docs/memory/backlog.md b/aidd_docs/memory/backlog.md index b152a7711..7b30f72c3 100644 --- a/aidd_docs/memory/backlog.md +++ b/aidd_docs/memory/backlog.md @@ -6,9 +6,9 @@ | --- | --- | --- | | GitHub Issues | the work items, their type and their scope | where a change is agreed before a branch exists | | AIDD Roadmap board (org project 8) | order, status, and what ships next | the single source of truth for the roadmap | -| Milestones | themes | a theme with a Thursday due date; no milestone means backlog | +| Milestones | themes | one milestone per theme; no milestone means backlog | | GitHub Discussions | ideas and their upvotes | where an idea is weighed before it becomes an issue | -| `aidd_docs/` task documents | the local plans, specs and reviews behind an issue | read by `aidd kanban`, never a substitute for the issue | +| `aidd_docs/` task documents | the local plans, specs and reviews behind an issue | never a substitute for the issue | ## Representation @@ -17,16 +17,18 @@ | Feature | GitHub Issues | issue type `Feature`, form 🌱 Quick Contribution | | Bug | GitHub Issues | issue type `Bug`, form 🐛 Bug Report | | Task | GitHub Issues | issue type `Task`, form 📋 Detailed Contribution | -| Theme | Milestones | one milestone, due on a Thursday | +| Theme | Milestones | one milestone | | Idea | GitHub Discussions | a discussion, ranked by 👍 | -The form stamps the type; nobody sets it by hand. Labels categorize nothing: one exists only when a bot or a human reads it, and `.github/labels.yml` is their source of truth. +The form stamps the type; nobody sets it by hand. Labels categorize nothing: one exists only when a bot or a human reads it. `.github/labels.yml` declares them, nothing syncs it, and drift is reconciled by hand. ## Workflow | Support | Native status | Meaning | | --- | --- | --- | +| Roadmap board | Ideation | weighed, not agreed | | Roadmap board | Todo | agreed, not started | +| Roadmap board | In Progress | being worked | | Roadmap board | In review | a pull request is open | | Roadmap board | Done | merged | @@ -34,9 +36,9 @@ The board is moved by hand, by a human or an agent through `gh`. No workflow adv ## Planning -- Priority: set by a community vote, mechanism in `GOVERNANCE.md`. -- Iteration: weekly, a milestone per theme due on a Thursday. -- Milestone: closed automatically once all its issues are, by `.github/workflows/close-finished-milestones.yml`. +- Priority: a public 👍 is a signal, not a vote. Only Core Team, Certified and Maintainer cast a counted one — mechanism in `GOVERNANCE.md`. +- Iteration: weekly, a milestone per theme. +- Milestone: closed by `.github/workflows/close-finished-milestones.yml`, on a schedule, once all its issues are. An empty one is left open. ## Relations diff --git a/aidd_docs/memory/cli.md b/aidd_docs/memory/cli.md index 574e6d088..0f7a0dce5 100644 --- a/aidd_docs/memory/cli.md +++ b/aidd_docs/memory/cli.md @@ -6,19 +6,14 @@ The `aidd` binary, built from `cli/` and published to npm as `@ai-driven-dev/cli ## Commands -| Group | Does | -| --- | --- | -| `setup`, `ai`, `ide` | install and refresh AI tool configurations in a target project | -| `plugin`, `marketplace` | add, list, search, update, remove plugins and their marketplaces | -| `framework build` | build a target-native distribution of this repo. CI calls it at a pinned version | -| `kanban` | render `aidd_docs/` task frontmatter as a board | -| `auth`, `status`, `doctor`, `clean`, `restore`, `update`, `self-update` | credentials, diagnosis, upkeep | +- Eleven groups: `setup`, `framework`, `translate`, `plugin`, `marketplace`, `auth`, `sync`, `update`, `doctor`, `clean`, `telemetry`. +- No list here. The surface moves; `aidd --help` is the only reading that stays true. +- `telemetry`'s sink, run journal, record shapes, report axes and per-tool declarations: [`cli/aidd_docs/memory/telemetry.md`](../../cli/aidd_docs/memory/telemetry.md). ## Interface -- Node `>=22.12`, ESM, Commander. Ink and React for the interactive views. -- `kanban/` is bundled from source at build time, so its deps must resolve before any `cli` job. +- Node (floor in `architecture.md`'s stack table), ESM, Commander. ## Distribution -npm, through OIDC trusted publishing, no token. `publish-cli` runs when release-please releases the `cli` path. +Published to npm; the pipeline, its OIDC publishing and its gates are in `deployment.md`. diff --git a/aidd_docs/memory/codebase-map.md b/aidd_docs/memory/codebase-map.md index eab2ee2da..5a93a6a1b 100644 --- a/aidd_docs/memory/codebase-map.md +++ b/aidd_docs/memory/codebase-map.md @@ -13,6 +13,7 @@ flowchart TD Root --> Docs["docs/"] Root --> AiddDocs["aidd_docs/"] Root --> Manifest[".claude-plugin/"] + Root --> Claude[".claude/"] Root --> GH[".github/"] ``` @@ -20,13 +21,14 @@ flowchart TD | Path | Holds | | --- | --- | -| `plugins/` | the product — one dir per plugin, each with `skills/`, optionally `agents/`, `commands/`, `hooks/`, `rules/` | +| `plugins/` | the product — one dir per plugin, each with `plugins//.claude-plugin/plugin.json` and `skills/`, optionally `agents/`, `commands/`, `hooks/`, `rules/` | | `cli/` | the `aidd` binary. Has its own memory bank and `CLAUDE.md` | -| `kanban/` | the task board, bundled into the CLI, never published alone | +| `kanban/` | the task board. A private package with its own lockfile, unwired from the CLI | | `scripts/` | repository checks and generators, run by lefthook and CI. Tests in `scripts/__tests__/` | -| `docs/` | durable docs — architecture, plugin authoring, glossary, maintainer runbook | -| `aidd_docs/` | this memory bank, plus task documents read by `aidd kanban` | -| `.claude-plugin/` | `marketplace.json`, the version manifest | +| `docs/` | durable docs — architecture, plugin authoring, glossary, maintainer runbook. `prompts-documentation.md` is generated | +| `aidd_docs/` | this memory bank, plus `tasks/`, `runs/`, `product/`, `specs/`, `recipes/`, `brainstorm/` | +| `.claude-plugin/` | `marketplace.json`, the plugin manifest. Versions live in `.release-please-manifest.json` — see `deployment.md` | +| `.claude/` | registers this checkout as a local marketplace, so a contributor runs the plugins they edit | | `.github/` | workflows, issue templates, rulesets | ## Entry points @@ -36,10 +38,11 @@ flowchart TD | Binary | `cli/src/cli.ts` → `dist/cli.js`, bin name `aidd` | | Workflow | `plugins//skills/-/SKILL.md` | | Memory refresh | `plugins/aidd-context/hooks/update_memory.js`, on `SessionStart` | +| Run journal | `plugins/aidd-telemetry/hooks/journal.cjs`, on `SessionStart`, `Stop`, `PostToolUse` | ## Packages | Package | Released | | --- | --- | | `cli` (`@ai-driven-dev/cli`) | npm, the only published package | -| `kanban` | private, compiled into the CLI. Depends on npm packages only, never on `cli/` | +| `kanban` (`@ai-driven-dev/kanban-source`) | private. Depends on npm packages only, never on `cli/` | diff --git a/aidd_docs/memory/coding-assertions.md b/aidd_docs/memory/coding-assertions.md index f163ad7c4..886a4fe62 100644 --- a/aidd_docs/memory/coding-assertions.md +++ b/aidd_docs/memory/coding-assertions.md @@ -26,16 +26,24 @@ Never state in a commit message or a report anything not just observed in output | Order | Command | Checks | | ----- | ------- | ------ | -| 1 | `pnpm exec lefthook run pre-commit` | JSON and YAML validity, skill frontmatter and argument hints, context imports, markdown links, `scripts/` tests; `cli lint` and `cli typecheck` when `cli/` or `kanban/` changed | +| 1 | `pnpm exec lefthook run pre-commit` | JSON and YAML validity, `scripts/` tests, skill frontmatter and argument hints, context imports and reference form, markdown links, the paths the prose names and a sentence written in two documents (`scripts/check-doc-duplication.js`); `cli` lint, architecture, typecheck and type honesty when `cli/` changed. `cli` knip and the full `cli` suite are pre-push, not pre-commit — see below | | 2 | `pnpm exec commitlint --edit` | the message against `commitlint.config.cjs` | -Same hook regenerates each plugin's `CATALOG.md` and the README counts, and stages them. +Same hook regenerates each plugin's `CATALOG.md`, the README counts and `docs/prompts-documentation.md`, and stages them. + +Every `cli` job is globbed on `cli/**`. A change under `kanban/` alone fires no local job; run `cd kanban && pnpm test` by hand. CI does cover it — `cli-ci.yml`'s `kanban-checks`, see `deployment.md`. + +- `context-reference-form` reads only the three files `update_memory.js`'s `TARGET_FILES` names: root `CLAUDE.md`, root `AGENTS.md`, the `copilot-instructions.md` under `.github/` (absent here). It never walks the tree, so `cli/CLAUDE.md`'s memory block is outside it. +- `context-imports` walks the whole tree (`scripts/check-context-imports.js`'s `collectContextFiles`). +- `context-reference-form` also globs `plugins/aidd-context/hooks/update_memory.js`, whose table is its source of truth. ## Before push | Order | Command | Checks | | ----- | ------- | ------ | -| 1 | `pnpm exec lefthook run pre-push` | `cli knip:production`, then the full `cli` suite, when `cli/` changed | +| 1 | `pnpm exec lefthook run pre-push` | `cli knip`, then the full `cli` suite, when `cli/` changed | + +`--no-verify` buys nothing: `validate.yml` re-runs the whole pre-commit over the whole tree on every push and pull request. ## Behavior diff --git a/aidd_docs/memory/deployment.md b/aidd_docs/memory/deployment.md index 077001876..c7a486b95 100644 --- a/aidd_docs/memory/deployment.md +++ b/aidd_docs/memory/deployment.md @@ -8,14 +8,15 @@ Where the project runs and how it ships: CI/CD, environments, and release. | Workflow | Runs | | --- | --- | -| `ci.yml` | commitlint on PRs, release-please on `main`, then the release jobs | -| `cli-ci.yml` | typecheck, lint, test, build with its bundle budget, knip on `cli/` | -| `validate.yml` | plugin and marketplace manifests against their schemas | +| `ci.yml` | commitlint on pull requests and on `main`'s tip, plus the PR title itself — the subject a squash merge uses — then release-please on `main` and the release jobs | +| `cli-ci.yml` | the `cli` and `kanban` gates — job list in the CLI bank. No `paths:` filter, deliberately: it runs on every push and pull request, and a `changes` job decides in bash whether the rest has anything to do — `cli/**`, `kanban/**`, `scripts/__tests__/**`, `README.md`, the workflow file itself, and `plugins/aidd-telemetry/**` except its `*.md` prose | +| `validate.yml` | plugin and marketplace manifests against their schemas, plus the whole pre-commit over the whole tree | | `codeql.yml` | code scanning | | `promote.yml` | opens the `next` to `main` promote PR, merge auto-merge | | `back-merge.yml` | folds `main` back into `next` after each release | | `dependabot-auto-merge.yml` | merges dependency PRs that pass | | `close-finished-milestones.yml` | closes a milestone once its issues are | +| `star-history.yml` | refreshes the README star chart | ```mermaid flowchart LR @@ -26,7 +27,7 @@ flowchart LR Release --> Back["back-merge.yml"] ``` -Automatic on a push to `main`. `promote.yml` is the only manual entry. +Automatic on a push to `main`. Three workflows also accept a manual run: `promote.yml`, `close-finished-milestones.yml`, `star-history.yml`. ## Environments @@ -36,19 +37,24 @@ None — no server, no container, no IaC. What ships are release assets and publ | --- | --- | | Repository | | | npm | `@ai-driven-dev/cli`, OIDC trusted publishing, no token | -| Archives | GitHub Releases and GitHub Packages | +| Archives | GitHub Releases | +| Mirror | GitHub Packages, npm package only, best-effort | ## Release Branch model in `vcs.md`, cadence and safety rules in [`RELEASE.md`](../../RELEASE.md). -1. release-please opens the Release PR, bumping `marketplace.json` and each `plugin.json`. CI auto-merges it, so `main` never holds merged but unversioned code. -2. Merging creates the release and its tags — per plugin, `include-component-in-tag: true`, shaped `-v`. -3. Release jobs: `build-and-attach` (marketplace bundle), `build-per-tool` (nine distributions, CLI at a **pinned** version), `build-plugin` (one archive per released path), `publish-cli`. +1. release-please opens the Release PR. Only paths with commits bump; the root bumps every cycle. CI auto-merges it with `--squash --admin`, because the branch policy refuses a plain merge, so `main` never holds merged but unversioned code. +2. Merging creates the release and its tags — a root umbrella tag, `cli-v`, and one `-v` per plugin, `include-component-in-tag: true`. +3. Release jobs: `build-and-attach` (marketplace bundle), `build-per-tool` (nine distributions), `build-plugin` (one archive per released path), `publish-cli`. 4. Archives are staged outside the repo tree, uploaded with `gh release upload --clobber`. 5. `back-merge.yml` folds `main` into `next`. -Config: `release-please-config.json`. Manifest: `.release-please-manifest.json`. +Config: `release-please-config.json`, ten packages. Manifest: `.release-please-manifest.json`. + +## Gotchas + +- `build-per-tool` builds the CLI from this run's own checkout (`cd cli && pnpm install && pnpm build`, then `node cli/dist/cli.js translate`) rather than pinning a published version — no version to bump, and nothing can go stale the way the old `@ai-driven-dev/cli@5.1.1 framework build` pin did once `framework build` was replaced by `translate`. ## Monitoring diff --git a/aidd_docs/memory/ecosystem.md b/aidd_docs/memory/ecosystem.md index 7ac3ad64a..12d8ebb8e 100644 --- a/aidd_docs/memory/ecosystem.md +++ b/aidd_docs/memory/ecosystem.md @@ -5,23 +5,28 @@ flowchart LR Human([Human]) Agent([Agent]) App([App]) + Tool["AI coding tool · project-brief.md"] GitHub["GitHub · vcs.md"] Board["Roadmap project board · backlog.md"] - Npm["npm registry"] - Packages["GitHub Packages"] - Discord["Discord · human only"] - ReleasePlease["release-please"] - Dependabot["Dependabot"] + Discussions["GitHub Discussions · backlog.md"] + Npm["npm registry · deployment.md"] + Packages["GitHub Packages · deployment.md"] + Discord["Discord"] + ReleasePlease["release-please · deployment.md"] + Dependabot["Dependabot · deployment.md"] - Agent -- cli --> GitHub - Agent -- cli --> Board - Human -- cli --> Board + Human -- web --> GitHub + Human -- gh --> Board + Human -- web --> Discussions + Human -- web --> Discord + Agent -- gh --> GitHub + Agent -- gh --> Board + Agent -- aidd --> Tool App -- http --> GitHub App -- http --> Npm - Human -- web --> Discord Dependabot -- "dependency update PR" --> GitHub - ReleasePlease -- "merge on main" --> GitHub - GitHub -- "release created" --> Npm - GitHub -- "release created" --> Packages + ReleasePlease -- "release PR, then tags" --> GitHub + GitHub -- "released paths" --> Npm + GitHub -- "npm package, best-effort" --> Packages ``` diff --git a/aidd_docs/memory/internal/decisions/measurement-may-reach-a-hosted-destination.md b/aidd_docs/memory/internal/decisions/measurement-may-reach-a-hosted-destination.md index a7fe2791a..d650edbd4 100644 --- a/aidd_docs/memory/internal/decisions/measurement-may-reach-a-hosted-destination.md +++ b/aidd_docs/memory/internal/decisions/measurement-may-reach-a-hosted-destination.md @@ -56,8 +56,8 @@ Issue #660 was opened to resolve this. Its body quotes only the privacy clause, - **`aidd telemetry connect` gets a specification it can be written against**: authenticate to a destination the person names, and bind this machine's identity to an account there. Until now it had a purpose but no boundary. - **The deletion is not reversed.** Nothing here restores a listener or an export writer. What ships today opens no port and sends nothing; a destination is the next thing to build, deliberately, with its own consent. - **An amount in currency still has no route.** That is #654's price table, unaffected either way by this decision. -- **The local report is already past "light", and this record says so rather than pretending otherwise.** It carries seven axes — total, day, step, model, tool, project, person — which is an aggregation surface, built before this boundary existed. Nothing here requires removing it; what it does require is that it stops growing. An eighth axis now needs an argument for why a machine must answer that question, not merely that it could. -- **#720 is the first thing this boundary touches.** It asks for a task axis on the local report. Under clause 4 that is a destination's question unless someone can say why a single machine must answer it, and it should be re-argued rather than built by default. +- **The local report is already past "light", and this record says so rather than pretending otherwise.** It carried seven axes when this decision was written; read `ARTEFACT_AXES` in `cli/src/presentation/display/cost-report-artefact.ts` for the current list rather than a count here, which only decays as the surface grows. Nothing here requires removing any of them; what it does require is that growth stops happening for free. A new axis needs an argument for why a machine must answer that question, not merely that it could. +- **#720's task axis already shipped** (`task` is in `ARTEFACT_AXES` today, alongside others this record did not anticipate — `agent`, `prompt`, `backlog`, `flow`). Under clause 4 that is a destination's question unless someone can say why a single machine must answer it. This record's caution did not gate that landing; reconciling the two is for whichever issue re-litigates this boundary, not a silent rewrite here. - **#656 lands squarely on the destination side.** Per person, per team, per epic, across repositories — that is the analysis this boundary assigns to a hosted destination, and it should be re-scoped there rather than pursued as local work. - **What the framework owes in exchange is a clean exposure.** The record contract, the sink's shape and their versioning stop being internal documentation and become the interface a destination is written against. They now deserve the care an API gets. - **This record is the amendment #297 needs.** Both issues should reference it rather than continuing to state the superseded text. diff --git a/aidd_docs/memory/project-brief.md b/aidd_docs/memory/project-brief.md index 8daf334a8..781087fc7 100644 --- a/aidd_docs/memory/project-brief.md +++ b/aidd_docs/memory/project-brief.md @@ -2,6 +2,8 @@ What this project is, the problem it solves, and its domain language. The non-derivable "why", not the "how". +> What the `aidd` binary is, on its own terms: [`cli/aidd_docs/memory/project-brief.md`](../../cli/aidd_docs/memory/project-brief.md). + ## What it is - A plugin marketplace that installs structured SDLC workflows into AI coding tools — Claude Code, Cursor, GitHub Copilot, Codex, opencode — plus the `aidd` binary that installs them. @@ -22,19 +24,22 @@ What this project is, the problem it solves, and its domain language. The non-de | Agent | isolated executor; own context, returns only a result | | Rule | coding standard injected into the tool's context automatically | | Memory | the bank under `aidd_docs/memory/`, loaded every session | -| Marketplace | `.claude-plugin/marketplace.json`, the plugin and version registry | +| Marketplace | `.claude-plugin/marketplace.json`, the plugin registry | | Concern | what a plugin owns; decides where a capability lives | +| Run journal | what a session did, appended by a hook under `aidd_docs/runs/` | | Promote | sending `next` to `main`, which opens the release | ## Key features | Capability | Entry | | --- | --- | -| Install and update plugins per tool | `aidd plugin add`, `aidd ai`, `aidd ide` | -| Build a target-native distribution | `aidd framework build` | +| Install and refresh a tool's configuration | `aidd setup --ai --ide ` | +| Install plugins from a marketplace | `aidd plugin install`, `aidd marketplace add` | +| Build a target-native distribution | `aidd translate --to --out ` | | Bootstrap and refresh project memory | `aidd-context:02-project-memory` | | Generate context artifacts | `aidd-context:03-context-generate` and its per-kind generators | | Development loop | `aidd-dev` — plan, implement, assert, audit, review, test, refactor, debug | | Typed product backlog | `aidd-pm` — brief, epic, story, spec, spike, defect | +| Refine input and output | `aidd-refine` — brainstorm, challenge, blind spots | | End-to-end orchestration | `aidd-orchestrator:01-sdlc` | -| Task board | `aidd kanban` | +| Measure what a session cost | `aidd-telemetry`, opt-in, plus `aidd telemetry` | diff --git a/aidd_docs/memory/testing.md b/aidd_docs/memory/testing.md index b04d02000..39acfa3e6 100644 --- a/aidd_docs/memory/testing.md +++ b/aidd_docs/memory/testing.md @@ -9,9 +9,10 @@ How the project is tested: the layers, the tools, and the conventions. Where tes | Surface | Validated by | | --- | --- | | Skills, agents, rules (markdown) | each action's own `## Test`, run end to end against a real project | -| `scripts/` and bundled hooks | `node --test scripts/__tests__/*.test.js` | -| `cli/` and `kanban/` | vitest, three tiers — see the CLI bank | -| Per-tool distributions | golden snapshots in `cli/tests/golden/`, mirrored by the `build-per-tool` CI matrix | +| `scripts/` and bundled hooks | `node --test` under the wrapper below | +| `cli/` | vitest, four projects — see the CLI bank | +| `kanban/` | its own vitest suite. It shares no code with `cli/` | +| Per-tool distributions | golden snapshots in `cli/tests/golden/`, mirrored by the `build-per-tool` CI matrix; Claude Code's own `plugin validate` over a fresh claude build, in `cli-ci.yml` | | Browser journeys | `aidd-dev:11-browser-qa`, see below | ## Tools @@ -19,27 +20,27 @@ How the project is tested: the layers, the tools, and the conventions. Where tes | Tool | Use | | --- | --- | | vitest | `cli/`, `kanban/` | -| fast-check | property-based cases | -| ink-testing-library | terminal views | -| stryker | mutation, on demand, never in CI | +| stryker | mutation, per CLI scope, gated in `cli-ci.yml` on the scopes a change touches | | knip | dead code, before push and in `cli-ci.yml` | | `@playwright/cli` | browser QA evidence, pinned, never an app dependency | ## Conventions - A plugin never holds its own tests: `hooks/` ships recursively into user projects. Tests for a bundled script go in `scripts/__tests__/`. -- `cli/tests/fixtures/` is excluded from the JSON and link checks; it holds deliberately invalid inputs. -- Adapters are substituted, not mocked, wherever the seam exists. +- The scripts suite writes into a git repository. Run it wrapped, never bare: unwrapped it can overwrite this repository's own `.git/hooks`, and `.git` is in no history. ## Run | Command | Scope | | --- | --- | | `pnpm test:changed` | only the specs a change can break — vitest resolves the CLI's import graph, and the plugin specs are selected by the paths their own text names. What to run while working | -| `node --test 'scripts/__tests__/*.test.js'` | repository scripts and hooks | -| `cd cli && pnpm test` | build, then all three CLI tiers | -| `cd cli && pnpm smoke` | built binary against `scripts/smoke-tools.sh` | -| `pnpm exec lefthook run pre-push` | what CI runs | +| `node scripts/check-tests-leave-git-alone.js -- node --test 'scripts/__tests__/**/*.test.js'` | repository scripts and hooks | +| `cd cli && pnpm test` | the four CLI projects. It does not build | +| `cd cli && pnpm smoke` | built binary against `cli/scripts/smoke-tools.sh` | +| `cd kanban && pnpm test` | the board | +| `pnpm exec lefthook run pre-push` | the local gate before pushing | + +CI runs more: `validate.yml` re-runs the whole pre-commit over the whole tree on every push and pull request, and `cli-ci.yml` adds jobs no local hook has. Both are in `deployment.md`. ## Browser QA diff --git a/aidd_docs/memory/vcs.md b/aidd_docs/memory/vcs.md index 004370e51..0b2387385 100644 --- a/aidd_docs/memory/vcs.md +++ b/aidd_docs/memory/vcs.md @@ -6,7 +6,8 @@ The version-control conventions this project follows: branches, commits, and the ## Setup -- Production branch: `main`. Integration branch: `next`, the default target for day-to-day work. +- Production branch: `main`. Integration branch: `next`, the target for day-to-day work. +- GitHub's default branch is `main`, so `gh pr create` without `--base next` targets production. - Platform: GitHub, driven through `gh`. Tickets are GitHub Issues. - Pull request template: `.github/PULL_REQUEST_TEMPLATE.md`. - The release model — weekly promotion, hotfix path — is in [`RELEASE.md`](../../RELEASE.md); the tooling behind it is in `deployment.md`. @@ -28,14 +29,15 @@ The version-control conventions this project follows: branches, commits, and the - Everything batches on `next` and ships in the weekly release. **Only `hotfix/*` targets `main`.** - The issue type categorizes, it never routes, and the form stamps it — never set it by hand. Labels exist only where a bot or a human reads one (`.github/labels.yml`). -- The board does not advance on its own: `Todo → In review → Done` is moved by hand, by a human or an agent through `gh`. Board conventions are in `backlog.md`. +- The board does not advance on its own; it is moved by hand, by a human or an agent through `gh`. Board conventions are in `backlog.md`. +- Automation owns `promote/*` and `back-merge/*`, which follow neither the format nor the table. ## Commits - Convention: [Conventional Commits](https://www.conventionalcommits.org/), enforced by `commitlint.config.cjs`. **Read that file before composing a message; if this page and the config disagree, the config wins.** -- Format: `type(scope): description`, description in the imperative, lowercase, no trailing period, 72 characters max. +- Format: `type(scope): description`, description in the imperative, lowercase, no trailing period. The enforced header limit is 100. - Scope is optional and must be kebab-case. The encouraged list lives in `scope-enum` at warning level, so an unknown scope warns without blocking. Introduce a new one only when none fits. -- `framework` and `marketplace` are the scopes that bump `marketplace.json` through release-please. +- The scope never routes a release: release-please attributes by changed path. - A breaking change goes in the footer as `BREAKING CHANGE: …`. ## Commit Strategy diff --git a/aidd_docs/product/cost-report-contract.md b/aidd_docs/product/cost-report-contract.md index bbe712466..52b8bf629 100644 --- a/aidd_docs/product/cost-report-contract.md +++ b/aidd_docs/product/cost-report-contract.md @@ -70,9 +70,9 @@ pasteable artefact instead of the whole object — a convenience for copying one not a second way to group. Every figure `--axis` can show is already in the plain `--json` object; only the one-artefact-at-a-time rendering is what it adds. A name outside the twelve is a usage error naming the valid list (`Error: Unknown axis 'bogus'. Expected one of: -total, day, step, model, task, backlog, flow, tool, project, person.`, exit `1`), not a -silently empty artefact. Given both flags at once, `--json` wins and `--axis` is ignored, never the -reverse. +total, day, step, model, agent, prompt, task, backlog, flow, tool, project, person.`, exit +`1`), not a silently empty artefact. Given both flags at once, `--json` wins and `--axis` is +ignored, never the reverse. **A filter matching nothing names itself**, in `empty_selection`, rather than the object quietly reporting the same shape a genuinely idle period would: @@ -330,9 +330,13 @@ interval last regardless of size, in `reason`'s own fixed order (`"no-journal"`, the remainder in the same order every time. `by_backlog` places its own named rows first, then the row for a known task declaring none, then the row for one whose declaration could not be read, then -every `reason` row in that same fixed order. `by_flow` carries no such tail: the row for -what fell in no flow interval sorts by size exactly like every named one, since there is -only ever one such row, never a reason to place last. `by_day` is the one exception: it is +every `reason` row in that same fixed order. `by_flow` carries no reason taxonomy the way +`by_task`'s and `by_backlog`'s own remainders do — a flow is read from the same sequence +either way, so there is only one fact to state about falling outside every one of them — +but its own single row for what fell in no flow interval is pinned last exactly like +theirs, never sorted by size with the named rows: that row is ordinarily the largest one +in a period, and sorting it in would lead the axis with its own remainder while every axis +beside it leads with its largest named row. `by_day` is the one exception: it is chronological, one row per day the period spans — a series read out of order is not a series, and a day nothing ran on is a row of zeros rather than an omitted day. @@ -353,23 +357,31 @@ project at all - never its own row. permit a request with no model, and that record gets its own row rather than vanishing from the breakdown while staying in `totals`. -### `by_task` — grouped by the closed interval a record falls in, never by a written file - -`by_task` groups by exactly the same declared intervals `--task` already filters on (see -"Attributing records to a task"), and by nothing else: a record's own moment either falls -inside one session's closed interval, or it does not. It never consults a written path the -way the `--task` filter's own "inferred" route does, because that route decides for a whole -session at once and could place one session's records under two task rows at -once — the opposite of what a breakdown promises. A record's session is closed, sequential -intervals never overlap (see "Attributing records to a task"), so at most one interval -ever matches, and a record lands in exactly one row. - -`attribution` is present, and always `"declared"`, on every row that carries a `task` — -travelling with the row rather than assumed, so a consumer never has to know which route a -breakdown reads. A row for what fell in no declared interval carries `reason` instead of -`task` and `attribution`, naming which distinct fact applies — never one label standing in -for all of them, and never more than one row per reason. The first names a fact about the -read, the rest facts about the work: +### `by_task` — grouped by the closed interval a record falls in, or a narrower inferred route of its own + +`by_task` groups first by exactly the same declared intervals `--task` already filters on +(see "Attributing records to a task"): a record's own moment either falls inside one +session's closed interval, or it does not. A record's session is closed, sequential +intervals never overlap (see "Attributing records to a task"), so at most one declared +interval ever matches. + +Since `cost_report_version` `12`, a record no declared interval covers can still land under +a task through a second, narrower route of its own — never the `--task` filter's own +"inferred" route wholesale, because that route decides for a whole session at once and, for +a session that wrote into two task folders, would place the same undeclared record under +both — the opposite of what a breakdown promises, which is a partition. `by_task`'s own +route only fires when the session wrote into exactly **one** task folder and the record's +own moment falls inside what that session's journal actually witnessed; a record from a +session that touched two task folders, or one outside the witnessed window, gets no +task and a `reason` row instead. Bounded this way, one task can hold two rows — one per +route — but a record is never placed under two different tasks. + +`attribution` is present on every row that carries a `task`, naming which route placed it +there — travelling with the row rather than assumed, so a consumer never has to know which +route a breakdown reads. A row for what fell in no declared interval and no inferred one +carries `reason` instead of `task` and `attribution`, naming which distinct fact applies — +never one label standing in for all of them, and never more than one row per reason. The +first names a fact about the read, the rest facts about the work: `attribution` says which route named the task, and is present only on a row that names one: @@ -397,12 +409,16 @@ the whole object. `by_task` sums to `totals.requests` exactly like every other breakdown. **Alongside a `--task` filter, a row carrying `reason` can still appear, and is not a -contradiction of the header naming that task.** `--task` also keeps a session's records -through its own "inferred" route - the whole-session written-file fallback - for a record -no declared interval covers. `by_task` does not read that route at all, so that same -record lands in whichever `reason` row applies. Read the row as it is named: no *declared -interval* covers this record, not "this session never touched a task." Cross-check against -`task_attribution`'s own `declared`/`inferred` split when the distinction matters. +contradiction of the header naming that task.** `--task` keeps a session's records through +its own "inferred" route for a record no declared interval covers — whole-session and +unbounded by time, so a session that wrote into that task's folder at any point keeps every +one of its otherwise-undeclared records, whichever task is being filtered for. `by_task`'s +own inferred route is narrower — bounded to a session that wrote into exactly **one** task +folder and to a record inside what that session's journal actually witnessed — so a record +`--task` would keep can still land in a `reason` row here. Read the row as it is named: no +*declared interval, and no bounded inferred route,* covers this record — not "this session +never touched a task." Cross-check against `task_attribution`'s own `declared`/`inferred` +split when the broader, whole-session reading is what is wanted. ### `by_backlog` — grouped by what each task's own folder declares @@ -565,11 +581,12 @@ empty, which is a different fact from the tool never producing this figure at al ### Attribution -`attribution` always has exactly three rows, in this order: +`attribution` always has exactly four rows, in this order: | `attribution` | Means | | --- | --- | | `tool-stated` | The tool named the running skill itself, on the line with the counters. Exact. | +| `prompt-matched` | The record's own prompt opened a step, and both sides name the same one — an identifier two sources agree on, stronger than an inference from moments and the one reading that stays true when two tasks advance at once. | | `journal-interval` | Derived from the interval between two boundaries the framework recorded. An inference. | | `unattributed` | Neither source could say. | diff --git a/aidd_docs/tasks/2026_09/2026_09_04_memory-check/report.md b/aidd_docs/tasks/2026_09/2026_09_04_memory-check/report.md new file mode 100644 index 000000000..83b130b68 --- /dev/null +++ b/aidd_docs/tasks/2026_09/2026_09_04_memory-check/report.md @@ -0,0 +1,202 @@ +# Memory check — the repository + +The repository's own bank and public docs, read against the tree at commit `04348966`. Ten +memory files on disk, no gap, no orphan. Nothing was changed. + +The `cli/` bank was rewritten first and is current; its own report sits beside this one. + +## Findings + +### aidd_docs/memory/architecture.md + +| Finding | Evidence | +| --- | --- | +| 7 plugins, twice — the table and the diagram | `marketplace.json` lists 8; `release-please-config.json` versions 8 plugin paths | +| `kanban/` is bundled into the CLI from source | `tsconfig.include` is `src`/`tests`, the tsup entry is `src/cli.ts` alone, `cli/src/` never names kanban | +| `cli/` and `kanban/` are released here | `kanban/package.json` is `private: true` and has no release-please package | +| the taxonomy has no Observation layer | `docs/ARCHITECTURE.md`, which this page names as canonical, gives `aidd-telemetry` its own layer and its own rule | +| 8 plugins reads as 8 installable | `aidd-ui` is alpha and off the install path, `aidd-telemetry` beta and opt-in; the repo's own settings enable 6 | +| a plugin never holds its own tests | stated in full by `testing.md`; a parallel copy | +| bundled hooks run Node, so no node means no memory refresh | it also costs the run journal now: the telemetry plugin binds three more events | +| the `KanbanCommandDeps` injection point | a path inside the child's tree; the child's own page owns that decision | + +### aidd_docs/memory/codebase-map.md + +| Finding | Evidence | +| --- | --- | +| `kanban/` is bundled into the CLI, never published alone | the second half is true, the first is not | +| `aidd_docs/` is the bank plus task documents | it also holds `runs/`, `product/`, `specs/`, `recipes/`, `brainstorm/` | +| `.claude/` is absent from the diagram and the table | it registers this checkout as a local marketplace, which is how a contributor dogfoods | +| the plugin row omits the one universal file | every plugin carries `.claude-plugin/plugin.json`, the version release-please bumps | +| `docs/` is durable docs | `docs/prompts-documentation.md` is generated and staged on every commit | +| the memory refresh is the lifecycle entry | telemetry registers three more, plus an OpenCode-only module | + +### aidd_docs/memory/cli.md + +| Finding | Evidence | +| --- | --- | +| groups `ai`, `ide`, `status`, `restore`, `self-update`, `framework build`, `kanban` | none exists | +| the whole `telemetry` group is absent | 7 leaves, one carrying 4 more | +| `sync` and `translate` unnamed | both are top-level groups | +| `plugin` and `marketplace` verbs | names 5 of the 8 real ones, and `add` without `install` is what teaches the wrong command | +| Ink and React for the interactive views | those are kanban's dependencies | +| `kanban/` must resolve before any `cli` job | it resolves nowhere in `cli/` | +| enumerating commands at all | the child page refuses to, on purpose, and that refusal is what keeps it true | + +### aidd_docs/memory/project-brief.md + +| Finding | Evidence | +| --- | --- | +| `aidd plugin add`, `aidd ai`, `aidd ide` | `plugin add` errors; `ai` and `ide` are `setup` flags | +| `aidd framework build` | unknown command | +| `aidd kanban` | unknown command | +| `aidd-dev` has 8 skills, `aidd-pm` 6 | 11 and 10 | +| telemetry, `aidd-refine` and `aidd-ui` unnamed | all three ship | +| the five AI tools are listed, VS Code is not | it is a supported IDE target | +| no pointer to its child | every other paired page has one | + +### aidd_docs/memory/testing.md + +| Finding | Evidence | +| --- | --- | +| run the scripts suite as `node --test 'scripts/__tests__/*.test.js'` | the hook wraps it, because unwrapped it can write into this repository's own `.git/hooks` — and `.git` is in no history | +| `cli/` has three tiers | four | +| kanban shares the CLI's tiers | its `test` is a bare `vitest run`, and the two share no code at all | +| `cd cli && pnpm test` builds first | it does not, deliberately: a concurrent rebuild corrupted golden captures | +| `pnpm exec lefthook run pre-push` is what CI runs | CI runs the whole pre-commit over the whole tree, plus coverage, smoke, jscpd, Windows, kanban and an identifier-join probe | +| the `identifier-join` gate is named nowhere | it installs the real Claude Code and runs a root-owned probe | +| no way to run the kanban suite | and no lefthook glob covers it, so a kanban-only change fires no local gate | +| tools, fixtures and the substitution rule | all three restated from the child page this one defers to | + +### aidd_docs/memory/coding-assertions.md + +| Finding | Evidence | +| --- | --- | +| `cli knip:production` | the script is `knip` | +| the pre-commit list omits three commands | `context-reference-form`, `cli-architecture`, `cli-layering` all fire | +| lint and typecheck run when `cli/` or `kanban/` changed | both globs are `cli/**`; a kanban-only change fires neither | +| the hook regenerates catalogs and README counts | it also regenerates and stages `docs/prompts-documentation.md` | +| nothing says CI re-runs the whole hook over the whole tree | that is what makes it a requirement rather than a courtesy, and what still gates a `--no-verify` commit | + +### aidd_docs/memory/deployment.md + +| Finding | Evidence | +| --- | --- | +| `promote.yml` is the only manual entry | two more declare `workflow_dispatch` | +| commitlint runs on pull requests | it also lints main's tip, and the promote flow depends on that | +| `cli-ci.yml` runs typecheck, lint, test, build, knip | also arch, coverage, smoke, jscpd, kanban, Windows, identifier-join — and its filter covers the telemetry plugin and `scripts/__tests__/` | +| archives go to Releases and GitHub Packages | Packages receives only the npm package, best-effort; every archive goes to Releases | +| tags are shaped `-v` | there is also a root umbrella tag and `cli-v` | +| a release bumps the marketplace and each plugin | only paths with commits bump; the root bumps every cycle | +| `build-plugin` produces one archive per released path | the matrix lists 6, release-please releases 8 | +| the CLI is pinned for the per-tool build | true, and load-bearing: `framework build` no longer exists, so the pin cannot move | +| the release PR is auto-merged | it takes `--squash --admin` under the App token, because the branch policy refuses a plain merge | +| `star-history.yml` absent, and the umbrella-latest step absent | both exist, the second because releases are created unordered | + +### aidd_docs/memory/vcs.md + +| Finding | Evidence | +| --- | --- | +| `next` is the default target for day-to-day work | GitHub's default branch is `main`, so `gh pr create` with no `--base` targets `main` | +| `framework` and `marketplace` are the scopes that bump the marketplace | release-please attributes by changed path, never by scope | +| subject max 72 | the gate rejects at 101 | +| no `ci/` row in the routing table | the repo's own automation emits `ci:` commits and `ci/*` branches exist | +| the automation-owned prefixes are unnamed | `promote/*` targets `main`, `back-merge/*` targets `next`, and neither follows the format | + +### aidd_docs/memory/backlog.md + +| Finding | Evidence | +| --- | --- | +| task documents are read by `aidd kanban` | no such command | +| the board is Todo / In review / Done | the live board also offers Ideation and In Progress | +| priority is set by a community vote | `GOVERNANCE.md` calls the public reaction a signal, not a counted vote | +| a milestone closes once its issues are | an empty one is deliberately left open, and it runs on a Monday cron | +| `.github/labels.yml` is the source of truth | nothing syncs it; the file says drift is reconciled by hand | +| a Thursday due date | undecidable from the API alone — three of seven milestones read Friday in UTC | + +### aidd_docs/memory/ecosystem.md + +| Finding | Evidence | +| --- | --- | +| npm, Packages, release-please and Dependabot carry no owner file | all four are documented in `deployment.md` | +| Discord carries an owner annotation naming no file | no memory file documents it, and the edge already carries the access mode | +| `release created` reaches npm and Packages | the publish is gated on the released paths, never on a release event | +| the release-please edge carries a trigger, not a payload | what moves is the Release PR, then the tags | +| no `gh` node | it is how both actors reach GitHub and the board | +| no Discussions node | `backlog.md` makes it the authority for ideas | +| no host AI tools | nine per-tool distributions ship, and CI installs one of the hosts | +| no human edge to GitHub | a human starts the promotion from the Actions tab | +| the Packages edge omits that it is best-effort | `continue-on-error` | + +### Public docs + +| File | Finding | Evidence | +| --- | --- | --- | +| `docs/MAINTAINERS.md` | `aidd-cli framework build`, and "bump it deliberately" | the command no longer exists; following that sentence breaks the per-tool release job | +| `docs/MAINTAINERS.md` | 8 packages, root plus 7 plugins | 10 | +| `docs/MAINTAINERS.md` | the do-not-hand-edit list | omits the generated prompts document | +| `docs/MAINTAINERS.md` | a `GOVERNANCE.md` anchor | the heading carries no such suffix | +| `docs/ARCHITECTURE.md` | the memory hook is `update_memory.cjs` | it is `.js` | +| `docs/ARCHITECTURE.md` | a journal line is `session_start`, `turn_end` or `file_written` | three more record types ship, one of them the very thing the FAQ promises | +| `CONTRIBUTING.md` | two anchors into `vcs.md` | neither heading exists | +| `docs/FAQ.md` | two directories a person can delete | a third file survives, and the command that removes all three is never named | +| `README.md` | — | nothing drifted; its counts are hook-generated | + +## Notes + +- Two findings are executable traps, not stale prose. `docs/MAINTAINERS.md` instructs a + maintainer to bump a pin whose old command is the only one that works. `testing.md` gives + the unwrapped form of a suite the hook wraps precisely because it can destroy a hook + install nothing can restore. +- Two guards have the same shape of blind spot as the CLI's did. `scripts/check-markdown-links.js` + discards a `#fragment` before resolving, so no gate has ever looked at an anchor — five are + dead. And no gate reads the repository bank for dead paths at all: the CLI's + `referenced-paths` ratchet is scoped to `cli/`. +- `.github/workflows/ci.yml` installs kanban's dependencies before building the CLI, in the + job that publishes to npm, under a comment saying the CLI bundles kanban from source. Three + facts contradict it. The step is dead weight on the release path, and removing it is a + change to that path, so it is recorded rather than made. + +## Applied + +Every finding above is fixed in place. So are the four things this check found that the +review had not: + +| Was | Now | +| --- | --- | +| `check-markdown-links.js:239` discarded every `#fragment`, so five dead anchors passed CI for months | it resolves them against the target's headings, GitHub's slug rule, `#L119` line fragments excluded. The five are repaired; a sixth, `docs/MARKETPLACE.md`, was found by the gate itself | +| No gate read this bank for a path that names nothing | `scripts/check-referenced-paths.js`, wired into pre-commit. It found one: `testing.md` named `scripts/smoke-tools.sh`, which lives under `cli/` | +| `lefthook.yml` ran the scripts suite twice, `scripts-tests` wrapped and `scripts-test` bare | one job. It keeps the wrapper, the bare job's wider `{scripts,plugins}/**` glob and its count guard | +| `ci.yml` installed kanban's dependencies before publishing to npm, and built archives for six of the eight released plugins | both gone. `release-covers-every-plugin.test.js` now fails if the matrix and `marketplace.json` disagree | + +`AGENTS.md` was also staged carrying the `@` import form, which is the exact regression +`fix(framework)` #751 closed. The tools reading that file resolve a markdown link and not an +`@` line, so the memory block would have loaded nothing. Restored to its committed form. + +## Evidence + +Each guard was proved by the mutation it exists for, not by passing: + +- Re-pointing `CONTRIBUTING.md:49` back at `vcs.md#commit-convention` turns the link check red, + and only that link. +- Re-writing `testing.md:40` back to `scripts/smoke-tools.sh` turns the path check red. +- Staging a file under `plugins/aidd-dev/` fires `scripts-tests`, which the old narrow glob + would have skipped. +- The release matrix test was written against the six-plugin list and failed before the two + were added. + +The path gate reads files, never directories: a token with no extension is skipped, so +`plugins/` and `scripts/__tests__/` stay outside its reach. It also reads the whole tree on +every run - the hook's glob decides only whether it runs. + +One line of it was a shipping decision rather than a CI fix: `build-plugin` now attaches a +public archive for `aidd-ui`, whose own description reads "ALPHA, not ready for use". Raised +and kept — release-please already tags that plugin, and a tag with no archive is the odd +state, not the archive. + +`pnpm exec lefthook run pre-commit`, 12 jobs green. Links 0 broken in 734 files, anchors 0 +broken in 902, referenced paths 0 dead in 32. `cd cli && pnpm build` succeeds with +`kanban/node_modules` moved aside, and the bundle carries no kanban symbol. + +Not run this pass: `pre-push`, which is the `cli` suite. Nothing under `cli/src` or +`cli/tests` changed here. diff --git a/cli/.claude/rules/00-architecture/0-contexts.md b/cli/.claude/rules/00-architecture/0-contexts.md new file mode 100644 index 000000000..09d716079 --- /dev/null +++ b/cli/.claude/rules/00-architecture/0-contexts.md @@ -0,0 +1,40 @@ +--- +description: Apply when adding or moving a module; keeps the import chain one-way and the kernel free of business logic. +paths: + - "src/**/*.ts" +--- + +# Contexts + +Organise by bounded context, never by layer. + +## Chain + +- Allowed edges live in `tests/architecture/helpers.ts` (`ALLOWED`). +- Arrows run one way, toward the kernel. +- `presentation` and `runtime` depend on any context. +- A context depending on either is `BASELINE` debt; it only shrinks. +- `context-graph.arch.test.ts` fails any other edge. +- `biome-context-parity.arch.test.ts` holds `biome.json` to the same data. + +## Kernel + +- `kernel/` imports no context. +- Keep only shared vocabulary: types, pure helpers, typed errors. +- A port two contexts need lives in `kernel/ports/`. +- A domain decision is never kernel material. +- Measurement vocabulary sits in `kernel/measurement.ts`; `tools` never imports `telemetry`. + +## Interior + +- Import only a module listed in `PUBLIC_MODULES` (`context-boundary.arch.test.ts`). +- Everything else is internal. +- Never leave a context to re-enter it (`context-self-reentry.arch.test.ts`). + +## Adding + +- Pick the owning context first. +- A use case fitting nowhere means the contexts are wrong. +- Cross through the target's public module, in the allowed direction. +- Wrong direction: the caller sits in the wrong context. +- Placement is `aidd_docs/memory/codebase-map.md`, held by `codebase-map.arch.test.ts`. diff --git a/cli/.claude/rules/00-architecture/0-deps-wiring.md b/cli/.claude/rules/00-architecture/0-deps-wiring.md index cf8a1b63c..a3bb914e5 100644 --- a/cli/.claude/rules/00-architecture/0-deps-wiring.md +++ b/cli/.claude/rules/00-architecture/0-deps-wiring.md @@ -1,27 +1,31 @@ --- +description: Apply when a command or use case needs a collaborator; every dependency is built once, in the composition root. paths: - - "src/application/commands/**/*.ts" + - "src/presentation/commands/**/*.ts" - "src/cli.ts" - - "src/infrastructure/deps.ts" + - "src/runtime/wiring/**/*.ts" --- # Dependency Wiring ## Factories -- `createDeps` — full dep graph, command actions only -- `createMenuDeps` — `ManifestRepository` + `Prompter`, pre-parse only -- Never instantiate adapters directly in `cli.ts` +- `createDeps(projectRoot, options, output)` builds the full graph. +- Call it from a command action, never before `program.parse()`. +- `createMenuDeps(projectRoot)` serves the menu: a `ManifestRepository`, a `Prompter`. +- Extend that factory for a pre-parse need. +- Never instantiate an adapter in `cli.ts`. +- One wiring module per context under `runtime/wiring/`. +- `runtime/wiring/framework.ts` composes them. +- `cli.ts` builds two things: the version reader, the `CLIOutput`. -## Memoization +## Once -- `createDeps` is memoized by `projectRoot` -- `preAction` hook always first caller per project root -- Commands reuse cached instance, no extra I/O -- No second cache layer in command files +- `createDeps` memoizes on `projectRoot`. +- The `preAction` hook warms it for `process.cwd()`. +- `AIDD_SKIP_UPDATE_CHECK` short-circuits that hook. +- No second cache in a command file. -## cli.ts body rules +## Not an area -- `createMenuDeps` only before `program.parse()` -- Never call `createDeps` before `program.parse()` -- Extend `createMenuDeps` if pre-parse needs grow +- The composition root never counts as a caller (`0-shared-modules.md`). diff --git a/cli/.claude/rules/00-architecture/0-error-handling.md b/cli/.claude/rules/00-architecture/0-error-handling.md index 459f3c993..27590c251 100644 --- a/cli/.claude/rules/00-architecture/0-error-handling.md +++ b/cli/.claude/rules/00-architecture/0-error-handling.md @@ -1,12 +1,29 @@ --- +description: Apply when handling a failure; a typed exception travels inward and is caught once, at the command edge. paths: - "src/**/*.ts" --- -# Error Handling +# Errors -- Use-cases and adapters throw, no try/catch inside them -- Adapters translate raw errors to typed domain exceptions before throwing -- Adapters may try/catch only to convert third-party errors to typed exceptions -- Commands catch at action level only via `errorHandler.handle(error)` -- No silent errors, every failure surfaces to the user +## Flow + +- A use case throws. +- It never catches its own errors. +- It never returns a failure as a value. +- An adapter catches only to type an I/O error (`kernel/errors.ts`). +- Never `catch {}` into a default: unreadable is not absent. +- An empty catch is a listed best effort or a failure (`catches-that-swallow.arch.test.ts`). +- The command layer is the only catcher: `errorHandler.handle(error)`, exit 1. +- The update-check hook is the one deliberate swallow. + +## Catalog + +- `kernel/errors.ts` is the one catalog. +- Every class in it is thrown (`errors-that-are-thrown.arch.test.ts`, empty baseline). + +## Instructing + +- A message naming a command is a contract. +- `errors-that-instruct.arch.test.ts` checks each named command against the CLI. +- Descriptive prose is not pinned. diff --git a/cli/.claude/rules/00-architecture/0-folder-size.md b/cli/.claude/rules/00-architecture/0-folder-size.md new file mode 100644 index 000000000..82e4ca5c2 --- /dev/null +++ b/cli/.claude/rules/00-architecture/0-folder-size.md @@ -0,0 +1,13 @@ +--- +description: Apply when a directory grows; ten direct source files is the limit. +paths: + - "src/**/*.ts" +--- + +# Folder Size + +- Ten direct `.ts` files per directory (`folder-size.arch.test.ts`). +- Past that, helpers get written twice. +- Baseline an oversize directory with its count and reason. +- An entry leaves when the defect is fixed. +- Never shuffle files to satisfy a number. diff --git a/cli/.claude/rules/00-architecture/0-hexagonal.md b/cli/.claude/rules/00-architecture/0-hexagonal.md index 211b7cf5a..190da7ee1 100644 --- a/cli/.claude/rules/00-architecture/0-hexagonal.md +++ b/cli/.claude/rules/00-architecture/0-hexagonal.md @@ -1,48 +1,32 @@ --- +description: Apply inside a context; four layers, dependencies pointing inward, one adapter per port. paths: - - "src/**/*.ts" - - "tests/**/*.ts" + - "src/contexts/**/*.ts" + - "src/runtime/**/*.ts" --- -# Hexagonal Architecture +# Layers Inside a Context -## Layers - -- `domain/models/` — entities, value objects, discriminant types -- `domain/ports/` — interface contracts only, no implementations -- `domain/formats/` — pure string transforms (TOML, JSON, Markdown, placeholders) -- `domain/capabilities/` — capability classes (agents, commands, hooks, mcp, plugins, rules, settings, skills) -- `domain/tools/contracts.ts` — `AiTool`, `Has*` interfaces, `IdeToolConfig` -- `domain/tools/registry.ts` — tool registry, `ToolConfig` union, guards -- `domain/tools/ai/` — AI tool definitions (claude, cursor, copilot, opencode, codex) -- `domain/tools/ide/` — IDE tool definitions (vscode) -- `application/use-cases/` — orchestrators, sub-use-cases in subdirs (`install/`, `update/`, `sync/`, `auth/`, `shared/`) -- `application/commands/` — CLI wiring only -- `infrastructure/adapters/` — port implementations, all I/O - -## Dependency direction - -- Dependencies point inward: infrastructure → application → domain -- Domain never imports from application or infrastructure -- Application imports ports, not adapters - -## Ports & Adapters - -- Port: interface in `domain/ports/` -- Adapter: implementation in `infrastructure/adapters/` with `Adapter` suffix -- Inject adapters via constructor, typed as port interface +Which context owns a concept is `0-contexts.md`. -## Entry point - -- `cli.ts` wires commands only — no business logic -- `deps.ts` assembles the dependency graph - -## Type honesty - -- No type is widened through `as unknown as`, `as any`, `as never`, `@ts-expect-error` or - `@ts-ignore`, in `tests/` as much as in `src/` — `scripts/check-cli-layering.mjs` enforces - it over both trees, and lists in `CASTS_ALLOWED` the ones the type system cannot express - -## Exceptions +## Layers -- `CLIOutput` (Logger adapter) lives in `application/`, not `infrastructure/` +- `domain/`: entities, value objects, pure transforms, capabilities. No I/O. +- Validate an invariant at construction. +- `domain/ports/`: interfaces only. +- `application/`: one class per use case, orchestration only. +- Never branch on the targeted tool; read its capability class. +- `infrastructure/`: port implementations and every piece of I/O. +- Dependencies point inward; `biome.json` holds one `noRestrictedImports` override per layer. +- `biome.json`'s `noRestrictedGlobals` refuses `process` in `domain/` and `application/`; take a port. +- `import-rules-bite.arch.test.ts` fails a pattern naming a vanished directory. + +## Ports and adapters + +- One context: `domain/ports/`. Two: `kernel/ports/`. +- Name an adapter `*Adapter`, in `infrastructure/`; `runtime/` for a kernel port. +- An adapter does I/O and format translation only. +- Inject through the constructor, typed as the port. +- One adapter, one port; `FileAdapter` is recorded debt. +- A port declares no uncalled method (`ports-are-called.arch.test.ts`, empty baseline). +- `CLIOutput` implements the kernel `Logger` from `presentation/output.ts`. diff --git a/cli/.claude/rules/00-architecture/0-layer-responsibilities.md b/cli/.claude/rules/00-architecture/0-layer-responsibilities.md deleted file mode 100644 index 1a06f301f..000000000 --- a/cli/.claude/rules/00-architecture/0-layer-responsibilities.md +++ /dev/null @@ -1,38 +0,0 @@ ---- -paths: - - "src/**/*.ts" ---- - -# Layer Responsibilities - -## Use Case (`src/application/use-cases/`) - -- Orchestrate domain operations end-to-end -- Return a typed result object -- Throw on errors — never catch internally -- No tool-specific logic in use-cases -- Extend capability class for tool runtime behavior -- Methods ≤ 20 lines - -## Shared Use Cases (`src/application/use-cases/shared/`) - -- Only called from other use-cases -- Examples: `PostInstallPipelineUseCase` -- Same rules as top-level use-cases - -## Domain Model (`src/domain/models/`) - -- Entities, value objects, and pure domain functions -- Validate invariants in constructor or dedicated function -- No I/O, no infrastructure dependencies - -## Port (`src/domain/ports/`) - -- Interface contract only — no classes, no default implementations -- Define the boundary between application and infrastructure - -## Adapter (`src/infrastructure/adapters/`) - -- Implement exactly one port -- Translate I/O to/from domain types -- No business logic — I/O and format translation only diff --git a/cli/.claude/rules/00-architecture/0-orchestration.md b/cli/.claude/rules/00-architecture/0-orchestration.md new file mode 100644 index 000000000..073afaf7a --- /dev/null +++ b/cli/.claude/rules/00-architecture/0-orchestration.md @@ -0,0 +1,15 @@ +--- +description: Apply to a use case spanning several areas; one dependency per area crossed, four at most. +paths: + - "src/contexts/*/application/**/*.ts" +--- + +# Orchestration + +- Depend on an area's entry point. +- One dependency per area crossed. +- Four other use cases at most. +- `orchestrator-deps.arch.test.ts` counts injections and `new XUseCase(...)`, deduped by class. +- Keep an area's steps inside it. +- A second collaborator from one area means its entry point is missing. +- Baseline an oversize orchestrator with its count and reason. diff --git a/cli/.claude/rules/00-architecture/0-shared-modules.md b/cli/.claude/rules/00-architecture/0-shared-modules.md new file mode 100644 index 000000000..d6e9f8a1d --- /dev/null +++ b/cli/.claude/rules/00-architecture/0-shared-modules.md @@ -0,0 +1,18 @@ +--- +description: Apply before creating or moving into a shared/ directory; sharing is earned by two calling areas. +paths: + - "src/contexts/**/*.ts" +--- + +# Shared Modules + +- `shared/` needs callers in two areas. +- One caller: move it down. +- Never share for a caller not yet written. +- An area is `application//` or the application root. +- `runtime/wiring/` is never an area. +- Count first: `grep -rl src`; `earned-sharing.arch.test.ts` counts the same. +- A file nested under a shared module is its private step. +- A promoted module follows `0-hexagonal.md`. +- Crossing a context: declare the module public (`0-contexts.md`). +- Move to `kernel/` only once it stops being business logic. diff --git a/cli/.claude/rules/01-standards/.gitkeep b/cli/.claude/rules/01-standards/.gitkeep deleted file mode 100644 index e69de29bb..000000000 diff --git a/cli/.claude/rules/01-standards/1-comments.md b/cli/.claude/rules/01-standards/1-comments.md new file mode 100644 index 000000000..7ccdb07e7 --- /dev/null +++ b/cli/.claude/rules/01-standards/1-comments.md @@ -0,0 +1,17 @@ +--- +description: Apply when writing or keeping a comment; a comment says what the code cannot, and names no external reference. +paths: + - "src/**/*.ts" + - "tests/**/*.ts" + - "scripts/**" +--- + +# Comments + +- Comment only what code and names cannot say. +- Worth one: a constraint types miss, a measured host quirk, a refused simpler shape. +- Delete a comment that restates code or narrates history. +- Name no ticket, PR, commit, date, review or URL. +- Keep the fact and its consequence. +- One or two lines; a paragraph belongs in `aidd_docs/memory/`. +- `tests/architecture/comments.arch.test.ts` refuses external references and ratchets volume down. diff --git a/cli/.claude/rules/01-standards/1-conventions.md b/cli/.claude/rules/01-standards/1-conventions.md new file mode 100644 index 000000000..93553d0d3 --- /dev/null +++ b/cli/.claude/rules/01-standards/1-conventions.md @@ -0,0 +1,36 @@ +--- +description: Apply to every source and test file; module shape, naming and test tiers. +paths: + - "src/**/*.ts" + - "tests/**/*.ts" +--- + +# Conventions + +## Modules + +- Named exports only; biome's `noDefaultExport` holds it under `src/` and `tests/`. +- No barrel, no re-export: biome's `noBarrelFile`, `noReExportAll`, `noExportedImports`, plus `no-re-export.arch.test.ts` for `export { x } from` (`tests/helpers/ports/` exempt). +- Import from the defining module, relative, `.js` extension. +- `import type` for a type-only import. + +## Names + +- `kebab-case.ts` files; suffixes `-adapter.ts`, `-use-case.ts`. +- A port file is named for its interface: `file-merger.ts` for `FileMerger`. +- The ratchets read those suffixes; a misnamed file is invisible. +- `camelCase` values, `PascalCase` types, `CONSTANT_CASE` module constants. +- A leading underscore marks a parameter an interface forces. +- Name the intention: `applyFrameworkFile`, not `writeThenHash`. +- `executeInternal` means no concept was found; do not make it. + +## Tests + +- The extension declares the tier: `*.unit.test.ts`, `*.integration.test.ts`, `*.e2e.test.ts`. +- An `*.arch.test.ts` outside `tests/architecture/` runs nowhere. +- `tests/` mirrors `src/`. + +## Duplication + +- `pnpm jscpd` fails a copied block. +- Extract at the second caller (`0-shared-modules.md`). diff --git a/cli/.claude/rules/01-standards/1-exports.md b/cli/.claude/rules/01-standards/1-exports.md deleted file mode 100644 index 76b4a6f06..000000000 --- a/cli/.claude/rules/01-standards/1-exports.md +++ /dev/null @@ -1,11 +0,0 @@ ---- -paths: - - "src/**/*.ts" ---- - -# Exports - -- Named exports only — no `export default` -- No barrel files (`index.ts`) — import from the source file directly -- Use cases: export the class, never a plain `async function` -- Domain helpers: named function exports at module level diff --git a/cli/.claude/rules/01-standards/1-naming.md b/cli/.claude/rules/01-standards/1-naming.md deleted file mode 100644 index b42a15d77..000000000 --- a/cli/.claude/rules/01-standards/1-naming.md +++ /dev/null @@ -1,19 +0,0 @@ ---- -paths: - - "src/**/*.ts" ---- - -# File Naming - -## Source files - -- `kebab-case.ts` for all source files -- Adapter files: `*-adapter.ts` -- Use-case files: `*-use-case.ts` -- Port files: match the interface name (e.g. `file-system.ts` for `FileSystem`) - -## Test files - -- `*.unit.test.ts` — domain models, pure functions -- `*.integration.test.ts` — use-cases, adapters -- `*.e2e.test.ts` — full CLI invocation diff --git a/cli/.claude/rules/01-standards/1-type-honesty.md b/cli/.claude/rules/01-standards/1-type-honesty.md new file mode 100644 index 000000000..73567cccc --- /dev/null +++ b/cli/.claude/rules/01-standards/1-type-honesty.md @@ -0,0 +1,14 @@ +--- +description: Apply to every cast; a value is never widened away from the type it holds. +paths: + - "src/**/*.ts" + - "tests/**/*.ts" +--- + +# Type Honesty + +- No `as unknown as`, `as any`, `as never`. +- No `@ts-expect-error` or `@ts-ignore` in `src/`; biome's `noTsIgnore` refuses the latter everywhere. +- A test proving non-compilation may use `@ts-expect-error`. +- `scripts/check-cli-type-honesty.mjs` enforces both scopes, from the repository root. +- `CASTS_ALLOWED` lists each surviving cast with its reason. diff --git a/cli/.claude/rules/02-programming-languages/2-typescript.md b/cli/.claude/rules/02-programming-languages/2-typescript.md deleted file mode 100644 index e8377e94d..000000000 --- a/cli/.claude/rules/02-programming-languages/2-typescript.md +++ /dev/null @@ -1,31 +0,0 @@ ---- -paths: - - "src/**/*.ts" - - "tests/**/*.ts" ---- - -# TypeScript - -## Imports - -- Relative imports only, with `.js` extension (ESM) -- Use `import type` for type-only imports - -## Naming - -- `camelCase` — variables, functions, methods -- `PascalCase` — classes, interfaces, types -- `CONSTANT_CASE` — module-level constants -- `_param` — only when interface contract forces unused method param -- `readonly _field` — constructor-injected dep not used in body - -## Async - -- `async/await` only, no `.then()` chains -- All I/O operations are async - -## Types - -- No `any` — use explicit types or generics -- Prefer interfaces for contracts, types for unions/aliases -- `readonly` on arrays and maps that must not mutate diff --git a/cli/.claude/rules/03-frameworks-and-libraries/3-cli-lifecycle.md b/cli/.claude/rules/03-frameworks-and-libraries/3-cli-lifecycle.md deleted file mode 100644 index 2a38f97ae..000000000 --- a/cli/.claude/rules/03-frameworks-and-libraries/3-cli-lifecycle.md +++ /dev/null @@ -1,23 +0,0 @@ ---- -paths: - - "src/cli.ts" - - "src/application/commands/**/*.ts" ---- - -# CLI Process Lifecycle - -## Background work - -- A short-lived CLI process exits as soon as its action resolves -- Never fire-and-forget background I/O with in-process `unref()` / detached promise — the process exits before the fetch settles, the side-effect never runs, the feature is silently dead -- Allowed patterns for deferred network work: - - Piggyback: run it inside a command that is already paying for network I/O, awaited - - Bounded await: `await` it with a hard timeout on the hot path - - Detached child: spawn a separate process that outlives the parent -- Hot path stays read-only and offline — read cache, print, return; never block startup on the network - -## Validating side-effects - -- A unit test that `await`s a mocked fetch erases the real exit-before-settle race — it proves the function, not the feature -- For any feature whose value is an observable side-effect (file written, cache refreshed, request sent), assert it on the **real built binary** (`03-assert`), not only in unit tests -- Green gates (typecheck, unit, coverage) do not prove a lifecycle-dependent feature actually fires diff --git a/cli/.claude/rules/03-frameworks-and-libraries/3-cli-output.md b/cli/.claude/rules/03-frameworks-and-libraries/3-cli-output.md deleted file mode 100644 index 8c86f7d0e..000000000 --- a/cli/.claude/rules/03-frameworks-and-libraries/3-cli-output.md +++ /dev/null @@ -1,22 +0,0 @@ ---- -paths: - - "src/application/**/*.ts" ---- - -# CLI Output - -## Channels - -- `stdout` → nominal output (info, success, print) -- `stderr` → signals (debug, warn, error) -- `Logger` (domain port) ≠ `CLIOutput` (command layer) — never mix -- Conflicts and skips → `warn`, never `error` -- `exit(1)` only via `errorHandler.handle(error)` in catch blocks -- Final summary: one line - -## Contract - -- Zero logic : only routes messages by log level -- No `exit()` method : error handling belongs in `ErrorHandler` -- No helper methods (formatBytes, formatCounts, etc.) -- Any formatting/transformation belongs in use-cases or domain models diff --git a/cli/.claude/rules/03-frameworks-and-libraries/3-cli-process.md b/cli/.claude/rules/03-frameworks-and-libraries/3-cli-process.md new file mode 100644 index 000000000..837a33bc3 --- /dev/null +++ b/cli/.claude/rules/03-frameworks-and-libraries/3-cli-process.md @@ -0,0 +1,35 @@ +--- +description: Apply to the process, its channels and its exit codes; the CLI exits when the action resolves. +paths: + - "src/cli.ts" + - "src/presentation/**/*.ts" +--- + +# The CLI Process + +## Exit + +- Never fire and forget: `unref()` and floating promises die with the process. +- Deferred work rides an online command (`ONLINE_COMMAND_PATHS` in `cli.ts`), awaits behind a timeout, or runs detached. +- The hot path is offline: read cache, print, return. +- Assert a side effect against the built binary (e2e or `pnpm smoke`). + +## Channels + +- `stdout`: info, success, results. +- `stderr`: debug, warnings, errors. +- A conflict or skip is `warn`, never `error`. +- One-line final summary. + +## Formatting + +- `CLIOutput` routes by level, nothing else. +- Rendering: `presentation/display/`. Deciding: the use case. + +## Exit codes + +- `0`: did what was asked. +- `1`: thrown error, unhealthy `doctor`, guard needing a TTY. +- `errorHandler.handle` alone turns a domain failure into a code. +- A command may exit on a pre-use-case refusal: missing flag, non-interactive guard. +- Nothing under `contexts/`, `kernel/`, `runtime/` calls `process.exit` (`biome-plugins/no-process-exit.grit`). diff --git a/cli/.claude/rules/04-tooling/4-biome.md b/cli/.claude/rules/04-tooling/4-biome.md deleted file mode 100644 index 262b11685..000000000 --- a/cli/.claude/rules/04-tooling/4-biome.md +++ /dev/null @@ -1,11 +0,0 @@ ---- -paths: - - "src/**/*.ts" - - "tests/**/*.ts" ---- - -# Biome - -- Sole linter and formatter — no ESLint, no Prettier -- Config: `config/biome.json` -- Fix: `biome check --write` diff --git a/cli/.claude/rules/04-tooling/4-git-hooks.md b/cli/.claude/rules/04-tooling/4-git-hooks.md deleted file mode 100644 index ac324e6ee..000000000 --- a/cli/.claude/rules/04-tooling/4-git-hooks.md +++ /dev/null @@ -1,11 +0,0 @@ ---- -paths: - - "lefthook.yml" ---- - -# Git Hooks (lefthook) - -- `pre-commit` — biome + typecheck -- `pre-push` — knip + tests -- `commit-msg` — Conventional Commits (commitlint) -- Never bypass with `--no-verify` diff --git a/cli/.claude/rules/06-design-patterns/6-method-size.md b/cli/.claude/rules/06-design-patterns/6-method-size.md deleted file mode 100644 index 061739052..000000000 --- a/cli/.claude/rules/06-design-patterns/6-method-size.md +++ /dev/null @@ -1,20 +0,0 @@ ---- -paths: - - "src/application/use-cases/**/*.ts" - - "src/domain/**/*.ts" ---- - -# Method Size Limit - -## Rules - -- Hard limit: ≤ 20 lines per method (public or private) -- Code lines count; blank lines and comment-only lines excluded -- Extracted method name describes intent, not mechanics - -## Anti-patterns - -- `executeInternal()` — splits execute() without naming a concept -- `handleXxxWithLongBody()` — names mechanics, not intent -- Bad: `writeThenHash()` → Good: `applyFrameworkFile()` -- Bad: `loopOverAddedEntries()` → Good: `installAddedFiles()` diff --git a/cli/.claude/rules/07-quality/7-auth.md b/cli/.claude/rules/07-quality/7-auth.md index 265b405fe..02ed2a7ef 100644 --- a/cli/.claude/rules/07-quality/7-auth.md +++ b/cli/.claude/rules/07-quality/7-auth.md @@ -1,26 +1,28 @@ --- +description: Apply when a token is read, stored or checked; one GitHub token, resolved once, never checked eagerly. paths: - - "src/infrastructure/auth/**/*.ts" - - "src/application/use-cases/**/*.ts" + - "src/runtime/auth/**/*.ts" + - "src/contexts/*/application/**/*.ts" --- # Auth -## Token resolution priority +## Resolution -1. `AIDD_TOKEN` env var -2. Project `.aidd/auth.json` -3. User `~/.config/aidd/auth.json` -4. `gh auth token` — only when stored config uses `method: "gh"` +- `AuthReaderAdapter.resolve()`: `AIDD_TOKEN`, project `.aidd/auth.json`, user `auth.json`. +- First hit wins; memoized, so `gh` spawns at most once. +- `method: "stored"` holds the token. +- `method: "external"` runs `gh auth token` at read time, at its own level. +- No `"gh"` method exists. ## Storage -- Credentials stored with `chmod 600` -- Two levels: `"project"` (`.aidd/`) and `"user"` (`~/.config/aidd/`) -- Auth validated via GitHub API — token presence alone is not sufficient +- Write a credential `0600` on POSIX, `icacls /inheritance:r` on win32. +- Throw when the restriction fails. -## Auth entry point +## Not checked -- `RequireAuthUseCase` — single source of auth validation -- Never duplicate auth checks across commands or use-cases -- Auth for local framework paths is never required +- No command refuses for a missing token. +- Authorization surfaces as `CatalogFetchAuthError` on a 401. +- A local framework path needs no token. +- Full contract: `aidd_docs/memory/auth.md`. diff --git a/cli/.claude/rules/07-quality/7-clean-code.md b/cli/.claude/rules/07-quality/7-clean-code.md deleted file mode 100644 index 001dda241..000000000 --- a/cli/.claude/rules/07-quality/7-clean-code.md +++ /dev/null @@ -1,45 +0,0 @@ ---- -paths: - - "src/**/*.ts" - - "tests/**/*.ts" ---- - -# Clean Code - -## YAGNI - -- No stub methods for future milestones -- Unimplemented method lives in the ticket that implements it -- Remove placeholder `throw new Error("not yet implemented")` methods immediately - -## Dead code - -- Remove unused function parameters immediately -- `_param` prefix only when interface contract requires it -- No commented-out code - -## DRY - -- Extract private helper when ≥2 callers share identical logic -- Class methods use own fields, not their literal values - -## Magic values - -- Named constant for any string or number literal used more than once -- Use `this.field` instead of hardcoding the field's value inline - -## Fail fast - -- Guard clauses first: `if (!condition) return` or `throw` -- No nested conditionals — flatten with early returns - -## KISS - -- Simplest solution that satisfies the requirement -- No clever tricks - -## Single responsibility - -- One reason to change per function, class, and file -- Extract private methods for each distinct operation -- If it needs a comment to explain "what", split it diff --git a/cli/.claude/skills/adapter/SKILL.md b/cli/.claude/skills/adapter/SKILL.md deleted file mode 100644 index aff6e5e34..000000000 --- a/cli/.claude/skills/adapter/SKILL.md +++ /dev/null @@ -1,50 +0,0 @@ ---- -name: adapter -description: > - Creates or modifies infrastructure adapters in src/infrastructure/adapters/ and their - corresponding port interfaces in src/domain/ports/. Use when adding a new I/O boundary - (file system, HTTP, git, npm, OS), changing how an existing adapter translates errors, or - wiring a new adapter into createDeps. Do NOT use for business orchestration — use `use-case` - instead. Do NOT use for creating domain types — use `domain-model` instead. ---- - -# Adapter - -Builds the I/O translation layer: port interfaces that describe what the application needs, and -adapter classes that fulfill those contracts by talking to the real world (filesystem, git, HTTP, -npm, OS). Adapters own all technical constants; domain errors never cross the port boundary raw. - -## Available actions - -| # | Action | Role | Input | -| --- | ------------------ | ------------------------------------------------- | --------------------------------------- | -| 01 | `define-port` | Write the port interface in src/domain/ports/ | port name + method list | -| 02 | `implement-adapter` | Write the *Adapter class implementing the port | port interface from 01 | -| 03 | `wire-deps` | Register the adapter in createDeps / createMenuDeps | adapter class from 02 | -| 04 | `test` | Write infrastructure integration tests | completed adapter from 02 | - -## Default flow - -`01 → 02 → 03 → 04` - -Skip 01 when the port already exists; start at 02. - -## Transversal rules - -- Adapter class name ends in `Adapter`, implements exactly one port interface. -- Port: interface only, no classes, ≤5 methods, all I/O methods `async`, no `null` returns. -- No business logic in adapters — I/O and format translation only. -- Throw typed domain exceptions; never let raw third-party errors cross the port boundary. -- Never instantiate adapters directly in commands or `cli.ts` — all wiring via `createDeps`. -- Named export only. -- File name is `-adapter.ts`. - -## References - -- `references/adapter-rules.md` — adapter class conventions and technical-constants ownership -- `references/port-design.md` — port interface contract: ≤5 methods, async, no null, intent naming - -## Invariant rules - -- `references/adapter-rules.md` — authoritative adapter rules -- `references/port-design.md` — authoritative port design rules diff --git a/cli/.claude/skills/adapter/actions/01-define-port.md b/cli/.claude/skills/adapter/actions/01-define-port.md deleted file mode 100644 index 5dffd0c72..000000000 --- a/cli/.claude/skills/adapter/actions/01-define-port.md +++ /dev/null @@ -1,36 +0,0 @@ -# 01 - Define Port - -Write the port interface in `src/domain/ports/` that describes what the application layer needs. - -## Inputs - -- `port-name` (required) - string, PascalCase name without suffix (e.g. `PluginFetcher`) -- `methods` (required) - list of method names and return types the application needs - -## Outputs - -```typescript -// src/domain/ports/widget-fetcher.ts -export interface WidgetFetcher { - fetch(widgetId: string, options?: WidgetFetchOptions): Promise; - list(filter: WidgetFilter): Promise; -} - -export interface WidgetFetchOptions { - forceRefresh?: boolean; -} -``` - -## Process - -1. Create `src/domain/ports/.ts`. The file name matches the interface name: `WidgetFetcher` → `widget-fetcher.ts`. -2. Declare only an `interface` — no classes, no default implementations. -3. Apply ≤5 methods per port per `references/port-design.md`. If more are needed, split into two focused interfaces. -4. All I/O methods must be `async` and return `Promise`. Never `T | null` in return types — adapters resolve null internally. -5. Name methods using domain vocabulary (intent over mechanism): `install`, `register`, `fetch` — not `resolve`, `parse`, `build`. -6. Hide implementation details: no OS-level strings, hook names, or runtime identifiers in the port signature. -7. No imports from `application/` or `infrastructure/`. - -## Test - -Run `pnpm typecheck` — exits 0 confirms the port compiles and has no import-cycle violations. diff --git a/cli/.claude/skills/adapter/actions/02-implement-adapter.md b/cli/.claude/skills/adapter/actions/02-implement-adapter.md deleted file mode 100644 index 68d2b473a..000000000 --- a/cli/.claude/skills/adapter/actions/02-implement-adapter.md +++ /dev/null @@ -1,44 +0,0 @@ -# 02 - Implement Adapter - -Write the `*Adapter` class that fulfills the port interface, owns all technical constants, and translates third-party errors to typed domain exceptions. - -## Inputs - -- `adapter-name` (required) - string, PascalCase name with `Adapter` suffix (e.g. `PluginFetcherAdapter`) -- `port-interface` (required) - string, the port interface name from 01 - -## Outputs - -```typescript -// src/infrastructure/adapters/widget-fetcher-adapter.ts -const WIDGET_API_BASE = "https://api.example.com/v1"; - -export class WidgetFetcherAdapter implements WidgetFetcher { - constructor(private readonly http: HttpClient) {} - - async fetch(widgetId: string, options?: WidgetFetchOptions): Promise { - // ... I/O translation only, error wrapped to typed domain exception - } - - async list(filter: WidgetFilter): Promise { - // ... I/O translation only - } -} -``` - -## Depends on - -- `01-define-port` - -## Process - -1. Create `src/infrastructure/adapters/-adapter.ts`. Class name `Adapter implements `. -2. Inject all dependencies via constructor as `private readonly`, typed as port interfaces — never concrete types. -3. Own all technical constants at module level (`CONSTANT_CASE`): runtime names, OS paths, protocol strings, error-pattern regexes. None of these belong in the port or the use-case. -4. For each port method: translate I/O — no domain decisions, no business logic. -5. Wrap third-party errors in `try/catch` only to convert them to typed domain exceptions from `src/domain/errors.ts`. Never let raw errors cross the port boundary. -6. All methods (public or private) ≤20 lines — extract private helpers as needed per `.claude/rules/06-design-patterns/6-method-size.md`. - -## Test - -Run `pnpm typecheck` — exits 0 and `pnpm lint` exits 0 confirming the adapter fully satisfies the port interface. diff --git a/cli/.claude/skills/adapter/actions/03-wire-deps.md b/cli/.claude/skills/adapter/actions/03-wire-deps.md deleted file mode 100644 index 8fa379031..000000000 --- a/cli/.claude/skills/adapter/actions/03-wire-deps.md +++ /dev/null @@ -1,35 +0,0 @@ -# 03 - Wire Deps - -Register the new adapter in the dependency factory so commands can use it via `createDeps`. - -## Inputs - -- `adapter-class` (required) - string, the `*Adapter` class name from 02 -- `port-interface` (required) - string, the port interface the adapter implements - -## Outputs - -```typescript -// src/infrastructure/deps.ts (additions only) -import { WidgetFetcherAdapter } from "./adapters/widget-fetcher-adapter.js"; - -// Inside createDeps: -const widgetFetcher = new WidgetFetcherAdapter(http); -``` - -## Depends on - -- `02-implement-adapter` - -## Process - -1. Open `src/infrastructure/deps.ts`. -2. Add an `import` for the new adapter at the top (relative path with `.js`). -3. Instantiate the adapter inside `createDeps`, passing its port-typed dependencies — never concrete adapter types as constructor args. -4. Add the adapter instance to the returned deps object with a camelCase field name matching the port interface name. -5. If the adapter is only needed pre-parse (manifest resolution, prompter), add it to `createMenuDeps` instead. Otherwise use `createDeps`. -6. Never add `new *Adapter()` calls in command files or `cli.ts` — see `.claude/rules/00-architecture/0-deps-wiring.md`. - -## Test - -Run `pnpm typecheck` — exits 0 confirms the new field type in the deps object matches the port interface exactly. diff --git a/cli/.claude/skills/adapter/actions/04-test.md b/cli/.claude/skills/adapter/actions/04-test.md deleted file mode 100644 index d0dcd2764..000000000 --- a/cli/.claude/skills/adapter/actions/04-test.md +++ /dev/null @@ -1,31 +0,0 @@ -# 04 - Test - -Write infrastructure integration tests for the adapter covering error translation, format transformation, and retry/fallback logic. - -## Inputs - -- `adapter-name` (required) - string, PascalCase name with `Adapter` suffix -- `adapter-file` (required) - string, path to the source file from 02 - -## Outputs - -``` -Test file: tests/infrastructure/adapters/-adapter.integration.test.ts -``` - -## Depends on - -- `03-wire-deps` - -## Process - -1. Create `tests/infrastructure/adapters/-adapter.integration.test.ts`. Use `*.integration.test.ts` suffix per `references/test-pyramid.md` in the `test` skill. -2. Use mock server responses or file fixtures — never real network, never real machine state outside temp directories. -3. Cover: error parsing (third-party error → typed domain exception), retry logic if present, format transformation not visible in E2E. -4. Name `it()` blocks as behavior sentences describing observable outcomes, not internal method calls. -5. Group tests with `describe('')` block — see memory `feedback_test_naming.md`. -6. One test file per adapter — do not mix adapter tests. - -## Test - -Run `pnpm test:integration` — exits 0 with all new `it()` blocks passing. diff --git a/cli/.claude/skills/adapter/evals/scenarios.json b/cli/.claude/skills/adapter/evals/scenarios.json deleted file mode 100644 index 7a1ae34d1..000000000 --- a/cli/.claude/skills/adapter/evals/scenarios.json +++ /dev/null @@ -1,8 +0,0 @@ -[ - { "prompt": "Create a port interface for fetching plugins from GitHub", "expect_action": "define-port" }, - { "prompt": "Implement the GhCliAdapter for the TokenProvider port", "expect_action": "implement-adapter" }, - { "prompt": "Register the new PluginFetcherAdapter in createDeps", "expect_action": "wire-deps" }, - { "prompt": "Write integration tests for the FileAdapter error handling", "expect_action": "test" }, - { "prompt": "Add a use-case that orchestrates plugin installation", "expect_action": null }, - { "prompt": "Add a value object for plugin source with kind discriminant", "expect_action": null } -] diff --git a/cli/.claude/skills/adapter/references/adapter-rules.md b/cli/.claude/skills/adapter/references/adapter-rules.md deleted file mode 100644 index 46651edc4..000000000 --- a/cli/.claude/skills/adapter/references/adapter-rules.md +++ /dev/null @@ -1,52 +0,0 @@ -# Reference: Adapter Rules - -## Class shape - -- Class with `*Adapter` suffix -- Implements exactly one port interface -- No business logic — I/O and format translation only -- All dependencies injected via constructor as `private readonly`, typed as port interfaces - -## Technical constants ownership - -Adapters own ALL technical constants for their integration domain: -- Runtime names (hook identifiers, OS-level strings) -- System file paths (config file locations, lockfile names) -- Protocol details (API base URLs, endpoint patterns) -- Error-pattern regexes for classifying third-party failures - -None of these belong in ports, use-cases, or domain models. - -## Error translation - -- `try/catch` is allowed only to convert third-party errors to typed domain exceptions -- Never let raw errors (Node.js system errors, HTTP errors, git errors) cross the port boundary -- Import typed exceptions from `src/domain/errors.ts` -- Example: `throw new PluginFetchError(\`git clone failed: ${scrubCredentials(msg)}\`)` - -## File naming - -- `-adapter.ts` — e.g. `plugin-fetcher-adapter.ts` -- One adapter per file; one port per adapter - -## Agnostic shape example - -```typescript -const WIDGET_API_BASE = "https://api.example.com/v1"; -const WIDGET_NOT_FOUND_RE = /404 Not Found/; - -export class WidgetFetcherAdapter implements WidgetFetcher { - constructor(private readonly http: HttpClient) {} - - async fetch(widgetId: string, options?: WidgetFetchOptions): Promise { - try { - return await this.http.get(`${WIDGET_API_BASE}/widgets/${widgetId}`); - } catch (err) { - if (WIDGET_NOT_FOUND_RE.test(String(err))) { - throw new WidgetNotFoundError(widgetId); - } - throw new WidgetFetchError(`fetch failed: ${String(err)}`); - } - } -} -``` diff --git a/cli/.claude/skills/adapter/references/port-design.md b/cli/.claude/skills/adapter/references/port-design.md deleted file mode 100644 index 297f02135..000000000 --- a/cli/.claude/skills/adapter/references/port-design.md +++ /dev/null @@ -1,38 +0,0 @@ -# Reference: Port Design - -## Interface contract - -- Interface only — no classes, no implementations -- Single responsibility — ≤5 methods per port -- All I/O methods are `async` and return `Promise` -- No `null` in return types — adapters resolve null internally -- No `I` prefix — file location signals the role - -## Intent over mechanism - -- Method names describe what the caller wants, not how it's done -- Use domain vocabulary: `install`, `register`, `sync`, `fetch` — not `resolve`, `parse`, `build`, `compute` - -## Hide adapter internals - -- Implementation details (hook names, runtime strings, system paths) stay in the adapter -- Port signature must not leak the adapter's internal structure - -## Exception to ≤5 methods rule - -`FileWriter` (6 methods) — documented pragmatic exception for the project's file-system port. All other ports must respect ≤5. - -## Genuine-absence ports (null allowed) - -A port may return `T | null` only when "not found" is a normal, expected domain state (not an error). These are documented exceptions to the no-null rule: - -- `ManifestRepository.load()` — `null` means no manifest exists yet (uninitialized project) -- `PluginCatalogRepository.load()` — `null` means framework has no plugin catalog -- `LatestReleaseResolver.resolveLatest()` — `null` means no release found (pre-release/empty repo) -- `TokenProvider.resolve()` — `null` means no token available (unauthenticated state) - -For all other ports, adapters must convert "not found" to a typed state or empty collection. - -## Canonical location - -`src/domain/ports/.ts` — e.g. `plugin-fetcher.ts` for `PluginFetcher` diff --git a/cli/.claude/skills/audit-remediate/SKILL.md b/cli/.claude/skills/audit-remediate/SKILL.md deleted file mode 100644 index 901b1964f..000000000 --- a/cli/.claude/skills/audit-remediate/SKILL.md +++ /dev/null @@ -1,83 +0,0 @@ ---- -name: audit-remediate -description: > - Macro workflow for auditing a single domain layer against its authoritative layer skill, - applying fixes, and gating the result. Use when you need to prove a layer skill on real - code, clean up an existing layer after a skill update, or verify that a layer is already - compliant. Always captures a golden baseline before touching any file and rolls back - automatically if any gate fails. Do NOT use for adding new features — use `feature` - instead. Do NOT use for changes that touch multiple layers at once — run this macro once - per layer. ---- - -# Audit-Remediate - -Executes the audit → apply-layer-skill → gate → rollback loop for a single target layer. -Each step delegates entirely to the relevant action or layer skill. The macro never inlines -layer-specific rules — it routes to the authoritative layer skill for all judgements about -what is correct or incorrect. - -## Available actions - -| # | Action | Role | Input | -| --- | ----------------------------- | ---------------------------------------------------------------------- | -------------------------------------------------- | -| 01 | `capture-golden-baseline` | Record the current passing state as the immutable reference point | target layer path + layer skill name | -| 02 | `audit-layer` | Enumerate all violations in the target layer per the layer skill | layer skill + target layer files | -| 03 | `apply-layer-skill` | Apply the layer skill to fix each violation; log fix-or-clean per file | violation list from 02 + layer skill | -| 04 | `gate-golden-and-tests` | Verify golden baseline is byte-identical and all tests pass | baseline from 01 + test suite | -| 05 | `verify-or-rollback` | Commit if gate passes; roll back to baseline if gate fails | gate result from 04 | - -## Default flow - -`01 → 02 → 03 → 04 → 05` - -Skip 03 when 02 finds zero violations (clean verdict) — document the skip explicitly: -"03 skipped — layer audited clean by \". - -## Layer skill routing - -Apply the correct layer skill in action 03 based on the target directory: - -| Target directory | Authoritative layer skill | -| ------------------------ | ------------------------- | -| `domain/formats/` | `format` | -| `domain/capabilities/` | `capability` | -| `domain/tools/ai/` | `tool` | -| `domain/models/` | `domain-model` | -| `application/use-cases/` | `use-case` | -| `infrastructure/adapters/` | `adapter` | -| `application/commands/` | `command` | - -If the target directory does not map to a known layer skill, stop and report the ambiguity -before proceeding to action 02. - -## Rollback protocol - -- If action 04 fails (gate red): invoke `git restore ` to discard all - uncommitted changes in the target layer, then append a failure entry to the task log. -- Never commit a red state. Never rename the tracking file to `.done.md` unless gate passes. -- A failed run is retried only with a meaningfully different approach; log the change. - -## Transversal rules - -- Each action delegates fully to its layer skill or sub-process. Do not inline layer rules here. -- The baseline captured in 01 is immutable — it is the ground truth for gate comparisons. -- Action 02 produces a named violation list; action 03 works through that list one item at a time. -- After action 03, the layer must have zero uncommitted behavior changes that cannot be traced - to a fix in the violation list. -- Log every fix AND every confirmed-clean verdict in the task tracking file — that log is the - proof the layer skill was exercised. -- Never skip 04 — the gate is mandatory even when 02 found no violations (clean run still - re-runs tests to confirm nothing drifted). - -## External data - -- `.claude/skills/format/SKILL.md` — layer skill for `domain/formats/` -- `.claude/skills/capability/SKILL.md` — layer skill for `domain/capabilities/` -- `.claude/skills/tool/SKILL.md` — layer skill for `domain/tools/ai/` -- `.claude/skills/domain-model/SKILL.md` — layer skill for `domain/models/` -- `.claude/skills/use-case/SKILL.md` — layer skill for `application/use-cases/` -- `.claude/skills/adapter/SKILL.md` — layer skill for `infrastructure/adapters/` -- `.claude/skills/command/SKILL.md` — layer skill for `application/commands/` -- `references/rollback-protocol.md` — rollback commands and safe-restore procedures -- `references/gate-criteria.md` — what constitutes a passing gate diff --git a/cli/.claude/skills/audit-remediate/actions/01-capture-golden-baseline.md b/cli/.claude/skills/audit-remediate/actions/01-capture-golden-baseline.md deleted file mode 100644 index 853ec1a64..000000000 --- a/cli/.claude/skills/audit-remediate/actions/01-capture-golden-baseline.md +++ /dev/null @@ -1,32 +0,0 @@ -# 01 - Capture Golden Baseline - -Record the current passing state as the immutable reference point before any file is touched. - -## Inputs - -- `target-layer-path` (required) - the directory being audited (e.g. `domain/formats/`) -- `layer-skill` (required) - the authoritative layer skill name (e.g. `format`) - -## Outputs - -- Confirmed passing test run (all tests green, build succeeds, typecheck exits 0) -- A noted baseline state that the gate in action 04 will compare against - -## Process - -1. Confirm the working tree is clean in the target layer: `git status `. - If the tree is dirty (uncommitted changes), stop and report — do not proceed with an - unclean baseline. Stash or commit any unrelated work first. -2. Run the full test suite and confirm it exits 0. Record the test file count and test count. -3. Run typecheck and confirm it exits 0. -4. Run the build and confirm it exits 0. -5. Record the baseline in the task log: - - Target layer path - - Layer skill name - - Test count at baseline - - Build status - -## Test - -All three commands (`typecheck`, `test`, `build`) exit 0 — this is the non-negotiable entry -condition. Do not proceed to action 02 if any command fails at baseline. diff --git a/cli/.claude/skills/audit-remediate/actions/02-audit-layer.md b/cli/.claude/skills/audit-remediate/actions/02-audit-layer.md deleted file mode 100644 index 209554168..000000000 --- a/cli/.claude/skills/audit-remediate/actions/02-audit-layer.md +++ /dev/null @@ -1,45 +0,0 @@ -# 02 - Audit Layer - -Enumerate all violations in the target layer by applying the layer skill's transversal rules -and invariant checks to every file. Produces a named violation list; does not touch any file. - -## Inputs - -- `target-layer-path` (required) - directory to audit -- `layer-skill` (required) - the authoritative layer skill (read its transversal rules) - -## Outputs - -A violation list. Each entry: -- File path (relative to project root) -- Violation type (from the layer skill's transversal rules) -- Description of the violation -- Proposed fix approach (how the layer skill resolves it) - -## Process - -1. Read the layer skill's SKILL.md. Extract its transversal rules and invariant rules sections. -2. For each file in `target-layer-path`: - a. Check each transversal rule against the file's content. - b. Record any violation with its file path, rule violated, and fix approach. -3. Produce a numbered violation list. If the list is empty, record a confirmed-clean verdict: - "02 complete — layer \ audited clean by \. No violations found." -4. Do not edit any file in this action. - -## Common check categories - -Consult the layer skill for the definitive list. Typical checks by layer: - -- `format`: named export only, no `any`, pure function (no I/O/side effects), lossless - round-trip inverse present, `.js` ESM imports, `CONSTANT_CASE` for repeated literals. -- `capability`: `Has*` interface in the tool contracts file, constructor accepts single params object, - all public fields `readonly`, throws `CapabilityConfigError` on invalid params, named export - only, no `any`, `in` operator for presence guard, `.js` imports. -- `tool`: `AiTool` type annotation, `signalDir` non-null and pointing to the correct dir, - `rewriteContent`/`reverseRewriteContent` are lossless inverses, `registerTool` at file bottom, - named export only, no `any`, `.js` imports. - -## Test - -The violation list is complete when every file in `target-layer-path` has been evaluated -against every transversal rule in the layer skill. Confirm file count matches `ls` output. diff --git a/cli/.claude/skills/audit-remediate/actions/03-apply-layer-skill.md b/cli/.claude/skills/audit-remediate/actions/03-apply-layer-skill.md deleted file mode 100644 index 7beb7fee9..000000000 --- a/cli/.claude/skills/audit-remediate/actions/03-apply-layer-skill.md +++ /dev/null @@ -1,42 +0,0 @@ -# 03 - Apply Layer Skill - -Apply the authoritative layer skill to each violation found in action 02. The layer skill is -the sole authority for what constitutes correct code in the target layer. - -## Inputs - -- `violation-list` (required) - numbered list from action 02 -- `layer-skill` (required) - the layer skill to apply (e.g. `format`, `capability`, `tool`) - -## Outputs - -For each item in the violation list: -- The fix applied, referencing the layer skill rule that mandated it -- OR a "confirmed-clean" verdict if re-inspection finds no violation - -## Process - -1. For each violation in the violation list (work item by item, never in bulk): - a. Re-read the relevant section of the layer skill's SKILL.md. - b. Apply the minimal fix that satisfies the rule. Do not refactor beyond the stated violation. - c. Confirm the fix compiles: run `pnpm typecheck` after each file edit. - d. Log the fix: "Fixed \: \ — resolved per \ transversal rule '\'." -2. If a violation cannot be fixed without changing observable behavior, stop and record: - "Skipped \: fix requires behavior change — escalate." -3. If the layer skill's rule is incorrect or incomplete for the real case: - a. Stop. Do not apply a wrong fix. - b. Fix the layer skill first (edit its SKILL.md or action file). - c. Rollback any partial changes to the target layer: `git restore `. - d. Retry from action 02 with the improved skill. - e. Log the skill improvement. - -## Behavior-preservation invariant - -Every change in this action must be behavior-preserving. Tests and the golden baseline -(captured in action 01) are the proof. If a fix causes a test to fail, it is not -behavior-preserving — rollback and re-approach. - -## Test - -Run `pnpm typecheck` after each file. Confirm the exit code is 0. Do not proceed to action 04 -until all per-file typechecks pass. diff --git a/cli/.claude/skills/audit-remediate/actions/04-gate-golden-and-tests.md b/cli/.claude/skills/audit-remediate/actions/04-gate-golden-and-tests.md deleted file mode 100644 index ea2395100..000000000 --- a/cli/.claude/skills/audit-remediate/actions/04-gate-golden-and-tests.md +++ /dev/null @@ -1,39 +0,0 @@ -# 04 - Gate Golden and Tests - -Verify that the baseline captured in action 01 is still fully satisfied: all tests pass, the -build succeeds, and typecheck exits 0. This is the mandatory quality gate before committing. - -## Inputs - -- `baseline` (required) - the recorded state from action 01 (test count, build status) - -## Outputs - -- PASS: all gate conditions met — proceed to action 05 (commit) -- FAIL: at least one condition not met — proceed to action 05 (rollback) - -## Process - -1. Run `pnpm typecheck`. Confirm exit 0. If not, record FAIL. -2. Run `pnpm test`. Confirm: - - Exit code 0 - - Test file count equals or exceeds the baseline count - - No previously passing test is now failing - If any condition fails, record FAIL with the exact error output. -3. Run `pnpm build`. Confirm exit 0. If not, record FAIL. -4. Confirm that all changes in the target layer are traceable to a violation fix logged in - action 03. No "bonus" edits, no accidental formatting-only changes that might diverge - behavior or snapshot output. -5. Record the gate result: PASS or FAIL with details. - -## Gate conditions (all must be true for PASS) - -- `pnpm typecheck` exits 0 -- `pnpm test` exits 0 and count >= baseline -- `pnpm build` exits 0 -- Every changed file has a corresponding violation-fix entry in the action 03 log - -## Test - -The gate is self-verifying: its output is the evidence. Record the exact exit codes and test -counts in the task tracking log. diff --git a/cli/.claude/skills/audit-remediate/actions/05-verify-or-rollback.md b/cli/.claude/skills/audit-remediate/actions/05-verify-or-rollback.md deleted file mode 100644 index 5d384d820..000000000 --- a/cli/.claude/skills/audit-remediate/actions/05-verify-or-rollback.md +++ /dev/null @@ -1,44 +0,0 @@ -# 05 - Verify or Rollback - -Commit the changes if the gate passed. Roll back to the baseline state if the gate failed. -In either case, append a log entry to the task tracking file. - -## Inputs - -- `gate-result` (required) - PASS or FAIL from action 04 -- `target-layer-path` (required) - the directory that was edited -- `phase-id` (required) - the phase identifier (e.g. "P2", "P3", "P4") -- `layer-skill` (required) - the layer skill used - -## Outputs - -- On PASS: a commit on the current branch, log entry in the tracking file -- On FAIL: a clean working tree (all edits reverted), log entry with failure reason - -## Process — PASS path - -1. Stage all changes in `target-layer-path`: `git add `. -2. Commit with a conventional message: - `refactor(domain): audit-remediate via ` -3. Log entry format: - "[PASS] \ \ — \ drove \ fix(es). Tests: \. Build: OK." - -## Process — FAIL path - -1. Restore all changes in `target-layer-path`: `git restore `. -2. Confirm working tree is clean: `git status ` shows no modifications. -3. Log entry format: - "[FAIL] \ \ — gate failed: \. Working tree restored. Next attempt: \." -4. Do not increment the phase checkbox. Retry with a different approach. - -## Confirmed-clean path (no violations found in action 02) - -If action 02 produced a clean verdict and action 03 was skipped: -1. No commit needed (no files changed). -2. Log entry format: - "[CLEAN] \ \ — \ confirmed clean (0 violations). Tests: \. Build: OK." - -## Test - -After PASS: `git log --oneline -1` shows the new commit. After FAIL: `git status` shows a -clean tree for the target layer path. diff --git a/cli/.claude/skills/audit-remediate/evals/scenarios.json b/cli/.claude/skills/audit-remediate/evals/scenarios.json deleted file mode 100644 index 5e2180633..000000000 --- a/cli/.claude/skills/audit-remediate/evals/scenarios.json +++ /dev/null @@ -1,10 +0,0 @@ -[ - { "prompt": "Audit and clean the domain/formats/ layer using the format skill", "expect_action": "capture-golden-baseline" }, - { "prompt": "Prove the capability skill on the domain/capabilities/ layer", "expect_action": "capture-golden-baseline" }, - { "prompt": "Run audit-remediate on domain/tools/ai/ to verify tool skill compliance", "expect_action": "capture-golden-baseline" }, - { "prompt": "Apply the use-case skill to application/use-cases/ to fix any violations", "expect_action": "capture-golden-baseline" }, - { "prompt": "The format skill was just updated — re-run it on domain/formats/ to clean up", "expect_action": "capture-golden-baseline" }, - { "prompt": "Write a new pure string transform for CSV in domain/formats/", "expect_action": null }, - { "prompt": "Add a new AgentsCapability with custom frontmatter conversion", "expect_action": null }, - { "prompt": "Fix a bug in the install use-case", "expect_action": null } -] diff --git a/cli/.claude/skills/audit-remediate/references/gate-criteria.md b/cli/.claude/skills/audit-remediate/references/gate-criteria.md deleted file mode 100644 index a9fe80856..000000000 --- a/cli/.claude/skills/audit-remediate/references/gate-criteria.md +++ /dev/null @@ -1,42 +0,0 @@ -# Gate Criteria - -A gate PASSES when all of the following are true simultaneously. A single failure collapses -the gate to FAIL — no partial passes. - -## Required conditions - -| Condition | Command | Required outcome | -| ---------------------------------- | ------------------------------- | ------------------------------------------ | -| TypeScript compilation | `pnpm typecheck` | Exit code 0 | -| Test suite | `pnpm test` | Exit code 0; count >= baseline | -| Build | `pnpm build` | Exit code 0 | -| Traceability | (manual inspection) | Every changed file has a logged fix entry | - -## Baseline comparison - -The baseline is the state captured in action 01 (before any file was touched): - -- **Test count**: `pnpm test` at gate time must report a test count >= the baseline count. - New tests added during remediation are fine. Missing tests are not. -- **Build size**: the build output size must stay within the project's configured budget - (typically displayed as "within budget" by the build tool). A size regression is not a gate - failure by itself, but record it in the log if it occurs. - -## Traceability requirement - -After action 03, list all files modified with `git diff --name-only`. Each file in that list -must have a corresponding entry in the action 03 log. Any file without a logged fix entry -represents an unintentional change — restore it with `git restore ` before running the gate. - -## Clean-run gate (no violations found) - -When action 02 produced a confirmed-clean verdict and action 03 was skipped, the gate still runs: -- `pnpm typecheck` exits 0 -- `pnpm test` exits 0 with count >= baseline -- `pnpm build` exits 0 -- `git diff --name-only` in the target layer is empty (no files changed) - -## Escalation - -If the gate fails and the root cause is unclear after one retry, append a blockers entry to -the task tracking file and stop. Do not continue iterating blindly. diff --git a/cli/.claude/skills/audit-remediate/references/rollback-protocol.md b/cli/.claude/skills/audit-remediate/references/rollback-protocol.md deleted file mode 100644 index 9caaa6861..000000000 --- a/cli/.claude/skills/audit-remediate/references/rollback-protocol.md +++ /dev/null @@ -1,61 +0,0 @@ -# Rollback Protocol - -Rollback procedures for the audit-remediate macro. All commands are scoped to the target -layer path to avoid disturbing unrelated uncommitted work. - -## Restore uncommitted changes in a specific directory - -```bash -git restore -``` - -Example: `git restore src/domain/formats/` - -Reverts all unstaged modifications in the given directory. Does not touch staged changes or -commits. Run `git status ` to confirm the directory is clean after. - -## Restore a specific file - -```bash -git restore -``` - -## Undo a committed phase (if gate was passed but a later check proves it wrong) - -```bash -git revert HEAD --no-edit -``` - -Creates a revert commit rather than destroying history. Use only for the immediately preceding -commit. Never force-push to shared branches. - -## Confirm the tree is clean - -```bash -git status -``` - -Expected output after rollback: no modified files listed under ``. - -## Staged changes - -If changes were staged with `git add` before the gate failed: - -```bash -git restore --staged -git restore -``` - -The first command unstages, the second reverts the working-tree edits. - -## When NOT to rollback - -- If the gate passes, do not rollback. Commit immediately. -- If the failure is in a file outside `target-layer-path`, do not rollback — investigate the - external dependency instead. - -## Invariants - -- Never `git reset --hard HEAD` on a branch others may be tracking. -- Never `git checkout .` (too broad — discards all unrelated work). -- Scope every restore to the exact paths modified in action 03. diff --git a/cli/.claude/skills/capability/SKILL.md b/cli/.claude/skills/capability/SKILL.md deleted file mode 100644 index d7797b1ca..000000000 --- a/cli/.claude/skills/capability/SKILL.md +++ /dev/null @@ -1,55 +0,0 @@ ---- -name: capability -description: > - Creates or modifies a capability class in domain/capabilities/ and its corresponding Has* - interface in domain/tools/contracts.ts. Use when adding a new tool runtime behavior (agents, - skills, commands, rules, mcp, hooks, settings, plugins), changing the constructor params of - an existing capability class, or wiring a new capability into an existing AiTool definition. - Do NOT use for AI tool definitions — use `tool` instead. Do NOT use for domain value objects - or discriminant unions — use `domain-model` instead. Do NOT use for pure string transforms - — use `format` instead. ---- - -# Capability - -Builds a capability class that encapsulates one tool runtime behavior and its corresponding -`Has*` interface in `domain/tools/contracts.ts`. Each capability class is instantiated in -exactly one `AiTool` file; the `Has*` interface declares the typed field in the `C` parameter. - -## Available actions - -| # | Action | Role | Input | -| --- | ------------------------ | ------------------------------------------------------------ | ------------------------------------------- | -| 01 | `define-has-interface` | Declare the Has* interface in domain/tools/contracts.ts | capability name + field type | -| 02 | `write-capability-class` | Write the capability class in domain/capabilities/ | Has* interface from 01 | -| 03 | `wire-into-tool` | Add the capability to an AiTool definition | capability class from 02 | -| 04 | `test` | Write unit tests covering constructor params and public API | completed capability from 02 | - -## Default flow - -`01 → 02 → 03 → 04` - -Skip 01 when the `Has*` interface already exists in `contracts.ts` and only the class needs updating. -Skip 03 when the new capability is not yet needed by any existing tool (e.g. adding it speculatively). - -## Transversal rules - -- `Has*` interface lives in `domain/tools/contracts.ts`; the field type is the capability class. -- Capability class file lives in `domain/capabilities/-capability.ts`; one class per file. -- Capability class name ends in `Capability` (e.g. `WidgetsCapability`). -- Constructor accepts a single params object; no positional arguments. -- All public fields are `readonly`; no setters. -- Throw `CapabilityConfigError` (from `domain/errors.ts`) on invalid constructor params. -- Capability presence guard uses the `in` operator: `"widgets" in tool.capabilities` — never `instanceof`. -- Named export only; no default export. -- No `any` types. -- `.js` extensions on all relative imports. - -## References - -- `references/capability-conventions.md` — class shape, constructor params object, CapabilityConfigError, readonly fields -- `references/has-interface.md` — Has* interface placement, naming, and capability-presence guard - -## Invariant rules - -- `references/capability-conventions.md` — authoritative capability class rules diff --git a/cli/.claude/skills/capability/actions/01-define-has-interface.md b/cli/.claude/skills/capability/actions/01-define-has-interface.md deleted file mode 100644 index b24dd57fa..000000000 --- a/cli/.claude/skills/capability/actions/01-define-has-interface.md +++ /dev/null @@ -1,32 +0,0 @@ -# 01 - Define Has Interface - -Add the `Has*` interface to `domain/tools/contracts.ts` so that `AiTool` definitions can -include the new capability field in their `C` intersection. - -## Inputs - -- `capability-name` (required) - string, PascalCase name without the `Capability` suffix (e.g. `Widgets`) -- `class-name` (required) - string, full class name including suffix (e.g. `WidgetsCapability`) - -## Outputs - -```typescript -// Addition in domain/tools/contracts.ts -import type { WidgetsCapability } from "../capabilities/widgets-capability.js"; - -export interface HasWidgets { - readonly widgets: WidgetsCapability; -} -``` - -## Process - -1. Open `domain/tools/contracts.ts`. -2. Add `import type { } from "../capabilities/-capability.js";` in alphabetical order among existing capability imports. -3. Add `export interface Has { readonly : ; }` in alphabetical order among the existing `Has*` interfaces. -4. The field name in the interface is the camelCase capability name (e.g. `HasWidgets` → field `widgets: WidgetsCapability`). -5. Do not add the capability class file yet — that is action 02. - -## Test - -Run `pnpm typecheck` — exits 0 confirms the new interface compiles and does not break any existing `Has*` intersection. diff --git a/cli/.claude/skills/capability/actions/02-write-capability-class.md b/cli/.claude/skills/capability/actions/02-write-capability-class.md deleted file mode 100644 index 430004c39..000000000 --- a/cli/.claude/skills/capability/actions/02-write-capability-class.md +++ /dev/null @@ -1,59 +0,0 @@ -# 02 - Write Capability Class - -Create the capability class file with its constructor params object, readonly public fields, -and any derived public methods the tool layer needs. - -## Inputs - -- `capability-name` (required) - string, PascalCase name including the `Capability` suffix (e.g. `WidgetsCapability`) -- `params` (required) - list of constructor parameter names and types -- `methods` (optional) - list of public method names and signatures needed by tool files - -## Outputs - -```typescript -// domain/capabilities/widgets-capability.ts -import { CapabilityConfigError } from "../errors.js"; - -const DEFAULT_WIDGET_DIR = ".widgets/"; - -export class WidgetsCapability { - readonly widgetsDir: string; - readonly maxWidgets: number; - - constructor(params: { - widgetsDir?: string; - maxWidgets: number; - }) { - if (params.maxWidgets <= 0) { - throw new CapabilityConfigError("WidgetsCapability: maxWidgets must be > 0"); - } - this.widgetsDir = params.widgetsDir ?? DEFAULT_WIDGET_DIR; - this.maxWidgets = params.maxWidgets; - } - - widgetOutputPath(widgetName: string): string { - return `${this.widgetsDir}${widgetName}/`; - } -} -``` - -## Depends on - -- `01-define-has-interface` - -## Process - -1. Create `domain/capabilities/-capability.ts`. Confirm it does not already exist. -2. Declare module-level constants in `CONSTANT_CASE` for any default values or repeated literals. -3. Export the class with the `Capability` suffix. No default export. -4. Constructor takes a single params object (never positional arguments). -5. For each optional param, provide a sensible default via the `??` operator or a module constant. -6. Validate required invariants in the constructor body; throw `CapabilityConfigError` (imported from `domain/errors.js`) on invalid input. -7. All public fields are `readonly`; assign them from the params object in the constructor. -8. Declare any derived public methods needed by tool files (≤20 lines each). -9. No imports from `application/` or `infrastructure/`. - -## Test - -Run `pnpm typecheck` — exits 0 and `pnpm lint` exits 0 confirming the class compiles, satisfies the `Has*` field type, and passes lint. diff --git a/cli/.claude/skills/capability/actions/03-wire-into-tool.md b/cli/.claude/skills/capability/actions/03-wire-into-tool.md deleted file mode 100644 index baf183a7b..000000000 --- a/cli/.claude/skills/capability/actions/03-wire-into-tool.md +++ /dev/null @@ -1,43 +0,0 @@ -# 03 - Wire Into Tool - -Add the new capability to an existing `AiTool` definition by updating its type parameter -and instantiating the class in the `capabilities` object. - -## Inputs - -- `tool-name` (required) - string, kebab-case name of the target tool (e.g. `acme`) -- `capability-class` (required) - string, full class name (e.g. `WidgetsCapability`) -- `has-interface` (required) - string, the `Has*` interface name (e.g. `HasWidgets`) - -## Depends on - -- `02-write-capability-class` - -## Outputs - -```typescript -// domain/tools/ai/acme.ts — diff -import { WidgetsCapability } from "../../capabilities/widgets-capability.js"; -import type { ..., HasWidgets } from "../contracts.js"; - -export const acme: AiTool = { - // ... - capabilities: { - agents: new AgentsCapability({ ... }), - skills: new SkillsCapability({ ... }), - widgets: new WidgetsCapability({ maxWidgets: 50 }), - }, -}; -``` - -## Process - -1. Open `domain/tools/ai/.ts`. -2. Add `import { } from "../../capabilities/-capability.js";` in alphabetical order. -3. Add `HasWidgets` (or the appropriate `Has*` name) to the `AiTool` type parameter intersection. -4. Add the new field to the `capabilities` object with `: new ({ ... })`. -5. Confirm the capability presence guard in any use-site that inspects capabilities uses the `in` operator: `"widgets" in tool.capabilities`. - -## Test - -Run `pnpm typecheck` — exits 0 confirms the tool's `C` intersection is satisfied and the new capability field is type-correct. diff --git a/cli/.claude/skills/capability/actions/04-test.md b/cli/.claude/skills/capability/actions/04-test.md deleted file mode 100644 index 26c86fed2..000000000 --- a/cli/.claude/skills/capability/actions/04-test.md +++ /dev/null @@ -1,40 +0,0 @@ -# 04 - Test - -Write unit tests for the capability class covering valid construction, invalid params, and -all public method behaviors. - -## Inputs - -- `capability-name` (required) - string, PascalCase class name (e.g. `WidgetsCapability`) -- `capability-file` (required) - string, path to the source file from 02 - -## Outputs - -``` -Test file: tests/domain/capabilities/-capability.unit.test.ts -``` - -## Depends on - -- `02-write-capability-class` - -## Process - -1. Create `tests/domain/capabilities/-capability.unit.test.ts`. Use `*.unit.test.ts` suffix — no I/O, no mocks, no filesystem. -2. Import only the class under test and `CapabilityConfigError` from `domain/errors.js`. -3. Cover valid construction: - - All required params provided → fields are assigned correctly. - - Optional param omitted → default value is used. - - Optional param provided → provided value overrides default. -4. Cover invalid construction: - - Each validation that throws `CapabilityConfigError` → confirm the error is thrown. -5. Cover each public method: - - Happy path returns the expected value. - - Edge case (empty string, zero, boundary value) returns expected value or throws expected error. -6. Name `it()` blocks as behavior sentences: "assigns the default widget dir when none is provided" not "calls constructor". -7. Group tests with `describe('WidgetsCapability')` block — see memory `feedback_test_naming.md`. -8. No mocks — capability classes are pure objects; call constructors and methods directly. - -## Test - -Run `pnpm test:unit` — exits 0 with all new `it()` blocks passing. diff --git a/cli/.claude/skills/capability/evals/scenarios.json b/cli/.claude/skills/capability/evals/scenarios.json deleted file mode 100644 index fcf8a5b3d..000000000 --- a/cli/.claude/skills/capability/evals/scenarios.json +++ /dev/null @@ -1,10 +0,0 @@ -[ - { "prompt": "Add a HasWidgets interface to contracts.ts for the new WidgetsCapability", "expect_action": "define-has-interface" }, - { "prompt": "Declare a Has* interface in contracts.ts for the new FooCapability", "expect_action": "define-has-interface" }, - { "prompt": "Create the WidgetsCapability class with a maxWidgets constructor param", "expect_action": "write-capability-class" }, - { "prompt": "Write the FooCapability class that encapsulates foo runtime behavior", "expect_action": "write-capability-class" }, - { "prompt": "Wire WidgetsCapability into the acme tool definition", "expect_action": "wire-into-tool" }, - { "prompt": "Write unit tests for the WidgetsCapability class", "expect_action": "test" }, - { "prompt": "Add a new AI tool definition for the acme assistant", "expect_action": null }, - { "prompt": "Add a pure serialize function for widget frontmatter", "expect_action": null } -] diff --git a/cli/.claude/skills/capability/references/capability-conventions.md b/cli/.claude/skills/capability/references/capability-conventions.md deleted file mode 100644 index 8652113f5..000000000 --- a/cli/.claude/skills/capability/references/capability-conventions.md +++ /dev/null @@ -1,69 +0,0 @@ -# Reference: Capability Conventions - -## Class shape - -```typescript -export class WidgetsCapability { - readonly widgetsDir: string; - readonly maxWidgets: number; - - constructor(params: { - widgetsDir?: string; // optional — has a default - maxWidgets: number; // required — no default - }) { - if (params.maxWidgets <= 0) { - throw new CapabilityConfigError("WidgetsCapability: maxWidgets must be > 0"); - } - this.widgetsDir = params.widgetsDir ?? DEFAULT_WIDGET_DIR; - this.maxWidgets = params.maxWidgets; - } -} -``` - -## Required invariants - -- Class name ends in `Capability`. -- Constructor takes exactly one params object — never positional arguments. -- All public fields are `readonly`. -- Optional params provide defaults via `??` or a module-level `CONSTANT_CASE` constant. -- Throw `CapabilityConfigError` (from `domain/errors.ts`) on any invalid param combination. -- No business logic — the class models configuration, not behavior decisions. -- No imports from `application/` or `infrastructure/`. - -## Module constants - -```typescript -const DEFAULT_WIDGET_DIR = ".widgets/"; -const MAX_WIDGET_LABEL_LENGTH = 128; -``` - -Place above the class definition. Use `CONSTANT_CASE`. Never inline literals used more than once. - -## File naming - -- One capability per file. -- File name: `-capability.ts` (e.g. `widgets-capability.ts`). -- Location: `domain/capabilities/`. - -## Public methods - -Capability classes may expose derived methods (path builders, resolvers). Each method must -be ≤20 lines and have no side effects. - -Example: -```typescript -widgetOutputPath(widgetName: string): string { - return `${this.widgetsDir}${widgetName}/`; -} -``` - -## CapabilityConfigError - -Import from `domain/errors.js`. Throw when constructor params violate a required invariant. -Message format: `": "`. - -```typescript -import { CapabilityConfigError } from "../errors.js"; -// ... -throw new CapabilityConfigError("WidgetsCapability: maxWidgets must be > 0"); -``` diff --git a/cli/.claude/skills/capability/references/has-interface.md b/cli/.claude/skills/capability/references/has-interface.md deleted file mode 100644 index 33f77f6e9..000000000 --- a/cli/.claude/skills/capability/references/has-interface.md +++ /dev/null @@ -1,60 +0,0 @@ -# Reference: Has* Interface - -## Location and placement - -All `Has*` interfaces live in `domain/tools/contracts.ts`. They are placed in alphabetical order -among the existing interfaces. The `Has*` interfaces make up the `C` type parameter of `AiTool`. - -## Naming rule - -- Interface name: `Has` (e.g. `HasWidgets`, `HasAgents`, `HasPlugins`). -- Field name: camelCase of the capability name (e.g. `HasWidgets` → `widgets`). -- Field type: the capability class (e.g. `WidgetsCapability`). - -## Shape - -```typescript -export interface HasWidgets { - readonly widgets: WidgetsCapability; -} -``` - -Always `readonly`. Never optional (`?:` is not allowed on `Has*` fields — a tool either has -the capability or does not include `Has` in its `C` intersection). - -## Import rule - -The capability class is imported with `import type` because it is used only as a type: - -```typescript -import type { WidgetsCapability } from "../capabilities/widgets-capability.js"; -``` - -## Capability presence guard - -At call sites that inspect a tool's capabilities, use the `in` operator: - -```typescript -if ("widgets" in tool.capabilities) { - // tool.capabilities.widgets is WidgetsCapability - const dir = tool.capabilities.widgets.widgetsDir; -} -``` - -Never use `instanceof`. The `in` check narrows the TypeScript type correctly when the -`Has*` interface is part of the `C` intersection. - -## Existing Has* interfaces (as of current contracts.ts) - -| Interface | Field | Capability class | -| ------------- | ---------- | ----------------------- | -| `HasAgents` | `agents` | `AgentsCapability` | -| `HasCommands` | `commands` | `CommandsCapability` | -| `HasHooks` | `hooks` | `HooksCapability` | -| `HasMcp` | `mcp` | `McpCapability` | -| `HasPlugins` | `plugins` | `PluginsCapability` | -| `HasRules` | `rules` | `RulesCapability` | -| `HasSettings` | `settings` | `SettingsCapability` | -| `HasSkills` | `skills` | `SkillsCapability` | - -New `Has*` interfaces are added in this alphabetical order. diff --git a/cli/.claude/skills/command/SKILL.md b/cli/.claude/skills/command/SKILL.md deleted file mode 100644 index 8ca4a316d..000000000 --- a/cli/.claude/skills/command/SKILL.md +++ /dev/null @@ -1,45 +0,0 @@ ---- -name: command -description: > - Creates or modifies CLI commands in src/application/commands/. Use when adding a new command - or subcommand, changing flags or the action handler, registering a command in cli.ts, or - reviewing a command for thin-wrapper compliance. Do NOT use for implementing business logic — - use `use-case` instead. Do NOT use for infrastructure changes — use `adapter` instead. ---- - -# Command - -A CLI command is a thin wrapper. It wires user input to exactly one use-case and displays the -typed result. It holds no business logic. These actions keep it that way. - -## Available actions - -| # | Action | Role | Input | -| --- | ------------------- | ------------------------------------------------- | --------------------------------------- | -| 01 | `declare-surface` | Define command name, description, and flags | command name + flag list | -| 02 | `write-handler` | Write the thin-wrapper action handler | surface from 01 + use-case name | -| 03 | `register` | Add the register call to cli.ts | command file from 01-02 | - -## Default flow - -`01 → 02 → 03` - -## Transversal rules - -- One `registerCommand(program: Command): void` per file; no logic outside the action handler. -- Action handler wires only: parse globals → flag guards → createDeps → one use-case → display → catch. -- Flag guards abort via `output.error()` + `process.exit(1)` — never `throw`. -- Exactly one use-case call; never chain multiple use-cases or add orchestration logic. -- All deps via `createDeps` / `createMenuDeps`; zero `new *Adapter()` in commands or `cli.ts`. -- Display through `CLIOutput` channels only — no helper methods, no domain formatting logic. -- Named export only. - -## References - -- `references/thin-wrapper.md` — action-handler contract, interactive mode rules, handler template -- `references/commander.md` — command registration, options, flag conventions -- `references/wiring.md` — createDeps / createMenuDeps usage + CLIOutput channels - -## Invariant rules - -- `.claude/rules/00-architecture/0-deps-wiring.md` — authoritative deps-wiring rules diff --git a/cli/.claude/skills/command/actions/01-declare-surface.md b/cli/.claude/skills/command/actions/01-declare-surface.md deleted file mode 100644 index 9997b8a20..000000000 --- a/cli/.claude/skills/command/actions/01-declare-surface.md +++ /dev/null @@ -1,37 +0,0 @@ -# 01 - Declare Surface - -Define the commander command name, description, and all flags. - -## Inputs - -- `command-name` (required) - string, kebab-case CLI name (e.g. `install`, `framework build`) -- `flags` (required) - list of flags with types (required/optional, value/boolean) - -## Outputs - -```typescript -export function registerWidgetCommand(program: Command): void { - program - .command("widget") - .description("Apply widget configuration to the project") - .requiredOption("--id ", "Widget identifier") - .option("--force", "Overwrite existing configuration") - .action(async (cmdOptions: { id: string; force?: boolean }) => { - // handler in 02 - }); -} -``` - -## Process - -1. Create `src/application/commands/.ts`. One file per top-level command; subcommands live in the same file. -2. Declare `export function registerCommand(program: Command): void`. -3. Chain `.command("name")`, `.description("...")` on `program` (or on a parent command for subcommands) — see `references/commander.md`. -4. Add `.requiredOption("-- ", "desc")` for mandatory inputs. -5. Add `.option("--", "desc")` for optional inputs; provide defaults inline in `.option()` when applicable. -6. CLI flags use kebab-case; their TypeScript names in `cmdOptions` use camelCase — see `references/commander.md`. -7. Leave the `.action()` body empty for now — filled in 02. - -## Test - -Run `pnpm typecheck` — exits 0 confirms the function signature and Commander option types compile. diff --git a/cli/.claude/skills/command/actions/02-write-handler.md b/cli/.claude/skills/command/actions/02-write-handler.md deleted file mode 100644 index f326b2ca7..000000000 --- a/cli/.claude/skills/command/actions/02-write-handler.md +++ /dev/null @@ -1,54 +0,0 @@ -# 02 - Write Handler - -Fill the `.action()` body with the canonical thin-wrapper wiring sequence. - -## Inputs - -- `command-surface` (required) - string, the command file from 01 -- `use-case-name` (required) - string, the PascalCase `*UseCase` class to call - -## Outputs - -```typescript -.action(async (cmdOptions: { id: string; force?: boolean }) => { - const { verbose, output, projectRoot } = parseGlobalOptions(program); - const errorHandler = new ErrorHandler(output); - - // Flag guards — before try block - if (!cmdOptions.id) { - output.error("--id is required."); - process.exit(1); - } - - try { - const deps = await createDeps(projectRoot, { verbose }, output); - const result = await deps.applyWidgetUseCase.execute({ - widgetId: cmdOptions.id, - force: cmdOptions.force ?? false, - interactive: process.stdout.isTTY, - }); - output.success(`Applied widget ${result.widgetId} (${result.fileCount} files)`); - } catch (error) { - errorHandler.handle(error); - } -}) -``` - -## Depends on - -- `01-declare-surface` - -## Process - -1. First line inside `.action()`: `const { verbose, output, projectRoot } = parseGlobalOptions(program)`. -2. Second line: `const errorHandler = new ErrorHandler(output)`. -3. Write all flag guards BEFORE the `try` block. Each guard: `output.error("...")` then `process.exit(1)`. No `throw` — see `references/thin-wrapper.md`. -4. Resolve / parse inputs: paths via `resolve(projectRoot, ...)`, option strings to typed values. Keep this between guards and `try`. -5. Inside `try`: `const deps = await createDeps(projectRoot, { verbose }, output)`. -6. Call exactly ONE use-case: `await deps..execute({ ..., interactive: process.stdout.isTTY })`. -7. Display the result via `output.success(...)` or `output.print(...)`. No formatting helpers, no counters — see `references/wiring.md`. -8. `catch` block: `errorHandler.handle(error)`. This is the ONLY catch block in the file. - -## Test - -Run `pnpm typecheck` — exits 0 and `pnpm lint` exits 0 confirms the handler compiles without type errors or lint violations. diff --git a/cli/.claude/skills/command/actions/03-register.md b/cli/.claude/skills/command/actions/03-register.md deleted file mode 100644 index 5a8bc2937..000000000 --- a/cli/.claude/skills/command/actions/03-register.md +++ /dev/null @@ -1,33 +0,0 @@ -# 03 - Register - -Add the `register*Command` call to `cli.ts` so the command appears in the CLI. - -## Inputs - -- `register-function` (required) - string, the `registerCommand` function name from 01 - -## Outputs - -```typescript -// src/application/cli.ts (additions only) -import { registerWidgetCommand } from "./commands/widget.js"; - -// Inside the setup section: -registerWidgetCommand(program); -``` - -## Depends on - -- `01-declare-surface` - -## Process - -1. Open `src/application/cli.ts`. -2. Add an `import { registerCommand }` at the top with a relative path ending in `.js`. -3. Call `registerCommand(program)` in the command registration section — after existing `register*` calls and before `program.parse()`. -4. Do NOT add any logic to `cli.ts` beyond the import and the one registration call — see `references/commander.md`. -5. Confirm `cli.ts` still has zero `createDeps` calls, zero `new *Adapter()` calls, and zero business logic. - -## Test - -Run `pnpm build` — exits 0 and the new command appears in `pnpm start -- --help` output. diff --git a/cli/.claude/skills/command/evals/scenarios.json b/cli/.claude/skills/command/evals/scenarios.json deleted file mode 100644 index 5b5a8a55d..000000000 --- a/cli/.claude/skills/command/evals/scenarios.json +++ /dev/null @@ -1,8 +0,0 @@ -[ - { "prompt": "Add a new CLI command called aidd doctor with --verbose flag", "expect_action": "declare-surface" }, - { "prompt": "Write the action handler for the framework build command", "expect_action": "write-handler" }, - { "prompt": "Register the new restore command in cli.ts", "expect_action": "register" }, - { "prompt": "Change the --force flag from optional to required on the install command", "expect_action": "declare-surface" }, - { "prompt": "Implement business logic for installing AI tools", "expect_action": null }, - { "prompt": "Create a port interface for fetching plugins", "expect_action": null } -] diff --git a/cli/.claude/skills/command/references/commander.md b/cli/.claude/skills/command/references/commander.md deleted file mode 100644 index 0cb93192a..000000000 --- a/cli/.claude/skills/command/references/commander.md +++ /dev/null @@ -1,41 +0,0 @@ -# Reference: commander wiring - -How a command registers itself and declares its surface. Commander.js. - -## Command registration - -- One `register*Command(program)` function per file, in `src/application/commands/` -- All commands registered in `cli.ts` — no business logic there -- Deps created inside the action handler, never in `register*Command` -- Parent + subcommand pattern: `const parent = program.command("x"); parent.command("sub")...` - -## Action handler contract - -- Wiring only: parse globals → guards → create deps → call one use-case → display result -- No helper functions (formatters, counters, predicates) inside command files -- No business logic inside action handlers — extract to use-cases or domain models - -## Options - -- Camel-case option names in code, kebab-case in CLI flags -- `.requiredOption("--source ", "desc")` for mandatory inputs -- `.option("--flat", "desc")` for optional boolean/value flags -- Provide defaults in the `.option()` declaration when applicable -- Validate inputs via `output.error()` + `process.exit(1)` — never `throw` - -## Example (parent + subcommand) - -```typescript -const widget = program.command("widget").description("Widget management tools"); - -widget - .command("apply") - .description("Apply a widget configuration to the project") - .requiredOption("--id ", "Widget identifier") - .requiredOption("--target ", "Target environment (dev, prod)") - .option("--dry-run", "Preview changes without writing files") - .option("--force", "Overwrite existing configuration") - .action(async (cmdOptions: { id: string; target: string; dryRun?: boolean; force?: boolean }) => { - // ...thin-wrapper handler (see references/thin-wrapper.md) - }); -``` diff --git a/cli/.claude/skills/command/references/thin-wrapper.md b/cli/.claude/skills/command/references/thin-wrapper.md deleted file mode 100644 index 31dcb3d36..000000000 --- a/cli/.claude/skills/command/references/thin-wrapper.md +++ /dev/null @@ -1,56 +0,0 @@ -# Reference: thin-wrapper contract - -Source of truth for the command action handler. A command wires, it does not orchestrate. - -## Rules - -- One use-case per command handler -- Commands wire, not orchestrate -- Parse and validate CLI flags before `try/catch` -- Abort with `output.error()` + `process.exit(1)` — never `throw` for flag validation -- Create deps via `createDeps()` -- Call one use-case with `interactive: process.stdout.isTTY` -- Display the typed result with `CLIOutput` -- Catch all errors: `errorHandler.handle(error)` — at the action level only - -## Forbidden - -- Prompter for domain decisions → move to the use-case -- Repository or manifest access → move to the use-case -- Multiple use-case calls or orchestration → extract one orchestrator use-case -- Business decisions or domain logic in the handler - -## Interactive mode - -- Use `Prompter` only to resolve missing CLI inputs **before** calling the use-case -- The use-case receives fully-resolved values -- Prompter inside use-cases is for domain interaction only (conflict resolution, strategy choice) -- Non-interactive guards stay in the command (`if (!process.stdout.isTTY && missing) { output.error; exit(1) }`) - -## Template - -```typescript -export function registerFooCommand(program: Command): void { - program - .command("foo") - // ...flags - .action(async (cmdOptions) => { - const { verbose, output, projectRoot } = parseGlobalOptions(program); - const errorHandler = new ErrorHandler(output); - - // CLI flag guards (abort, not throw) - if (badFlags) { output.error("..."); process.exit(1); } - - try { - const deps = await createDeps(projectRoot, { verbose }, output); - const result = await new FooUseCase(...deps).execute({ - ..., - interactive: process.stdout.isTTY, - }); - output.success(`...${result.x}...`); - } catch (error) { - errorHandler.handle(error); - } - }); -} -``` diff --git a/cli/.claude/skills/command/references/wiring.md b/cli/.claude/skills/command/references/wiring.md deleted file mode 100644 index c06332a9d..000000000 --- a/cli/.claude/skills/command/references/wiring.md +++ /dev/null @@ -1,43 +0,0 @@ -# Reference: dependency wiring + CLI output - -How a command obtains its dependencies and how it talks to the user. - -## Dependency factories - -- `createDeps(projectRoot, globalOptions, output)` — full dependency graph. Command actions only. - Memoized by `projectRoot`; the `preAction` hook is always the first caller per root, so - commands reuse the cached instance with no extra I/O. No second cache layer in command files. -- `createMenuDeps(projectRoot)` — minimal: `ManifestRepository` + `Prompter`. Pre-parse only - (the interactive menu before `program.parse()`). -- **Never instantiate adapters directly** in a command or in `cli.ts` (`new GhCliAdapter()`, - `new CurrentVersionAdapter()`, etc. are forbidden). If pre-parse needs grow, extend `createMenuDeps`. - -## cli.ts body rules - -- `createMenuDeps` only before `program.parse()` -- Never call `createDeps` before `program.parse()` -- `cli.ts` wires commands and global flags only — zero business logic, zero adapter construction - -## CLI output channels - -`CLIOutput` (lives in `application/output.ts`, the documented hexagonal exception) routes by level: - -- **stdout** — nominal output: `output.info()`, `output.success()`, `output.print()` -- **stderr** — signals: `output.warn()`, `output.error()` -- Conflicts and skips → `warn`, never `error` -- `process.exit(1)` only via `errorHandler.handle(error)` in the catch block (or a flag guard) - -## CLIOutput contract - -- Zero logic: it only routes messages by log level -- No `exit()` method — error handling belongs in `ErrorHandler` -- No helper methods (`formatBytes`, `formatCounts`, …) — formatting belongs in use-cases or - domain models, never in the output adapter or the command - -## Display helpers - -Multi-step display logic (banners, result summaries, progress output) that uses `CLIOutput` must -not live in the command file itself. Extract to `src/application/display/-display.ts`. -Pure domain formatters (no `CLIOutput` dependency) belong in `src/domain/models/`. -Parser helpers that convert CLI strings into typed domain values belong in -`src/domain/models/.ts` or remain inlined if ≤5 lines and used only once. diff --git a/cli/.claude/skills/distribution/SKILL.md b/cli/.claude/skills/distribution/SKILL.md new file mode 100644 index 000000000..53407934a --- /dev/null +++ b/cli/.claude/skills/distribution/SKILL.md @@ -0,0 +1,69 @@ +--- +name: distribution +description: > + Owns where plugin and marketplace content comes from and how it is fetched, under + src/contexts/distribution/ — marketplace registration, catalog parsing, and the ports/adapters + that reach git and HTTP. Use when adding a new marketplace source kind, a catalog parser for a + foreign format, or a fetch/cache/trust-store adapter. Do NOT use for what a tool does with + fetched content — use `tools` or `translate`. Do NOT use for recording what got installed on a + project — use `framework`. +--- + +# Distribution + +`distribution` is a leaf: it depends on `kernel` only, and knows no tool and no manifest. It +answers exactly one question — where does content come from, and how is it fetched — for +whoever asks. `framework` is the only context that reaches it (`framework → distribution`); a +plugin's own content and how it gets translated are someone else's job once it has arrived here. + +## What goes in + +| Concept | Location | +|---|---| +| A marketplace registration (name, source, scope, staleness) | `domain/marketplace.ts`, `domain/marketplace-source-mode.ts` | +| A cached catalog fetch | `domain/ports/marketplace-cache.ts` | +| The plugin catalog shape | `domain/catalog.ts` (the Claude-shaped parser lives here too) | +| A reader for a non-Claude catalog shape | `domain/catalog-parsers/` | +| A port this context's callers hold | `domain/ports/` — registry, cache, trust-store, catalog-repository, fetcher, raw-catalog-fetcher | +| Add / list / refresh / register / resolve / fetch a marketplace source | `application/` | +| The concrete adapter behind one of the six ports | `infrastructure/` | + +## How + +- This context is a leaf by construction: it must never gain an edge to `tools`, `translate`, or + `framework` — `tests/architecture/context-graph.arch.test.ts` enforces the chain + (`framework → distribution`, plus everything to `kernel`) and fails the build the moment a new + edge appears. If a change seems to need one, the orchestration belongs to the caller + (`framework`), not here — see `BASELINE` in `tests/architecture/helpers.ts` for the one + documented exception, `distribution->framework`, and the comment there that explains it + (`marketplace add --overwrite` removing before adding), which is framework work that has not + yet been moved out. +- A port here follows the port/adapter rule in `.claude/rules/00-architecture/`: interface only, ≤5 + methods, no `null` in the return type unless "not found" is genuinely a normal domain state + (documented per-port, not assumed). +- An adapter owns every technical constant for its integration (API base URLs, cache TTLs, + error-pattern regexes for classifying a third-party failure) — none of that belongs in a port, + a use-case, or a domain model. `try/catch` inside an adapter exists only to translate a raw + error into a typed one from `kernel/errors.ts`. +- A new foreign catalog shape gets its own parser in `domain/catalog-parsers/`, producing the + same `PluginCatalog`/`PluginCatalogEntry` shape the Claude parser produces — callers above this + context never branch on which format a catalog came from. +- Follow the use-case rule in `.claude/rules/00-architecture/` for the application layer's shape. + +## Public surface + +Nothing outside `contexts/distribution/` may import a module this context has not declared +public — `tests/architecture/context-boundary.arch.test.ts` holds the list +(`PUBLIC_MODULES.distribution`). Measured at extraction, ten modules were reached from outside +and not one was an adapter: the adapters are wired by the composition root +(`runtime/wiring/distribution.ts`) alone, and stay internal for that reason. A module that +exposes its own plumbing to a caller outside the composition root is not a leaf context anymore +— keep new adapters unreachable from outside. + +## How it's tested + +- `tests/contexts/distribution/` mirrors `src/contexts/distribution/` — domain models and + application use-cases are unit-tier; adapters against a real temp filesystem or a mocked + network boundary are integration-tier. See the `test` skill for tier conventions. +- A new catalog parser needs a fixture of the real foreign format and a test asserting the parsed + `PluginCatalog` matches what the Claude-shaped parser would produce for an equivalent catalog. diff --git a/cli/.claude/skills/domain-model/SKILL.md b/cli/.claude/skills/domain-model/SKILL.md deleted file mode 100644 index c233e0016..000000000 --- a/cli/.claude/skills/domain-model/SKILL.md +++ /dev/null @@ -1,49 +0,0 @@ ---- -name: domain-model -description: > - Creates or modifies domain types in src/domain/ — value objects, discriminant unions, and - aggregate roots. Use when adding a new domain concept, defining invariants for an existing - type, or placing a shared discriminant union that is used across multiple use-cases. Do NOT - use for orchestrating I/O or business logic — use `use-case` instead. Do NOT use for - infrastructure concerns — use `adapter` instead. ---- - -# Domain Model - -Builds and places the typed vocabulary of the application: value objects, discriminant types, -and aggregate roots that live in `src/domain/models/`. The domain layer must never import from -`application/` or `infrastructure/`. - -## Available actions - -| # | Action | Role | Input | -| --- | ----------------- | ------------------------------------------------- | --------------------------------------- | -| 01 | `choose-shape` | Decide between value object, discriminant type, or aggregate | concept description | -| 02 | `define-invariants` | Encode readonly fields, validation, factory | chosen shape from 01 | -| 03 | `place` | Pick canonical file location, add named export | defined type from 02 | -| 04 | `test` | Write unit tests for the domain type | placed type from 03 | - -## Default flow - -`01 → 02 → 03 → 04` - -## Transversal rules - -- All domain types must be free of `application/` and `infrastructure/` imports. -- All fields are `readonly`; return new instances for mutations. -- Never inline a discriminant union used in ≥2 use-cases; place it in `src/domain/models/`. -- Named export only, no default export. -- File name is `kebab-case.ts`. -- Validate invariants in constructor or static factory; throw a typed domain error on invalid input. -- Module-level `const` in `CONSTANT_CASE` for any literal used more than once. - -## References - -- `references/value-objects.md` — value object conventions (readonly, equals, constructor params) -- `references/discriminant-types.md` — discriminant union placement rules and canonical locations -- `references/manifest.md` — aggregate root conventions for the Manifest model - -## Invariant rules - -- `references/value-objects.md` — authoritative value object rules -- `references/discriminant-types.md` — authoritative discriminant type rules diff --git a/cli/.claude/skills/domain-model/actions/01-choose-shape.md b/cli/.claude/skills/domain-model/actions/01-choose-shape.md deleted file mode 100644 index 6e6279364..000000000 --- a/cli/.claude/skills/domain-model/actions/01-choose-shape.md +++ /dev/null @@ -1,28 +0,0 @@ -# 01 - Choose Shape - -Decide which domain construct to use before writing any code: value object, discriminant union, or aggregate root. - -## Inputs - -- `concept` (required) - string, the domain concept name and a one-sentence description of what it represents - -## Outputs - -``` -Shape decision: - kind: value-object | discriminant-union | aggregate - rationale: - target-file: src/domain/models/.ts -``` - -## Process - -1. Read `references/value-objects.md`. If the concept has fields, invariants, and equality semantics → choose `value-object`. -2. Read `references/discriminant-types.md`. If the concept is a string union used in ≥2 use-cases → choose `discriminant-union`. -3. If the concept tracks mutable state, has an identity, and owns related child collections → choose `aggregate`. -4. Confirm the target file does not already exist. If it does, use the existing file and skip 01 in future actions. -5. Output the shape decision. - -## Test - -Run `pnpm typecheck` — exits 0 confirms no new import cycles were introduced by the target file location decision. diff --git a/cli/.claude/skills/domain-model/actions/02-define-invariants.md b/cli/.claude/skills/domain-model/actions/02-define-invariants.md deleted file mode 100644 index 71ddf9de8..000000000 --- a/cli/.claude/skills/domain-model/actions/02-define-invariants.md +++ /dev/null @@ -1,46 +0,0 @@ -# 02 - Define Invariants - -Encode the type's fields, validation rules, and construction contract based on the shape chosen in 01. - -## Inputs - -- `shape` (required) - string, one of `value-object`, `discriminant-union`, `aggregate` -- `concept` (required) - string, concept name and field list - -## Outputs - -```typescript -// value-object example -export class Widget { - readonly id: string; - readonly mode: WidgetMode; - readonly label: string; - - constructor(params: { id: string; mode: WidgetMode; label: string }) { - if (!params.id) throw new DomainError("Widget id is required"); - this.id = params.id; - this.mode = params.mode; - this.label = params.label; - } - - equals(other: Widget): boolean { - return this.id === other.id && this.mode === other.mode; - } -} -``` - -## Depends on - -- `01-choose-shape` - -## Process - -1. For `value-object`: declare all fields `readonly`. Use a params object when ≥3 constructor parameters (@`references/value-objects.md`). Throw a typed domain error on invalid input. Implement `.equals()` if the type will be compared or stored in collections. -2. For `discriminant-union`: declare a `type Foo = "a" | "b" | "c"` string union. Add a module-level `const FOO_VALUES = ["a", "b", "c"] as const` if iteration is needed. Do NOT add a class. -3. For `aggregate`: declare all fields `readonly`. Expose mutation methods that return `void` and update internal state. Track invariants across child collections. Delegate complex sub-computations to private methods (≤20 lines each per `.claude/rules/06-design-patterns/6-method-size.md`). -4. Use `CONSTANT_CASE` for any literal string or number used more than once at module level. -5. No imports from `application/` or `infrastructure/` — see `.claude/rules/00-architecture/0-hexagonal.md`. - -## Test - -Run `pnpm typecheck` — exits 0 confirms the type definitions are internally consistent and import-cycle-free. diff --git a/cli/.claude/skills/domain-model/actions/03-place.md b/cli/.claude/skills/domain-model/actions/03-place.md deleted file mode 100644 index abff3259e..000000000 --- a/cli/.claude/skills/domain-model/actions/03-place.md +++ /dev/null @@ -1,33 +0,0 @@ -# 03 - Place - -Pick the canonical file location, add the named export, and verify the placement aligns with existing canonical locations. - -## Inputs - -- `type-name` (required) - string, the PascalCase name of the type -- `shape` (required) - string, one of `value-object`, `discriminant-union`, `aggregate` - -## Outputs - -``` -Placement: - file: src/domain/models/.ts - export: export class | export type - canonical-location-table: updated? yes | no -``` - -## Depends on - -- `02-define-invariants` - -## Process - -1. Check `references/discriminant-types.md` canonical location table. If the type is already listed there, use the exact path from the table. If not listed, create `src/domain/models/.ts`. -2. Add only named exports — no `export default`. Export the class, type, and any module-level constants together at the end of the file block (not as re-exports from another file — see `.claude/rules/01-standards/1-exports.md`). -3. Confirm no barrel file (`index.ts`) is created. Callers import directly from the source file. -4. For a new type: update `references/discriminant-types.md` canonical location table if the type is a discriminant union used in ≥2 use-cases. -5. For a value object or aggregate: ensure the file name matches `.ts` per `.claude/rules/01-standards/1-naming.md`. - -## Test - -Run `grep -rn "import.*" src/application/ src/infrastructure/` and confirm all existing imports resolve to the new canonical path, exits 0. diff --git a/cli/.claude/skills/domain-model/actions/04-test.md b/cli/.claude/skills/domain-model/actions/04-test.md deleted file mode 100644 index 5a92e2a86..000000000 --- a/cli/.claude/skills/domain-model/actions/04-test.md +++ /dev/null @@ -1,31 +0,0 @@ -# 04 - Test - -Write unit tests for the domain type covering invariants, equality, and invalid input rejection. - -## Inputs - -- `type-name` (required) - string, the PascalCase name of the type -- `file-path` (required) - string, absolute path to the source file produced in 03 - -## Outputs - -``` -Test file: tests/domain/models/.unit.test.ts -``` - -## Depends on - -- `03-place` - -## Process - -1. Create `tests/domain/models/.unit.test.ts`. Use `*.unit.test.ts` suffix — no I/O, no mocks, no filesystem per `references/test-pyramid.md` in the `test` skill. -2. Name each `it()` block as a behavior sentence describing the observable outcome, not the method called — see `references/test-pyramid.md` in the `test` skill. -3. Cover: valid construction succeeds, invalid inputs throw a typed error, `.equals()` returns true for structurally equal instances and false when different (value objects only), mutations return new instances (value objects only). -4. For discriminant unions: test that the union type exhaustively covers all expected members by writing a switch that TypeScript narrows without a `default` branch. -5. No mocks — domain types are pure; call constructors and methods directly. -6. Group tests with `describe()` blocks by type name, not by method name — see memory file `feedback_test_naming.md`. - -## Test - -Run `pnpm test:unit` — exits 0 with all new `it()` blocks passing. diff --git a/cli/.claude/skills/domain-model/evals/scenarios.json b/cli/.claude/skills/domain-model/evals/scenarios.json deleted file mode 100644 index 459a658ec..000000000 --- a/cli/.claude/skills/domain-model/evals/scenarios.json +++ /dev/null @@ -1,8 +0,0 @@ -[ - { "prompt": "Create a value object for plugin source with kind discriminant", "expect_action": "choose-shape" }, - { "prompt": "Add a readonly field with validation to the FileDiff class", "expect_action": "define-invariants" }, - { "prompt": "Where should I put the new MergeDecision discriminant union?", "expect_action": "place" }, - { "prompt": "Write unit tests for the new ToolScope value object", "expect_action": "test" }, - { "prompt": "Add a new CLI command to the AIDD tool", "expect_action": null }, - { "prompt": "Implement the install use-case for a new plugin type", "expect_action": null } -] diff --git a/cli/.claude/skills/domain-model/references/discriminant-types.md b/cli/.claude/skills/domain-model/references/discriminant-types.md deleted file mode 100644 index 320dc94ba..000000000 --- a/cli/.claude/skills/domain-model/references/discriminant-types.md +++ /dev/null @@ -1,38 +0,0 @@ -# Reference: Discriminant Types - -## Rules - -- Every discriminant string union used in ≥2 use-cases → named type in `src/domain/models/` -- Never inline `type Foo = "a" | "b"` in use-case files -- Register newly created discriminant types in the project's canonical location table (maintained in `references/discriminant-types.md` for the active project) so future contributors know where to import from - -## Naming - -- Type name: `PascalCase` -- File name: `kebab-case.ts` matching the concept — `widget-mode.ts` for `WidgetMode` - -## Pattern (agnostic example) - -Bad — inline union duplicated across two use-cases: - -```typescript -// apply-widget-use-case.ts -type WidgetMode = "sync" | "push" | "dry-run"; - -// remove-widget-use-case.ts -type WidgetMode = "sync" | "push" | "dry-run"; // duplicated! -``` - -Good — single named export in `src/domain/models/widget-mode.ts`: - -```typescript -// src/domain/models/widget-mode.ts -export type WidgetMode = "sync" | "push" | "dry-run"; -export const WIDGET_MODE_VALUES = ["sync", "push", "dry-run"] as const; -``` - -Both use-cases import from the canonical path: - -```typescript -import type { WidgetMode } from "../../domain/models/widget-mode.js"; -``` diff --git a/cli/.claude/skills/domain-model/references/manifest.md b/cli/.claude/skills/domain-model/references/manifest.md deleted file mode 100644 index 988d1561f..000000000 --- a/cli/.claude/skills/domain-model/references/manifest.md +++ /dev/null @@ -1,48 +0,0 @@ -# Reference: Manifest Aggregate Root - -## Role - -- Tracks every installed framework file with its MD5 hash -- Persisted at `.aidd/manifest.json` -- Single source of truth for installed state - -## Write guard (applies to any aggregate writing files) - -- Before writing any framework file: check `fs.fileExists(path)` AND `!manifest.isFileTracked(relativePath)` -- If both true → skip write, emit `logger.warn()`, never add to manifest -- Never overwrite a user-owned file - -## Saving - -- Always save via `PostInstallPipelineUseCase` -- Exception: `InitUseCase` may call the pipeline directly (documented inline) -- Never call `manifestRepo.save()` in isolation outside the pipeline - -## Merge file tracking - -- Merge config files tracked in `ToolEntry.mergeFiles` (not in `files`) -- `isFileTracked()` checks both `files` and `mergeFiles` -- Uninstall/clean must delete merge files alongside regular files - -## Agnostic shape example - -```typescript -export class InventoryAggregate { - private readonly entries: Map; - readonly version: number; - - constructor(params: { entries: InventoryEntry[]; version: number }) { - this.entries = new Map(params.entries.map((e) => [e.id, e])); - this.version = params.version; - } - - isTracked(id: string): boolean { - return this.entries.has(id); - } - - track(entry: InventoryEntry): InventoryAggregate { - const updated = [...this.entries.values(), entry]; - return new InventoryAggregate({ entries: updated, version: this.version }); - } -} -``` diff --git a/cli/.claude/skills/domain-model/references/value-objects.md b/cli/.claude/skills/domain-model/references/value-objects.md deleted file mode 100644 index bb721376a..000000000 --- a/cli/.claude/skills/domain-model/references/value-objects.md +++ /dev/null @@ -1,46 +0,0 @@ -# Reference: Value Objects - -## Rules - -- All fields `readonly` — no setters -- Return a new instance for mutations — never mutate in place -- Validate invariants in the constructor; throw a typed domain error on invalid input -- Use a params object when ≥3 constructor parameters -- Add a static factory only when there are multiple distinct creation paths -- Implement `.equals()` when the type will be compared or stored in collections - -## Module-level constants - -- `CONSTANT_CASE` for any string or number literal used more than once -- Place constants above the class definition in the same file - -## Import rules - -- `src/domain/models/` files must not import from `src/application/` or `src/infrastructure/` -- Cross-domain imports within `src/domain/models/` are allowed - -## Agnostic shape example - -```typescript -export class Widget { - readonly id: string; - readonly label: string; - readonly mode: WidgetMode; - - constructor(params: { id: string; label: string; mode: WidgetMode }) { - if (!params.id) throw new DomainError("Widget id is required"); - if (!params.label) throw new DomainError("Widget label is required"); - this.id = params.id; - this.label = params.label; - this.mode = params.mode; - } - - equals(other: Widget): boolean { - return this.id === other.id && this.mode === other.mode; - } - - withLabel(label: string): Widget { - return new Widget({ id: this.id, label, mode: this.mode }); - } -} -``` diff --git a/cli/.claude/skills/feature/SKILL.md b/cli/.claude/skills/feature/SKILL.md deleted file mode 100644 index d9401e7a5..000000000 --- a/cli/.claude/skills/feature/SKILL.md +++ /dev/null @@ -1,64 +0,0 @@ ---- -name: feature -description: > - Macro workflow for building or changing a vertical slice of the CLI. Use as the entry point - when adding a new end-to-end feature (domain → use-case → adapter → command → tests) or - when a change touches multiple layers at once. Do NOT use for single-layer changes — use the - layer skill directly (`domain-model`, `use-case`, `adapter`, `command`, or `test`). ---- - -# Feature - -Coordinates the five layer skills in vertical-slice order. Each step delegates entirely to the -relevant layer skill. Skip any step when the change does not touch that layer. - -## Available actions - -| # | Action | Role | Input | -| --- | -------------- | ------------------------------------------------- | ------------------------ | -| 01 | `domain-model` | Define types, value objects, and invariants | concept description | -| 02 | `use-case` | Implement business orchestration | domain types from 01 | -| 03 | `adapter` | Add I/O boundary (only if a new port is needed) | use-case port needs | -| 04 | `command` | Expose the feature in the CLI | use-case from 02 | -| 05 | `test` | Write pyramid coverage | all layers from 01-04 | - -## Default flow - -`01 → 02 → 03 → 04 → 05` - -Skip rule: if a step's layer is not affected by the change, skip it explicitly and document why (e.g. "03 skipped — no new port needed, reusing existing PluginFetcher"). - -## Conditional layers - -These layers are triggered only when the change explicitly touches their domain. Evaluate each -before starting the main flow and apply them in parallel with whichever main steps they overlap. - -| Layer | Trigger condition | Skill | -| ------------ | ------------------------------------------------------------------------- | ------------ | -| `tool` | Adding or modifying an AI tool definition in `domain/tools/ai/` | `tool` | -| `format` | Adding or modifying a pure string-transform function in `domain/formats/` | `format` | -| `capability` | Adding or modifying a capability class in `domain/capabilities/` | `capability` | - -- If the feature adds a new AI tool: run `tool` before step 01 (the tool definition underpins the domain model). -- If the feature adds a pure format transform: run `format` at the same level as step 01. -- If the feature adds a capability class: run `capability` before `tool` (the Has* interface must exist before the tool composes it). -- All three may be skipped when the change does not touch their respective domains — document the skip explicitly. - -## Transversal rules - -- Each action delegates fully to its layer skill. Do not inline layer-specific rules here. -- Never skip 05 — every feature change requires tests at the appropriate pyramid tier. -- Skipping 01 is allowed only when no new domain type is introduced and no existing invariant changes. -- Skipping 03 is the most common skip — only add an adapter when a genuinely new I/O boundary is required. -- Skipping 04 is allowed for internal refactors that don't expose a new CLI surface. - -## External data - -- `.claude/skills/domain-model/SKILL.md` — layer skill for step 01 -- `.claude/skills/use-case/SKILL.md` — layer skill for step 02 -- `.claude/skills/adapter/SKILL.md` — layer skill for step 03 -- `.claude/skills/command/SKILL.md` — layer skill for step 04 -- `.claude/skills/test/SKILL.md` — layer skill for step 05 -- `.claude/skills/tool/SKILL.md` — conditional layer skill for AI tool definitions -- `.claude/skills/format/SKILL.md` — conditional layer skill for pure string transforms -- `.claude/skills/capability/SKILL.md` — conditional layer skill for capability classes diff --git a/cli/.claude/skills/feature/actions/01-domain-model.md b/cli/.claude/skills/feature/actions/01-domain-model.md deleted file mode 100644 index 3405f794f..000000000 --- a/cli/.claude/skills/feature/actions/01-domain-model.md +++ /dev/null @@ -1,22 +0,0 @@ -# 01 - Domain Model - -Define or update domain types for the feature. - -## Inputs - -- `feature-description` (required) - string, what the feature does and what domain concepts it introduces - -## Outputs - -New or updated files in `src/domain/` — value objects, discriminant unions, or aggregates. - -## Process - -1. Determine whether new domain types are needed. If no new type is introduced and no existing invariant changes, skip this action and document the skip. -2. Invoke the `domain-model` skill starting at its `01-choose-shape` action. -3. Complete all four actions of the `domain-model` skill (`choose-shape → define-invariants → place → test`). -4. Confirm `pnpm typecheck` exits 0 before proceeding to 02. - -## Test - -`pnpm test:unit` exits 0 for the domain type's unit tests — same as the `domain-model` skill's `04-test` action. diff --git a/cli/.claude/skills/feature/actions/02-use-case.md b/cli/.claude/skills/feature/actions/02-use-case.md deleted file mode 100644 index 459332352..000000000 --- a/cli/.claude/skills/feature/actions/02-use-case.md +++ /dev/null @@ -1,26 +0,0 @@ -# 02 - Use Case - -Implement the business orchestration for the feature. - -## Inputs - -- `feature-description` (required) - string, what the feature orchestrates -- `domain-types` (optional) - list of domain types from 01 to use as input/output - -## Outputs - -New or updated files in `src/application/use-cases/`. - -## Depends on - -- `01-domain-model` (or confirmed skip) - -## Process - -1. Invoke the `use-case` skill starting at its `01-define-types` action. -2. Complete all five actions of the `use-case` skill (`define-types → write-execute → extract-methods → wire-errors-and-pipeline → test`). -3. Confirm `pnpm typecheck` exits 0 before proceeding to 03. - -## Test - -`pnpm test:unit` exits 0 for the use-case's unit tests — same as the `use-case` skill's `05-test` action. diff --git a/cli/.claude/skills/feature/actions/03-adapter.md b/cli/.claude/skills/feature/actions/03-adapter.md deleted file mode 100644 index 31c7e9d9f..000000000 --- a/cli/.claude/skills/feature/actions/03-adapter.md +++ /dev/null @@ -1,26 +0,0 @@ -# 03 - Adapter - -Add an I/O boundary only when the use-case needs a new port that does not yet exist. - -## Inputs - -- `use-case-ports` (required) - list of ports the use-case needs; identify which are new vs existing - -## Outputs - -New port interface in `src/domain/ports/` and adapter in `src/infrastructure/adapters/` — only if a new port is required. - -## Depends on - -- `02-use-case` - -## Process - -1. Check whether every port the use-case requires already exists in `src/domain/ports/`. If all ports exist, skip this action and document: "03 skipped — reusing existing ". -2. For each new port needed: invoke the `adapter` skill starting at its `01-define-port` action. -3. Complete all four actions of the `adapter` skill (`define-port → implement-adapter → wire-deps → test`). -4. Confirm `pnpm typecheck` exits 0 before proceeding to 04. - -## Test - -`pnpm test:integration` exits 0 for the adapter's integration tests — same as the `adapter` skill's `04-test` action. diff --git a/cli/.claude/skills/feature/actions/04-command.md b/cli/.claude/skills/feature/actions/04-command.md deleted file mode 100644 index 971acff7f..000000000 --- a/cli/.claude/skills/feature/actions/04-command.md +++ /dev/null @@ -1,27 +0,0 @@ -# 04 - Command - -Expose the feature in the CLI as a thin-wrapper command. - -## Inputs - -- `feature-description` (required) - string, what the CLI user invokes -- `use-case-name` (required) - string, the `*UseCase` class from 02 - -## Outputs - -New or updated file in `src/application/commands/` and updated `src/application/cli.ts`. - -## Depends on - -- `02-use-case` - -## Process - -1. If the change is an internal refactor that does not expose a new CLI surface, skip this action and document: "04 skipped — no new CLI surface". -2. Invoke the `command` skill starting at its `01-declare-surface` action. -3. Complete all three actions of the `command` skill (`declare-surface → write-handler → register`). -4. Confirm `pnpm build` exits 0 and the new command appears in `--help` output before proceeding to 05. - -## Test - -`pnpm build` exits 0 and the command name appears in the `--help` output — same as the `command` skill's `03-register` action test. diff --git a/cli/.claude/skills/feature/actions/05-test.md b/cli/.claude/skills/feature/actions/05-test.md deleted file mode 100644 index 2bc899d5c..000000000 --- a/cli/.claude/skills/feature/actions/05-test.md +++ /dev/null @@ -1,29 +0,0 @@ -# 05 - Test - -Write pyramid coverage across all touched layers. - -## Inputs - -- `touched-layers` (required) - list of layers changed in 01-04 (e.g. `domain-model, use-case, command`) - -## Outputs - -Test files at the appropriate tiers in `tests/`. - -## Depends on - -- `01-domain-model`, `02-use-case`, `03-adapter`, `04-command` (or confirmed skips) - -## Process - -1. Invoke the `test` skill starting at its `01-pick-tier` action for each touched layer. -2. For domain types: unit tests (`tests/domain/models/`). -3. For use-cases: unit tests (`tests/application/use-cases/`). -4. For adapters: integration tests (`tests/infrastructure/adapters/`). -5. For commands: E2E tests (`tests/e2e/`) covering the full user journey — 5–10 scenarios max. -6. Never skip 05. Every feature change requires tests; skipping is not allowed. -7. For user-reported bug fixes: also invoke `04-empirical-repro` from the `test` skill. - -## Test - -`pnpm test` exits 0 — full build + all tiers pass. diff --git a/cli/.claude/skills/feature/evals/scenarios.json b/cli/.claude/skills/feature/evals/scenarios.json deleted file mode 100644 index a5797a5dc..000000000 --- a/cli/.claude/skills/feature/evals/scenarios.json +++ /dev/null @@ -1,8 +0,0 @@ -[ - { "prompt": "Build a new aidd doctor command end to end", "expect_action": "domain-model" }, - { "prompt": "Add a restore feature that reverts installed files to their framework version", "expect_action": "domain-model" }, - { "prompt": "I need to add a new full feature with domain model, use-case, and command", "expect_action": "domain-model" }, - { "prompt": "Write unit tests for the CleanUseCase", "expect_action": null }, - { "prompt": "Create a port interface for fetching plugins", "expect_action": null }, - { "prompt": "Add a --dry-run flag to the existing install command", "expect_action": null } -] diff --git a/cli/.claude/skills/format/SKILL.md b/cli/.claude/skills/format/SKILL.md deleted file mode 100644 index ce2fcc340..000000000 --- a/cli/.claude/skills/format/SKILL.md +++ /dev/null @@ -1,50 +0,0 @@ ---- -name: format -description: > - Creates or modifies pure string-transform functions in domain/formats/. Use when adding a - new format module (toml, markdown, json, placeholders, command), implementing a lossless - round-trip transform and its inverse, or writing exhaustive unit tests for an existing pure - function. Do NOT use for capability classes — use `capability` instead. Do NOT use for AI - tool definitions — use `tool` instead. Do NOT use for I/O-bearing code — use `adapter` instead. ---- - -# Format - -Builds pure string-transform functions that live in `domain/formats/`. Every function in this -layer is stateless, has no I/O, is a named export, and uses `.js` ESM import paths. Where a -forward transform exists, a lossless reverse transform must accompany it. - -## Available actions - -| # | Action | Role | Input | -| --- | ----------------------- | -------------------------------------------------------- | ---------------------------------------- | -| 01 | `define-pure-function` | Write the named export with correct signature | function name + transform description | -| 02 | `round-trip` | Implement the inverse function, verify lossless identity | forward function from 01 | -| 03 | `test` | Write exhaustive unit tests (all branches + edge cases) | both functions from 01-02 | - -## Default flow - -`01 → 02 → 03` - -Skip 02 when the transform has no meaningful inverse (e.g. a lossy stringify with no parse -counterpart) — document this explicitly with a comment in the source file. - -## Transversal rules - -- Pure functions only: no I/O, no network, no filesystem, no side effects. -- Named exports only; no default exports. -- No `any` types; use generics or explicit union types. -- `.js` extensions on all relative imports. -- Inverse function name follows the pattern `reverse` or `deserialize`. -- A lossless round-trip means `reverse(forward(x)) === x` for all valid inputs. -- Module-level `const` in `CONSTANT_CASE` for any literal used more than once. -- File name is `.ts` (e.g. `toml.ts`, `markdown.ts`, `command.ts`). - -## References - -- `references/format-conventions.md` — naming, file placement, no-any rule, ESM imports -- `references/round-trip.md` — lossless identity requirement, composition order, verification pattern - -## Invariant rules - -- `references/format-conventions.md` — authoritative format layer rules diff --git a/cli/.claude/skills/format/actions/01-define-pure-function.md b/cli/.claude/skills/format/actions/01-define-pure-function.md deleted file mode 100644 index 75af97f02..000000000 --- a/cli/.claude/skills/format/actions/01-define-pure-function.md +++ /dev/null @@ -1,50 +0,0 @@ -# 01 - Define Pure Function - -Write a named-export pure function with an explicit TypeScript signature. No I/O, no side -effects, no `any` types. - -## Inputs - -- `function-name` (required) - string, camelCase name of the function (e.g. `serializeWidgetFrontmatter`) -- `transform` (required) - one sentence describing what the function does to its input string -- `file` (required) - string, target file in `domain/formats/` (e.g. `widget-frontmatter.ts`) - -## Outputs - -```typescript -// domain/formats/widget-frontmatter.ts - -const FRONTMATTER_DELIMITER = "---"; - -export interface WidgetFrontmatter { - name: string; - mode: string; - version?: string; -} - -/** - * Serializes a WidgetFrontmatter object to a YAML frontmatter block. - * Inverse: deserializeWidgetFrontmatter - */ -export function serializeWidgetFrontmatter(fm: WidgetFrontmatter): string { - const lines: string[] = [FRONTMATTER_DELIMITER]; - lines.push(`name: ${fm.name}`); - lines.push(`mode: ${fm.mode}`); - if (fm.version !== undefined) lines.push(`version: ${fm.version}`); - lines.push(FRONTMATTER_DELIMITER); - return lines.join("\n"); -} -``` - -## Process - -1. Create or open `domain/formats/.ts`. If the file exists, add the function; do not overwrite existing exports. -2. Declare module-level constants in `CONSTANT_CASE` for any literal used more than once. -3. Declare input/output types explicitly. No `any`, no implicit `unknown` that narrows to `any`. -4. Write the function body as a pure transformation: input → output, no I/O. -5. Add a JSDoc comment that names the inverse function (`Inverse: `) so consumers can find the round-trip pair. -6. Add a named export — never a default export. - -## Test - -Run `pnpm typecheck` — exits 0 confirms the function signature is type-correct and the file has no import-cycle violations. diff --git a/cli/.claude/skills/format/actions/02-round-trip.md b/cli/.claude/skills/format/actions/02-round-trip.md deleted file mode 100644 index 494de88fb..000000000 --- a/cli/.claude/skills/format/actions/02-round-trip.md +++ /dev/null @@ -1,48 +0,0 @@ -# 02 - Round Trip - -Implement the inverse function and verify that `reverse(forward(x)) === x` holds for all -valid inputs. - -## Inputs - -- `forward-function` (required) - string, name of the function from 01 (e.g. `serializeWidgetFrontmatter`) -- `inverse-name` (required) - string, name for the inverse function (e.g. `deserializeWidgetFrontmatter`) - -## Outputs - -```typescript -/** - * Parses a YAML frontmatter block back into a WidgetFrontmatter object. - * Inverse: serializeWidgetFrontmatter - */ -export function deserializeWidgetFrontmatter(block: string): WidgetFrontmatter { - const lines = block - .split("\n") - .filter((l) => l !== FRONTMATTER_DELIMITER && l.trim().length > 0); - const entries = Object.fromEntries(lines.map((l) => l.split(": ", 2) as [string, string])); - if (!entries.name || !entries.mode) { - throw new Error("Missing required frontmatter fields: name, mode"); - } - return { name: entries.name, mode: entries.mode, version: entries.version }; -} -``` - -## Depends on - -- `01-define-pure-function` - -## Process - -1. Open the same `domain/formats/.ts` file as in 01. -2. Write the inverse function immediately below the forward function. Name it `reverse` or `deserialize` as appropriate. -3. Add a JSDoc comment that names the forward function (`Inverse: `). -4. Verify the lossless identity by tracing the round-trip manually with one representative example: - - Choose a valid input value. - - Apply the forward function to get the intermediate form. - - Apply the inverse to get back the original. - - Confirm the final value equals the original — same fields, same types. -5. If the forward transform is lossy by design (e.g. a hash, a truncation), do not write an inverse. Instead add a comment `// Lossy: no inverse defined` and skip this action. Document the skip in your implementation notes. - -## Test - -Run `pnpm typecheck` — exits 0 confirms the inverse function compiles and shares types correctly with the forward function. diff --git a/cli/.claude/skills/format/actions/03-test.md b/cli/.claude/skills/format/actions/03-test.md deleted file mode 100644 index 1219734b3..000000000 --- a/cli/.claude/skills/format/actions/03-test.md +++ /dev/null @@ -1,40 +0,0 @@ -# 03 - Test - -Write exhaustive unit tests for both the forward and inverse functions, covering all branches -and meaningful edge cases. - -## Inputs - -- `forward-function` (required) - string, name of the forward function from 01 -- `inverse-function` (required) - string, name of the inverse function from 02 (or `null` if lossy) -- `source-file` (required) - string, path to the format module being tested - -## Outputs - -``` -Test file: tests/domain/formats/.unit.test.ts -``` - -## Depends on - -- `02-round-trip` - -## Process - -1. Create `tests/domain/formats/.unit.test.ts`. Use `*.unit.test.ts` suffix — no I/O, no mocks, no filesystem. -2. Import only the functions under test and their types. No test helpers that do I/O. -3. Cover the following for the forward function: - - Happy path: valid input produces the expected output string. - - Each optional field: omitting it produces correct output; including it produces correct output. - - Invalid input: if the function throws on bad input, confirm the thrown error. -4. Cover the following for the inverse function (when present): - - Happy path: valid serialized form parses back correctly. - - Missing required fields: throws a typed error. - - Round-trip identity: `reverse(forward(validInput))` deeply equals `validInput`. -5. Name `it()` blocks as behavior sentences: "serializes optional version field when provided" not "calls lines.push". -6. Group tests with `describe('')` by function name — see memory `feedback_test_naming.md`. -7. No mocks — format functions are pure; call them directly with literal inputs. - -## Test - -Run `pnpm test:unit` — exits 0 with all new `it()` blocks passing. diff --git a/cli/.claude/skills/format/evals/scenarios.json b/cli/.claude/skills/format/evals/scenarios.json deleted file mode 100644 index cc5b75522..000000000 --- a/cli/.claude/skills/format/evals/scenarios.json +++ /dev/null @@ -1,9 +0,0 @@ -[ - { "prompt": "Add a function to serialize widget frontmatter to YAML", "expect_action": "define-pure-function" }, - { "prompt": "Write a pure function that converts command frontmatter to a JSON object", "expect_action": "define-pure-function" }, - { "prompt": "Implement the inverse of serializeWidgetFrontmatter so it round-trips losslessly", "expect_action": "round-trip" }, - { "prompt": "The deserializeWidgetFrontmatter function needs to be the exact inverse of serialize", "expect_action": "round-trip" }, - { "prompt": "Write unit tests for the widgetFrontmatter format module", "expect_action": "test" }, - { "prompt": "Add a new capability class for widget support", "expect_action": null }, - { "prompt": "Add a new AI tool definition for the acme assistant", "expect_action": null } -] diff --git a/cli/.claude/skills/format/references/format-conventions.md b/cli/.claude/skills/format/references/format-conventions.md deleted file mode 100644 index e5b92424f..000000000 --- a/cli/.claude/skills/format/references/format-conventions.md +++ /dev/null @@ -1,79 +0,0 @@ -# Reference: Format Conventions - -## File placement - -Format modules live in `domain/formats/`. One concept per file. File name is `.ts`: - -| File | Responsibility | -| ----------------------- | --------------------------------------------------- | -| `markdown.ts` | Frontmatter parsing and serialization | -| `toml.ts` | TOML serialization for agent configs | -| `json.ts` | JSON serialization helpers | -| `placeholders.ts` | Base `rewriteContent` / `reverseRewriteContent` | -| `command.ts` | Command frontmatter conversion and suffix stripping | - -New modules follow the same pattern: name the file after the concept it transforms. - -## Function naming - -- Forward transform: `serialize`, `convert`, or a descriptive verb phrase. -- Inverse transform: `deserialize`, `reverse`, or the natural inverse verb. -- Both functions must carry a JSDoc `Inverse:` cross-reference comment. - -## Purity constraints - -- No `import` from `node:fs`, `node:path`, or any I/O module. -- No network calls, no environment reads. -- No class state — all transforms are standalone functions. -- Calls to `Date.now()`, `Math.random()`, or similar non-deterministic sources are forbidden. - -## Type constraints - -- No `any` — use generics, discriminated unions, or `unknown` narrowed with type guards. -- Input and output types must be explicit named interfaces or type aliases — never inline objects in signatures. -- `import type` for type-only imports. - -## Module constants - -- Declare literals as `CONSTANT_CASE` module-level `const` when used more than once. -- Place constants above the function definitions in the same file. - -## ESM imports - -- `.js` extension on all relative imports. -- No barrel re-exports from `domain/formats/` — consumers import from the specific module. - -## Agnostic shape example - -```typescript -// domain/formats/widget-frontmatter.ts - -const FRONTMATTER_DELIMITER = "---"; - -export interface WidgetFrontmatter { - name: string; - mode: "fast" | "safe"; - label?: string; -} - -/** - * Serializes a WidgetFrontmatter to a YAML frontmatter block. - * Inverse: deserializeWidgetFrontmatter - */ -export function serializeWidgetFrontmatter(fm: WidgetFrontmatter): string { - const lines: string[] = [FRONTMATTER_DELIMITER]; - lines.push(`name: ${fm.name}`); - lines.push(`mode: ${fm.mode}`); - if (fm.label !== undefined) lines.push(`label: ${fm.label}`); - lines.push(FRONTMATTER_DELIMITER); - return lines.join("\n"); -} - -/** - * Parses a YAML frontmatter block into a WidgetFrontmatter. - * Inverse: serializeWidgetFrontmatter - */ -export function deserializeWidgetFrontmatter(block: string): WidgetFrontmatter { - // ... parse logic -} -``` diff --git a/cli/.claude/skills/format/references/round-trip.md b/cli/.claude/skills/format/references/round-trip.md deleted file mode 100644 index e82855197..000000000 --- a/cli/.claude/skills/format/references/round-trip.md +++ /dev/null @@ -1,56 +0,0 @@ -# Reference: Round-Trip Requirement - -## Lossless identity - -A pair of functions `forward` and `reverse` is a lossless round-trip when: - -``` -reverse(forward(x)) === x // for all valid inputs x -``` - -In practice, "===" means deep structural equality (same fields, same types, same values). -If the output type is a string, `===` is strict string equality. -If the output type is an object, every field must match after the round-trip. - -## Verification pattern - -Before marking 02 complete, trace the round-trip manually with one representative example: - -```typescript -// Example: widget frontmatter -const input: WidgetFrontmatter = { name: "my-widget", mode: "fast", label: "My Widget" }; -const serialized = serializeWidgetFrontmatter(input); -const restored = deserializeWidgetFrontmatter(serialized); -// Assert: restored.name === input.name, restored.mode === input.mode, restored.label === input.label -``` - -Choose an input that exercises all optional fields. - -## Composition order for content rewrites - -When forward and inverse are composed with base helpers (see `tool` skill): - -- Forward: apply base transform first, then tool-specific transforms. -- Inverse: apply tool-specific reverse transforms first, then base reverse transform. - -This ordering is mandatory: violating it breaks the lossless identity. - -## When lossless is not achievable - -Some transforms are intentionally lossy (hash functions, truncation, schema validation). -In these cases: -- Do NOT implement an inverse. -- Add `// Lossy: no inverse defined — ` at the top of the function. -- Skip action 02 and document the skip. - -## Unit test for round-trip identity - -The test for the inverse (action 03) must include one `it()` block that asserts the full -round-trip identity: - -```typescript -it("round-trips a complete WidgetFrontmatter without loss", () => { - const input: WidgetFrontmatter = { name: "foo", mode: "safe", label: "Foo" }; - expect(deserializeWidgetFrontmatter(serializeWidgetFrontmatter(input))).toEqual(input); -}); -``` diff --git a/cli/.claude/skills/framework/SKILL.md b/cli/.claude/skills/framework/SKILL.md new file mode 100644 index 000000000..45c2ac67d --- /dev/null +++ b/cli/.claude/skills/framework/SKILL.md @@ -0,0 +1,76 @@ +--- +name: framework +description: > + Owns the installation record and everything done to a project, under + src/contexts/framework/ — the manifest aggregate, and the setup/install/restore/uninstall/doctor + orchestration built on top of it. This is the only context allowed to reach `translate` and + `distribution`; `tools` is reachable from `telemetry` too. Use when adding a use-case that touches the manifest, a + setup/doctor/sync/uninstall flow, a new top-level CLI orchestration, or a launcher that runs an + external binary (kanban-shaped). Do NOT use for a tool's own profile or capability classes — + use `tools`. Do NOT use for the translation pipeline — use `translate`. Do NOT use for where + content is fetched from — use `distribution`. +--- + +# Framework + +`framework` is what is posed on a project and the record of it: the manifest that tracks every +installed file, and every flow that reads or changes that record — setup, doctor, sync (restore), +uninstall, plugin install/update/remove, and the global chain orchestrators. It is the one +context the dependency chain lets reach every other context but `telemetry` (`framework → +translate → tools → kernel`, plus `framework → distribution`), because assembling what goes on +disk is exactly the job that needs all three. + +## What goes in + +| Concept | Location | +|---|---| +| The manifest aggregate and its members | `domain/manifest.ts`, `domain/manifest/` (tool-entry, tracked-files, merge-files, mcp-exclusions, native-registrations) | +| A plugin's declared state | `domain/plugins/` (installed-plugin, source-resolver, requested-version-policy) | +| The diagnosis shape | `domain/doctor.ts` | +| Setup orchestration state | `domain/setup-flow.ts` | +| A port only `framework` needs | `domain/ports/` (manifest-repository, plugin-distribution-reader, user-source-references) | +| A top-level flow's orchestrator | `application/` root, or a feature subdirectory (`clean/`, `doctor/`, `flows/`, `framework/`, `global/`, `install/`, `plugin/`, `restore/`, `setup/`, `uninstall/`) | +| Logic needed by ≥2 top-level use-cases | `application/shared/` — never called from a command | +| The manifest-repository and plugin-distribution-reader adapters | `infrastructure/` | + +## How + +- A use-case class ends in `UseCase`, has a single `async execute(options): Promise`, + never catches its own errors except the three carve-outs (global aggregate-error loops, + cache/network fallback, typed-throw translation) — see the use-case and orchestration rules in + `.claude/rules/00-architecture/`. +- A use case that installs framework files saves the manifest and updates `.gitignore` + through `PostInstallPipelineUseCase`. A use case that only changes the manifest (plugin + add/remove, marketplace sync, restore) saves it directly. See `references/post-install-pipeline.md`. +- Before writing any framework file: check `fs.fileExists(path) && !manifest.isFileTracked(path)`. + If both are true, skip the write, warn, and never add it to the manifest — never overwrite a + user-owned file. See `references/manifest.md`. +- A global chain orchestrator (`*-all-use-case.ts`) iterates every scope and must finish even if + one fails: wrap one iteration in `try/catch`, push a typed entry to an `errors[]` array, and + return it in the result — never let one tool's failure abort the whole run. +- A capability-guard sub-use-case (`install-agents-use-case.ts` and its siblings) checks + `"name" in caps` before dispatching to a narrowed sub-use-case in `install/` — see + `references/post-install-pipeline.md`, section "Capability sub-use-cases". These five files reach directly into `tools`' + capability classes rather than through a declared public module; that reach is a tracked, + shrinking exception in `context-boundary.arch.test.ts`, not a pattern to add to. +- **A launcher spawns its target, never imports it.** The decision and its cost are in + `aidd_docs/memory/architecture.md`. + +## Public surface + +Nothing outside `contexts/framework/` may import a module this context has not declared public. +`framework` is also the context most other contexts should never see: nothing in `tools`, +`translate`, or `distribution` may import from `framework` at all — the arrow only runs the other +way. Check `tests/architecture/context-graph.arch.test.ts` before adding an edge; check +`context-boundary.arch.test.ts`'s `PUBLIC_MODULES` before assuming a module framework itself +exposes is reachable from `presentation` or `runtime`. + +## How it's tested + +- `tests/contexts/framework/` mirrors `src/contexts/framework/` — domain models are unit-tier, + use-cases against in-memory ports (`tests/helpers/ports/`) are unit or integration depending on + whether they touch a real temp filesystem. +- `tests/e2e/` exercises full CLI invocations through `runCli()`; `tests/golden/` snapshots a + built framework tree end to end — see the `test` skill for tier and golden-snapshot rules. +- A manifest version-guard change needs a fixture manifest at the boundary version, asserting the + exact refusal message names the fix. diff --git a/cli/.claude/skills/framework/references/manifest.md b/cli/.claude/skills/framework/references/manifest.md new file mode 100644 index 000000000..57580c7bb --- /dev/null +++ b/cli/.claude/skills/framework/references/manifest.md @@ -0,0 +1,34 @@ +# Reference: Manifest Aggregate Root + +## Role + +- Tracks every installed framework file with its MD5 hash +- Persisted at `.aidd/manifest.json` +- Single source of truth for installed state — the version guard reads `MANIFEST_VERSION` + (`contexts/framework/domain/manifest-serialization.ts`) and nothing else on load, refusing an older manifest by + naming what to do with the document (no CLI migrates one forward), and a newer one by naming + self-update + +## Write guard (applies to any use-case writing framework files) + +- Before writing any framework file: check `fs.fileExists(path)` AND `!manifest.isFileTracked(relativePath)` +- If both true → skip the write, emit `logger.warn()`, never add it to the manifest +- Never overwrite a user-owned file + +## Saving + +- Installing files: save through `PostInstallPipelineUseCase`, which also updates `.gitignore` — see `references/post-install-pipeline.md` +- Changing only the manifest (plugin add/remove, marketplace sync, restore): `manifestRepo.save()` directly + +## Merge file tracking + +- Merge config files are tracked in `ToolEntry.mergeFiles` (not in `files`) +- `isFileTracked()` checks three sources: `files`, `mergeFiles`, and each installed plugin's own `isFileTracked` (`domain/manifest/tool-entry.ts`'s `isFileTrackedInEntry`) +- Uninstall and clean must delete merge files alongside regular files + +## Delegation to its members + +`manifest.ts` is the aggregate root and entry point; it delegates tracked files, merge files, MCP +exclusions, and plugins to the sibling modules in `domain/manifest/`. Add a new tracked concept +as its own module there, exposed through the aggregate root — never by growing `manifest.ts` +itself with a new field it manages directly. diff --git a/cli/.claude/skills/framework/references/post-install-pipeline.md b/cli/.claude/skills/framework/references/post-install-pipeline.md new file mode 100644 index 000000000..895f5875c --- /dev/null +++ b/cli/.claude/skills/framework/references/post-install-pipeline.md @@ -0,0 +1,62 @@ +# Reference: Post-Install Pipeline, Shared Use-Cases, Capability Sub-Use-Cases + +## Post-install pipeline + +**Rule**: a use case that installs framework files ends with `PostInstallPipelineUseCase`, +never with its steps inline. A use case that only changes the manifest saves it directly. + +**Steps, in order**: `manifestRepo.save()`, then `GitignoreUseCase.execute()` with +`.aidd/cache/`, every installed tool's machine-local files, and `aidd_docs/runs/`. + +```typescript +import { PostInstallPipelineUseCase } from "../install/post-install-pipeline-use-case.js"; + +await new PostInstallPipelineUseCase(this.manifestRepo, this.gitignoreUseCase).execute({ + projectRoot: options.projectRoot, + manifest: options.manifest, +}); +``` + +The source is `install/post-install-pipeline-use-case.ts`; read it before citing a step. + +## Shared use-cases + +Location: `application/shared/`. Rules: + +- Never called from commands — only from other use-cases. +- Same class shape as a top-level use-case: single `execute()`, typed `*Options` in, typed + `*Result` out. +- Create one only when the same orchestration logic is needed by ≥2 top-level use-cases — do not + inline equivalent logic in each caller instead. `ensure-built-marketplace-use-case.ts` is the + canonical example: both `plugin install` and `framework update` materialize a tool's build from + the same per-target cache. + +## Capability sub-use-cases + +**Pattern**: an orchestrator guards capability presence before dispatching to a sub-use-case that +receives a narrowed type. + +```typescript +if ("agents" in caps) { + const result = await new InstallAgentsUseCase(/* ... */).execute({ + config: toolConfig as AiTool, + }); +} +``` + +- Check `"name" in caps` before dispatching — skip tools that lack the capability. +- Never access `caps.agents` without first confirming presence via the guard. +- The sub-use-case receives pre-filtered, pre-typed input — never a raw `ToolConfig` or + unnarrowed union — and returns `InstallationFile[]` or a typed result, no side effects beyond + what it's explicitly asked to do. +- Sub-use-cases live in subdirectories of the parent feature: `install/`, and the equivalent + update/uninstall directories. + +These five files — `install-agents-use-case.ts` with its `commands`/`rules`/`skills` +siblings, and `install-content-section-use-case.ts`, the engine they hand a descriptor to, all +under `install/content/` — are the one place `framework` reaches directly into a `tools` +capability class instead of through a module `tools` has declared public. The exact five pairs +are listed in that test's baseline; there is no `hooks` sibling. `context-boundary.arch.test.ts` tracks this as a +shrinking baseline, not a pattern — it resolves once `install/` moves fully under an +application layer inside the `tools` context, which has not happened yet. Do not add a sixth +file to that list. diff --git a/cli/.claude/skills/telemetry/SKILL.md b/cli/.claude/skills/telemetry/SKILL.md new file mode 100644 index 000000000..00b365c58 --- /dev/null +++ b/cli/.claude/skills/telemetry/SKILL.md @@ -0,0 +1,68 @@ +--- +name: telemetry +description: > + Owns what a session cost and who it was for, under src/contexts/telemetry/ — reading a tool's + own local files, attributing a figure to a person, a task, a flow or a step, and the sink that + keeps records per machine. Use when adding a reader for another tool's transcript format, + changing how a figure is attributed or reported, touching the sink or the identity store, or + wiring a telemetry port. Do NOT use for what a tool declares about being measured — that is + `kernel/measurement.ts`, read via the `tools` skill. Do NOT use for installing or removing the + hook that writes the run journal into a project — that record belongs to `framework`. +--- + +# Telemetry + +`telemetry` answers two questions about work already done: what did it cost, and whose was it. +It never causes the work and never installs anything on a project's behalf. Everything it reads +already exists on disk because a tool wrote it, so the whole context is a set of readers, a set +of attribution rules, and one sink. + +Where the sink and the run journal live, what the report renders, and what each tool declares +about being measured are in `aidd_docs/memory/telemetry.md`. Read that for the facts; this page +says where new code goes and what it must not do. + +## What goes in + +| Concept | Location | +|---|---| +| A rendered answer's shape | `domain/cost-report.ts`, `domain/cost-report-envelope.ts` | +| How a figure is tied to something | `domain/step-attribution.ts`, `domain/task-attribution.ts`, `domain/flow-attribution.ts` | +| Reading one tool's own file format | `domain/formats/` (one module per tool's transcript or export) | +| A stored record and what happens to it over time | `domain/telemetry-sink-record.ts`, `domain/telemetry-sink-retention.ts` | +| Who a session was for, and how strongly that is known | `domain/person-resolution.ts`, `domain/ports/person-identity-reader.ts`, `domain/ports/person-identity-store.ts` | +| Something telemetry needs from outside itself | `domain/ports/` — declared here, satisfied at the composition root | +| The concrete reader behind one of those ports | `infrastructure/` | +| One question the `aidd telemetry` command asks | `application/` | + +## How + +- **A tool declares, telemetry reads.** What a route was measured to supply, and where a tool's + transcripts live, are declared in `kernel/measurement.ts` and filled in per tool under + `contexts/tools/domain/profiles//`. Every field there is required on purpose: a default + would be a capability nobody measured, quietly asserted for a tool nobody looked at. Never add + a branch on a tool id inside this context. +- **What telemetry needs from another context, it declares as its own port.** `installed-plugins-reader.ts` + and `ignore-entries.ts` are the pattern: telemetry states the question, `runtime/wiring/telemetry.ts` + hands it an answer, and no context reaches into telemetry in return. +- A figure with no established denomination is not an amount. A zero whose denomination was never + established, a credit and a premium request are each their own thing; conflating them is how a + report lies without ever being wrong about a number. +- An interval derived from the run journal is this CLI's inference, not the tool's statement. + Keep the two distinguishable in whatever you add — `toolStatedStep` exists for exactly that. +- Follow the use-case and port/adapter rules in `.claude/rules/00-architecture/`. + +## Public surface + +`tests/architecture/context-boundary.arch.test.ts` holds the list (`PUBLIC_MODULES.telemetry`): +the use cases the `telemetry` command drives, the shapes a rendered answer is made of, the +commit-trailer format the git adapter writes, and the two ports a caller wires a concrete +adapter into (`domain/ports/telemetry-sink.ts`, `domain/ports/version-control.ts`). Nothing +else, and no adapter. Rendering happens in `presentation/display/`, never here. + +## How it's tested + +- `tests/contexts/telemetry/` mirrors `src/contexts/telemetry/` — a format reader and an + attribution rule are unit-tier; an adapter against a real temp filesystem is integration-tier. +- A reader for a new tool's format needs a fixture captured from that tool's real output. A + format module tested only against a fixture this repository wrote proves the parser, not the + format. See the `test` skill before touching a golden snapshot. diff --git a/cli/.claude/skills/test/SKILL.md b/cli/.claude/skills/test/SKILL.md index e83bde77d..4c8b82927 100644 --- a/cli/.claude/skills/test/SKILL.md +++ b/cli/.claude/skills/test/SKILL.md @@ -1,56 +1,33 @@ --- name: test description: > - Creates or modifies tests in tests/ following the project's three-tier pyramid. Use when - writing tests for a new or existing use-case, adapter, domain model, or CLI command; when - reproducing a user-reported bug; or when auditing coverage. Do NOT use for implementing - production code — use the layer skills (`use-case`, `adapter`, `domain-model`, `command`) - instead. + Holds the two testing disciplines this package learned by paying for them, and points at the + rest. Use when writing or changing a test under tests/, touching a golden snapshot, or fixing a + user-reported bug. Do NOT use for tier conventions, the vitest projects, doubles, fixtures or + how to run a suite — those live in `aidd_docs/memory/testing.md`. Do NOT use for writing + production code — use the context skill that owns the concept (`tools`, `translate`, + `distribution`, `framework`, `telemetry`). --- # Test -Writes tests at the correct tier of the project's test pyramid: unit for domain and use-case -logic, integration for adapter behavior and real-filesystem contracts, E2E for full CLI -journeys. Bug fixes always start with a failing test that reproduces the exact reported -scenario before any production code is touched. +The tiers, the four vitest projects, the doubles, the fixtures and the run commands are in +`aidd_docs/memory/testing.md`; read that first and do not restate it here. This skill exists for +the two failure modes that cost this package a shipped bug each, and that a convention page +states as a rule without saying how to satisfy it. -## Available actions +## Read before you touch one -| # | Action | Role | Input | -| --- | --------------------- | ------------------------------------------------- | --------------------------------------- | -| 01 | `pick-tier` | Choose unit, integration, or e2e based on what is under test | target file or behavior | -| 02 | `name-behaviorally` | Draft behavior-sentence test names | list of scenarios to cover | -| 03 | `write` | Write the test file following tier conventions | tier + names from 01-02 | -| 04 | `empirical-repro` | Produce an empirical reproduction transcript for user-reported bugs | bug report | -| 05 | `smoke` | Run real CLI binary in /tmp, verify end-to-end | command + expected behavior | +- `references/golden-machine-independence.md` — a golden that snapshots a value derived from an + absolute path passes locally and fails on another machine. The rule is in `testing.md`; this is + the symptom, the root cause and the two fixes, plus the proof to run afterwards. +- `references/bug-empirical-reproduction.md` — a fix for a user-reported bug is not done until + the reported scenario has been reproduced end to end against the real binary, before and after. + Green unit and E2E tests have shipped an unfixed bug here; the transcript format is what + catches that. -## Default flow +## The order -`01 → 02 → 03` - -For user-reported bugs: `01 → 02 → 03 → 04` - -Smoke (`05`) is standalone: invoke it when validating that a CLI command or feature works for a real user, after a feature ships or before a release. It is not part of the `01 → 03` write flow. - -## Transversal rules - -- File suffix must match tier: `*.unit.test.ts`, `*.integration.test.ts`, `*.e2e.test.ts`. -- Mock only ports (domain interfaces) — never mock use-case internals or adapter implementations. -- Test name = observable behaviour sentence; use nested `describe` blocks not prefix separators. -- `describe.concurrent()` is forbidden in unit tests; required in E2E tests. -- Zero real network, zero real machine state outside temp dirs in any automated test. -- Write the failing test FIRST for every bug fix. -- Smoke tests run the real built binary in a fresh `/tmp` dir, never the repo root. - -## References - -- `references/test-pyramid.md` — tier definitions, rules per tier, forbidden patterns (authoritative) -- `references/bug-empirical-reproduction.md` — empirical reproduction mandate, transcript format (authoritative) -- `references/golden-machine-independence.md` — golden/snapshot tests must never snapshot values derived from absolute paths (including hashes over path-bearing content); normalize source content before hashing (authoritative) -- `references/smoke-in-tmp.md` — smoke/dogfood installs must run in /tmp only; in-repo leaks tool residue; gitignore non-Claude install dirs if unavoidable - -## Test infrastructure - -- `tests/helpers/ports/` — in-memory port implementations for unit mocking -- `tests/fixtures/` — local fixture directory (never mutate; copy before use) +Write the failing test first and watch it fail for the reason its name gives — a bug fix starts +there, never with the production edit. That discipline is stated for the whole repository in +`aidd_docs/memory/coding-assertions.md`. diff --git a/cli/.claude/skills/test/actions/01-pick-tier.md b/cli/.claude/skills/test/actions/01-pick-tier.md deleted file mode 100644 index bc157f59a..000000000 --- a/cli/.claude/skills/test/actions/01-pick-tier.md +++ /dev/null @@ -1,30 +0,0 @@ -# 01 - Pick Tier - -Decide which test tier is appropriate based on what is under test. - -## Inputs - -- `target` (required) - string, description of what is being tested (e.g. "InstallRuntimeConfigUseCase", "PluginFetcherAdapter error handling", "aidd install command full journey") - -## Outputs - -``` -Tier decision: - tier: unit | integration | e2e - suffix: .unit.test.ts | .integration.test.ts | .e2e.test.ts - location: tests// - rationale: -``` - -## Process - -1. Read `references/test-pyramid.md` for tier definitions. -2. If the target is a domain model, pure function, or use-case business logic → `unit`. Mock all ports via in-memory implementations from `tests/helpers/ports/`. -3. If the target is an adapter's error translation, retry logic, or format transformation, or a use-case's real-filesystem layout enforcement → `integration`. One file per adapter. -4. If the target is a full CLI user journey (command invocation to terminal output) → `e2e`. Maximum 5–10 scenarios per command. -5. If a unit test already covers the same assertion as a planned integration test, prefer the unit test and skip the integration test. -6. Output the tier decision. - -## Test - -The tier decision is verified implicitly when the test file created in 03 runs under the correct vitest project (`pnpm test:unit`, `pnpm test:integration`, or `pnpm test:e2e`). diff --git a/cli/.claude/skills/test/actions/02-name-behaviorally.md b/cli/.claude/skills/test/actions/02-name-behaviorally.md deleted file mode 100644 index 402f8bbe5..000000000 --- a/cli/.claude/skills/test/actions/02-name-behaviorally.md +++ /dev/null @@ -1,34 +0,0 @@ -# 02 - Name Behaviorally - -Draft `describe` and `it` block names that describe observable outcomes, not method calls. - -## Inputs - -- `scenarios` (required) - list of behaviors or edge cases to cover - -## Outputs - -``` -describe('ApplyWidgetUseCase') { - it('returns skipped result when widget already exists and force is false') - it('writes output files and returns file count on first apply') - it('overwrites existing files when force is true') - it('throws WidgetNotFoundError when widgetId is not recognized') -} -``` - -## Depends on - -- `01-pick-tier` - -## Process - -1. For each scenario, write a sentence that completes: "it ". -2. Avoid method names in `it()` blocks: NOT "calls hasTool", NOT "invokes execute" — describe what happens FROM THE CALLER'S perspective. -3. Group related scenarios under a parent `describe('')` block. Use nested `describe` for sub-groups (e.g. `describe('when force is true')`) — NOT prefix separators like "ClassName — behavior". -4. Follow the memory note on `describe()` grouping: `feedback_test_naming.md`. -5. Do not write the actual test code yet — names only in this action. - -## Test - -Names are evaluated when the test file created in 03 runs and every `it()` produces a meaningful vitest output line — readable without looking at the source. diff --git a/cli/.claude/skills/test/actions/03-write.md b/cli/.claude/skills/test/actions/03-write.md deleted file mode 100644 index a9acf5cdb..000000000 --- a/cli/.claude/skills/test/actions/03-write.md +++ /dev/null @@ -1,42 +0,0 @@ -# 03 - Write - -Write the test file following tier-specific conventions. - -## Inputs - -- `tier` (required) - string, one of `unit`, `integration`, `e2e` -- `names` (required) - list of describe/it names from 02 -- `target-file` (required) - string, path to the production source under test - -## Outputs - -``` -Test file at the correct path with the correct suffix. -``` - -## Depends on - -- `02-name-behaviorally` - -## Process - -1. Create the test file at: - - Unit: `tests/application/use-cases/.unit.test.ts` or `tests/domain/models/.unit.test.ts` - - Integration: `tests/infrastructure/adapters/-adapter.integration.test.ts` or `tests/application/use-cases/.integration.test.ts` - - E2E: `tests/e2e/.e2e.test.ts` - -2. **Unit tests** — mock all ports via `tests/helpers/ports/` in-memory implementations. No real I/O. No `describe.concurrent()`. - -3. **Integration tests** — use real temp filesystem when adapter boundary behavior is the target. Mock servers for HTTP. Cover: error parsing, retry logic, format serialization. - -4. **E2E tests** — invoke CLI via `runCli()` from `tests/e2e/helpers.ts`. Use `describe.concurrent()` at the top level. `try/finally` for cleanup. Marketplace = local fixture at `tests/fixtures/framework-real`. Zero real network. - -5. Use fixtures from `tests/fixtures/` — never mutate directly; copy to a temp directory before use. - -6. Apply the names from 02. No snapshot tests on menu trees or output strings. - -7. For bug fixes: write the test BEFORE touching production code. Confirm the test fails on the current code, then fix. - -## Test - -Run `pnpm test:unit`, `pnpm test:integration`, or `pnpm test:e2e` (matching the tier) — exits 0 with all new `it()` blocks passing. diff --git a/cli/.claude/skills/test/actions/04-empirical-repro.md b/cli/.claude/skills/test/actions/04-empirical-repro.md deleted file mode 100644 index ee550febf..000000000 --- a/cli/.claude/skills/test/actions/04-empirical-repro.md +++ /dev/null @@ -1,41 +0,0 @@ -# 04 - Empirical Repro - -Produce an empirical reproduction transcript for a user-reported bug using the real production CLI binary. - -## Inputs - -- `bug-report` (required) - string, the user's exact reported scenario and commands -- `branch` (required) - string, the fix branch name - -## Outputs - -```text -## Empirical reproduction - -### Pre-fix (main / broken baseline) -$ aidd ai install cursor -Error: VersionMismatchError — expected 4.1.0, got 4.0.2 - -### Post-fix (this branch) -$ aidd ai install cursor -Installed cursor (12 files) -``` - -## Depends on - -- `03-write` - -## Process - -1. Read `references/bug-empirical-reproduction.md` in full before proceeding. -2. Build the CLI on the broken baseline (`main` or the broken commit): `pnpm build`. -3. Run the user's exact commands from the bug report against the baseline binary. Capture the output verbatim including stderr and exit code. -4. Check out the fix branch. Build: `pnpm build`. -5. Run the same commands against the fix branch. Capture output. -6. Format both captures as the transcript block shown in Outputs above. -7. Include in the PR description under "## Empirical reproduction". -8. If the scenario requires real network access, run once locally and include the transcript verbatim in the PR body. Do NOT automate as a CI test. - -## Test - -The transcript is the test. Pre-fix output shows the reported error; post-fix output shows expected success. diff --git a/cli/.claude/skills/test/actions/05-smoke.md b/cli/.claude/skills/test/actions/05-smoke.md deleted file mode 100644 index 59cfd0599..000000000 --- a/cli/.claude/skills/test/actions/05-smoke.md +++ /dev/null @@ -1,43 +0,0 @@ -# 05 - Smoke - -Run the real built CLI binary in an isolated `/tmp` dir and verify a command works end-to-end. - -## Inputs - -- `command` (required) - string, the CLI command to invoke (e.g. `aidd widget apply`) -- `expected-behavior` (required) - string, observable outcomes to assert: exit code, stdout/stderr content, files written to the tmp dir - -## Outputs - -```text -Smoke result: - dir: /tmp/smoke- - exit-code: 0 - stdout: - files-written: - verdict: PASS | FAIL -``` - -## Process - -1. Read `references/smoke-in-tmp.md` in full before proceeding. -2. Build the CLI from the current branch: `pnpm build`. -3. Create a fresh isolated directory: `mkdir -p /tmp/smoke- && cd /tmp/smoke- && git init`. -4. Invoke the real binary with the command under test: `node /dist/cli.js `. -5. Capture exit code, stdout, and stderr verbatim. -6. Assert all expected-behavior items: exit code equals 0, expected strings appear in stdout or stderr, expected files exist relative to the tmp dir. -7. Fail explicitly if any assertion fails — show the diff between expected and observed. -8. Cleanup: `rm -rf /tmp/smoke-`. -9. Report the smoke result in the format shown in Outputs. - -## Test - -```sh -mkdir -p /tmp/smoke-widget && cd /tmp/smoke-widget && git init -node dist/cli.js widget apply -echo "exit: $?" -ls /tmp/smoke-widget -rm -rf /tmp/smoke-widget -``` - -All three assertions exit 0: build succeeds, binary exits 0, expected files are present. diff --git a/cli/.claude/skills/test/evals/scenarios.json b/cli/.claude/skills/test/evals/scenarios.json deleted file mode 100644 index 502eba440..000000000 --- a/cli/.claude/skills/test/evals/scenarios.json +++ /dev/null @@ -1,8 +0,0 @@ -[ - { "prompt": "Write unit tests for the new CleanUseCase", "expect_action": "pick-tier" }, - { "prompt": "Draft test names for the PluginFetcherAdapter error translation scenarios", "expect_action": "name-behaviorally" }, - { "prompt": "Write the integration test file for the FileAdapter", "expect_action": "write" }, - { "prompt": "Reproduce the bug where aidd ai install cursor fails with a version mismatch", "expect_action": "empirical-repro" }, - { "prompt": "Implement the CleanUseCase that removes all installed files", "expect_action": null }, - { "prompt": "Add a new --dry-run flag to the install command", "expect_action": null } -] diff --git a/cli/.claude/skills/test/references/bug-empirical-reproduction.md b/cli/.claude/skills/test/references/bug-empirical-reproduction.md index bd6437d70..8f9180d90 100644 --- a/cli/.claude/skills/test/references/bug-empirical-reproduction.md +++ b/cli/.claude/skills/test/references/bug-empirical-reproduction.md @@ -27,7 +27,7 @@ Include: ## Why automated tests aren't sufficient -A previous fix shipped with 1813 passing tests, two green E2E scenarios, and a reviewer score of 82/100 — but the user-reported bug was NOT fixed. The E2E used `--source local` which bypassed the broken code path. A 30-second manual `aidd marketplace add + aidd ai install cursor` would have caught it. +A previous fix shipped with 1813 passing tests, two green E2E scenarios, and a reviewer score of 82/100 — but the user-reported bug was NOT fixed. The E2E used `--source local` which bypassed the broken code path. A 30-second manual `aidd marketplace add + aidd framework install --tool cursor` would have caught it. ## Coverage ranking diff --git a/cli/.claude/skills/test/references/smoke-in-tmp.md b/cli/.claude/skills/test/references/smoke-in-tmp.md deleted file mode 100644 index b2883999e..000000000 --- a/cli/.claude/skills/test/references/smoke-in-tmp.md +++ /dev/null @@ -1,65 +0,0 @@ -# Reference: Smoke / Dogfood Install Isolation - -## Rule - -Smoke and dogfood installs MUST run in a fresh `/tmp/` directory, never in the repo root. - -## Why the repo root is forbidden - -Running a CLI install command in the repo root leaks tool-specific residue into the working tree: - -- `.codex/` (OpenAI Codex) -- `.cursor/` (Cursor) -- `.github/copilot/` (GitHub Copilot) -- `.opencode/`, `opencode.json` (OpenCode) -- `.vscode/` (VS Code) - -This project is Claude-only. Only `.claude/` and `.aidd/` are legitimate in-repo directories. Any other tool scaffold committed to the repo contaminates the tree and poisons other contributors' environments. - -## Pattern - -```sh -# 1. Build from current branch -pnpm build - -# 2. Create and initialize a clean workspace -mkdir -p /tmp/smoke- -cd /tmp/smoke- -git init - -# 3. Invoke the real binary -node /abs/path/to/repo/dist/cli.js [flags] - -# 4. Assert: exit code, stdout content, files written -echo "Exit: $?" -ls -la /tmp/smoke- - -# 5. Cleanup -rm -rf /tmp/smoke- -``` - -## When an in-repo install is unavoidable - -If a test fixture or CI job requires the install to run inside a subdirectory of the repo, add the generated directories to `.gitignore` before running the install. Do not commit them. - -Example `.gitignore` entries to add: - -``` -.codex/ -.cursor/ -.opencode/ -opencode.json -.vscode/ -``` - -Only add entries for tools actually being installed. Remove the entries after the test if they are no longer needed. - -## Scope - -This rule applies to: - -- Manual smoke runs during development -- Empirical reproduction transcripts (action 04) -- Any automated test that invokes the real CLI binary outside the standard `runCli()` helper - -It does NOT apply to unit or integration tests that run entirely in memory or in stdlib temp dirs. diff --git a/cli/.claude/skills/test/references/test-pyramid.md b/cli/.claude/skills/test/references/test-pyramid.md deleted file mode 100644 index b6b30f1c8..000000000 --- a/cli/.claude/skills/test/references/test-pyramid.md +++ /dev/null @@ -1,49 +0,0 @@ -# Reference: Test Pyramid - -## Tiers - -| Suffix | Target | Mock strategy | -| ------ | ------ | ------------- | -| `*.unit.test.ts` | domain models, pure functions, use-case logic | mock all ports via in-memory implementations from `tests/helpers/ports/` | -| `*.integration.test.ts` | adapters + real-FS contracts, use-case format serialization | real temp filesystem where needed; mock HTTP servers | -| `*.e2e.test.ts` | full CLI user journeys | real binary via `runCli()`, local fixtures only | - -## Unit rules - -- No real filesystem, no real I/O -- Mock only ports (domain interfaces) — never mock use-case internals -- `describe.concurrent()` forbidden -- Cover: business logic, branches, error paths - -## Integration rules — adapters - -- One file per adapter -- Cover: error parsing, retry logic, format transformation not visible in E2E - -## Integration rules — application - -- Real temp filesystem only when adapter boundary behavior is the test target -- Mock all ports otherwise - -## E2E rules - -- 5–10 scenarios per command max -- `describe.concurrent()` required at top level -- `try/finally` cleanup -- Marketplace = local fixture (`tests/fixtures/framework-real`); real GitHub only in manual smoke -- TTY interactive flows: use `expect(1)` shell-out via `execFile` -- Wall clock: <30s for the full suite - -## Forbidden - -- `it.skipIf(networkAvailable)` patterns -- Tests depending on real GitHub / external HTTP / real filesystem outside tmp -- Snapshot tests on menu trees / output strings -- Multiple permutations of the same flag combination — pick one representative case -- Deleting unit tests that an E2E now covers (only delete if same scenario AND same assertion) - -## Test name rules - -- Test name = observable behaviour sentence -- Use nested `describe` not prefix separators -- `describe('')` wraps all tests for that class diff --git a/cli/.claude/skills/tool/SKILL.md b/cli/.claude/skills/tool/SKILL.md deleted file mode 100644 index d50cd31e5..000000000 --- a/cli/.claude/skills/tool/SKILL.md +++ /dev/null @@ -1,61 +0,0 @@ ---- -name: tool -description: > - Adds or modifies an AI tool definition in domain/tools/ai/ and wires its framework-build - target. Use when defining a new AI assistant tool (composing AiTool from Has* capabilities), - changing an existing tool's capability intersection, adding or updating content-rewrite logic, - configuring PluginsCapability with marketplaceSettings, or registering the tool in the registry. - Do NOT use for adding a new capability class — use `capability` instead. Do NOT use for pure - string transforms — use `format` instead. Do NOT use for domain type or model changes — use - `domain-model` instead. ---- - -# Tool - -Builds a complete AI tool definition: a typed object implementing `AiTool` where `C` is an -intersection of `Has*` interfaces sourced from `domain/tools/contracts.ts`, registered via -`registerTool`, and optionally equipped with `PluginsCapability` and `marketplaceSettings`. - -## Available actions - -| # | Action | Role | Input | -| --- | -------------------------- | -------------------------------------------------------- | --------------------------------------- | -| 01 | `define-toolconfig` | Compose the AiTool object from Has* capabilities | tool name + required capabilities list | -| 02 | `content-rewrite` | Implement lossless rewriteContent / reverseRewriteContent | tool file from 01 | -| 03 | `plugins-and-marketplace` | Configure PluginsCapability + marketplaceSettings | tool file from 01 | -| 04 | `register-and-test` | Call registerTool and validate the full definition | completed tool from 01-03 | -| 05 | `build-contract` | Declare the tool's `framework build` behavior via `ToolBuildContract` | tool from 01 + modes (marketplace/flat) | - -## Default flow - -`01 → 02 → 03 → 04` then `05` when the tool must be an `aidd framework build` target. - -Skip 03 when the tool has no plugin capability. Skip 02 when the tool reuses base rewrite -helpers without modification (document this explicitly). Skip 05 when the tool is not a -framework-build target. - -## Transversal rules - -- Tool file lives in `domain/tools/ai/.ts`; one file per tool. -- `AiTool` where `C` is an intersection of `Has*` interfaces — never a plain object literal without the type annotation. -- Capability presence guard uses `"agents" in tool.capabilities` (in-check), not `instanceof`. -- `rewriteContent` and `reverseRewriteContent` must be exact inverses; compose `baseRewriteContent`/`baseReverseRewriteContent` first, then apply tool-specific transforms. -- `signalDir` points to the directory scanned for `name: aidd:` signals; required and non-null for AI tools. -- `directory` is the root output directory for the tool (e.g. `.acme/`). -- Call `registerTool(config)` at module bottom — never from use-cases or application layer. -- Named export only; no default export. -- `.js` extensions on all relative imports. -- No `any` types. -- Framework-build behavior is declared by ONE artifact-symmetric `ToolBuildContract` (all six - artifact kinds: skills/agents/mcp/hooks/rules/commands), consumed by the two per-mode - orchestrators — NEVER a per-tool `*OutputStrategy` class, NEVER a per-tool/per-artifact branch in - an orchestrator. Unsupported kinds are `{ supported: false }` (warn-and-skip). -- Build contracts reuse existing path/transform/merge helpers; generalize a helper rather than - reimplement it. Flat MCP merges key-prefix servers by `-`. - -## References - -- `references/aitool-shape.md` — AiTool fields, Has* interfaces, IdeToolConfig, ToolConfig union -- `references/plugins-capability.md` — PluginsCapability constructor params, modes, marketplaceSettings, translationMode, installScope -- `references/content-rewrite.md` — rewriteContent/reverseRewriteContent contract, base helpers, lossless-round-trip requirement -- `references/build-contract.md` — ToolBuildContract + ArtifactContract shape, artifact symmetry, the two per-mode orchestrators, reuse points, registry wiring diff --git a/cli/.claude/skills/tool/actions/01-define-toolconfig.md b/cli/.claude/skills/tool/actions/01-define-toolconfig.md deleted file mode 100644 index de8c749d5..000000000 --- a/cli/.claude/skills/tool/actions/01-define-toolconfig.md +++ /dev/null @@ -1,52 +0,0 @@ -# 01 - Define ToolConfig - -Compose the `AiTool` object by intersecting the required `Has*` capability interfaces and -setting the required base fields. - -## Inputs - -- `tool-name` (required) - string, kebab-case identifier for the new AI tool (e.g. `acme`) -- `capabilities` (required) - list of capability names the tool supports (e.g. `agents`, `skills`, `mcp`) - -## Outputs - -```typescript -// domain/tools/ai/acme.ts -import type { AiTool, HasAgents, HasSkills, UserFileSectionKey } from "../contracts.js"; -import { registerTool } from "../registry.js"; - -const DIRECTORY = ".acme/"; -const TOOL_SUFFIX = ".acme.md"; - -export const acme: AiTool = { - kind: "ai", - toolId: "acme", - directory: DIRECTORY, - toolSuffix: TOOL_SUFFIX, - signalDir: `${DIRECTORY}skills/`, - capabilities: { - agents: new AgentsCapability({ /* ... */ }), - skills: new SkillsCapability({ /* ... */ }), - }, - rewriteContent(content, docsDir) { return content; }, - reverseRewriteContent(content, docsDir) { return content; }, - detectUserFileSectionKey(relativePath) { return null; }, -}; - -registerTool(acme); -``` - -## Process - -1. Create `domain/tools/ai/.ts`. Confirm the file does not already exist. -2. Declare module-level constants for `DIRECTORY` and `TOOL_SUFFIX` in `CONSTANT_CASE`. -3. Declare `export const : AiTool` — type parameter is the intersection of all required `Has*` interfaces from `domain/tools/contracts.ts`. -4. Set required fields: `kind: "ai"`, `toolId`, `directory`, `toolSuffix`, `signalDir` (the directory the registry scans for aidd signals; `null` if the tool has no skill signals). -5. For each capability in the list, import its class from `domain/capabilities/` and instantiate it in the `capabilities` object. -6. Add stub implementations for `rewriteContent`, `reverseRewriteContent`, and `detectUserFileSectionKey` — these are completed in 02. -7. Add `registerTool()` at the bottom of the file. Do not call `registerTool` from elsewhere. -8. Use `import type` for type-only imports (`AiTool`, `Has*`, `UserFileSectionKey`); concrete imports for capability classes and `registerTool`. - -## Test - -Run `pnpm typecheck` — exits 0 confirms the `AiTool` type is correctly assembled and all `Has*` interfaces are satisfied. diff --git a/cli/.claude/skills/tool/actions/02-content-rewrite.md b/cli/.claude/skills/tool/actions/02-content-rewrite.md deleted file mode 100644 index a5d2b0fd5..000000000 --- a/cli/.claude/skills/tool/actions/02-content-rewrite.md +++ /dev/null @@ -1,40 +0,0 @@ -# 02 - Content Rewrite - -Implement the `rewriteContent` and `reverseRewriteContent` methods so they form a lossless -round-trip. Both must satisfy: `reverse(rewrite(content)) === content` for any input string. - -## Inputs - -- `tool-name` (required) - string, kebab-case tool name matching the file from 01 -- `tool-specific-transforms` (optional) - list of tool-specific string substitutions to apply on top of base helpers - -## Outputs - -```typescript -rewriteContent(content: string, docsDir: string): string { - const base = baseRewriteContent(content, docsDir); - return base.replaceAll("[[ACME_DOCS]]", docsDir); -}, - -reverseRewriteContent(content: string, docsDir: string): string { - const reversed = content.replaceAll(docsDir, "[[ACME_DOCS]]"); - return baseReverseRewriteContent(reversed, docsDir); -}, -``` - -## Depends on - -- `01-define-toolconfig` - -## Process - -1. Open `domain/tools/ai/.ts`. -2. Import `baseRewriteContent` and `baseReverseRewriteContent` from `domain/formats/placeholders.js`. -3. In `rewriteContent`: call `baseRewriteContent(content, docsDir)` first, then apply any tool-specific transforms on the result. -4. In `reverseRewriteContent`: apply tool-specific reversal transforms first (in reverse order relative to step 3), then call `baseReverseRewriteContent(result, docsDir)`. -5. If no tool-specific transforms are needed, delegate entirely to the base helpers and document this in a comment. -6. Verify round-trip manually with one example: pick a sample string containing the transformed token and confirm the chain `reverse(rewrite(sample)) === sample`. - -## Test - -Run `pnpm typecheck` — exits 0, and `pnpm test:unit` passes on any existing rewrite unit tests in the test suite to confirm the round-trip contract is not broken. diff --git a/cli/.claude/skills/tool/actions/03-plugins-and-marketplace.md b/cli/.claude/skills/tool/actions/03-plugins-and-marketplace.md deleted file mode 100644 index 632e32533..000000000 --- a/cli/.claude/skills/tool/actions/03-plugins-and-marketplace.md +++ /dev/null @@ -1,59 +0,0 @@ -# 03 - Plugins and Marketplace - -Configure `PluginsCapability` for the tool, including `marketplaceSettings` when the tool -supports a plugin marketplace, and wire `translationMode` and `installScope` appropriately. - -## Inputs - -- `tool-name` (required) - string, kebab-case tool name matching the file from 01 -- `mode` (required) - one of `native`, `flat`, `unsupported` -- `marketplace` (optional) - boolean, whether the tool has a marketplace registry - -## Outputs - -```typescript -// native mode with marketplace -plugins: new PluginsCapability({ - mode: "native", - pluginsDir: ".acme/plugins/", - pluginManifestRelativePath: "MANIFEST.md", - translationMode: "marketplace", - installScope: "project", - marketplaceSettings: { - settingsPath: ".acme/settings.json", - settingsKey: "extensions", - valueShape: "map", - toEntry({ name, source }) { - return { valueShape: "map", key: name, value: { source: source.url } }; - }, - }, -}), - -// flat mode (no marketplace) -plugins: new PluginsCapability({ - mode: "flat", - flatNamespacePrefix: "acme-", -}), -``` - -## Depends on - -- `01-define-toolconfig` - -## Process - -1. Open `domain/tools/ai/.ts`. Locate the `capabilities` object. -2. Import `PluginsCapability` from `domain/capabilities/plugins-capability.js` if not already imported. -3. For `mode: "native"`: - - Set `pluginsDir` to the tool's plugin directory path. - - Set `pluginManifestRelativePath` to the manifest file name relative to each plugin dir, or `null` to suppress manifest writing. - - Set `translationMode: "marketplace"` if `marketplaceSettings` is provided (Mode A — registry-only, no file materialization). Omit or set `null` for neutral native. - - Set `installScope: "user"` only when plugins install to the user home directory; provide `userPluginsDir` resolver in that case. Defaults to `"project"`. - - Define `marketplaceSettings` with `settingsPath`, `settingsKey`, and `toEntry` when the tool has a marketplace registry. -4. For `mode: "flat"`: set `flatNamespacePrefix` to the tool's flat namespace prefix. -5. For `mode: "unsupported"`: set `{ mode: "unsupported" }` — no other fields needed. -6. Update the `Has*` intersection in the type annotation to include `HasPlugins` if not already present. - -## Test - -Run `pnpm typecheck` — exits 0 confirms `PluginsCapability` is instantiated with valid params and the tool's `HasPlugins` interface is satisfied. diff --git a/cli/.claude/skills/tool/actions/04-register-and-test.md b/cli/.claude/skills/tool/actions/04-register-and-test.md deleted file mode 100644 index fccead4a0..000000000 --- a/cli/.claude/skills/tool/actions/04-register-and-test.md +++ /dev/null @@ -1,38 +0,0 @@ -# 04 - Register and Test - -Verify that `registerTool` is called correctly, the tool resolves from the registry, and -the full definition satisfies all type constraints. - -## Inputs - -- `tool-name` (required) - string, kebab-case tool name matching the file from 01 -- `tool-id` (required) - string, the `AiToolId` registered for this tool - -## Depends on - -- `01-define-toolconfig` -- `02-content-rewrite` -- `03-plugins-and-marketplace` (if applicable) - -## Outputs - -``` -Validation checklist: - - [ ] registerTool(acme) present at module bottom - - [ ] toolId is declared in domain/models/tool-ids.ts AI_TOOL_IDS - - [ ] pnpm typecheck exits 0 - - [ ] pnpm build exits 0 - - [ ] pnpm lint exits 0 -``` - -## Process - -1. Confirm `registerTool()` is the last statement in the module (after the `export const` declaration). -2. Confirm `toolId` is a valid member of `AI_TOOL_IDS` in `domain/models/tool-ids.ts`. If not, add it to the array in that file first. -3. Confirm the tool file imports `registerTool` from `domain/tools/registry.js` (not re-exported from elsewhere). -4. Run the validation checklist in order: typecheck, then build, then lint. Fix any failures before moving on. -5. Write a unit test in `tests/domain/tools/` that calls `getToolConfig("")` and asserts the returned config is not undefined and `config.kind === "ai"`. - -## Test - -Run `pnpm typecheck && pnpm build && pnpm lint` — all exit 0, confirming the tool definition compiles, bundles, and passes style checks. diff --git a/cli/.claude/skills/tool/actions/05-build-contract.md b/cli/.claude/skills/tool/actions/05-build-contract.md deleted file mode 100644 index 86cb0d8b3..000000000 --- a/cli/.claude/skills/tool/actions/05-build-contract.md +++ /dev/null @@ -1,62 +0,0 @@ -# 05 - Build Contract - -Declare the tool's `aidd framework build` behavior by implementing one artifact-symmetric -`ToolBuildContract` and registering its `(target, mode)` rows. Do this when a tool must be a -framework-build target (marketplace and/or flat). Never write a new `*OutputStrategy` class — that -pattern is gone; the two per-mode orchestrators consume the contract. - -## Inputs - -- `tool-name` (required) - kebab-case tool name matching the file from 01 -- `modes` (required) - which modes the tool supports: `marketplace`, `flat`, or both. A tool with no - native marketplace supports `flat` only. - -## Depends on - -- `01-define-toolconfig` (the tool's capabilities + `buildInstallPath` functions are the contract's path source) - -## Outputs - -``` -Build-contract checklist: - - [ ] contract declares ALL six artifact kinds (skills/agents/mcp/hooks/rules/commands) - as ArtifactContract | { supported: false } — no kind omitted, no agent special-casing - - [ ] paths reuse the tool's buildInstallPath / generic flat-path primitives (no inline reinvention) - - [ ] transforms + merges reuse existing helpers (generalize, never reimplement) - - [ ] flat mcp merge key-prefixes servers by "-" - - [ ] (target,mode) rows added to the framework-build registry; unsupported pairs absent - - [ ] tool id in FrameworkBuildTarget union + command SUPPORTED_TARGETS - - [ ] orchestrators still contain zero per-tool / per-artifact branches -``` - -## Process - -1. Read `references/build-contract.md` for the contract shape and rules. -2. Decide each artifact kind: `{ supported: false }` for kinds the tool has no native concept for - (today: `rules`, `commands` for all tools; `hooks` for a tool with no hook capability), else a - `{ supported: true, ... }` with `source` + `path` + (only as needed) `ext`/`transform`/`merge`. -3. For `path`, reuse the tool's per-capability `buildInstallPath` and the generic flat-path - primitives — pass the tool's dir prefix + ext; do not inline a new path string. -4. For `transform`, reuse the tool's existing format helper (frontmatter strip, markdown→TOML, …). - For `merge` (mcp/config targets), reuse the existing merge helper; if its signature does not fit, - generalize the helper with a parameter rather than writing a parallel merge. Key-prefix mcp - servers by `-`. -5. If the tool needs a post-build artifact (a config file that registers skills, a workspace - config), implement `emitConfigArtifact`; otherwise omit it. -6. If two tools differ only by dir prefix + a small transform, factor a single parameterised - contract factory; isolate a structurally distinct tool in its own builder. -7. Register: add `":"` rows to the framework-build registry mapping to - `MarketplaceBuildStrategy(contract)` / `FlatBuildStrategy(contract)`. Add the tool id to the - `FrameworkBuildTarget` union and the command `SUPPORTED_TARGETS`. Leave unsupported pairs absent. - -## Test - -- `aidd framework build --target [--flat] --out ` exits 0 and produces the tool's - documented native layout (verify against the tool's own docs — skills/agents/mcp/hooks paths, - agent format, config file). For flat, `--out` must be an existing directory. -- Smoke in `/tmp` (never the repo root): build into a fresh `/tmp/`, assert the tree matches - the documented format (e.g. valid TOML / valid JSON config where applicable) and mcp servers are - `-`-prefixed. -- Grep gate: zero `if (tool === …)` and zero `if (kind === "agents")` in the two orchestrators. -- Existing targets' output stays byte-identical (regression — compare against a pre-change baseline, - not a freshly regenerated snapshot). diff --git a/cli/.claude/skills/tool/evals/scenarios.json b/cli/.claude/skills/tool/evals/scenarios.json deleted file mode 100644 index a2863b6c3..000000000 --- a/cli/.claude/skills/tool/evals/scenarios.json +++ /dev/null @@ -1,11 +0,0 @@ -[ - { "prompt": "Add a new AI tool called opencode to the framework", "expect_action": "define-toolconfig" }, - { "prompt": "Compose an AiTool with agents and skills capabilities for the Acme assistant", "expect_action": "define-toolconfig" }, - { "prompt": "Implement rewriteContent for the new acme tool so docs paths are replaced", "expect_action": "content-rewrite" }, - { "prompt": "Configure PluginsCapability with marketplace settings for the acme tool", "expect_action": "plugins-and-marketplace" }, - { "prompt": "Register the acme tool in the registry and run typecheck", "expect_action": "register-and-test" }, - { "prompt": "Make acme a framework build target so aidd framework build --target acme --flat works", "expect_action": "build-contract" }, - { "prompt": "Add flat-mode framework build support for the acme tool", "expect_action": "build-contract" }, - { "prompt": "Add a new use-case for installing plugins", "expect_action": null }, - { "prompt": "Create a pure function to transform widget frontmatter to JSON", "expect_action": null } -] diff --git a/cli/.claude/skills/tool/references/aitool-shape.md b/cli/.claude/skills/tool/references/aitool-shape.md deleted file mode 100644 index c2251ffae..000000000 --- a/cli/.claude/skills/tool/references/aitool-shape.md +++ /dev/null @@ -1,112 +0,0 @@ -# Reference: AiTool Shape - -## AiTool — base type - -```typescript -interface AiTool { - readonly kind: "ai"; - readonly toolId: AiToolId; - readonly directory: string; // root output directory (e.g. ".acme/") - readonly toolSuffix: string; // per-file suffix (e.g. ".acme.md") - readonly signalDir: string | null; // scanned for `name: aidd:` signals; null = no signals - readonly requiredIdeIds?: readonly IdeToolId[]; - readonly capabilities: C; - readonly configOutputPaths?: Readonly>; - rewriteContent(content: string, docsDir: string): string; - reverseRewriteContent(content: string, docsDir: string): string; - detectUserFileSectionKey(relativePath: string): UserFileSectionKey | null; -} -``` - -`C` is always an intersection of `Has*` interfaces (e.g. `HasAgents & HasSkills & HasMcp`). - -## Has* interfaces (in domain/tools/contracts.ts) - -| Interface | Field | Capability class | -| -------------- | ------------------- | ------------------------ | -| `HasAgents` | `agents` | `AgentsCapability` | -| `HasSkills` | `skills` | `SkillsCapability` | -| `HasCommands` | `commands` | `CommandsCapability` | -| `HasRules` | `rules` | `RulesCapability` | -| `HasMcp` | `mcp` | `McpCapability` | -| `HasHooks` | `hooks` | `HooksCapability` | -| `HasSettings` | `settings` | `SettingsCapability` | -| `HasPlugins` | `plugins` | `PluginsCapability` | - -Include only the `Has*` interfaces the tool actually supports. Unused capability fields must not appear. - -## Two config variants - -- `AiTool` — AI assistants; `kind: "ai"`; has capabilities -- `IdeToolConfig` — IDE integrations; `kind: "ide"`; no capabilities; `signalDir: null` -- `ToolConfig = AiTool | IdeToolConfig` — the union used throughout the registry - -## Capability presence guard - -```typescript -if ("agents" in tool.capabilities) { - // tool.capabilities.agents is AgentsCapability -} -``` - -Use the `in` operator against the capabilities object, never `instanceof`. - -## ToolConfig discriminant - -```typescript -function isAiTool(config: ToolConfig): config is AiTool { - return config.kind === "ai"; -} -``` - -## registerTool - -```typescript -import { registerTool } from "../registry.js"; -// At module bottom, after the export const declaration: -registerTool(acme); -``` - -`registerTool` stores the config in a module-level `Map`. Call it -exactly once per tool file, at module bottom. Never call it from use-cases, adapters, or commands. - -## Agnostic shape example (fictional `acme` tool) - -```typescript -// domain/tools/ai/acme.ts -import { AgentsCapability } from "../../capabilities/agents-capability.js"; -import { SkillsCapability } from "../../capabilities/skills-capability.js"; -import type { AiTool, HasAgents, HasSkills, UserFileSectionKey } from "../contracts.js"; -import { registerTool } from "../registry.js"; - -const DIRECTORY = ".acme/"; -const TOOL_SUFFIX = ".acme.md"; - -export const acme: AiTool = { - kind: "ai", - toolId: "acme", - directory: DIRECTORY, - toolSuffix: TOOL_SUFFIX, - signalDir: `${DIRECTORY}skills/`, - capabilities: { - agents: new AgentsCapability({ - directory: `${DIRECTORY}agents/`, - toolSuffix: TOOL_SUFFIX, - convertFrontmatter: (fm) => fm, - reverseConvertFrontmatter: (fm) => fm, - }), - skills: new SkillsCapability({ - directory: DIRECTORY, - toolSuffix: TOOL_SUFFIX, - buildInstallPath: (fileName) => fileName, - convertFrontmatter: (fm) => fm, - reverseConvertFrontmatter: (fm) => fm, - }), - }, - rewriteContent(content, docsDir) { return content; }, - reverseRewriteContent(content, docsDir) { return content; }, - detectUserFileSectionKey(_relativePath) { return null; }, -}; - -registerTool(acme); -``` diff --git a/cli/.claude/skills/tool/references/build-contract.md b/cli/.claude/skills/tool/references/build-contract.md deleted file mode 100644 index 12d25694f..000000000 --- a/cli/.claude/skills/tool/references/build-contract.md +++ /dev/null @@ -1,76 +0,0 @@ -# ToolBuildContract — framework-build behavior per tool - -`aidd framework build --target [--flat]` translates the Claude-format framework into a -tool-native plugin tree (marketplace mode) or a project workspace (flat mode). A tool's build -behavior is declared by **one `ToolBuildContract`**, NOT by writing a new strategy class. Two thin -per-mode orchestrators consume the contract: - -- `MarketplaceBuildStrategy(contract)` — emits the tool's marketplace plugin tree + catalog. -- `FlatBuildStrategy(contract)` — materialises content into a project workspace (per-plugin namespace). - -Both implement the shared `BuildOutputStrategy` interface and iterate artifact kinds **generically**. - -## Artifact symmetry (the core rule) - -A plugin carries six artifact kinds: `skills`, `agents`, `mcp`, `hooks`, `rules`, `commands`. The -contract exposes ONE `ArtifactContract` per kind — it never special-cases a single kind (e.g. no -`transformAgent` field). Each kind is either: - -- `{ supported: false }` → warn-and-skip (no native concept in this tool; e.g. `rules`/`commands` - today, or `hooks` for a tool that has no hook capability), or -- `{ supported: true, source, path, ext?, transform?, merge?, mergeDest?, mcpServersKey?, - hooksMerge?, hooksMergeDest? }`. - -The orchestrators contain **zero** `if (tool === …)` and **zero** `if (kind === "agents")` branches. -Adding a tool = writing its contract; adding tool-specific behavior = the contract's fields, never a -branch in an orchestrator. - -## `ArtifactContract` fields - -| field | role | -| --- | --- | -| `source` | where the input files come from: `filteredTree` (e.g. agents `.md`), `fullTree` (skills), `configFile` (mcp `.mcp.json`), `hooksBundle` (hooks.json + scripts) | -| `path(plugin, relPath)` | output path for one file — reuse the tool def's existing per-capability `buildInstallPath` for the primary-dir path; the orchestrator/contract adds the per-plugin namespace in flat mode | -| `ext?` | output extension override (e.g. `.agent.md`, `.toml`); absent = preserve source ext | -| `transform?(content, plugin, basename)` | per-kind content transform; default = identity (byte-copy). Examples: strip `tools`/`color` frontmatter; markdown → TOML | -| `merge?` / `mergeDest?` / `mcpServersKey?` | for config-file kinds (mcp) that merge into one shared file rather than per-plugin write; reuse an existing merge helper, never reimplement | -| `hooksMerge?` / `hooksMergeDest?` | for tools whose hooks register into one shared file rather than per-plugin write | - -Contract-level: `manifestDir` / `marketplaceRelative` / `synthesizeManifest` (marketplace mode; -`null` when the tool has no native marketplace) and an optional `emitConfigArtifact(builtPlugins, -outDir)` (post-build artifact — e.g. a config file that registers skills, or a workspace config). - -## Reuse, never reinvent - -The tool definition already holds the per-tool knowledge — the contract wires it up: - -- paths → the capability `buildInstallPath` functions + the generic flat-path primitives. -- agent format → reuse the tool's existing transform (e.g. a markdown→TOML formatter, a - frontmatter-strip helper) — do not inline a new one in the contract. -- mcp / config merges → reuse the existing merge helper for that tool's target format; if the - helper's signature doesn't fit, **generalize the helper** (add a parameter) rather than writing a - parallel merge. -- manifest synthesis → reuse the shared Claude-style manifest synthesizer where the tool adopts the - Claude plugin shape. - -## MCP namespacing (correctness) - -Every flat MCP merge must key-prefix servers by `-`. Tools whose MCP config lives at a -primary location (not a per-plugin file) have no isolation otherwise — two plugins declaring a -server of the same name would collide. The prefix is mandatory for all tools. - -## Shared vs own contract - -When two tools differ only by output directory + a small transform, share **one parameterised -contract factory** (pass the dir prefix + ext). When a tool's format is structurally distinct -(e.g. TOML agents + a config-file registration, or a JSON-config merge with no marketplace), give it -its own contract. This mirrors the layer convention: DRY via a shared factory, isolate genuine -divergence in its own builder — never a base class, never a per-tool branch in the orchestrator. - -## Registration - -Each `(target, mode)` pair is one row in the framework-build registry (`infrastructure/deps.ts`), -mapping the key `":"` to `mode-orchestrator(tool-contract)`. A tool with no native -marketplace simply has no `:marketplace` row — the unsupported pair falls through to the -existing "Unsupported target/mode" error. The tool id must also be in the `FrameworkBuildTarget` -union and the command's `SUPPORTED_TARGETS`. diff --git a/cli/.claude/skills/tool/references/content-rewrite.md b/cli/.claude/skills/tool/references/content-rewrite.md deleted file mode 100644 index 14f833336..000000000 --- a/cli/.claude/skills/tool/references/content-rewrite.md +++ /dev/null @@ -1,77 +0,0 @@ -# Reference: Content Rewrite - -## Contract - -`rewriteContent` and `reverseRewriteContent` must form a lossless round-trip: - -``` -reverseRewriteContent(rewriteContent(content, docsDir), docsDir) === content -``` - -for every possible `content` string and every `docsDir` value. - -## Base helpers - -Two base helpers in `domain/formats/placeholders.ts` handle the common cases: - -- `baseRewriteContent(content, docsDir)` — replaces `docsDir` occurrences with a canonical placeholder. -- `baseReverseRewriteContent(content, docsDir)` — restores the placeholder back to `docsDir`. - -All tools must delegate to these as the foundation layer. Tool-specific transforms are composed -on top. - -## Composition order - -**rewriteContent**: apply `baseRewriteContent` first, then tool-specific transforms. - -**reverseRewriteContent**: apply tool-specific reverse transforms first (in the reverse order -of the forward transforms), then `baseReverseRewriteContent`. - -This ordering ensures the base placeholder is always in the correct normalized form for -tool-specific substitutions to operate on. - -## When no tool-specific transforms are needed - -If the tool only needs the base helpers, delegate entirely and add a comment: - -```typescript -rewriteContent(content: string, docsDir: string): string { - // No tool-specific transforms; delegate to base. - return baseRewriteContent(content, docsDir); -}, -reverseRewriteContent(content: string, docsDir: string): string { - // No tool-specific transforms; delegate to base. - return baseReverseRewriteContent(content, docsDir); -}, -``` - -## Agnostic example (fictional `acme` tool with one extra transform) - -```typescript -import { baseReverseRewriteContent, baseRewriteContent } from "../../formats/placeholders.js"; - -const ACME_DOCS_PLACEHOLDER = "[[ACME_DOCS]]"; - -export const acme: AiTool<...> = { - // ... - rewriteContent(content: string, docsDir: string): string { - const base = baseRewriteContent(content, docsDir); - return base.replaceAll(docsDir, ACME_DOCS_PLACEHOLDER); - }, - reverseRewriteContent(content: string, docsDir: string): string { - const restored = content.replaceAll(ACME_DOCS_PLACEHOLDER, docsDir); - return baseReverseRewriteContent(restored, docsDir); - }, -}; -``` - -## Round-trip verification - -Before marking the action complete, verify manually: - -``` -const sample = "see [[ACME_DOCS]]/guide.md or /docs/guide.md for details"; -const after = acme.rewriteContent(sample, "/docs"); -const back = acme.reverseRewriteContent(after, "/docs"); -assert(back === sample); -``` diff --git a/cli/.claude/skills/tool/references/plugins-capability.md b/cli/.claude/skills/tool/references/plugins-capability.md deleted file mode 100644 index ee400374a..000000000 --- a/cli/.claude/skills/tool/references/plugins-capability.md +++ /dev/null @@ -1,83 +0,0 @@ -# Reference: PluginsCapability - -## Three modes - -| Mode | When to use | -| --------------- | -------------------------------------------------------- | -| `"native"` | Tool has a first-class plugin directory structure | -| `"flat"` | Tool stores plugins as flat files under a name prefix | -| `"unsupported"` | Tool has no plugin concept | - -## Native mode params - -```typescript -new PluginsCapability({ - mode: "native", - pluginsDir: ".acme/plugins/", // directory where plugins are installed - pluginManifestRelativePath: "MANIFEST.md", // relative to each plugin dir; null suppresses writing - mcpRelativePath: ".mcp.json", // optional; defaults to ".mcp.json" - hooksRelativePath: "hooks/hooks.json", // optional; defaults to "hooks/hooks.json" - hooksContentFormat: "claude", // optional; defaults to "claude" - acceptsHooks: true, // optional; defaults to false - acceptsMcp: true, // optional; defaults to false - translationMode: "marketplace", // set to "marketplace" when using marketplaceSettings - installScope: "project", // "project" (default) or "user" - userPluginsDir: (h) => join(h, ".acme", "plugins"), // required when installScope is "user" - marketplaceSettings: { ... }, // optional; configure when tool has a registry -}); -``` - -## Flat mode params - -```typescript -new PluginsCapability({ - mode: "flat", - flatNamespacePrefix: "acme-", // prepended to plugin names in flat mode -}); -``` - -## marketplaceSettings shape - -```typescript -interface MarketplaceSettings { - settingsPath: string; // path to the tool's settings file (e.g. ".acme/settings.json") - settingsKey: string; // key in settings where plugin entries live (e.g. "extensions") - valueShape?: "map" | "array"; // "map" = { key: name, value: {...} }; "array" = string entry - enabledPluginsKey?: string; - enabledPluginsSettingsPath?: string; - toEntry(input: { name: string; source: PluginSource; version?: string }): MarketplaceSettingsEntry | null; -} -``` - -## translationMode - -- `"marketplace"` — Mode A: register plugin reference in tool's native config; no file materialization. -- `"flat"` — Mode B: materialize plugin content as flat files on disk (automatic for `mode: "flat"`). -- `null` — neutral native; no translation strategy applies. - -Set `translationMode: "marketplace"` explicitly on native tools that use Mode A routing. - -## installScope - -- `"project"` (default) — plugins installed relative to project root. -- `"user"` — plugins installed relative to user home dir; requires `userPluginsDir` resolver. - -## Agnostic example (fictional `acme` with marketplace) - -```typescript -plugins: new PluginsCapability({ - mode: "native", - pluginsDir: ".acme/plugins/", - pluginManifestRelativePath: null, - translationMode: "marketplace", - marketplaceSettings: { - settingsPath: ".acme/config.json", - settingsKey: "plugins", - valueShape: "map", - toEntry({ name, source }) { - if (source.kind !== "github") return null; - return { valueShape: "map", key: name, value: { repo: source.url } }; - }, - }, -}), -``` diff --git a/cli/.claude/skills/tools/SKILL.md b/cli/.claude/skills/tools/SKILL.md new file mode 100644 index 000000000..8c559f4b4 --- /dev/null +++ b/cli/.claude/skills/tools/SKILL.md @@ -0,0 +1,72 @@ +--- +name: tools +description: > + Defines or modifies what the project targets, under src/contexts/tools/ — an AI/IDE tool + profile, its build contract, the content-translation capability classes it composes + (agents/skills/commands/rules/hooks), and its own native-plugin adapter. Use when adding a new + AI or IDE tool, changing a tool's Has* capability intersection, adding or modifying a + capability class, or declaring a tool's `aidd translate` build contract. Do NOT use for the + canonical-to-native translation pipeline itself — use `translate`. Do NOT use for where plugin + content comes from — use `distribution`. Do NOT use for manifest or install orchestration — use + `framework`. +--- + +# Tools + +`tools` is what the project targets and how each target is configured. Every AI assistant and +IDE the CLI supports is one `AiTool` or `IdeToolConfig` object in +`contexts/tools/domain/profiles//profile.ts`, where `C` is the intersection of `Has*` +capability interfaces the tool actually supports. `translate` depends on `tools` (never the +reverse) to call the tool's own `rewriteContent` and to read its build contract — a tool profile +is data and behavior the rest of the CLI is handed, not a place that reaches out to fetch or +install anything itself. + +## What goes in + +| Concept | Location | +|---|---| +| A tool's identity, capabilities, content-rewrite | `domain/profiles//profile.ts` | +| A tool's `aidd translate` build behavior | `domain/profiles//build.ts` (only if the tool is a build target) | +| A string transform used by exactly one profile | that profile's own directory | +| A string transform shared by ≥2 profiles | `domain/formats/` | +| A content-translation capability class (agents/skills/commands/rules/hooks) | `domain/capabilities/` + a `Has*` entry in `contracts.ts` | +| Catalog/manifest shaping shared by ≥2 tools' build contracts | `domain/marketplace-catalog.ts` | +| A port only `tools` needs | `domain/ports/` | +| A tool's own plugin-CLI driver | `infrastructure/` | + +## How + +- `AiTool` fields: read `domain/contracts.ts`. A list copied here aged at every field. +- `Has*` interfaces live in `contracts.ts`, always `readonly`, never optional — + a tool either includes `Has` in its `C` intersection or does not have the field at all. + Guard presence with `"name" in tool.capabilities`, never `instanceof`. +- `rewriteContent(content)` is one way, per tool, with no reverse and no shared base helper. + See `references/content-rewrite.md`. +- A capability class ends in `Capability`, takes one params object, all fields `readonly`, throws + `CapabilityConfigError` (from `kernel/errors.ts`) on an invalid combination, carries no + application/infrastructure imports. See `references/capability-conventions.md`. +- `PluginsCapability` has three modes (`native`, `flat`, `unsupported`) and a `translationMode` + (`marketplace` | `flat` | `null`) — see `references/plugins-capability.md`. +- Build behavior is ONE artifact-symmetric `ToolBuildContract` per tool, read by the two + mode-generic orchestrators (`MarketplaceBuildStrategy`, `FlatBuildStrategy`) in `translate` — + never a per-tool strategy class, never a per-tool or per-artifact-kind branch in an + orchestrator. See `references/build-contract.md`. +- `registerTool(config)` is called once, at the bottom of `profile.ts`, never from a use-case. +- Follow the port/adapter rule in `.claude/rules/00-architecture/` for the shape of a port and + its adapter, and the shared-module rule there before promoting a helper out of a single profile. + +## Public surface + +Nothing outside `contexts/tools/` may import a module this context has not declared public — +`tests/architecture/context-boundary.arch.test.ts` holds the list (`PUBLIC_MODULES.tools`). A new +module is invisible to `translate` and `framework` until it is added there; there is no +`index.ts` and there never will be (barrels are forbidden by the export rule in +`.claude/rules/01-standards/`). + +## How it's tested + +- `tests/contexts/tools/` mirrors `src/contexts/tools/` — one profile's `profile.ts`/`build.ts` + gets a unit test asserting the `AiTool` type is satisfied and each rewrite rule holds. +- `tests/architecture/tool-addition-cost.arch.test.ts` ratchets how many files outside a new + tool's own directory must change to add it — keep new tool-specific logic inside the profile. +- See the `test` skill for tier conventions; capability/format round-trip tests are unit-tier. diff --git a/cli/.claude/skills/tools/references/build-contract.md b/cli/.claude/skills/tools/references/build-contract.md new file mode 100644 index 000000000..fef0dcc81 --- /dev/null +++ b/cli/.claude/skills/tools/references/build-contract.md @@ -0,0 +1,77 @@ +# Reference: ToolBuildContract — a tool's `aidd translate` behavior + +`aidd translate --to --out ` translates the Claude-format framework into +a tool-native plugin tree (`--as marketplace`, the default) or a project workspace +(`--as flat`). A tool's build behavior is declared by **one `ToolBuildContract`**, never by +writing a new strategy class. Two mode-generic orchestrators in `translate` consume it: + +- `MarketplaceBuildStrategy(contract)` — emits the tool's marketplace plugin tree + catalog. +- `FlatBuildStrategy(contract)` — materializes content into a project workspace (per-plugin namespace). + +Both implement the shared `BuildOutputStrategy` interface and iterate artifact kinds +**generically** — this is the `translate → tools` edge in practice: the orchestrator lives in +`translate`, reads a contract `tools` declared, and contains zero knowledge of any one tool. + +## Artifact symmetry (the core rule) + +A plugin carries six artifact kinds: `skills`, `agents`, `mcp`, `hooks`, `rules`, `commands`. The +contract exposes ONE `ArtifactContract` per kind — never a kind-specific field (no +`transformAgent`). Each kind is either: + +- `{ supported: false }` → warn-and-skip (no native concept in this tool), or +- `{ supported: true, source, path, ext?, transform?, merge?, mergeDest?, mcpServersKey?, hooksMerge?, hooksMergeDest? }`. + +The orchestrators contain **zero** `if (tool === …)` and **zero** `if (kind === "agents")` +branches. Adding a tool means writing its contract; adding tool-specific behavior means adding a +field to the contract — never a branch in an orchestrator. + +## `ArtifactContract` fields + +| Field | Role | +|---|---| +| `source` | where input files come from: `filteredTree` (e.g. agents `.md`), `fullTree` (skills), `configFile` (mcp `.mcp.json`), `hooksBundle` (hooks.json + scripts) | +| `path(plugin, relPath)` | output path for one file — reuse the profile's own `buildInstallPath`; the orchestrator adds the per-plugin namespace in flat mode | +| `ext?` | output extension override (e.g. `.agent.md`, `.toml`); absent means preserve source ext | +| `transform?(content, plugin, basename)` | per-kind content transform; default is identity. Examples: strip `tools`/`color` frontmatter; markdown → TOML | +| `merge?` / `mergeDest?` / `mcpServersKey?` | for config-file kinds (mcp) merging into one shared file rather than a per-plugin write; reuse an existing merge helper, never reimplement | +| `hooksMerge?` / `hooksMergeDest?` | for tools whose hooks register into one shared file rather than a per-plugin write | + +Contract-level: `manifestFileRelative` / `synthesizeManifest` / `manifestSchemaName` +(marketplace mode; each `null` when the tool has no native manifest), the optional +`pluginRootToken`, and an optional `emitConfigArtifact(builtPlugins, outDir, …)`. Read +`contexts/tools/domain/build-contract.ts` for the live field list. + +## Reuse, never reinvent + +The tool profile already holds the per-tool knowledge — the contract wires it up: + +- paths → the capability `buildInstallPath` functions + the generic flat-path primitives in `kernel/materialization/flat-paths.ts`. +- agent format → the tool's existing transform (markdown→TOML formatter, frontmatter-strip helper). +- mcp / config merges → the existing merge helper for that target format; generalize the helper + (add a parameter) rather than write a parallel one. +- manifest synthesis → the shared Claude-style manifest synthesizer, where the tool adopts that shape. + +A helper reused by more than one tool's contract (manifest/catalog shaping shared by +claude+cursor+copilot+codex) does not belong inside any one tool's directory — it lives in +`contexts/tools/domain/marketplace-catalog.ts`, next to `build-contract.ts`. + +## MCP namespacing (correctness) + +Every flat MCP merge must key-prefix servers by `-`. Tools whose MCP config lives at a +primary location (not a per-plugin file) have no isolation otherwise — two plugins declaring a +server of the same name would collide. The prefix is mandatory for every tool. + +## Where the contract lives, and how it reaches the pipeline + +Each tool's contract(s) live in `contexts/tools/domain/profiles//build.ts`, exporting +`buildContract()` or `buildMarketplaceContract()` (marketplace — copilot uses the +second spelling) and/or `buildFlatContract()` (flat). The profile +declares which modes it supports via `buildContracts: { marketplace?, flat? }` on the `AiTool` +object — a tool with no native marketplace omits `marketplace`. + +`runtime/wiring/translate.ts` derives its build registry (the `":"` → +`mode-orchestrator(contract)` map) by iterating every registered tool id and reading +`buildContractFor(id, mode)` off its profile — there is no per-tool row to hand-add. A tool with +no `:marketplace` contract falls through to the existing "Unsupported target/mode" error. +The tool id must still be added to `AiToolId` in `kernel/tool.ts`, which `FrameworkBuildTarget` +aliases: that names which targets exist at all, independent of which contracts they declare. diff --git a/cli/.claude/skills/tools/references/capability-conventions.md b/cli/.claude/skills/tools/references/capability-conventions.md new file mode 100644 index 000000000..da332f241 --- /dev/null +++ b/cli/.claude/skills/tools/references/capability-conventions.md @@ -0,0 +1,55 @@ +# Reference: Capability Conventions + +## Class shape + +```typescript +export class WidgetsCapability { + readonly widgetsDir: string; + readonly maxWidgets: number; + + constructor(params: { + widgetsDir?: string; // optional — has a default + maxWidgets: number; // required — no default + }) { + if (params.maxWidgets <= 0) { + throw new CapabilityConfigError("WidgetsCapability: maxWidgets must be > 0"); + } + this.widgetsDir = params.widgetsDir ?? DEFAULT_WIDGET_DIR; + this.maxWidgets = params.maxWidgets; + } +} +``` + +## Required invariants + +- Class name ends in `Capability`. +- Constructor takes exactly one params object — never positional arguments. +- All public fields are `readonly`. +- Optional params provide defaults via `??` or a module-level `CONSTANT_CASE` constant. +- Throw `CapabilityConfigError` (from `kernel/errors.ts`) on any invalid param combination — + message format `": "`. +- No business logic — the class models configuration, not behavior decisions. +- No imports from a context's `application/` or `infrastructure/`. +- One capability per file: `-capability.ts` in `domain/capabilities/`. `config-refs.ts` is the one shared helper there, not a capability. + +## Has* interface pairing + +Every capability class that a tool composes into its `C` type parameter has a matching `Has*` +interface in `contracts.ts`: + +```typescript +export interface HasWidgets { + readonly widgets: WidgetsCapability; +} +``` + +Field name is the camelCase of the capability name; always `readonly`, never optional — a tool +either includes `Has` in its intersection or does not carry the field. Import the +capability class with `import type` since `Has*` only uses it as a type. At a call site that +inspects capabilities, guard with `"widgets" in tool.capabilities`, never `instanceof` — the `in` +check is what narrows the type correctly against the `C` intersection. + +## Public methods + +A capability class may expose derived methods (path builders, resolvers). Each is ≤20 lines and +has no side effects — e.g. `widgetOutputPath(name: string): string`. diff --git a/cli/.claude/skills/tools/references/content-rewrite.md b/cli/.claude/skills/tools/references/content-rewrite.md new file mode 100644 index 000000000..bcc531341 --- /dev/null +++ b/cli/.claude/skills/tools/references/content-rewrite.md @@ -0,0 +1,73 @@ +# Reference: Content Rewrite + +## Contract + +```typescript +rewriteContent(content: string): string; +``` + +One direction, one argument. A tool profile declares it in `contexts/tools/domain/contracts.ts` +and implements it in `contexts/tools/domain/profiles//profile.ts`. It is called on the +install path (`install-content-section-use-case.ts`) and on the translate path +(`contexts/translate/domain/content-translator.ts`) — every file that reaches a tool's tree +passes through it. + +There is no reverse. The round-trip API that used to live here was deleted once nothing +produced input for it: the CLI writes owned files from the canonical source, it never reads a +tool's tree back into canonical form. If you find yourself wanting an inverse, the question to +answer first is what would call it. + +There are no base helpers either, and no `docsDir` parameter. `DOCS_DIR` is a constant in +`kernel/paths.ts`; a profile that needs it imports it. + +## What a profile actually does + +**Nothing, when the tool reads the canonical layout as-is.** `opencode` and `codex` rewrite +paths for their own directory shapes; `claude` rewrites only its numbered command directories: + +```typescript +rewriteContent(content: string): string { + return content.replace( + /(@?)\.claude\/commands\/(\d+)[_][^/]+\//g, + (_, at, phase) => `${at}${commandsDir(phase)}` + ); +}, +``` + +**Placeholder resolution, when the tool's host cannot follow the canonical references.** +`copilot` is the one real case: it turns `@{{TOOLS}}/…` and `@{{DOCS}}/…` into markdown links +with a relative href, because Copilot does not resolve `@`-includes. That profile is the +example to read before writing a new one — `profiles/copilot/profile.ts`, +`rewriteCopilotContent`. + +Note the two spellings it distinguishes, because a new tool will meet the same choice: +`{{TOOLS}}/` without `@` replaces a directory prefix only (frontmatter, prose); `@{{TOOLS}}/` +resolves to a full installed path. + +## The trap this reference exists to name + +A profile whose `rewriteContent` is the identity is indistinguishable from a profile that +forgot to implement it — until a placeholder reaches a user's file verbatim. That is not +hypothetical: the rewriting was deleted once on the reasoning that no current plugin emits +placeholders, and it broke `plugin install --tool copilot` while nine build captures and the +golden matrix all stayed green. The golden froze a rewrite that emits no placeholder to catch — `claude`'s own +`rewriteContent` does rewrite its numbered command directories — and the translate path never +calls `rewriteContent` for the marketplace mode. + +So: **prove a rewrite on the install path, with a fixture that contains the placeholder.** +A build comparison cannot see this. + +## Test + +```typescript +const INSTALLED = ".github/agents/checker.md"; + +it("turns an @{{TOOLS}} reference into a link copilot can follow", () => { + const rewritten = copilot.rewriteContent("see @{{TOOLS}}/agents/checker.md"); + + // A markdown link whose label is the installed path and whose href reaches it from + // two levels down. Asserted in two halves so this file stays link-checkable. + expect(rewritten).toContain(`[${INSTALLED}]`); + expect(rewritten).toContain(`(../../${INSTALLED})`); +}); +``` diff --git a/cli/.claude/skills/tools/references/plugins-capability.md b/cli/.claude/skills/tools/references/plugins-capability.md new file mode 100644 index 000000000..f6006b9cb --- /dev/null +++ b/cli/.claude/skills/tools/references/plugins-capability.md @@ -0,0 +1,44 @@ +# Reference: PluginsCapability + +The params are declared and documented in `contexts/tools/domain/capabilities/plugins-capability.ts`. +Read them there — this page carries only the decisions a field list cannot state, and a copied +list would age at every field. + +## Three modes + +| Mode | When to use | +|---|---| +| `"native"` | the tool has a first-class plugin directory structure | +| `"flat"` | the tool stores plugins as flat files under a name prefix | +| `"unsupported"` | the tool has no plugin concept | + +## translationMode + +- `"marketplace"` — register a plugin reference in the tool's native config; nothing is + materialized on disk. +- `"flat"` — materialize plugin content as flat files (automatic for `mode: "flat"`). +- `null` — neutral native; no translation strategy applies. + +The profile only declares the mode. Routing on it at install time is `framework`'s job +(`contexts/framework/application/framework/translator/plugin-translator-factory.ts` — a name that +predates the `translate` context and should not be confused with it). `translate` itself does the +author-side `aidd translate` build, a different pipeline reading the same capability. + +## installScope + +- `"project"` (the default) — plugins land relative to the project root. +- `"user"` — relative to the user home directory; requires a `userPluginsDir` resolver. + +## nativeActivation + +Declaring it says the tool writes its own marketplace registration through its own CLI, and the +marketplace sync stands back: `marketplaceSettings` still says *where* the file is, for the +gitignore and for `status`, but no longer *who* writes it. Every verb and argument on it was +measured against the real binary, and the doc comments in the source say what each measurement +found. Do not add one by analogy with another tool. + +## MCP namespacing + +Every flat MCP merge key-prefixes servers by `-`. A tool whose MCP config lives at one +primary location has no isolation otherwise: two plugins declaring a server of the same name +collide. The prefix is mandatory for every tool. diff --git a/cli/.claude/skills/translate/SKILL.md b/cli/.claude/skills/translate/SKILL.md new file mode 100644 index 000000000..35721e785 --- /dev/null +++ b/cli/.claude/skills/translate/SKILL.md @@ -0,0 +1,73 @@ +--- +name: translate +description: > + Builds the canonical-source-to-target-native translation pipeline under src/contexts/translate/ + — target-aware content transforms, the plugin content translator, and the build strategies + behind `aidd translate` and `aidd sync`. Use when adding a target-aware transform, changing + `PluginContentTranslator`, adding a build strategy, or wiring a new tool into the build + registry. Do NOT use for a tool's own profile, capability classes, or build contract — use + `tools`. Do NOT use for where content is fetched from — use `distribution`. Do NOT use for + manifest/install orchestration — use `framework`. +--- + +# Translate + +`translate` is the core: it turns the canonical, Claude-format framework source into +target-native content for every tool at once. It reaches two places and no others: `tools`, its +one outbound context edge, and `kernel`. Everything it reaches in `tools` is that context's +declared public surface (`contracts.ts`, `registry.ts`, `build-contract.ts`, and the handful of +capability and port modules listed as public), never an internal file. The whole graph is in +`.claude/rules/00-architecture/0-contexts.md`. + +## What goes in + +| Concept | Location | +|---|---| +| A transform whose behavior differs by target tool | `domain/formats/` | +| The plugin-files-to-installed-files translator | `domain/content-translator.ts` (`PluginContentTranslator`) | +| The canonical framework-doc shape | `domain/canon.ts` | +| The canonical single-plugin shape | `domain/plugin-distribution.ts` | +| Build targets and modes | `domain/build-target.ts` | +| The `aidd translate` use-case | `application/translate-source.ts` (`FrameworkBuildUseCase`) | +| A build orchestrator (one per mode, never per tool) | `application/strategies/` | +| Schema validation for marketplace/plugin manifests | `infrastructure/schema-validator.ts` | + +A transform used by exactly one tool profile does not belong here — it lives in that profile's +own directory. A transform shared by ≥2 profiles but identical regardless of target lives in +`contexts/tools/domain/formats/` instead. What belongs in `translate/domain/formats/` is a +transform that is *aware* of which target it is producing for. + +## How + +- `PluginContentTranslator` takes one plugin's canonical files and one tool's `AiTool`, and + calls the tool's own `rewriteContent` — it does not reimplement a + tool's rewrite logic, it invokes what `tools` declared. That function is one-way and has no + inverse; the `tools` skill's `references/content-rewrite.md` says why, and names the trap an + identity rewrite sets. +- `FrameworkBuildUseCase` (`aidd translate`) reads a `ToolBuildContract` per target and mode from + `tools`, and dispatches to `MarketplaceBuildStrategy` or `FlatBuildStrategy` — both implement + `BuildOutputStrategy` and iterate the six artifact kinds generically, with zero per-tool or + per-kind branching. Adding a build target means the target tool declares a contract in `tools`; + it never means adding a case here. See the `tools` skill's `references/build-contract.md`. +- `runtime/wiring/translate.ts` derives the `":"` build registry by iterating every + registered tool and reading its contract — there is no hand-maintained per-tool row. +- Follow the use-case and orchestration rules in `.claude/rules/00-architecture/` for the + application layer's shape, and the shared-module rule there before promoting a helper used by + only one strategy into `shared-plugin-helpers.ts`. + +## Public surface + +Nothing outside `contexts/translate/` may import a module this context has not declared public — +`tests/architecture/context-boundary.arch.test.ts` holds the list (`PUBLIC_MODULES.translate`). +`framework` is the only context that imports from here (`framework → translate`); a module used +by `framework` must be on that list. + +## How it's tested + +- `tests/contexts/translate/` mirrors `src/contexts/translate/` — formats, content-translator, + canon, and the two build strategies each have unit or integration coverage. +- `tests/golden/framework-build-golden.e2e.test.ts` snapshots a full build across every target — + see `test` skill's golden/machine-independence rules before touching a snapshot. +- A new target-aware transform gets a unit test over a representative input carrying the marker it + is meant to rewrite. A transform that is the identity for the fixture it was given is + indistinguishable from one that never ran. diff --git a/cli/.claude/skills/use-case/SKILL.md b/cli/.claude/skills/use-case/SKILL.md deleted file mode 100644 index f3bf174ee..000000000 --- a/cli/.claude/skills/use-case/SKILL.md +++ /dev/null @@ -1,51 +0,0 @@ ---- -name: use-case -description: > - Creates or modifies application use-cases in src/application/use-cases/. Use when implementing - business orchestration for a new feature, extracting a reusable shared use-case, adding a - capability sub-use-case, or wiring a PostInstallPipeline delegation. Do NOT use for creating a - new CLI command surface — use `command` instead. Do NOT use for I/O translation — use `adapter` - instead. Do NOT use for domain type definitions — use `domain-model` instead. ---- - -# Use Case - -Builds the business orchestration layer: classes that receive typed options, coordinate ports and -domain models, and return typed results. Each use-case has a single `execute()` method, never -catches its own errors, and delegates all file-and-manifest writes to `PostInstallPipelineUseCase`. - -## Available actions - -| # | Action | Role | Input | -| --- | ------------------- | ------------------------------------------------- | --------------------------------------- | -| 01 | `define-types` | Declare `*Options` and `*Result` interfaces | use-case name + field list | -| 02 | `write-execute` | Write the `execute()` method body (≤20 LOC) | types from 01 | -| 03 | `extract-methods` | Extract intent-named private helper methods | execute() body from 02 | -| 04 | `wire-errors-and-pipeline` | Add typed throws + delegate to PostInstallPipeline | methods from 03 | -| 05 | `test` | Write integration-tier unit tests | completed use-case from 04 | - -## Default flow - -`01 → 02 → 03 → 04 → 05` - -## Transversal rules - -- Class name ends in `UseCase`; single `async execute()` method; never a plain function. -- Every method (public or private) ≤ 20 lines; extract named private methods before reaching the limit. -- Shared sub-use-cases live in `src/application/use-cases/shared/` and are never called from commands. -- Capability sub-use-cases live in subdirectories (`install/`, `update/`) and receive narrowed types. -- Never call `manifestRepo.save()` in isolation; delegate to `PostInstallPipelineUseCase`. -- Use constructor injection order: FileSystem → Repository → Loader → Hasher → Logger → Platform → Prompter. -- Use `import type` for type-only imports; `.js` extensions on all relative imports. -- Named export only. - -## References - -- `references/use-case-rules.md` — class shape, constructor order, Prompter restrictions, user-file protection -- `references/shared-use-cases.md` — shared sub-use-case placement and contract -- `references/capability-sub-use-cases.md` — capability guard pattern, narrowed types -- `references/post-install-pipeline.md` — pipeline delegation rules - -## Invariant rules - -- `references/use-case-rules.md` — authoritative use-case rules diff --git a/cli/.claude/skills/use-case/actions/01-define-types.md b/cli/.claude/skills/use-case/actions/01-define-types.md deleted file mode 100644 index c50cccadf..000000000 --- a/cli/.claude/skills/use-case/actions/01-define-types.md +++ /dev/null @@ -1,38 +0,0 @@ -# 01 - Define Types - -Declare the `*Options` input interface and `*Result` output interface for the new use-case. - -## Inputs - -- `use-case-name` (required) - string, PascalCase name without the `UseCase` suffix (e.g. `InstallRuntimeConfig`) -- `fields` (required) - list of input fields with types and output fields with types - -## Outputs - -```typescript -export interface ApplyWidgetOptions { - widgetId: string; - projectRoot: string; - force: boolean; - interactive: boolean; -} - -export interface ApplyWidgetResult { - widgetId: string; - fileCount: number; - files: WidgetFile[]; - skipped: boolean; -} -``` - -## Process - -1. Create `src/application/use-cases/-use-case.ts` (top-level) or `src/application/use-cases//-use-case.ts` (sub-use-case). Confirm the file does not already exist. -2. Declare `export interface Options { ... }` with all required input fields. Use `import type` for domain types. -3. Declare `export interface Result { ... }` with all output fields. Never `Promise` — always return a typed result. -4. Import domain types from `src/domain/models/` using relative paths with `.js` extension. -5. Do not add the class yet — types only in this action. - -## Test - -Run `pnpm typecheck` — exits 0 confirms interfaces compile and import paths resolve correctly. diff --git a/cli/.claude/skills/use-case/actions/02-write-execute.md b/cli/.claude/skills/use-case/actions/02-write-execute.md deleted file mode 100644 index 83ee39b4f..000000000 --- a/cli/.claude/skills/use-case/actions/02-write-execute.md +++ /dev/null @@ -1,48 +0,0 @@ -# 02 - Write Execute - -Write the `execute()` method body using early-return guard clauses. Keep it to ≤20 lines by delegating to named helpers. - -## Inputs - -- `use-case-name` (required) - string, PascalCase name with `UseCase` suffix -- `options-type` (required) - string, the `*Options` interface name from 01 -- `result-type` (required) - string, the `*Result` interface name from 01 - -## Outputs - -```typescript -export class ApplyWidgetUseCase { - constructor( - private readonly fs: FileReader & FileWriter, - private readonly repo: WidgetRepository, - private readonly logger: Logger, - ) {} - - async execute(options: ApplyWidgetOptions): Promise { - const { widgetId, force } = options; - const existing = await this.repo.find(widgetId); - if (existing && !force) { - return { widgetId, fileCount: 0, files: [], skipped: true }; - } - const files = await this.buildOutputFiles(options); - await this.writeAndTrack(files, options); - return { widgetId, fileCount: files.length, files, skipped: false }; - } -} -``` - -## Depends on - -- `01-define-types` - -## Process - -1. Add the class declaration with `UseCase` suffix and constructor with injected ports (no `public` on constructor params — always `private readonly`). -2. Add constructor injection in canonical order per `references/use-case-rules.md`: FileSystem → Repository → Loader → Hasher → Logger → Platform → Prompter. -3. Write `async execute(options: *Options): Promise<*Result>` with guard clauses first (early returns for `skipped` or no-op cases). -4. Delegate remaining work to named private methods (stubs for now — filled in 03). -5. Verify the method body is ≤20 lines (counting code lines, not blanks or comments). - -## Test - -Run `pnpm typecheck` — exits 0 confirms the class signature, constructor types, and execute return type are consistent. diff --git a/cli/.claude/skills/use-case/actions/03-extract-methods.md b/cli/.claude/skills/use-case/actions/03-extract-methods.md deleted file mode 100644 index b84938618..000000000 --- a/cli/.claude/skills/use-case/actions/03-extract-methods.md +++ /dev/null @@ -1,39 +0,0 @@ -# 03 - Extract Methods - -Replace stubs with real private methods that each describe a single business intent. - -## Inputs - -- `execute-body` (required) - string, the drafted execute() with stubs from 02 - -## Outputs - -```typescript -private async buildOutputFiles(options: ApplyWidgetOptions): Promise { - const config = await this.repo.loadConfig(options.widgetId); - if (!config.outputPaths) return []; - const files: WidgetFile[] = []; - for (const [name, outputPath] of Object.entries(config.outputPaths)) { - const content = config.templates[name] ?? ""; - if (await this.isUserOwned(outputPath, options)) continue; - files.push(new WidgetFile({ relativePath: outputPath, content })); - } - return files; -} -``` - -## Depends on - -- `02-write-execute` - -## Process - -1. For each operation in `execute()` that is not a simple guard or return, extract a private method. -2. Name each method after its domain intent — not after mechanics: `buildConfigFiles` not `loopAndHashFiles`, `applyAndTrack` not `writeAllThenSave` — see `.claude/rules/06-design-patterns/6-method-size.md`. -3. Each extracted method must be ≤20 lines. -4. If a method still exceeds 20 lines, extract a further sub-method. Repeat until all are within limit. -5. Check that no hardcoded technical strings appear in use-case files — those belong in adapters per `references/use-case-rules.md`. - -## Test - -Run `pnpm typecheck` — exits 0 and `pnpm lint` exits 0 (no `any` types, no unused params introduced by extraction). diff --git a/cli/.claude/skills/use-case/actions/04-wire-errors-and-pipeline.md b/cli/.claude/skills/use-case/actions/04-wire-errors-and-pipeline.md deleted file mode 100644 index 9105eb1b5..000000000 --- a/cli/.claude/skills/use-case/actions/04-wire-errors-and-pipeline.md +++ /dev/null @@ -1,40 +0,0 @@ -# 04 - Wire Errors and Pipeline - -Add typed error throws and delegate manifest+file writes to PostInstallPipelineUseCase. - -## Inputs - -- `use-case-file` (required) - string, path to the use-case file from 03 - -## Outputs - -```typescript -// Error throw example -import { WidgetNotFoundError } from "../../../domain/errors.js"; - -if (!inventory.isTracked(widgetId)) { - throw new WidgetNotFoundError(widgetId); -} - -// Pipeline delegation example (delegate file writes + record save — never inline both) -await new FinalizeWriteUseCase(this.repo, this.indexWriter).execute({ - projectRoot: options.projectRoot, - record: updatedRecord, -}); -``` - -## Depends on - -- `03-extract-methods` - -## Process - -1. For every error condition in the use-case, throw a typed domain exception from `src/domain/errors.ts`. Never `throw new Error("user string")` — see `.claude/rules/00-architecture/0-error-handling.md`. -2. Identify all `manifestRepo.save()` calls. Replace each with a `PostInstallPipelineUseCase` delegation per `references/post-install-pipeline.md`. -3. Confirm `GitignoreUseCase` is never called directly — it must flow through the pipeline. -4. Add the `PostInstallPipelineUseCase` import from `../shared/post-install-pipeline-use-case.js`. -5. Confirm the use-case has no `try/catch` block — errors propagate to the caller (command layer) — see `.claude/rules/00-architecture/0-error-handling.md`. - -## Test - -Run `pnpm typecheck` and `pnpm test:unit` (or `pnpm test:integration` for integration-tier tests) — both exit 0. diff --git a/cli/.claude/skills/use-case/actions/05-test.md b/cli/.claude/skills/use-case/actions/05-test.md deleted file mode 100644 index 9b198bb66..000000000 --- a/cli/.claude/skills/use-case/actions/05-test.md +++ /dev/null @@ -1,32 +0,0 @@ -# 05 - Test - -Write unit tests for the use-case using in-memory port implementations. - -## Inputs - -- `use-case-name` (required) - string, PascalCase name with `UseCase` suffix -- `use-case-file` (required) - string, path to the source file from 04 - -## Outputs - -``` -Test file: tests/application/use-cases/-use-case.unit.test.ts -``` - -## Depends on - -- `04-wire-errors-and-pipeline` - -## Process - -1. Create `tests/application/use-cases/-use-case.unit.test.ts`. Use `*.unit.test.ts` suffix per `references/test-pyramid.md` in the `test` skill. -2. Mock all ports via in-memory implementations from `tests/helpers/ports/` — no real filesystem, no real I/O. -3. Cover: happy path returns the expected `*Result`, skipped/no-op path returns early with correct flags, each typed error is thrown when its condition is met. -4. Name `it()` blocks as behavior sentences: "returns skipped result when widget already exists and force is false" not "calls repo.find". -5. Group with `describe('')` block — see memory `feedback_test_naming.md`. -6. Use `describe.concurrent()` only for E2E tests — unit tests must NOT use it per `references/test-pyramid.md` in the `test` skill. -7. For bug fixes: write the failing test FIRST, confirm it fails, then fix the use-case — see `references/bug-empirical-reproduction.md`. - -## Test - -Run `pnpm test:unit` — exits 0 with all new `it()` blocks passing. diff --git a/cli/.claude/skills/use-case/evals/scenarios.json b/cli/.claude/skills/use-case/evals/scenarios.json deleted file mode 100644 index e8cd797b2..000000000 --- a/cli/.claude/skills/use-case/evals/scenarios.json +++ /dev/null @@ -1,9 +0,0 @@ -[ - { "prompt": "Create a new use-case for installing runtime config", "expect_action": "define-types" }, - { "prompt": "Write the execute method for the new SyncPluginUseCase", "expect_action": "write-execute" }, - { "prompt": "Extract the 30-line buildSectionFiles method in install-use-case.ts", "expect_action": "extract-methods" }, - { "prompt": "Delegate manifest writes to PostInstallPipeline in the new use-case", "expect_action": "wire-errors-and-pipeline" }, - { "prompt": "Write unit tests for the new CleanUseCase", "expect_action": "test" }, - { "prompt": "Add a new CLI command called aidd doctor", "expect_action": null }, - { "prompt": "Create a new port interface for fetching plugins", "expect_action": null } -] diff --git a/cli/.claude/skills/use-case/references/bug-empirical-reproduction.md b/cli/.claude/skills/use-case/references/bug-empirical-reproduction.md deleted file mode 100644 index 5d9752d39..000000000 --- a/cli/.claude/skills/use-case/references/bug-empirical-reproduction.md +++ /dev/null @@ -1,36 +0,0 @@ -# Reference: Bug Empirical Reproduction - -## The rule - -When fixing a user-reported bug, always write a failing test FIRST that reproduces the exact reported scenario. Unit tests, integration tests, and E2E tests with simplified fixtures are necessary but not sufficient on their own. - -The PR description must include an empirical reproduction transcript: - -```text -## Empirical reproduction - -### Pre-fix (main / broken baseline) -$ - - -### Post-fix (this branch) -$ - -``` - -## Coverage tier ranking - -| Tier | Sufficient alone? | -| ---- | ----------------- | -| Unit | no | -| Integration | no | -| E2E with simplified fixture | no | -| Empirical reproduction (real binary, real scenario) | yes | - -## How to skip (rare) - -The empirical reproduction may be skipped only when ALL of these hold: -- Fix is purely cosmetic (typo, doc, comment) -- No control flow change -- No new code path -- Stated explicitly in the review: "Skip empirical: purely cosmetic, no behavior change." diff --git a/cli/.claude/skills/use-case/references/capability-sub-use-cases.md b/cli/.claude/skills/use-case/references/capability-sub-use-cases.md deleted file mode 100644 index d6af5eb4a..000000000 --- a/cli/.claude/skills/use-case/references/capability-sub-use-cases.md +++ /dev/null @@ -1,51 +0,0 @@ -# Reference: Capability Sub-Use-Cases - -## Pattern - -An orchestrator use-case guards capability presence before dispatching to a sub-use-case that receives a narrowed type. - -## Capability guard - -```typescript -if ("widgets" in caps) { - const result = await new ApplyWidgetCapabilityUseCase(...).execute({ config: toolConfig as ToolConfig }); -} -``` - -- Check `section.name in caps` before dispatching — skips tools that lack the capability -- Never access `caps.widgets` without first confirming presence via the guard - -## Sub-use-case contract - -- Receives pre-filtered, pre-typed input — never raw `ToolConfig` or unnarrowed union -- Returns `InstallationFile[]` or typed result — no side effects, no I/O -- Single `execute()` method, same rules as all use-cases (≤20 lines per method) - -## Location - -Sub-use-cases live in subdirectories of the parent feature: `install/`, `update/` - -## Forbidden - -- No capability access without presence guard -- No sub-use-case logic inlined in orchestrator -- No sub-use-case called from commands - -## Sub-use-case agnostic shape - -```typescript -// src/application/use-cases/apply/apply-widget-capability-use-case.ts -export class ApplyWidgetCapabilityUseCase { - constructor(private readonly fs: FileWriter) {} - - async execute(options: ApplyWidgetCapabilityOptions): Promise { - const { config } = options; - // config is narrowed — caller already verified "widgets" in caps - return this.buildWidgetFiles(config.widgets); - } - - private buildWidgetFiles(widgets: WidgetList): WidgetFile[] { - // ... ≤20 lines - } -} -``` diff --git a/cli/.claude/skills/use-case/references/post-install-pipeline.md b/cli/.claude/skills/use-case/references/post-install-pipeline.md deleted file mode 100644 index f179bc852..000000000 --- a/cli/.claude/skills/use-case/references/post-install-pipeline.md +++ /dev/null @@ -1,30 +0,0 @@ -# Reference: Post-Install Pipeline - -## Rule - -Any use-case writing framework files AND updating the manifest must delegate to `PostInstallPipelineUseCase`. Never replicate the steps inline. - -## Steps (in order) - -1. `manifestRepo.save()` — persist updated manifest -2. `GitignoreUseCase.execute()` — update `.gitignore` with tracked framework paths - -## How to delegate - -```typescript -import { PostInstallPipelineUseCase } from "../shared/post-install-pipeline-use-case.js"; - -await new PostInstallPipelineUseCase(this.fs, this.manifestRepo).execute({ - projectRoot: options.projectRoot, - manifest: options.manifest, -}); -``` - -## Forbidden - -- Never call `manifestRepo.save()` in isolation outside the pipeline -- Never call `GitignoreUseCase` directly from a feature use-case - -## InitUseCase exception - -`InitUseCase` calls the pipeline directly (no skipped steps). This is the only documented exception and must be noted inline in the file. diff --git a/cli/.claude/skills/use-case/references/shared-use-cases.md b/cli/.claude/skills/use-case/references/shared-use-cases.md deleted file mode 100644 index 94d6c1001..000000000 --- a/cli/.claude/skills/use-case/references/shared-use-cases.md +++ /dev/null @@ -1,33 +0,0 @@ -# Reference: Shared Use Cases - -## Location - -`src/application/use-cases/shared/` - -## Rules - -- Never called from commands — only from other use-cases -- Same class shape as top-level use-cases: single `execute()`, typed `*Options` input, typed `*Result` output -- PostInstallPipelineUseCase is the canonical shared use-case for file + manifest writes - -## When to create a shared use-case - -Create a shared use-case when the same orchestration logic is needed by ≥2 top-level use-cases. Do not inline equivalent logic — import from `shared/`. - -## Agnostic shape example - -```typescript -// src/application/use-cases/shared/finalize-write-use-case.ts -export class FinalizeWriteUseCase { - constructor( - private readonly repo: RecordRepository, - private readonly index: IndexWriter, - ) {} - - async execute(options: FinalizeWriteOptions): Promise { - await this.repo.save(options.record); - await this.index.update(options.projectRoot, options.record); - return { saved: true }; - } -} -``` diff --git a/cli/.claude/skills/use-case/references/use-case-rules.md b/cli/.claude/skills/use-case/references/use-case-rules.md deleted file mode 100644 index f2b273153..000000000 --- a/cli/.claude/skills/use-case/references/use-case-rules.md +++ /dev/null @@ -1,126 +0,0 @@ -# Reference: Use Case Rules - -## Class shape - -- Class with `*UseCase` suffix -- Single `async execute(options: *Options): Promise<*Result>` method -- Input typed as `*Options` interface, output typed as `*Result` interface -- No `async function` exports — always a class - -## Constructor injection order - -FileSystem → Repository → Loader → Hasher → Logger → Platform → Prompter - -All dependencies injected as `private readonly`, typed as port interfaces (never concrete adapter types). - -## Method size - -- Every method (public or private) must be ≤ 20 lines -- Extract private helpers before reaching the limit -- Helper names describe domain intent, not mechanics - -## Throws - -- Throw on domain errors — no try/catch inside use-cases -- Typed domain exceptions from `src/domain/errors.ts` — never `new Error("string")` -- The caller (command layer) catches via `errorHandler.handle()` - -### Legitimate try/catch carve-outs (not violations) - -Three patterns are permitted; all others are violations requiring a fix. - -**1. Global-runner (aggregate-error) pattern** - -`*-all-use-case.ts` files that iterate over N scopes (tools, plugins, marketplaces) and must -complete all iterations even if one fails. The try/catch wraps a single iteration body, pushes a -typed error entry to an `errors[]` array, and continues. The outer `execute()` returns a result -object that contains the errors array — it never swallows failures silently. - -```typescript -const errors: ScopeError[] = []; -for (const scope of scopes) { - try { - await this.processScopeUseCase.execute(scope); - } catch (err) { - errors.push({ scope: scope.id, message: toMessage(err) }); - } -} -return { ...summary, errors }; -``` - -**2. Cache/network fallback pattern** - -Use-cases that first try a network port and fall back to a cached result on failure. The try/catch -wraps the network call only; the catch returns or yields the cached value. There must be a log/warn -call in the catch to surface the failure. - -```typescript -try { - return await this.networkPort.fetch(url); -} catch { - this.logger.warn("Network unavailable, using cached data"); - return await this.cachePort.read(key); -} -``` - -**3. Typed-throw translation** - -A use-case that calls a third-party or lower-level operation and needs to translate an opaque -`unknown` error into a typed domain exception. Catch, inspect, re-throw as typed. Never swallow. - -```typescript -try { - await this.port.doSomething(options); -} catch (err) { - throw new DomainSpecificError(toMessage(err)); -} -``` - -Any try/catch NOT matching one of these three patterns is a violation and must be removed. - -## User file protection - -- Before any `fs.writeFile()` on framework files: check `fs.fileExists(path)` AND `!manifest.isFileTracked(relativePath)` -- If both true → skip write, emit `logger.warn()`, never add to manifest -- Never overwrite a user-owned file - -## Prompter restrictions - -- Prompter is for domain interaction only (conflict resolution, strategy selection) -- Never use Prompter for CLI input collection in use-cases -- CLI input collection belongs in the command layer - -## No technical strings in use-cases - -- No hardcoded runtime names, OS hook names, system file paths in use-cases -- Technical integration details belong in adapters - -## Agnostic shape example - -```typescript -export class ApplyWidgetUseCase { - constructor( - private readonly fs: FileReader & FileWriter, - private readonly repo: WidgetRepository, - private readonly logger: Logger, - ) {} - - async execute(options: ApplyWidgetOptions): Promise { - const existing = await this.repo.find(options.widgetId); - if (existing && !options.force) { - return { widgetId: options.widgetId, applied: false, skipped: true }; - } - const files = await this.buildOutputFiles(options); - await this.writeFiles(files, options); - return { widgetId: options.widgetId, applied: true, skipped: false, fileCount: files.length }; - } - - private async buildOutputFiles(options: ApplyWidgetOptions): Promise { - // ... ≤20 lines, domain-intent name - } - - private async writeFiles(files: WidgetFile[], options: ApplyWidgetOptions): Promise { - // ... ≤20 lines, domain-intent name - } -} -``` diff --git a/cli/.gitattributes b/cli/.gitattributes new file mode 100644 index 000000000..fe567a6c8 --- /dev/null +++ b/cli/.gitattributes @@ -0,0 +1,9 @@ +# Fixtures a test compares byte for byte (manifest-round-trip.unit.test.ts reads each one +# and asserts `Manifest.toJSON()` reproduces it exactly) must check out with the same line +# ending on every platform. Without this, a Windows checkout's default `core.autocrlf` +# rewrites the committed LF to CRLF, and the comparison — which always rewrites LF, +# regardless of host OS — fails on a line-ending mismatch that has nothing to do with the +# content under test. Scoped to the whole fixtures tree, not just `.json`: the exposure is +# "a test reads this file's raw bytes", which is true of any fixture format, not a property +# of JSON specifically. +tests/fixtures/** text eol=lf diff --git a/cli/.gitignore b/cli/.gitignore index 69af81a8c..42bc75cce 100644 --- a/cli/.gitignore +++ b/cli/.gitignore @@ -32,3 +32,9 @@ tmp/ .aidd/manifest.json .aidd/marketplaces.json .aidd/cache/ + +# Per-run e2e binaries (tests/e2e/global-setup.ts) +.e2e-build/ + +# A Claude Code session lock, pid and session id of whoever ran last. Never shared. +.claude/scheduled_tasks.lock diff --git a/cli/ARCHITECTURE.md b/cli/ARCHITECTURE.md index 67b8492b6..eefdc0fd3 100644 --- a/cli/ARCHITECTURE.md +++ b/cli/ARCHITECTURE.md @@ -1,36 +1,53 @@ # Architecture -## Layer Diagram +## Contexts, not layers ``` -┌─────────────────────────────────────────────────────────────┐ -│ CLI Entry (src/cli.ts) │ -│ Command registration only — no business logic │ -├─────────────────────────────────────────────────────────────┤ -│ Commands (src/application/commands/) │ -│ Thin wiring: parse flags → call use-case → display result │ -├─────────────────────────────────────────────────────────────┤ -│ Use Cases (src/application/use-cases/) │ -│ Orchestration: auth/ global/ install/ marketplace/ plugin/ restore/ setup/ shared/ sync/ │ -│ SetupUseCase (orchestrator), SyncUseCase, UpdateUseCase │ -├─────────────────────────────────────────────────────────────┤ -│ Domain (src/domain/) │ -│ models/ — entities, value objects, pure functions │ -│ ports/ — interface contracts (no implementations) │ -│ formats/ — pure string transforms (TOML, Markdown, JSON) │ -│ capabilities/ — agents, commands, hooks, mcp, rules, skills│ -│ tools/ — AI + IDE tool definitions and registry │ -├─────────────────────────────────────────────────────────────┤ -│ Infrastructure (src/infrastructure/) │ -│ adapters/ — port implementations, all I/O │ -│ assets/ — bundled runtime configs (embedded in binary) │ -│ auth/ — credential storage and resolution │ -│ git/ — token injection for authenticated git fetches │ -│ http/ — HTTP client │ -└─────────────────────────────────────────────────────────────┘ +presentation ──> contexts ──> kernel +runtime ─────────> (wiring only) + +framework ──> translate ──> tools ──> kernel +framework ──> distribution ─────────> kernel ``` -Dependencies point inward only: infrastructure → application → domain. Domain never imports from application or infrastructure. +| where | what lives there | +|---|---| +| `src/kernel/` | the vocabulary every context speaks: tool identity, source location, paths, files and fingerprints, merge strategies, errors, and the ports used by two contexts or more. It imports no context and carries no business logic. | +| `src/contexts/tools/` | what the project targets and how each target is configured. One directory per tool: its profile beside its build contracts. | +| `src/contexts/translate/` | canonical source into target-native content, at every level. | +| `src/contexts/distribution/` | where content comes from and how it is fetched. A leaf: it knows no tool and no installation record. | +| `src/contexts/framework/` | the installation record and everything done to a project. The only context allowed to reach the others. | +| `src/presentation/` | what speaks to a human: commands, display, prompts, output. | +| `src/runtime/` | technical services and wiring: auth, http, git, platform, project root, self-update, one wiring module per context. | + +Four invariants hold this together, and none of them is enforced by this document. + +**Which contexts may see each other.** `tests/architecture/context-graph.arch.test.ts` +allows exactly the edges drawn above; the edges the tree has and the chain forbids are +listed in that file with what each admits, measured. + +**What of a context is visible.** `tests/architecture/context-boundary.arch.test.ts` +refuses an import that reaches a context's interior: a cross-context import targets a +module that context declares public, and there is no barrel file anywhere to make that +convenient. Every context on disk must appear in that list, or it would be skipped rather +than held. + +**Which way the layers point.** Inside a context, `application` may call `domain` and +`domain` may never call `application`, `infrastructure`, `presentation` or `runtime`; +`application` may not call `infrastructure` either — it takes a port and the composition +root supplies the adapter. This one is enforced by biome overrides rather than by a test, +and it holds against a type-only import, a dynamic import and any depth of `../`. +`tests/architecture/import-rules-bite.arch.test.ts` checks that each of those overrides +still names a path that exists, because a pattern matching nothing enforces nothing. + +**What the kernel may know.** A biome override refuses an import from the kernel into any +context. A module belongs there when two areas speak it, which +`tests/architecture/earned-sharing.arch.test.ts` measures. + +The layer rule stops at relative paths on purpose. A domain file may import `node:path` and +`smol-toml`: both are pure — string manipulation and serialization, no I/O, no lifecycle — +and forbidding them would mean injecting a TOML serializer through a port to gain nothing. +The rule exists to keep I/O and human interaction out of the domain, not imports. ## Key Domain Models (manifest v6) @@ -38,31 +55,46 @@ Dependencies point inward only: infrastructure → application → domain. Domai |---|---| | `SetupFlow` | Aggregate carrying all setup parameters (source, tools, pluginMode, interactive) | | `MarketplaceSourceMode` | Value object: `remote()` or `local(path)` | -| `MarketplaceEntry` | A registered marketplace (name, source, trustLevel) | +| `Marketplace` | A registered marketplace (name, source, scope). Registry stored at `.aidd/marketplaces.json`, not in the manifest | | `MarketplaceCacheEntry` | Cached catalog fetch (marketplace name, fetchedAt, size) | -| `Manifest` (v6) | Top-level schema: `version`, `tools`, `marketplaces`. Plugins live per-tool under `tools[id].plugins`. Stripped top-level fields: `docsDir`, `repo`, `mode`, `scripts`, `plugins`, `topPlugins`. Stored at `.aidd/manifest.json` | +| `Manifest` (v6) | Top-level schema: `version`, `tools`. Plugins live per-tool under `tools[id].plugins`. Stripped top-level fields: `docsDir`, `repo`, `mode`, `scripts`, `plugins`, `topPlugins`, `marketplaces`. Stored at `.aidd/manifest.json` | | `Plugin` | Installed plugin: id, source (marketplace + version), tool, files | | `PluginDistribution` | Capability files for a plugin as fetched from the source | -## Command Surface (noun-first) +## Command Surface (grammar, not noun-first) + +A bare verb is an action performed now, on the CLI or the current project. A noun then a +verb manages a resource's lifecycle — same convention Claude Code and Codex follow. ``` -aidd setup — orchestrator: init + marketplace + tools + plugins -aidd ai — AI tool management (install/uninstall/list/status/update/sync/restore/doctor) -aidd ide — IDE tool management (install/uninstall/list/status/update/doctor) -aidd plugin — plugin management (create/remove/list/install/search/update/doctor) -aidd marketplace — marketplace management (add/list/remove/refresh/check) -aidd status — global drift view (delegates to ai + ide status) -aidd doctor — global integrity check (delegates to ai + ide doctor) -aidd restore — global file restore (delegates to ai restore) -aidd sync — global sync (delegates to ai sync) -aidd update — global update (delegates to ai + ide update) -aidd clean — remove all AIDD files -aidd auth — credential management -aidd self-update — update the CLI binary +# actions — bare verb +aidd setup — bootstrap the whole project (marketplace + framework + tools + plugins) +aidd doctor [--tool ...] [--plugin] — detected/equipped tools, plugins, drift, problems +aidd sync [--tool ...] [--plugin] — regenerate owned files, driven by the manifest +aidd translate --to — convert an arbitrary source, records nothing +aidd update | upgrade — update the CLI itself +aidd clean — remove all AIDD-managed files +aidd auth — credential management (login/logout/status) + +# resources — noun then verb +aidd framework install | update | remove [--tool ...] +aidd plugin install | update | remove | list | search [--tool ...] +aidd marketplace add | refresh | remove | list ``` -Legacy commands removed: `aidd cache`, `aidd config`, `aidd install` (top-level), `aidd uninstall` (top-level). Plugin browsing folded into `aidd plugin install` (no arg); marketplace cache managed via `aidd marketplace refresh --force`. +Phase 18 (`aidd_docs/tasks/2026_08/2026_08_20_refactor-contextes-cli/`) moved the surface: + +| Removed | Replacement | +|---|---| +| `aidd ai ` / `aidd ide ` | `--tool ` on `doctor`, `sync`, `framework install\|update\|remove` | +| `aidd status`, `aidd ai status`, `aidd ide status` | `aidd doctor` | +| `aidd ai doctor`, `aidd ide doctor`, `aidd plugin doctor` | `aidd doctor --tool` / `aidd doctor --plugin` | +| `aidd restore`, `aidd ai restore`, `aidd ide restore` | `aidd sync` | +| `aidd self-update` | `aidd update` | +| `aidd framework build` | `aidd translate` | +| `aidd plugin create` | removed — never documented, never used | + +`aidd cache`, `aidd config`, top-level `aidd install`, and top-level `aidd uninstall` were removed in an earlier pass. Plugin browsing is folded into `aidd plugin install` (no arg); marketplace cache is managed via `aidd marketplace refresh --force`. ## Plugin Architecture @@ -70,9 +102,9 @@ Plugins are distributed via marketplace catalogs (Git repos with `marketplace.js Memory ownership (CLAUDE.md, AGENTS.md, copilot-instructions.md) is delegated to the `aidd-context` plugin — not bundled in the CLI binary. -## Framework Build (author-side) +## Translate (author-side) -`aidd framework build` translates a Claude-format framework source into a target-native distribution. Five targets (`claude`, `cursor`, `copilot`, `codex`, `opencode`) × two modes (`marketplace`, `flat`); `opencode` is flat-only, so 9 build cells. The orchestrators (`MarketplaceBuildStrategy`, `FlatBuildStrategy`) read a per-tool `ToolBuildContract` — no per-tool branching. **Scope:** skills, agents, mcp, and hooks are emitted; `rules` and `commands` are currently out of scope (warn + skip per plugin). See `README.md` → `aidd framework build` for the per-tool layout matrix. +`aidd translate` (renamed from `framework build` in phase 18) converts a Claude-format framework source into a target-native distribution. Five targets (`claude`, `cursor`, `copilot`, `codex`, `opencode`) × two modes (`marketplace`, `--as flat`); `opencode` is flat-only, so 9 build cells. The orchestrators (`MarketplaceBuildStrategy`, `FlatBuildStrategy`) read a per-tool `ToolBuildContract` — no per-tool branching. **Scope:** skills, agents, mcp, and hooks are emitted; `rules` and `commands` are currently out of scope (warn + skip per plugin). See `README.md` → `aidd translate` for the per-tool layout matrix. ## Dependency Wiring diff --git a/cli/CLAUDE.md b/cli/CLAUDE.md index 0d68bfa46..79c9101d3 100644 --- a/cli/CLAUDE.md +++ b/cli/CLAUDE.md @@ -45,11 +45,18 @@ Project docs, memory, specs, and plans live in `aidd_docs/`. @aidd_docs/memory/codebase-map.md @aidd_docs/memory/coding-assertions.md @aidd_docs/memory/deployment.md +@aidd_docs/memory/ecosystem.md @aidd_docs/memory/project-brief.md +@aidd_docs/memory/telemetry.md @aidd_docs/memory/testing.md @aidd_docs/memory/vcs.md +- aidd_docs/memory/internal/smoke-real.md +- aidd_docs/memory/internal/decisions/clean-drives-the-host-cli.md +- aidd_docs/memory/internal/decisions/framework-source-is-machine-scope.md +- aidd_docs/memory/internal/decisions/marketplace-identity-is-name-plus-plugins.md +- aidd_docs/memory/internal/decisions/plugin-enablement-carries-its-scope.md - aidd_docs/memory/internal/decisions/self-update-version-source-npm.md diff --git a/cli/README.md b/cli/README.md index b6b221937..846943774 100644 --- a/cli/README.md +++ b/cli/README.md @@ -1,581 +1,196 @@ # AIDD CLI -The **AIDD CLI** (`@ai-driven-dev/cli`) installs AI tool runtime configs, IDE integrations, and plugins from the [AIDD marketplace](https://github.com/ai-driven-dev/framework) across AI coding assistants. Runtime configs are bundled in the CLI binary; memory and context files are provided by the `aidd-context` plugin, not the binary. Plugins are fetched from the marketplace on demand. Every installed file is hash-tracked in a manifest for drift detection. +`@ai-driven-dev/cli` installs AI tool runtime configs, IDE integrations, and plugins from an AIDD marketplace into a project. +Every file it writes is hash-tracked in a manifest, so drift is detected and owned files can be restored. -**Supported tools:** Claude Code · Cursor · GitHub Copilot · OpenCode · Codex · VS Code (IDE integration) +Supported AI tools: Claude Code, Cursor, GitHub Copilot, Codex, OpenCode. Supported IDE: VS Code. +Requires Node.js >= 22.12, and `git` to fetch marketplace plugins. ---- +## Install -## Prerequisites +Run without installing: -| Prerequisite | Version | Notes | -| ----------------------- | ------- | ------------------------------------------------------- | -| **Node.js** | >= 22.12 | [nodejs.org](https://nodejs.org) | -| **git** | — | Required for marketplace plugin fetching | -| **gh CLI** _(optional)_ | — | Can be used as an authentication method via `aidd auth login --gh` | - -> **Windows:** works natively on Windows 10 1803+ (PowerShell or cmd) and on WSL. -> If you encounter permission issues with `npm install -g`, use an administrator terminal or WSL. - ---- - -## Installation - -Available on [npmjs.org](https://www.npmjs.com/package/@ai-driven-dev/cli). - -### Zero-install (recommended) - -Run any `aidd` command directly via `npx` — no global install needed: - -```bash +```sh npx @ai-driven-dev/cli@latest setup -npx @ai-driven-dev/cli@latest --version ``` -First call fetches the package (~3 s cold start, then cached by npm). Use this when you want a one-shot run or want to pin a specific version per project (`@4.2.1`). - -### Global install +Or install once for every project: -For repeated use across many projects: - -```bash +```sh npm install -g @ai-driven-dev/cli@latest -# or -pnpm add -g @ai-driven-dev/cli@latest - aidd --version ``` -> Run `which aidd` to identify the active binary and use the matching package manager (`npm`, `pnpm`, `yarn`, `bun`). - ---- - -## Authentication - -Authentication is **not required** for the default public marketplace (`github.com/ai-driven-dev/framework`). Authentication is only needed for private marketplaces. - -To authenticate for a private marketplace: - -### Method 1 — Personal Access Token (recommended) - -```bash -aidd auth login --token --level user -``` - -### Method 2 — GitHub CLI - -```bash -gh auth login -aidd auth login --gh --level user -``` - -### Method 3 — Environment variable - -```bash -export AIDD_TOKEN= -``` - -### Token resolution order - -`AIDD_TOKEN` env → project `.aidd/auth.json` → user `~/.config/aidd/auth.json` → `gh auth token` (only if stored config uses `method: "gh"`) - -### Storage levels - -| Level | File | Use case | -| --------- | ----------------------------- | ------------------------------------------- | -| `user` | `~/.config/aidd/auth.json` | Shared across all projects (default) | -| `project` | `.aidd/auth.json` | Per-project credential (add to `.gitignore`) | - -### Auth commands - -```bash -aidd auth login --token --level user # store a PAT -aidd auth login --gh --level user # use gh CLI token -aidd auth status # check current auth (exit 1 if not authenticated) -aidd auth logout # remove stored credential -``` - ---- - ## Quickstart -```bash -# 1. Interactive setup: init manifest + register default marketplace + install runtime config -aidd setup - -# 2. Non-interactive scriptable setup (CI / onboarding scripts) -aidd setup --source remote --ai claude --ide vscode --plugins recommended --yes - -# 3. Install an AI tool or IDE integration (noun-first surface) -aidd ai install claude -aidd ide install vscode - -# 4. Install a plugin from the marketplace -aidd plugin install aidd-context - -# 5. Check installation status -aidd ai status +```sh +aidd setup --ai claude --ide vscode --plugins recommended --yes +aidd plugin install aidd-dev +aidd doctor +aidd sync +aidd update ``` -### Setup flags - -```bash -# Remote marketplace (default) — optionally pin a specific tag -aidd setup --source remote --release v4.1.0 --ai claude --yes - -# Local framework checkout -aidd setup --source local --path /path/to/aidd-framework --ai claude --yes +The first command bootstraps the project: manifest, default marketplace, tool configs, plugins. +`--scope user` on `setup` registers the framework source and native activation machine-wide instead, and writes nothing under the project. -# All tools, no prompts -aidd setup --ai all --ide all --yes -``` - -`--source remote|local` selects the marketplace source. -`--release ` pins the marketplace version fetched during setup (default: latest tag). -`--yes` accepts all defaults. - -### Brownfield (existing project) - -A manifest from an older CLI version is upgraded to the latest schema automatically the -first time it is loaded — no manual migration command. Just run any `aidd` command: - -```bash -aidd status -``` - ---- - -## User Flows - -### Updating the framework - -```bash -aidd status # see what changed (drift + available update) -aidd update # re-install all tool configs, update plugins, refresh marketplaces -aidd update --force # overwrite modified files without prompting (CI-safe) -``` - -`aidd update` takes no scope flags — it refreshes every installed tool. To re-install a single tool, use `aidd ai update ` / `aidd ide update `. - -**Conflict behavior**: unmodified files (disk hash matches manifest hash) are always updated silently. Modified files prompt keep / overwrite / overwrite-all / skip-all in an interactive terminal; in non-interactive mode (no TTY, CI), the command exits 1 unless `--force` is passed. `--force` overwrites all modified files without prompting. Plugin and marketplace updates are never gated by this guard. - -### Restoring modified files - -```bash -aidd status # identify modified (~) files -aidd restore # restore all tracked files (all tools), prompts first -aidd restore --force # skip confirmation prompts (CI-safe) -aidd ai restore --tool claude # restore a specific AI tool's files -aidd ai restore rules/naming.md # restore specific files -``` - -Restore uses the version pinned in the manifest. It does not touch untracked files. Top-level `aidd restore` covers all tools; per-tool/per-file restore lives under `aidd ai restore` / `aidd ide restore`. - -### Managing plugins - -```bash -# Register a marketplace and install plugins -aidd marketplace add acme owner/aidd-plugins -aidd plugin install # no arg → interactive browse + install +## Authentication -# One-shot non-interactive install -aidd plugin install my-plugin --yes +Not needed for the public marketplace. Needed for a private one. -# Keep plugins up to date -aidd plugin update +Two methods are stored in `auth.json`: -# Check for stale catalogs or upstream-removed plugins -aidd marketplace check -``` +- `stored`: the token itself is written to the file, from `--token `. +- `external`: only the provider name is written, and the token is asked from it at fetch time, from `--gh`. -### Uninstalling a tool +Token resolution stops at the first hit: -```bash -aidd ai uninstall cursor # remove cursor files and clean up the manifest -aidd ide uninstall vscode # remove VS Code integration only -``` +1. `AIDD_TOKEN`. +2. The project file, `.aidd/auth.json`. +3. The user file, `auth.json` in the user config directory. -`aidd ai uninstall` / `aidd ide uninstall` take a tool argument; run once per tool to remove several. - ---- +`aidd auth login --level project` writes the project file, `--level user` the user one. +`aidd plugin install --token ` and `aidd marketplace add --token ` pass a token for one call without storing it. ## Commands -| Command | Description | Key options | -| ------------------------------- | ------------------------------------------------------------------------------------ | ----------------------------------------------------------------- | -| `aidd auth` | Manage authentication (login, logout, status) | `--token`, `--gh`, `--level` | -| `aidd setup` | Bootstrap a project: init manifest + register marketplace + install runtime config | `--source`, `--path`, `--release`, `--ai`, `--ide`, `--plugins`, `--yes` | -| `aidd ai install ` | Install an AI tool runtime configuration from bundled assets | `--force` | -| `aidd ai uninstall ` | Remove an AI tool's generated configuration files | — | -| `aidd ai list` | List installed AI tools | — | -| `aidd ai status` | Show drift for AI tools | — | -| `aidd ai update [tool]` | Re-install AI tool configs from bundled CLI assets; prompts on conflicts in TTY, exits 1 in non-TTY | `--force` | -| `aidd ai restore [files...]` | Restore AI tool tracked files to their installed version | `--force`, `--tool` | -| `aidd ai doctor` | Check AI tool installation health and detect issues | — | -| `aidd ide install ` | Install an IDE integration from bundled assets | `--force` | -| `aidd ide uninstall ` | Remove an IDE integration from the manifest | — | -| `aidd ide list` | List installed IDE tools | — | -| `aidd ide status` | Show drift for IDE tools | — | -| `aidd ide update [tool]` | Re-install IDE tool configs from bundled CLI assets; prompts on conflicts in TTY, exits 1 in non-TTY | `--force` | -| `aidd ide doctor` | Check IDE tool installation health and detect issues | — | -| `aidd status` | Show drift across all tools (AI + IDE) | — | -| `aidd doctor` | Structural integrity check — exits 1 on errors or warnings | — | -| `aidd restore [files...]` | Revert modified/deleted files to the manifest-pinned version | `--force`, `--tool` | -| `aidd plugin` | Manage plugins for AI tools | `create`, `remove`, `list`, `install`, `search`, `update`, `doctor` | -| `aidd marketplace` | Manage plugin marketplaces | `add`, `list`, `remove`, `refresh`, `check` | -| `aidd framework build` | Build a Claude-format framework into a tool-native plugin marketplace tree or flat workspace | `--source`, `--target`, `--out`, `--flat`, `--force` | -| `aidd clean` | Remove all AIDD files — dry-run without `--force` | `--force` | -| `aidd self-update` | Update the CLI itself to the latest version | `--check`, `--dry-run`, `--force` | - -### `aidd auth` - -Manages stored GitHub credentials used to download the framework. - -```bash -aidd auth login --token --level user # store a PAT at user level -aidd auth login --token --level project # store a PAT at project level -aidd auth login --gh --level user # use gh CLI as token source -aidd auth status # show current auth (exit 1 if not authenticated) -aidd auth logout # remove the active credential -``` - -Credentials are stored in JSON files with `600` permissions. The `project` level stores in `.aidd/auth.json` — add it to `.gitignore` to avoid committing secrets. - -### `aidd setup` - -Bootstraps a new project: initializes the manifest, registers the default marketplace, and writes the runtime config for the selected tools. Interactive by default; scriptable with flags. - -```bash -aidd setup # interactive guided setup -aidd setup --source remote --ai claude --yes # non-interactive: remote marketplace, claude -aidd setup --source remote --release v4.1.0 --ai claude --yes # pin a specific marketplace tag -aidd setup --source local --path /path/to/framework \ - --ai claude --ide vscode --plugins recommended --yes # local framework source -aidd setup --ai all --ide all --yes # all tools, no prompts -aidd setup --ai claude,cursor --ide vscode # mix AI and IDE tools -``` - -| Flag | Description | -|---|---| -| `--source remote\|local` | Marketplace source. `remote` fetches from GitHub; `local` uses a local checkout. | -| `--release ` | Marketplace version to fetch (e.g. `v4.1.0`). Defaults to latest tag. Remote only. | -| `--path ` | Path to local framework checkout. Required with `--source local`. | -| `--ai ` | Comma-separated AI tool IDs, or `all` (e.g. `claude,cursor` or `all`). | -| `--ide ` | Comma-separated IDE tool IDs, or `all` (e.g. `vscode` or `all`). | -| `--plugins ` | Plugin install mode: `none` \| `all` \| `recommended` \| comma-separated names. | -| `--no-default-marketplace` | Skip auto-registering `aidd-framework` (no source prompt, no plugin install). | -| `--yes` | Accept all defaults; disables interactive prompts. | - -`--ai`, `--ide`, `--plugins`, or `--source` each disable interactive prompts. - -### `aidd ai` - -Manages AI tools (install, uninstall, list, status, update, restore, doctor). - -```bash -aidd ai install claude # install Claude Code runtime config -aidd ai install cursor --force # overwrite existing files -aidd ai uninstall claude # remove Claude Code files -aidd ai list # list installed AI tools -aidd ai status # show drift for all AI tools -aidd ai update # re-install all AI tool configs (prompts on conflicts) -aidd ai update claude # re-install a specific AI tool -aidd ai update --force # overwrite modified files without prompting -aidd ai restore --tool claude # restore modified Claude files -aidd ai doctor # check AI tool installation health -``` - -### `aidd ide` - -Manages IDE integrations (install, uninstall, list, status, update, doctor). - -```bash -aidd ide install vscode # install VS Code integration -aidd ide uninstall vscode # remove VS Code integration -aidd ide list # list installed IDE tools -aidd ide status # show drift for IDE tools -aidd ide update # re-install all IDE tool configs (prompts on conflicts) -aidd ide update --force # overwrite modified files without prompting -aidd ide doctor # check IDE tool installation health -``` - -### `aidd status` - -Compares files on disk with the manifest. Shows drift and available framework updates. - -```bash -aidd status # drift across all tools (AI + IDE) -aidd ai status # AI tools only -aidd ide status # IDE tools only -``` - -Legend: `~` modified · `-` deleted · `+` untracked (on disk, not in manifest) - -### `aidd doctor` - -Checks structural integrity. Exits 1 if errors or warnings are found; exits 0 with a warning message if only the auth credential is missing (non-blocking in CI). - -```bash -aidd doctor # check all tools and plugins -aidd ai doctor # AI tools only -aidd ide doctor # IDE tools only -``` - -Detects: missing or corrupted manifest, orphaned tool directories, broken `@path` includes and markdown links in tracked files. - -> Drift (modified/deleted files) is not a structural issue — use `aidd status` for that. - -### `aidd update` - -Re-applies bundled configs and fetches updated plugin content. See [Updating the framework](#updating-the-framework) for examples. - -> `aidd update` refreshes every installed tool. To re-install one tool, use `aidd ai update ` / `aidd ide update ` (these only touch tools already in the manifest). Use `aidd ai install ` to add a new tool. - -Per-file conflict guard: unmodified files are always updated silently. Modified files prompt in TTY or exit 1 in non-TTY. Use `--force` to overwrite all modified files without prompting. Plugin and marketplace branches are always ungated. - -### `aidd restore` - -Reverts modified or deleted files to the version pinned in the manifest. See [Restoring modified files](#restoring-modified-files) for examples. - -### `aidd plugin` - -Manages plugins for AI tools. Plugins extend the framework with additional agents, rules, hooks, and commands distributed independently of the core framework. - -```bash -aidd plugin install ./path/to/plugin # install a local plugin into all installed tools -aidd plugin install ./path/to/plugin --tool claude # install into a specific tool only -aidd plugin install my-plugin # install a plugin from a registered marketplace -aidd plugin install my-plugin@1.2.0 # pin to a specific version -aidd plugin install my-plugin --from acme # resolve from a specific marketplace -aidd plugin install my-plugin --yes # auto-resolve prompts (CI mode) -aidd plugin list # list installed plugins (all tools) -aidd plugin list --tool claude # list for a specific tool -aidd plugin search hooks # search marketplaces by keyword -aidd plugin search hooks --recommended # show only recommended results -aidd plugin search hooks --marketplace acme # limit search to one marketplace -aidd plugin install # no arg → interactively browse and install from a marketplace -aidd plugin doctor # check plugin installation health -aidd plugin update # update all installed plugins -aidd plugin update my-plugin # update a specific plugin -aidd plugin remove my-plugin # remove a plugin from all tools -aidd plugin remove my-plugin --tool claude # remove from a specific tool -``` - -### `aidd marketplace` - -Registers and manages plugin marketplaces — sources that publish plugin catalogs. - -```bash -aidd marketplace add acme owner/aidd-plugins # register a marketplace (project scope) -aidd marketplace add acme owner/aidd-plugins --user # register at user scope -aidd marketplace add acme owner/aidd-plugins --yes # skip trust + cleanup prompts -aidd marketplace add acme owner/aidd-plugins --overwrite # replace existing entry -aidd marketplace list # list registered marketplaces -aidd marketplace list --plugins # also fetch + print every marketplace's plugin catalog -aidd marketplace refresh # refresh all marketplace catalogs -aidd marketplace refresh acme # refresh a specific marketplace -aidd marketplace refresh --force # clear cache before re-fetching -aidd marketplace remove acme # remove a registered marketplace -aidd marketplace remove acme --yes # skip orphan-cleanup prompt -aidd marketplace check # report stale marketplaces and removed plugins -``` - -Marketplace sources accept a GitHub shorthand (`owner/repo`) or a full path to a local catalog file. Use `--token` on `marketplace add` or `plugin install` when the source requires authentication. - -#### Marketplace formats supported - -The CLI can ingest plugin catalogs in five native formats and normalizes them into a common schema for installation: - -| Format | Catalog probe path (how it's detected) | -|---|---| -| AIDD / Claude native | `.claude-plugin/marketplace.json` | -| Cursor | `.cursor-plugin/marketplace.json` | -| GitHub Copilot | `.github/plugin/plugin.json` | -| Codex | `.agents/plugins/marketplace.json` | -| OpenCode | `opencode.json` | - -#### Per-tool settings file paths - -Marketplace registration and plugin enable state are written to per-tool settings files: - -| Tool | Settings file | -|---|---| -| Claude Code | `.claude/settings.json` | -| Cursor | `.cursor/settings.json` | -| GitHub Copilot | `.github/copilot/settings.json` | -| Codex | `.codex/config.json` | -| OpenCode | `opencode.json` (project root) | - -> **GitHub Copilot — workspace recommendations only.** Per [VS Code docs](https://code.visualstudio.com/docs/copilot/customization/agent-plugins), `.github/copilot/settings.json` registers marketplaces as **team recommendations**, not auto-activated. On first chat in the workspace VS Code shows a notification — the user must accept it (or filter Extensions by `@agentPlugins @recommended` and enable manually) before plugins load. To skip the per-project click, add the marketplace to the user-level setting `chat.plugins.marketplaces` (application-scoped, not writable from workspace). See [End-to-end: distribute a framework to Copilot](#end-to-end-distribute-a-framework-to-copilot-marketplace) for the full flow. - -### `aidd framework build` - -Translates a Claude-format framework source into a **target-native distribution** — one build per tool, in one of two modes. Used by framework authors to produce the dist trees consumers install. Not a CI step; run it manually (or in your own release script) against a framework checkout, typically a tagged framework release. - -```bash -aidd framework build \ - --source \ - --target \ - --out \ - [--flat] [--force] -``` - -| Flag | Required | Description | -|---|---|---| -| `--source` | yes | Path to a framework root with `plugins//.claude-plugin/plugin.json` entries | -| `--target` | yes | `claude`, `cursor`, `copilot`, `codex`, or `opencode` | -| `--out` | yes | Output directory. Marketplace mode: dist root (auto-wiped + recreated). Flat mode: the project root to materialize into | -| `--flat` | no | Materialize directly into a project workspace, bypassing the marketplace layer | -| `--force` | no | Overwrite existing files at canonical paths. **Flat mode only** (rejected without `--flat`) | - -#### Two modes - -- **Marketplace** (default) — emits a self-contained marketplace tree (`marketplace.json` + `plugins//...`). The consumer registers it with `aidd marketplace add` and installs plugins through the tool's native marketplace flow. Paths are rewritten to the tool's plugin-root token; no `${CLAUDE_PLUGIN_ROOT}` survives unless that token is the tool's own. -- **Flat** (`--flat`) — materializes plugin content directly under the tool's workspace config directory (e.g. `.claude/`, `.cursor/`), with no marketplace indirection. For tools without native marketplace support, or when you want files on disk in the project. - -#### Per-tool / per-mode matrix - -`opencode` is **flat-only** (no native marketplace). The other four support both modes. - -| Target | Marketplace layout (`/`) | Plugin-root token | Flat layout (`/`) | -|---|---|---|---| -| `claude` | `.claude-plugin/marketplace.json` · `plugins//.claude-plugin/plugin.json` · `agents/*.md` | `${CLAUDE_PLUGIN_ROOT}` | `.claude/` (+ `.mcp.json`); hooks merged into `.claude/settings.json` | -| `cursor` | `.cursor-plugin/marketplace.json` · `plugins//.cursor-plugin/plugin.json` · `agents/*.md` | `${CURSOR_PLUGIN_ROOT}` | `.cursor/` | -| `copilot` | `.plugin/marketplace.json` · `plugins//.plugin/plugin.json` (OpenPlugin spec) · `agents/*.md` | `${PLUGIN_ROOT}` | `.github/` (+ `.vscode/`) | -| `codex` | `.claude-plugin/marketplace.json` · `plugins//.codex-plugin/plugin.json` · `codex-agents/*.toml` | `${PLUGIN_ROOT}` | `.codex/` | -| `opencode` | — (flat-only) | — | `.opencode/` (+ `opencode.json` for MCP) | - -Copilot uses the [OpenPlugin spec](https://github.com/vercel/open-plugin-spec) (`.plugin/plugin.json`, `${PLUGIN_ROOT}`) — the only layout where Copilot's editor + CLI resolve the plugin-root token at runtime. Codex requires the manifest `skills` field as a **string** (`"./skills"`), and project subagents (`.codex/agents/*.toml`) load only when the project is **trusted**. - -#### End-to-end: distribute a framework to Copilot (marketplace) - -```bash -# 1. (author, per release) — produce the dist tree -aidd framework build --source ./framework --target copilot --out ./dist/aidd-framework-copilot - -# 2. (consumer) — register and install -aidd ai install copilot -aidd marketplace add aidd-fw ./dist/aidd-framework-copilot --yes -aidd plugin install aidd-dev --tool copilot --yes -``` - -After step 2 the CLI writes `.github/copilot/settings.json` with `extraKnownMarketplaces` + `enabledPlugins`. **VS Code shows a workspace recommendation notification on first chat**; the consumer accepts it once for plugins to surface in the slash menu. - -To skip the per-project notification, add the dist path to the user-level `chat.plugins.marketplaces` setting via VS Code Settings UI (search "chat plugins marketplaces"): - -```jsonc -// ~/Library/Application Support/Code/User/settings.json (macOS) -// %APPDATA%\Code\User\settings.json (Windows) -// ~/.config/Code/User/settings.json (Linux) -{ - "chat.plugins.marketplaces": [ - "file:///absolute/path/to/dist/aidd-framework-copilot" - ] -} -``` - -The CLI cannot write this setting programmatically (VS Code enforces application scope on it). - -#### Flat materialization (e.g. opencode) - -```bash -# Materialize the framework straight into a project workspace -aidd framework build --source ./framework --target opencode --out ./my-project --flat -# Re-run after source changes, overwriting canonical paths: -aidd framework build --source ./framework --target opencode --out ./my-project --flat --force -``` - -Flat mode writes directly under the project's tool directory — no `aidd marketplace add` / `aidd plugin install` step. opencode hooks are skipped (its runtime is JS modules, not declarative `hooks.json`). - -#### Build every target for a release - -```bash -for t in claude cursor copilot codex; do - aidd framework build --source ./framework --target "$t" --out "./dist/aidd-framework-$t" -done -aidd framework build --source ./framework --target opencode --out ./dist/aidd-framework-opencode-flat --flat -``` - -### Manifest schema upgrades - -There is no `aidd migrate` command. A manifest written by an older CLI version is upgraded to the current schema (v6) automatically when it is loaded — the version-to-version migrations live in `manifest.ts` and run on `Manifest.deserialize`. The upgraded shape is persisted the next time the manifest is written (e.g. on the next `install` or `update`). The migration chain is idempotent. - -### `aidd clean` - -Removes all AIDD-generated files and the manifest. - -```bash -aidd clean # dry-run: shows what will be removed -aidd clean --force # actual removal -``` - -### `aidd self-update` - -Updates the CLI itself to the latest published version. - -```bash -aidd self-update # install latest version -aidd self-update --check # check availability without installing -aidd self-update --dry-run # preview without installing -aidd self-update --force # reinstall even if already up to date -``` - ---- - -## Options - -### Global (all commands) - -```bash -aidd update --verbose # detailed logs -``` - -**Environment variables:** - -| Variable | Description | -| -------------- | ----------------------------------------------------------------------- | -| `AIDD_TOKEN` | GitHub token — takes precedence over stored credentials (needed for private marketplaces only) | -| `AIDD_VERBOSE` | Verbose mode (`true`/`false`) | - ---- - -## Removed surface (v4.0.x → v4.1.0) - -The following commands and flags were removed in v4.1.0. Do not use them in new scripts. - -| Removed | Replacement | -|---|---| -| `aidd install ai ` | `aidd ai install ` | -| `aidd install ide ` | `aidd ide install ` | -| `aidd uninstall ai ` | `aidd ai uninstall ` | -| `aidd uninstall ide ` | `aidd ide uninstall ` | -| `aidd cache list` | removed — caches are internal; inspect via `aidd marketplace list` | -| `aidd cache clear` | `aidd marketplace refresh --force` (clears cache before re-fetch) | -| `aidd config list\|get\|set` | removed — manifest fields `docsDir`/`repo` dropped | -| `aidd sync` / `aidd ai sync` | removed — install rebuilds each tool from the marketplace; re-install to refresh | -| `aidd restore [file]` (tool/file args) | `aidd ai restore [files...] --tool ` (top-level `aidd restore` still exists, force-only, all tools) | -| `--repo` global flag | `aidd marketplace add` | -| `--mode` on setup/install | `--source local\|remote` on `aidd setup` | -| `--path` on install | `aidd setup --source local --path ` | -| `--release`, `--from`, `--switch-mode` on install | removed — tarball download eliminated | -| `--docs-dir` on setup | removed — `docsDir` field dropped from manifest v5 | - -See [MIGRATION.md](MIGRATION.md) for the full migration guide from v4.0.x to v4.1.0. +Run `aidd --help`, then a group's own `--help`, for flags this page does not repeat. ---- +### Project lifecycle -## Contributing +| Command | Does | +| --- | --- | +| `aidd setup` | Bring the whole project to a correct state: marketplace, tool configs, plugins | +| `aidd doctor` | Report detected tools, installed plugins, drift, and what to run to fix each | +| `aidd sync` | Rewrite owned files from the manifest, then re-drive native activation | +| `aidd clean` | Remove every AIDD-managed file from the project | +| `aidd update` | Update the CLI itself, aliased `upgrade` | -See [CONTRIBUTING.md](../CONTRIBUTING.md) for the full contribution guide. +`setup`, `doctor`, `sync` and `clean` accept `--scope `. Project scope is the default and acts on this project alone. -Code contributions are open to certified **Obsidian+** members. +### Framework ---- +| Command | Does | +| --- | --- | +| `aidd framework install` | Write one tool's runtime configuration from the bundled assets | +| `aidd framework update` | Move installed tools to this CLI's assets, all of them without `--tool` | +| `aidd framework remove` | Delete the generated configuration files of one tool | +| `aidd framework rules` | List the rules installed across every AI tool, `--json` for a machine | -## License +### Plugins -Private repository — all AIDD team members. +| Command | Does | +| --- | --- | +| `aidd plugin install` | Install a plugin by marketplace name, by local path, or by interactive pick | +| `aidd plugin list` | List installed plugins for one tool or all of them | +| `aidd plugin update` | Move one plugin, or every plugin, to the catalog's current version | +| `aidd plugin remove` | Uninstall a plugin from one tool or all of them | +| `aidd plugin search` | Search registered marketplaces, `--recommended` for the curated set | ---- +### Marketplaces -← [Back to aidd-framework](https://github.com/ai-driven-dev/framework) +| Command | Does | +| --- | --- | +| `aidd marketplace add` | Register a marketplace by name and source | +| `aidd marketplace list` | List registered marketplaces, `--plugins` also prints their catalogs | +| `aidd marketplace refresh` | Re-fetch catalogs, `--force` clears the cache first | +| `aidd marketplace check` | Report stale catalogs and plugins removed upstream | +| `aidd marketplace remove` | Unregister a marketplace and offer to clean its orphans | + +### Auth + +| Command | Does | +| --- | --- | +| `aidd auth login` | Store a credential, `--token ` or `--gh` | +| `aidd auth status` | Show which credential resolves, from where, at which level | +| `aidd auth logout` | Delete the stored credential | + +### Telemetry + +Opt-in, off until asked. Record shapes and report axes: [`../plugins/aidd-telemetry/README.md`](../plugins/aidd-telemetry/README.md). + +| Command | Does | +| --- | --- | +| `aidd telemetry on` | Flip the git-tracked switch on and git-ignore the run journal | +| `aidd telemetry off` | Flip the switch off, warning when a tool still exports on its own | +| `aidd telemetry check` | Say whether the chain is actually recording for this project | +| `aidd telemetry read` | Read session cost from the files the tools already wrote | +| `aidd telemetry report` | Report a period, or one task in it, along one `--axis` | +| `aidd telemetry identity` | Attach or drop this person's identifier: `use`, `off`, `link`, `unlink` | +| `aidd telemetry forget` | Irreversibly drop the journal, the stored records, and the identity file | + +## Translate + +`aidd translate --to --out ` converts a framework source tree into a target-native plugin tree. +It records nothing in the manifest, unlike `aidd sync`. It is the author-side command that produces what consumers install. + +Two output layouts, chosen by `--as`: + +- `marketplace`, the default: a self-contained marketplace tree the consumer registers with `aidd marketplace add`, with plugin paths rewritten to the target's own plugin-root token. +- `flat`: plugin content materialized straight under the target's workspace directory, with no marketplace step. `--force` overwrites files already at those paths, and applies to this layout only. + +| Target | `marketplace` | `flat` | Workspace directory | +| --- | --- | --- | --- | +| `claude` | yes | yes | `.claude/` | +| `cursor` | yes | yes | `.cursor/` | +| `copilot` | yes | yes | `.github/` | +| `codex` | yes | yes | `.codex/` | +| `opencode` | no | yes | `.opencode/` | + +OpenCode declares no marketplace contract, so it is flat only. Every other target accepts both layouts. + +## Environment variables + +| Variable | Effect | +| --- | --- | +| `AIDD_TOKEN` | Token used for every fetch, ahead of any stored credential | +| `AIDD_USER_CONFIG_DIR` | Relocates the user config directory outright, credentials included | +| `XDG_CONFIG_HOME` | Names the config root when `AIDD_USER_CONFIG_DIR` is unset | +| `AIDD_TELEMETRY_DIR` | Names where telemetry records are kept, ahead of the config directory | +| `AIDD_RUNS_DIR` | Names the run journal directory, read alike by the CLI and the hook | +| `AIDD_SKIP_UPDATE_CHECK` | Set to `1`, skips the self-update check before a command | + +Share `AIDD_TELEMETRY_DIR` to pool figures across a team. Never share `AIDD_USER_CONFIG_DIR`: it also moves `auth.json`. + +## Where things live + +In the project: + +| Path | Holds | +| --- | --- | +| `.aidd/manifest.json` | Every owned file and its hash | +| `.aidd/config.json` | The telemetry switch, git-tracked, kept by `clean` | +| `.aidd/auth.json` | The project-level credential | +| `.aidd/marketplaces.json` | Marketplaces registered at project scope | +| `.aidd/cache/`, `.aidd/plugin-cache/` | Fetched catalogs, built trees, plugin sources | +| `aidd_docs/runs/` | The run journal, at the repository root above the project | + +On the machine, under `$AIDD_USER_CONFIG_DIR`, else `$XDG_CONFIG_HOME/aidd`, else `~/.config/aidd`: + +- `auth.json`, the user-level credential. +- `marketplaces.json` and `references.json`, the user-scope registry and the projects claiming it. +- `manifest.json`, what `--scope user` owns. +- `cache/built//`, one built tree per CLI version. +- Telemetry records, unless `AIDD_TELEMETRY_DIR` moves them. + +The person identity file stays in the user profile and is never relocated by `AIDD_USER_CONFIG_DIR`. + +Per tool, the settings file the CLI writes: + +| Tool | File | +| --- | --- | +| Claude Code | `.claude/settings.json`, MCP servers in `.mcp.json` | +| Cursor | `.cursor/settings.json`, MCP servers in `.cursor/mcp.json` | +| GitHub Copilot | Plugin recommendations in `.github/copilot/settings.json`, MCP servers in `.vscode/mcp.json`, plus `.vscode/settings.json` when the VS Code tool is installed too | +| Codex | `.codex/config.toml` | +| OpenCode | `opencode.json`, or `opencode.jsonc` when that is the one present | +| VS Code | `.vscode/settings.json`, `.vscode/extensions.json`, `.vscode/keybindings.json` | + +## More + +- [Architecture](../docs/ARCHITECTURE.md) +- [Write a plugin](../docs/CREATE_PLUGIN.md) +- [Marketplace](../docs/MARKETPLACE.md) +- [Glossary](../docs/GLOSSARY.md) +- [FAQ](../docs/FAQ.md) +- [Contributing](../CONTRIBUTING.md) diff --git a/cli/aidd_docs/GUIDELINES.md b/cli/aidd_docs/GUIDELINES.md index bf869f833..6bd3c939a 100644 --- a/cli/aidd_docs/GUIDELINES.md +++ b/cli/aidd_docs/GUIDELINES.md @@ -11,8 +11,8 @@ How this team drives AI coding assistants on the `@ai-driven-dev/cli` repo. Repo ## Validation depth -- Before commit: `pnpm typecheck` → `pnpm lint` → `pnpm knip:production` → `pnpm jscpd` → `pnpm test` (order in `memory/coding-assertions.md`). -- Before push (Lefthook): `pnpm knip:production` + `pnpm test`; build must stay under the 500 KB bundle budget. +- Before commit: `pnpm typecheck` → `pnpm lint` → `pnpm knip` → `pnpm jscpd` → `pnpm test` (order in `memory/coding-assertions.md`). +- Before push (Lefthook): `pnpm knip` + `pnpm test`; build must stay under the 500 KB bundle budget. - Tool-integration claims are empirical: verify against the real tool's CLI/IDE, not source inference (see `memory/testing.md`). ## When the AI drifts diff --git a/cli/aidd_docs/memory/README.md b/cli/aidd_docs/memory/README.md index 1f6c50d72..bbfe824fb 100644 --- a/cli/aidd_docs/memory/README.md +++ b/cli/aidd_docs/memory/README.md @@ -1,6 +1,6 @@ # memory/ - Project Memory -Structured context the AI assistant reads at the start of a session, so it does not rediscover the project each time. +The `cli/` bank. What a memory bank is: [`aidd_docs/memory/README.md`](../../../aidd_docs/memory/README.md). ## How it loads @@ -18,12 +18,19 @@ The list below is refreshed automatically by the memory hook. Do not edit it by - [codebase-map.md](codebase-map.md) - [coding-assertions.md](coding-assertions.md) - [deployment.md](deployment.md) +- [ecosystem.md](ecosystem.md) - [project-brief.md](project-brief.md) +- [telemetry.md](telemetry.md) - [testing.md](testing.md) - [vcs.md](vcs.md) Read on demand: +- [internal/smoke-real.md](internal/smoke-real.md) +- [internal/decisions/clean-drives-the-host-cli.md](internal/decisions/clean-drives-the-host-cli.md) +- [internal/decisions/framework-source-is-machine-scope.md](internal/decisions/framework-source-is-machine-scope.md) +- [internal/decisions/marketplace-identity-is-name-plus-plugins.md](internal/decisions/marketplace-identity-is-name-plus-plugins.md) +- [internal/decisions/plugin-enablement-carries-its-scope.md](internal/decisions/plugin-enablement-carries-its-scope.md) - [internal/decisions/self-update-version-source-npm.md](internal/decisions/self-update-version-source-npm.md) diff --git a/cli/aidd_docs/memory/architecture.md b/cli/aidd_docs/memory/architecture.md index a38f8710e..8fa139990 100644 --- a/cli/aidd_docs/memory/architecture.md +++ b/cli/aidd_docs/memory/architecture.md @@ -1,143 +1,59 @@ # Architecture -## Stack - -- TypeScript ESM, Node.js >= 22.12, bundled via tsup → `dist/cli.js` -- Runtime dependencies (6 allowed; each requires explicit justification; new additions require an ADR): - - `commander` — CLI argument/command parsing - - `@inquirer/prompts` — interactive terminal prompts - - `ajv` — JSON-schema validation for marketplace/plugin schemas - - `ajv-formats` — standard format validators (uri, date, etc.) for ajv - - `simple-git` — git clone/fetch for plugin distribution - - `smol-toml` — TOML read/write for Codex config round-trips -- Vitest (tests), Biome (lint/format), Lefthook (git hooks via parent monorepo) - -## Layers - -3-layer hexagonal architecture — dependencies flow inward only: - -``` -Infrastructure → Application → Domain -``` - -| Layer | Path | Role | -|---|---|---| -| Domain | `src/domain/` | Models, ports, formats, capabilities, tool definitions | -| Application | `src/application/` | Use-cases, commands (CLI wiring only) | -| Infrastructure | `src/infrastructure/` | Adapters (filesystem, HTTP, GitHub, auth, cache) | - -## Key Domain Concepts - -- `AiTool` — generic AI tool type; `C` = intersection of `Has*` capability interfaces -- `IdeToolConfig` — IDE tool type (vscode); no capabilities -- `ToolConfig = AiTool | IdeToolConfig` — discriminated union; `isAiTool()` is the guard -- `Manifest` — aggregate root, tracks every installed file with MD5 hash (`.aidd/manifest.json`) -- Framework layout is code-defined — no `framework.json` on disk - -## Domain Models (notable) - -| Model | File | Description | -|---|---|---| -| `MarketplaceSourceMode` | `domain/models/marketplace-source-mode.ts` | Marketplace source type with optional `ref` | -| `SetupFlow` | `domain/models/setup-flow.ts` | Aggregate: setup orchestration state | -| `MarketplaceEntry` | `domain/capabilities/marketplace-entry.ts` | Per-tool marketplace registration entry | -| `MarketplaceCacheEntry` | `domain/models/marketplace-cache-entry.ts` | Cached catalog TTL entry | -| `NormalizedPlugin` | `domain/models/normalized-plugin.ts` | Foreign-format AST (internal; non-versioned) | -| `LatestReleaseResolver` | `domain/ports/latest-release-resolver.ts` | Port: resolve latest GitHub release tag | +The macro shape of this package: the stack, how the pieces fit, and the decisions behind them. -## Install Flows (high-level) - -**AI tool runtime config** (`aidd ai install `): -``` -InstallRuntimeConfigUseCase → AssetLoader (bundled in binary) → FileSystem + ManifestRepository -``` - -**IDE config** (`aidd ide install `): -``` -InstallIdeConfigUseCase → AssetLoader (bundled in binary) → FileSystem + ManifestRepository -``` - -**Plugin** (`aidd plugin install `): -``` -PluginInstallFromMarketplaceUseCase → MarketplaceRegistry + PluginFetcher (git clone) -→ Distribution (per-tool rewrite) → FileSystem → PostInstallPipeline -``` - -**Framework build** (`aidd framework build --target `): -``` -FrameworkBuildUseCase → BuildOutputStrategy (MarketplaceBuildStrategy | FlatBuildStrategy, reading per-tool ToolBuildContract) -→ tool-native plugin tree (author-side distribution; all 5 targets shipped — claude/cursor/copilot/codex marketplace+flat, opencode flat-only) -``` -Author-side, not user-side: translates the Claude-format framework into a tool-native -marketplace dist (Mode A) or flat workspace materialization (Mode B `--flat`). - -**Manifest schema migration** (no command — runs on load): -``` -Manifest.deserialize → version-to-version migrations in manifest.ts (v1→v2→…→v6) -→ strips obsolete fields; upgraded shape persisted on next manifest write; idempotent on v6 -``` -The brownfield `aidd migrate` command (backup + strip dead files + rewire plugins) was removed; -older manifests now auto-upgrade when loaded. - -## Per-Tool Plugin Install Strategy - -Controlled by `PluginsCapability` in each tool definition. How each tool **actually -loads** plugins (verified live against each tool's real CLI/IDE, not inferred): - -| Tool | How plugins load | aidd writes | -|---|---|---| -| Claude | `.claude/settings.json` (`extraKnownMarketplaces` + `enabledPlugins`) — read natively, no CLI step | the settings file | -| Cursor | materialized to `~/.cursor/plugins/local//` (user-scope) — auto-discovered as "Local" plugins | the plugin files | -| OpenCode | flat files `.opencode/skills/`, `.opencode/agents/` — auto-discovered | the flat files | -| Codex | **native CLI activation** (`codex plugin add`) into user-global `~/.codex/` + cache | drives the CLI | -| Copilot | **native CLI activation** (`copilot plugin install`) into user-global `~/.copilot/` | drives the CLI + a recommendations file | - -- **Some tools' project config is inert — they need native CLI activation.** Codex and - Copilot do not load plugins from a project file (Codex reads only user-global - `~/.codex/`; Copilot's `enabledPlugins` only *recommends*). aidd drives their - ` plugin` subcommands instead. Claude / Cursor / OpenCode do load their project - artifacts natively. Which tools auto-load vs need activation is a per-tool fact — - verify it against the real tool, never assume. -- `flat` mode: plugins installed as flat files under a namespace prefix; no native marketplace concept (OpenCode only) - -## Auth - -Token resolution: `AIDD_TOKEN` env → project `.aidd/auth.json` → user `~/.config/aidd/auth.json` → `gh auth token` (only when `method: "gh"`) → none - -## Bundled Assets - -Runtime configs and IDE configs ship inside the CLI binary (tsup bundles them): -- `src/infrastructure/assets/asset-loader.ts` — typed loader, esbuild text/json loaders at build time -- `.md` files → text loader (string); `.json` → native import (object); `.toml` → text loader (string) -- No fs reads at runtime — all assets inlined at bundle time - -## Bundle Budget - -- Budget: 500 KB (`bundleBudgetKB` in `package.json`) -- Enforced at build time: `scripts/check-bundle-size.mjs` runs after `tsup` - -## Key Design Decisions - -- Merge files (JSON/TOML): surgical key-level tracking; uninstall removes only AIDD keys -- IDE-conditional distribution: AI tools declare `requiredIdeIds`; filtered at install time -- IDE tool files (user-prime): never deleted on uninstall -- Error handling: typed exceptions thrown from use-cases/adapters; caught only at command layer -- Manifest schema migration: idempotent version-to-version upgrade applied on load (`manifest.ts`), no manual command - -## Foreign-Format Adapters (COMPLETE) - -Ingests native marketplace/config formats from other AI tools. -Pipeline: `NativeFormat → Parser → NormalizedPlugin → Emitter[targetTool] → ToolNativeFiles` - -| Tool | File | Notes | -|---|---|---| -| Cursor | `src/domain/formats/cursor-marketplace.ts` | `parseCursorMarketplace(rawJson)` → `NormalizedCatalog` | -| Copilot | `src/domain/formats/copilot-marketplace.ts` | Single-entry degenerate catalog from `.github/plugin/plugin.json` | -| Codex | `src/domain/formats/codex-marketplace.ts` | Multi-entry catalog at `.agents/plugins/marketplace.json` | -| OpenCode | `src/domain/formats/opencode-marketplace.ts` | npm specifier strings from `opencode.json`; empty catalog when `plugin` field absent | - -**ForeignMarketplaceSource union:** `"cursor" | "copilot" | "codex" | "opencode"` - -**MARKETPLACE_PROBES:** cursor `.cursor-plugin/marketplace.json`, copilot `.github/plugin/plugin.json`, codex `.agents/plugins/marketplace.json`, opencode `opencode.json` +## Stack -**Error type:** `ForeignSchemaValidationError` in `src/domain/errors.ts` — thrown on invalid foreign schema +- TypeScript ESM on Node, bundled by tsup into one file. +- Six runtime dependencies, capped; a new one needs an ADR: `commander`, `@inquirer/prompts`, `ajv`, `ajv-formats`, `simple-git`, `smol-toml`. +- vitest, biome, stryker, knip: `testing.md`, `coding-assertions.md`. + +## How it fits together + +Every edge is a real import direction: the inter-context arrows are exactly `ALLOWED` in `tests/architecture/helpers.ts`, failed over by `context-graph.arch.test.ts`. Reverse edges are `BASELINE` debt in that file, deliberately not drawn. + +```mermaid +flowchart LR + Presentation["presentation"] --> Distribution["distribution"] + Presentation --> Framework["framework"] + Presentation --> Telemetry["telemetry"] + Presentation --> Tools["tools"] + Presentation --> Translate["translate"] + Runtime["runtime"] --> Distribution + Runtime --> Framework + Runtime --> Telemetry + Runtime --> Tools + Runtime --> Translate + Framework --> Translate + Framework --> Distribution + Framework --> Tools + Translate --> Tools + Telemetry --> Tools + Distribution --> Kernel["kernel"] + Framework --> Kernel + Telemetry --> Kernel + Tools --> Kernel + Translate --> Kernel +``` + +## Key decisions + +- Bounded contexts, never layers. Enforced by `tests/architecture/`, stated in `.claude/rules/00-architecture/0-contexts.md`. +- A tool declares, a context reads: measurement vocabulary lives in `kernel/measurement.ts`, so the edge runs `telemetry → tools` only. `telemetry.md` names what it reuses along it. +- Telemetry reaches no context but `tools`; what it needs elsewhere is its own port, satisfied at the composition root. +- Some tools' project config is inert: Codex, Copilot and Claude load a plugin only once their own CLI registered it. A per-tool fact, verified against the real tool. +- Claude's registration is driven at `--scope local`, so the hashed file keeps a single writer. +- Two file regimes: what this CLI owns is regenerated; what it co-owns with a person is merged, conflicts reported. +- The manifest reads one version and refuses the rest, naming the fix. No migration chain. +- Since v7 each installed plugin records its `scope` (`project` | `user`); base directories are resolved from that record, never the tool's current profile. +- The `aidd-framework` marketplace is machine-scope, shared by every project: [`framework-source-is-machine-scope.md`](internal/decisions/framework-source-is-machine-scope.md). +- Plugin enablement carries its own scope to the host CLI: [`plugin-enablement-carries-its-scope.md`](internal/decisions/plugin-enablement-carries-its-scope.md). +- A launcher runs an external binary, never embeds it. `kanban` broke this and was unwired. +- `clean` drives the host's own CLI and deletes under `$HOME` only inside a declared, `realpath`-contained root: [`clean-drives-the-host-cli.md`](internal/decisions/clean-drives-the-host-cli.md). +- A marketplace's identity is its declared name plus plugin set, never a path or version: [`marketplace-identity-is-name-plus-plugins.md`](internal/decisions/marketplace-identity-is-name-plus-plugins.md). + +## Gotchas + +- Configs are inlined at build time, schemas are not: five JSON files ship beside the binary. Drop them from `files` and the CLI breaks. +- The build empties its output directory; `AIDD_BUILD_OUT_DIR` accepts two shapes only. +- `git` exports `GIT_*` into everything it spawns. Strip them before reading a repository. diff --git a/cli/aidd_docs/memory/auth.md b/cli/aidd_docs/memory/auth.md index 3d70c2fe6..f63712ed3 100644 --- a/cli/aidd_docs/memory/auth.md +++ b/cli/aidd_docs/memory/auth.md @@ -1,23 +1,24 @@ # Auth -How identity and access work. There are no user accounts: a single GitHub token gates access to remote marketplaces and plugin sources. +How identity and access work. No user accounts: one GitHub token gates private marketplaces and plugin sources. ## Authentication -- Commands: `aidd auth login [--gh] [--token ] [--level user|project]`, `auth logout`, `auth status` (`application/commands/auth.ts`). -- Method per stored config: `stored` (a PAT written to `auth.json`) or `external` (resolved fresh from `gh auth token` at read time). -- Resolution order (`infrastructure/adapters/auth-reader-adapter.ts`), first hit wins: - 1. `AIDD_TOKEN` env - 2. project `/.aidd/auth.json` - 3. user `~/.config/aidd/auth.json` - 4. none → `null` -- Storage (`infrastructure/auth/auth-storage.ts`): user path `~/.config/aidd/auth.json` (dir overridable via env), project path `/.aidd/auth.json`. Shape `{ method, token?, level }`. +- Commands: `aidd auth login | logout | status` (`src/presentation/commands/auth.ts`). +- Method per stored config: `stored` (a token in `auth.json`) or `external` (resolved from `gh auth token` at read time). There is no `gh` method. +- Resolution order (`src/runtime/auth/auth-reader-adapter.ts`), first hit wins: `AIDD_TOKEN`, project `.aidd/auth.json`, user config `auth.json`, else none. An `external` config is resolved at its own level, not as a final fallback. +- Storage (`src/runtime/auth/auth-storage.ts`): project `/.aidd/auth.json`, user under the config dir, which `AIDD_USER_CONFIG_DIR` moves — that is how the suites stay out of a real profile. +- Shape: `{ version: 1, createdAt, method, token?, provider?, level }`. A file missing `version` or `createdAt` reads as null, so a hand-written one authenticates nobody. +- The file is written `0600` on POSIX, `icacls /inheritance:r` on win32. Failing to restrict throws rather than leaving a world-readable credential. ## Authorization -- No roles, scopes, or RBAC. Token presence is the only gate — it authorizes remote marketplace/framework fetches. -- `RequireAuthUseCase` throws when a command needs a token and none resolves; surfaced at the command layer. +- No roles, no scopes. A token is not checked up front: nothing refuses a command for lacking one. +- It surfaces at fetch time as `CatalogFetchAuthError` when the request 401s. ## Sessions -- No sessions, no refresh. A `stored` token persists in `auth.json` until `auth logout` clears it. An `external` (`gh`) token is resolved fresh on each read. +- No sessions, no refresh. A `stored` token lives until `auth logout`. +- `resolve()` memoizes per process, and the graph is built once per project root, so `gh` is spawned at most once per invocation. +- The `gh` spawn fails two ways: absent from `PATH` answers null and degrades silently; a non-zero exit or a 3s timeout throws and aborts the command. +- `AIDD_TOKEN` short-circuits the active-config read too, synthesising `method: "stored"`, `level: "user"` — so `auth status` reports stored user auth with nothing on disk. diff --git a/cli/aidd_docs/memory/cli.md b/cli/aidd_docs/memory/cli.md index 2f765c535..cc8b3f563 100644 --- a/cli/aidd_docs/memory/cli.md +++ b/cli/aidd_docs/memory/cli.md @@ -1,29 +1,56 @@ # CLI -The `aidd` command-line tool: its command surface, I/O conventions, and distribution. +The `aidd` command-line tool: its commands, inputs, and distribution. ## Commands -Grouped surface. Authoritative list + flags in `project-brief.md`; live help is the source of truth (`aidd --help`, `aidd --help`). +Thirty-three leaf commands. Read them live: `aidd --help`, then each group's. `scripts/smoke-tools.sh` exercises every one and fails when its list drifts. -- **Top-level**: `setup`, `status`, `update`, `restore`, `doctor`, `clean`, `self-update` -- **`ai `**: install / uninstall / list / status / update / restore / doctor -- **`ide `**: install / uninstall / list / status / update / doctor -- **`plugin`**: install / create / remove / list / update / search / doctor -- **`marketplace`**: add / list / remove / refresh / check -- **`auth`**: login / logout / status (see `auth.md`) -- **`framework build`**: maintainer/authoring only — not part of the consumer flow +- `setup`, `doctor`, `sync`, `clean`: bring a project to a correct state, keep it there. +- `framework install | update | remove | rules`, chosen by `--tool `. +- `plugin install | list | remove | search | update`. +- `marketplace add | list | remove | refresh | check`. +- `auth login | logout | status`. +- `telemetry on | off | read | report | check | forget | identity`; `identity` carries `use | off | link | unlink`. +- `translate `: author-side, converts a source into a target-native plugin tree. +- `update`, aliased `upgrade`: the CLI itself. ## Interface -- Parser: `commander` (`src/cli.ts`). `preAction` hook builds the dep graph once per `projectRoot` (memoized). -- Global flags: `--version`, `--verbose`. Interactive by default (`@inquirer/prompts`). -- Non-TTY needs explicit flags (`setup --yes`, …); an interactive-only command with no flag in non-TTY exits 1 with guidance. -- Output: text on stdout, errors on stderr via `application/output.ts`. `status --json` emits the full report as machine-readable JSON on stdout. Typed exceptions thrown inward, caught only at the command layer (`error-handler.ts`) — no silent failures. -- Exit codes: `0` ok; `1` on error, unhealthy `doctor`, or a non-interactive guard. +- Parser: `commander` (`src/cli.ts`). A `preAction` hook builds the graph once per project root. +- No argument on a TTY: the interactive menu. Without one: help. +- Global flags `--version`, `--verbose`. A non-TTY needs explicit flags or exits 1. +- Text on stdout, errors on stderr (`src/presentation/output.ts`). Typed exceptions caught at the command layer only (`src/presentation/error-handler.ts`). +- The update-check hook is the one swallow; it never fails the command asked for. +- Exit codes: `0` ok; `1` error, unhealthy `doctor`, non-interactive guard. + +## `doctor` + +- For claude, codex, copilot: compares `nativeRegistrations` against the host's registry file. +- Four answers: `registered`; `not-registered` and `registered-disabled` (`error`, fix names `aidd sync` or `aidd framework install --tool `); `unanswerable` (`info`, never gates: the host never ran). +- A fifth pass, `checkMarketplaceSources`, reports a source conflict by `hostName`: [`marketplace-identity-is-name-plus-plugins.md`](internal/decisions/marketplace-identity-is-name-plus-plugins.md). +- Warns on a host ahead of this aidd (`aidd update`) or behind the migration (`aidd sync`): [`framework-source-is-machine-scope.md`](internal/decisions/framework-source-is-machine-scope.md). + +## `sync` + +- Restores tracked files, then drives native activation (`MarketplaceSyncSettingsUseCase.execute`). Reversed, it would hash a file restoration overwrites. +- `marketplace add ` and `plugin install --from ` narrow activation to that marketplace and merge into `nativeRegistrations`; `sync` alone re-drives every one. +- A manifest whose registry names no marketplace (a fresh clone) re-registers the framework source, then proceeds. +- Records this project's claim in `references.json` when the source resolves to scope `"user"`. +- `sync --tool ` narrows to one tool. +- A missing binary warns and exits `0`; a genuine activation failure throws `SyncFailedError`, exit `1`. +- Refuses a marketplace-name conflict before calling the host's `add`, counted in `SyncFailedError`. +- Migrates a pre-shared-source project on every run: [`framework-source-is-machine-scope.md`](internal/decisions/framework-source-is-machine-scope.md). + +## `clean` + +- Leaves nothing of aidd's, removes nothing aidd did not write, drives the host CLI for its registry. +- Exception: the shared `aidd-framework` registration stays; the warning names what survives and the order to remove it (`aidd clean` per project, then `aidd clean --scope user`). +- `clean --scope user` purges the shared source under a hardcoded whitelist. +- Steps, containment and whitelist: [`clean-drives-the-host-cli.md`](internal/decisions/clean-drives-the-host-cli.md). ## Distribution -- npm bin `aidd` → `dist/cli.js` (`package.json` `bin`). Single ESM bundle (tsup); all assets inlined at build time, no fs reads at runtime. -- Run via `npx @ai-driven-dev/cli@latest ` (zero-install) or global install. Node.js >= 22.12. -- Published to public npm **and** GitHub Packages — see `deployment.md`. +- npm bin `aidd` → `dist/cli.js`, one ESM bundle plus five JSON schemas read from disk; `files` must ship them. +- `npx @ai-driven-dev/cli@latest`, or global install. Build and publish: `deployment.md`. +- `AIDD_USER_CONFIG_DIR` relocates `userConfigDir()`; the one list of what moves: `auth.json`; `marketplaces.json` and `cache/built//`; `references.json`; the `--scope user` `manifest.json`; `cache/update-check.json` and the older root `update-check.json` (`runtime/self-update/check-update-use-case.ts`); the telemetry sink root, only as a legacy fallback when `AIDD_TELEMETRY_DIR` is unset (`telemetry.md`). Never `identity.json`, which `resolveAiddConfigDir()` (`kernel/reading/home-dir.ts`) refuses this variable for. diff --git a/cli/aidd_docs/memory/codebase-map.md b/cli/aidd_docs/memory/codebase-map.md index a392abd33..90fe9aac2 100644 --- a/cli/aidd_docs/memory/codebase-map.md +++ b/cli/aidd_docs/memory/codebase-map.md @@ -1,93 +1,112 @@ # Codebase Map -## Where Things Live +Where things live. The architecture rules carry no paths on purpose, so this is the single place that says — and `tests/architecture/codebase-map.arch.test.ts` holds it to the tree in both directions. -``` +```txt src/ -├── cli.ts # Entry point — commander setup, global flags, preAction hook -├── application/ -│ ├── commands/ # CLI wiring only (1 file per command) -│ ├── use-cases/ # Business orchestration -│ │ ├── auth/ # login / logout / status / require-auth -│ │ ├── doctor/ # orchestrator + layout / merge-files / plugin / references / tracked-files -│ │ ├── global/ # cross-tool chains: update-all / status-all / restore-all / doctor-all -│ │ ├── install/ # capability sub-use-cases: runtime-config / ide-config / agents / commands / rules / skills / config -│ │ ├── marketplace/ # marketplace lifecycle: add / list / remove / refresh / check / register-framework / sync-settings -│ │ ├── plugin/ # create / add / install / install-from-marketplace / remove / list / update / search / pick -│ │ ├── restore/ # orchestrator + tool-files / all-plugins / plugin -│ │ ├── setup/ # sub-use-cases: marketplace-source / tools / plugins-prompt -│ │ ├── sync/ # conflict-resolver only — drift/conflict resolution reused by the update flow -│ │ ├── uninstall/ # orchestrator + tools / plugin / mcp-exclusion / ide -│ │ └── shared/ # helpers called by use-cases only (never by commands) -│ ├── error-handler.ts # central error handling -│ ├── errors.ts # application typed exceptions -│ └── output.ts # stdout/stderr formatting -├── domain/ -│ ├── formats/ # pure string transforms — no I/O (command, json, jsonc, markdown, toml, placeholders, cursor-hooks, mcp-format, markdown-references, *-marketplace parsers) -│ ├── models/ # entities, value objects, discriminant types -│ ├── ports/ # interface contracts (FileSystem, Hasher, Logger, Prompter, LatestReleaseResolver, etc.) -│ ├── capabilities/ # one capability class per Has* interface (agents, commands, rules, skills, hooks, mcp, settings, plugins, marketplace-entry) -│ └── tools/ -│ ├── contracts.ts # AiTool, Has* interfaces, IdeToolConfig, UserFileSectionKey -│ ├── registry.ts # ToolConfig union, isAiTool(), registerTool(), getToolConfig(), hasToolSignals() -│ ├── ai/ # one file per AI tool (claude, cursor, copilot, opencode, codex) -│ └── ide/ # one file per IDE tool (vscode) -└── infrastructure/ - ├── adapters/ # port implementations — one adapter per port (incl. auth-reader, auth-storage, http-client) - ├── assets/ # asset-loader.ts — typed loader for configs/stubs bundled in binary - ├── deps.ts # dependency injection wiring - └── errors.ts # infrastructure typed exceptions (internal only) +├── contexts/ # bounded contexts — no barrel, nothing reaches inside another +│ ├── distribution/ # where content comes from and how it is fetched +│ │ ├── application/ +│ │ ├── domain/ +│ │ │ ├── catalog-parsers/ +│ │ │ └── ports/ +│ │ └── infrastructure/ +│ ├── framework/ # the installation record and everything done to a project +│ │ ├── application/ +│ │ │ ├── clean/ +│ │ │ ├── doctor/ +│ │ │ ├── flows/ +│ │ │ ├── framework/ +│ │ │ │ └── translator/ +│ │ │ ├── global/ +│ │ │ ├── install/ +│ │ │ │ └── content/ +│ │ │ ├── plugin/ +│ │ │ ├── restore/ +│ │ │ ├── setup/ +│ │ │ ├── shared/ +│ │ │ └── uninstall/ +│ │ ├── domain/ +│ │ │ ├── formats/ +│ │ │ ├── manifest/ +│ │ │ ├── plugins/ +│ │ │ └── ports/ +│ │ └── infrastructure/ +│ ├── telemetry/ # what a session cost and who it was for +│ │ ├── application/ +│ │ ├── domain/ +│ │ │ ├── formats/ +│ │ │ ├── ports/ +│ │ │ └── report/ +│ │ │ └── axes/ +│ │ └── infrastructure/ +│ ├── tools/ # what a project targets, and what each target declares +│ │ ├── domain/ +│ │ │ ├── capabilities/ +│ │ │ ├── formats/ +│ │ │ ├── models/ +│ │ │ ├── ports/ +│ │ │ └── profiles/ # one directory per tool +│ │ │ ├── claude/ +│ │ │ ├── codex/ +│ │ │ ├── copilot/ +│ │ │ ├── cursor/ +│ │ │ ├── opencode/ +│ │ │ └── vscode/ +│ │ └── infrastructure/ +│ └── translate/ # canonical source to target-native content +│ ├── application/ +│ │ └── strategies/ +│ ├── domain/ +│ │ └── formats/ +│ └── infrastructure/ +├── kernel/ # shared vocabulary — imports no context, carries no business logic +│ ├── materialization/ # where content lands and how its links follow +│ ├── ports/ # a port two or more contexts both need +│ └── reading/ # getting at a file's location and its content safely +├── presentation/ # everything that talks to a human — depends on contexts, never the reverse +│ ├── commands/ # one file per command, wiring only +│ ├── display/ # rendering a result +│ └── prompts/ # asking the user; the decision stays in the context +└── runtime/ # technical services that are not a context + ├── assets/ + ├── auth/ + │ └── ports/ + ├── filesystem/ + ├── git/ + ├── http/ + ├── platform/ + ├── project-root/ + ├── prompter/ + ├── self-update/ + └── wiring/ # one composition module per context, plus the composition root ``` -## Use-Case Structure +## Areas -| Domain | Orchestrator | Sub-use-cases | -|---|---|---| -| doctor | `doctor-use-case.ts` | layout, merge-files, plugin, references, tracked-files | -| restore | `restore-use-case.ts` | tool-files, all-plugins, plugin (shared: restore-merge-files, restore-regular-files) | -| uninstall | `uninstall-use-case.ts` | tools, plugin, mcp-exclusion, ide | -| setup | `setup-use-case.ts` | marketplace-source, tools, plugins-prompt | -| global | — | update-all, status-all, restore-all, doctor-all (4 chain orchestrators) + update-ai-tools / update-ide-tools helpers | +- `src/kernel/`: what two or more contexts both speak. No context import, no business logic. +- `src/contexts/`: the five bounded contexts. Nothing reaches inside another; the allowed edges are in `architecture.md`. +- `src/presentation/`: commands, rendering, prompts. Depends on contexts, never the reverse. +- `src/runtime/`: services that are not a context — http, git, auth, assets, filesystem, self-update — and the wiring that composes everything. +- `tests/`: mirrors `src/`, one tier per file extension. `tests/architecture/` holds the ratchets, `tests/golden/` the snapshots, `tests/helpers/ports/` the doubles. +- `assets/`: configs inlined at build time, schemas copied beside the binary. +- `scripts/`: bundle budget, mutation runner, smoke harness. -## Where to Add Things +## Entry points -| What | Where | -|------|-------| -| New CLI command | `application/commands/` + top-level use-case | -| New use-case | `application/use-cases//` or root for top-level | -| Shared use-case helper | `application/use-cases/shared/` | -| New AI tool | `domain/tools/ai/.ts` | -| New capability | `Has*` in `contracts.ts` + class in `domain/capabilities/` | -| New string transform | `domain/formats/` | -| New domain type | `domain/models/` | -| New port | `domain/ports/` + adapter in `infrastructure/adapters/` | +- `src/cli.ts` → `dist/cli.js`, bin `aidd`. +- `src/runtime/wiring/framework.ts` — the composition root. Start here when wiring anything. -## Tests +## Where to add things -``` -tests/ -├── application/use-cases/ # unit — use-cases with in-memory ports from tests/helpers/ports/ -├── domain/capabilities/ # unit — capability class tests -├── domain/formats/ # unit — format parser tests (incl. *-marketplace parsers) -├── domain/models/ # unit — pure value object tests; manifest.property.unit.test.ts (property-based) -├── domain/tools/ # unit — tool config tests -├── e2e/ # full CLI invocation via runCli() -├── infrastructure/ # adapter tests with mock servers/fixtures -└── fixtures/ - ├── framework/ # minimal synthetic framework fixture - └── framework-real/ # pinned real framework tag (plugins: aidd-async-dev, etc.) -``` - -## Key Files - -| File | Purpose | -|------|---------| -| `infrastructure/deps.ts` | Full dependency graph — start here when wiring new deps | -| `infrastructure/assets/asset-loader.ts` | Typed loader for configs/stubs bundled in binary | -| `domain/tools/contracts.ts` | All tool/capability interfaces | -| `domain/tools/registry.ts` | Tool lookup, guards, signal detection | -| `application/use-cases/shared/post-install-pipeline-use-case.ts` | Mandatory post-write sequence | -| `application/use-cases/shared/ensure-built-marketplace-use-case.ts` | Per-target built-tree cache — install/update materialize tools from it (build/install parity) | -| `domain/models/manifest.ts` | Aggregate root — all installed file tracking + schema migration (v1→v6) on load | -| `domain/models/normalized-plugin.ts` | Internal AST for foreign-format plugin ingestion | -| `domain/models/setup-flow.ts` | Aggregate — setup orchestration state | +| What | Where | +| ---- | ----- | +| a command | `presentation/commands/`, plus the use case in whichever context owns the concept | +| a prompt | `presentation/prompts/`; the decision it feeds stays in the context | +| a use case | the context whose concept it serves. There is no landing zone: one that fits nowhere means the contexts are wrong | +| a tool | one profile directory under `contexts/tools/domain/profiles/` | +| a transform shared by two profiles | `contexts/tools/domain/formats/`; used by one, that profile's own directory | +| a port used by one context | that context's `domain/ports/`, adapter in its `infrastructure/` | +| a port used by two | `kernel/ports/`, adapter in `runtime/` | +| a runtime service | `runtime//`, wired from `runtime/wiring/` | +| a cost-report axis (its own key, sentinels, group shape, order) | `contexts/telemetry/domain/report/axes/`; the pass that fills it stays in `cost-report.ts`, which the axis reaches only through `import type` | diff --git a/cli/aidd_docs/memory/coding-assertions.md b/cli/aidd_docs/memory/coding-assertions.md index f7617c15c..8e5754379 100644 --- a/cli/aidd_docs/memory/coding-assertions.md +++ b/cli/aidd_docs/memory/coding-assertions.md @@ -1,39 +1,37 @@ -# Coding Guidelines +# Coding Assertions -> Those rules must be minimal because they MUST be checked after EVERY CODE GENERATION. +The checks that must pass for code here to count as done. The repository's own hooks also run; this page holds the `cli/` ones. -## Requirements to complete a feature +## Requirements -**A feature is really completed if ALL of the above are satisfied: if not, iterate to fix all until all are green.** +- No silent errors. A use case or an adapter throws; only the command layer catches. +- Validate at the adapter boundary into typed values. `unknown` never leaks past an adapter. +- No duplication. One fact, one home. +- A context's `domain/` imports no infrastructure, and nothing widens a type through `unknown` or `never` — in `tests/` as much as in `src/`. +- The tree is organised by bounded context (`src/contexts/`), not by layer. Placement rules: `.claude/rules/00-architecture/`. +- Runtime dependencies are capped; the list and its reason are in `architecture.md`. -- No silent errors — throw early, fail loudly -- Validate I/O at the adapter boundary into typed domain values — `unknown` never leaks past an adapter, and absence is modeled explicitly, not with empty-string sentinels -- No duplication — eliminate ruthlessly, reuse existing code -- Domain layer has zero infrastructure imports -- Runtime deps capped at the 6 justified in `architecture.md` (`commander`, `@inquirer/prompts`, `ajv`, `ajv-formats`, `simple-git`, `smol-toml`); a new one needs an ADR -- 3-layer architecture respected: Domain → Application → Infrastructure (no Presentation layer — output formatting lives in `application/output.ts`) +## Before commit -## Steps to follow +| Order | Command | Checks | +| ----- | ------- | ------ | +| 1 | `pnpm lint` | biome, lint and format, plus the GritQL plugin under `biome-plugins/` (`process.exit` below the command edge) and `noDefaultExport` under `src/` and `tests/`; `biome-guards-bite.arch.test.ts` proves both on a planted tree | +| 2 | `pnpm test:arch` | the architecture ratchets | +| 3 | `pnpm typecheck` | `tsc --noEmit` | +| 4 | `node scripts/check-cli-type-honesty.mjs` | no type widened through `unknown`, `any` or `never`, no `@ts-expect-error`/`@ts-ignore` outside a test proving something doesn't compile (`src/` and `tests/`). Dependency direction between layers and contexts is biome's job now (`cli/biome.json`'s `noRestrictedImports`). Run it from the repository root | -1. Check there is no duplication -2. Ensure code is re-used -3. Run all those commands, in order to ensure code is perfect. +## Before push -## Commands to run +| Order | Command | Checks | +| ----- | ------- | ------ | +| 1 | `pnpm knip` | dead code, unused exports | +| 2 | `pnpm test` | every tier | -### Before commit +## In CI only -| Order | Command | Description | -| ----- | --------------------- | ---------------------------------- | -| 1 | `pnpm typecheck` | Type checking | -| 2 | `pnpm lint` | Lint + format (biome) | -| 3 | `pnpm knip:production`| Dead code / unused exports (knip) | -| 4 | `pnpm jscpd` | Duplication check (jscpd) | -| 5 | `pnpm test` | Run unit tests | +- `pnpm test:coverage` against the thresholds in `vitest.config.ts`, `pnpm smoke`, `pnpm build` with its bundle budget, `pnpm jscpd`, and one mutation job per scope a change touches (`cli-mutation`, floors in `mutation-scopes.json`, scope choice in `scripts/mutation-scopes-to-run.mjs`) — none of them a local gate. The full job list behind them is `deployment.md`'s, not repeated here. Every job is fanned into one required check, `cli / gate` (`.github/workflows/cli-ci.yml`), which the branch rulesets enforce (`.github/rulesets/main.json`, `next.json`) — so all of them are blocking on a `cli/` pull request, through that one check. +- CodeQL (`.github/workflows/codeql.yml`) analyses this package on every pull request against `main` and `next`, outside `cli / gate` and blocking nothing — its findings are read from the Security tab, per pull request, and answered there. -### Before push +## Behavior -| Order | Command | Description | -| ----- | ------------ | ------------------- | -| 1 | `pnpm build` | Verify build output | -| 2 | `pnpm test` | Full test suite | +Same as the repository's own page (`aidd_docs/memory/coding-assertions.md`): every gate green, one agent per failing assertion. diff --git a/cli/aidd_docs/memory/deployment.md b/cli/aidd_docs/memory/deployment.md index 9e6765725..a9d9f1469 100644 --- a/cli/aidd_docs/memory/deployment.md +++ b/cli/aidd_docs/memory/deployment.md @@ -1,54 +1,36 @@ # Deployment -## Environment Variables +Where the package ships and how: CI, release, and the environment it reads. -- `AIDD_TOKEN` — GitHub token used to fetch private marketplaces / plugin sources (see `auth.md`). Not a registry credential. +## Pipeline -## Build & Publish +- `.github/workflows/cli-ci.yml` always triggers — no `on.paths` filter, so its required check stays satisfiable on every PR. Its own `changes` job decides relevance by `git diff`-ing the base against the head in bash: `cli/**`, `kanban/**`, `scripts/__tests__/**`, `README.md`, this workflow file itself, or `plugins/aidd-telemetry/**` excluding its own prose. Every other job — `cli-typecheck`, `cli-lint`, `cli-architecture`, `cli-coverage`, `cli-smoke`, `cli-build`, `cli-knip`, `identifier-join` (the session-identifier probe, then Claude Code's own `plugin validate` over a fresh claude build through `scripts/check-claude-accepts-build.cjs`), `cli-jscpd`, `kanban-checks`, `windows` — runs only when `changes` says relevant; `cli-mutation` is a matrix over the scopes `changes` names through `scripts/mutation-scopes-to-run.mjs`, each restoring the newest incremental file its branch or base saved, and `gate` (check name `cli / gate`) fans all of them in. `gate` is the one check the branch rulesets require (`.github/rulesets/main.json`, `next.json`). +- `.github/workflows/ci.yml` — commitlint, release-please, then the release jobs. It runs none of the checks above. +- Build: `pnpm build` (tsup) → `dist/cli.js`, plus the five JSON schemas its `onSuccess` copies beside it. They are read from disk at runtime; dropping them breaks the binary. +- Bundle budget in `package.json` (`bundleBudgetKB`), enforced by `scripts/check-bundle-size.mjs` after every build. That script's own header comment is the registry of every raise and reset, with the measurement behind each — read it there rather than a count here, which goes stale the next time the budget moves. +- `AIDD_BUILD_OUT_DIR` accepts only `dist` or a directory under `.e2e-build/`: the build empties its target first. -- Build: `pnpm build` → `dist/cli.js` (tsup, ESM bundle); runs `scripts/check-bundle-size.mjs` automatically. -- Bundle budget: 500 KB (`bundleBudgetKB` in `package.json`); build fails if exceeded. -- Local install test: `pnpm run install:local` (packs, then `npm install -g` the tarball with `--force`). -- Runtime requirements: Node.js >= 22.12, pnpm >= 9. -- **Release is automated, not manual.** `release-please` maintains a single open release PR off `main`; merging it bumps the version, writes the CHANGELOG, and tags `vX.Y.Z`. -- The tag fires the **Publish** job (`.github/workflows/ci.yml`), which publishes to **both** registries: - - GitHub Packages — `pnpm publish --no-git-checks` - - public npm — `registry.npmjs.org`, `pnpm publish --access public`, via OIDC trusted publishing (`id-token: write` + `NPM_TOKEN`). +```mermaid +flowchart LR + Push["push on main"] --> RP["release-please"] + RP --> Released["cli in paths_released"] + Released --> PublishCli["publish-cli"] + PublishCli --> Npm["npm · OIDC"] + PublishCli --> Packages["GitHub Packages · best effort"] +``` -## Self-update +## Environments -- `aidd self-update` reads the latest version from the **public npm registry** dist-tags (`registry.npmjs.org/-/package/@ai-driven-dev/cli/dist-tags`), not GitHub releases — see `internal/decisions/self-update-version-source-npm.md`. Changelog is best-effort from GitHub. -- npm registry reads must send `Accept: application/json`. The shared HTTP client defaults `Accept` to `application/vnd.github+json`; npm answers that with **HTTP 406**. +None. What ships are published packages and release assets. -## Tooling facts +## Release -- **Biome** is the sole linter + formatter — no ESLint, no Prettier. Config at repo root (`biome.json`). Fix with `biome check --write`. -- **Lefthook** runs git hooks; **commitlint** enforces Conventional Commits. +- release-please tags this package `cli-v`; a bare `v` is the root marketplace, a different line. The `include-component-in-tag` flag behind that is named once, in the repository's own `deployment.md`. +- `publish-cli` is gated on `cli` appearing in `paths_released`, never on a tag: a root or plugin release publishes nothing here. +- npm is the load-bearing step, `npm publish` under OIDC with no token. pnpm is avoided there on purpose. The GitHub Packages step is `continue-on-error`. +- `aidd update` reads the latest version from the npm dist-tags, not from GitHub releases — see `internal/decisions/self-update-version-source-npm.md`. `AIDD_SELF_UPDATE_NPM_BASE` and `AIDD_SELF_UPDATE_API_BASE` point both reads elsewhere for tests. +- npm answers `Accept: application/vnd.github+json` with **406**. The shared HTTP client defaults to it, so a registry read must set `application/json`. -## Git Hooks (`lefthook.yml`) +## Monitoring -Hooks run this repo's own checks directly (no parent-monorepo delegation): - -- `pre-commit`: `pnpm lint` (biome) + `pnpm typecheck` -- `pre-push`: `pnpm knip:production` + `pnpm test` -- `commit-msg`: `commitlint --edit` - -## CI/CD - -- `.github/workflows/ci.yml` — "CI & Publish", on push to `main`: commitlint → (typecheck, lint, test, build & bundle budget, knip, jscpd) → release-please → publish. -- No containerization, no monitoring infrastructure. - -## Scripts - -| Script | Purpose | -| --- | --- | -| `pnpm build` | tsup production build + bundle size check | -| `pnpm test` | build + vitest run (all tests) | -| `pnpm typecheck` | tsc --noEmit | -| `pnpm lint` | biome check | -| `pnpm format` | biome format --write | -| `pnpm smoke` | build + `scripts/smoke-tools.sh` (full-matrix smoke on the real binary) | -| `pnpm pack:local` | build + pack to dist/ | -| `pnpm install:local` | pack + npm install -g (`--force`) | -| `pnpm build:check-size` | run bundle size check only (no rebuild) | -| `pnpm test:mutation` | Stryker mutation testing (slow; CI gate) | +None. A failure is a red run. diff --git a/cli/aidd_docs/memory/ecosystem.md b/cli/aidd_docs/memory/ecosystem.md new file mode 100644 index 000000000..61e7aef32 --- /dev/null +++ b/cli/aidd_docs/memory/ecosystem.md @@ -0,0 +1,22 @@ +# Ecosystem + +```mermaid +flowchart LR + Human([Human]) + Agent([Agent]) + App([App]) + GitHub["GitHub · deployment.md"] + Npm["npm registry · deployment.md"] + Gh["gh · auth.md"] + Hosts["claude · codex · copilot · opencode · architecture.md"] + + Agent -- cli --> Gh + Agent -- cli --> Hosts + Human -- cli --> Hosts + App -- cli --> Gh + App -- cli --> Hosts + App -- http --> GitHub + App -- http --> Npm + + GitHub -- "release on cli-v*" --> Npm +``` diff --git a/cli/aidd_docs/memory/internal/decisions/clean-drives-the-host-cli.md b/cli/aidd_docs/memory/internal/decisions/clean-drives-the-host-cli.md new file mode 100644 index 000000000..0d6f926f4 --- /dev/null +++ b/cli/aidd_docs/memory/internal/decisions/clean-drives-the-host-cli.md @@ -0,0 +1,37 @@ +# `clean` drives the host's own CLI + +`clean` never writes a host registry by hand and never deletes what aidd did not write. Read when touching `clean`, `clean --scope user` or a cache purge. + +## Project scope, in order + +1. Undo native registration through the host's CLI: `uninstallPlugin` per recorded ref, then `removeMarketplace` at its scope. Only Copilot declares a force-remove; Claude and Codex can refuse a marketplace still holding plugins. +2. Tracked files, merge files, plugin files. +3. `.aidd/` itself. A host needs `.aidd/cache/` alive during step 1. +4. Machine-local files no `plugins[].files` tracks: `.claude/settings.local.json`, a project-merged `.cursor/hooks.json` and its `.cursor/hooks//`, through `application/shared/remove-project-hooks.ts`. +5. A user-scope plugin directory (`~/.cursor/plugins/local/`) only once `realpath` proves it strictly inside the tool's declared user-scope directory (`domain/plugins/user-scope-containment.ts`). A `..` segment or a post-install symlink is left and named. +6. Right after step 1, per tool driven: the cache root its profile declares (`NativeActivation.pluginCacheDir`), `/`, under the same containment. + +- A binary off `PATH` is named and left alone. +- The shared `aidd-framework` registration is the one exception: `undoMarketplaceRegistration` refuses on the scope and warns, naming the host registration, the `marketplaces.json` entry and the tool's cache path. +- This project's refs are still uninstalled, except one a machine-global host (codex, copilot) enables while `references.json` names another project: left enabled and named. +- The warning names how many other projects still reference the source, or that `clean --scope user` purges it. +- A dry-run reports the same list without dropping anything. + +## Cache purge + +- Claude declares `marketplaceRegistry` too: full purge, gated on a fresh registry read no longer naming the host. Measured: claude marks an orphaned tree `.orphaned_at`, never deletes it. +- Codex declares `pluginCacheDir` alone: purged only once proven empty. Measured: its `plugin remove` leaves the empty shell. +- Copilot declares neither: never touched. + +## `clean --scope user` + +- The one command that purges the shared source. +- The user manifest is optional: a project-scope `setup` never writes one yet leaves the whitelist behind. +- Without it, steps 1–3 are skipped and said so; other projects in `references.json` are named with the order to run `aidd clean` in each first. +- Steps 1–2 run at scope `"user"` always, never guessed from the host default. +- Step 3: `purgeAllNativeCaches`, shared with project-scope `clean`. +- Step 4, always: a hardcoded whitelist under `userConfigDir()`: `cache/built/` in full, `cache/update-check.json`, root `update-check.json`, the `cache/` shell once a fresh `listDirectory` proves it empty, `references.json`; each re-resolved through `realpath` and `isStrictlyWithinUserScope` before deletion. +- `manifest.json` goes through its repository, the `aidd-framework` entry alone out of `marketplaces.json` through the registry; neither takes a path from the manifest. +- `userConfigDir()` itself is never a candidate. +- Confirmation, unless `--force`, names the source, every version under `cache/built/`, every live project in `references.json`, and the no-registration note. +- A project pointed at the purged source repairs itself on its next `aidd sync`. diff --git a/cli/aidd_docs/memory/internal/decisions/framework-source-is-machine-scope.md b/cli/aidd_docs/memory/internal/decisions/framework-source-is-machine-scope.md new file mode 100644 index 000000000..f0597c8d1 --- /dev/null +++ b/cli/aidd_docs/memory/internal/decisions/framework-source-is-machine-scope.md @@ -0,0 +1,61 @@ +# The framework source is machine-scope + +One registration of `aidd-framework` per machine, shared by every project. Read when touching `setup`, `sync`, `doctor`, `clean` or `references.json`. + +## Why + +- A project-scope source made claude, codex and copilot disagree on a second project. +- Measured: codex and copilot refuse a second source under the same name; claude silently repoints the whole machine. +- So the source left `/.aidd/`. + +## Where + +- Registration: `userConfigDir()/marketplaces.json`, `scope: "user"`. +- Build: `userConfigDir()/cache/built//aidd-framework/` (`kernel/paths.ts`, `userBuiltMarketplaceDir`). +- Built once per CLI version. + +## Migration + +- `MarketplaceRegisterFrameworkUseCase` retires a project-scope entry to the shared one on every `setup` or `sync`. +- Unconditional, not behind `--force`: `MarketplaceRegistryAdapter.list()` answers project-scope entries first, so a leftover would win forever. +- The migration carries the entry's own recorded source, never the local-path default. +- It also repoints a host still tracking *another* project's pre-migration cache, without breaking that project, and records both claims. +- Codex and copilot have no readable marketplace registry; on a refusal at the reserved name and scope they reclaim it, `remove` then `add`. Never for an arbitrary marketplace. +- This project's stale `.aidd/cache/built/aidd-framework/` is deleted only once the run reports no error, no missing binary, no failed build. A host needs that tree to resolve what it unregisters. + +## `references.json` + +- `userConfigDir()/references.json`: `{ "": ["", …] }` (`contexts/framework/domain/ports/user-source-references.ts`). +- Written by `setup` and `sync` whenever the framework marketplace resolves to scope `"user"`. +- `clean` drops only this project's claim, once, never the registration. +- A help, not an authority: a `projectRoot` deleted with `rm -rf` is ignored at read. +- At zero claims, `clean` names `clean --scope user` as the purge. +- `aidd marketplace remove aidd-framework` refuses the same way; it carries no `--scope user` flag. + +## `doctor` + +- Reads the registered path's version segment structurally, never a catalog. +- Warns, never errors, when a host follows a newer aidd (names `aidd update`). +- Warns when a host still points at a per-project cache (names `aidd sync`). +- Whether the registry itself records project scope or only the host lags. + +## `sync`'s write path + +- Refuses to write to a host already ahead. +- Brings a host behind forward as an ordinary update. +- Same version: no-op. + +## `setup --scope user` + +- Registers the shared source and drives native activation machine-wide. +- Writes nothing under `projectRoot`: no content, no plugin prompt, no gitignore touch. +- Its manifest: `userManifestPath(userConfigDir())`, `userConfigDir()/manifest.json`, same schema and version as the project one. +- `UserManifestRepositoryAdapter` reuses `Manifest.fromJSON`/`toJSON`; its `delete()` removes that one file only. +- Each AI tool gets a manifest entry with an empty file list, so `MarketplaceSyncSettingsUseCase` has something to iterate. +- Records no `references.json` claim: absence is the state until `clean --scope user`. +- `doctor --scope user` runs only `DoctorRegistrationUseCase`. +- `sync --scope user` resolves the same manifest. +- `--scope ` on all three, default `project`. +- Refuses an `--ide` tool (`UserScopeIdeToolsError`): IDE config is project-relative. +- Refuses an AI tool without machine-wide activation (`UserScopeUnsupportedAiToolsError`; `registry.ts`'s `supportsUserScopeActivation`, false for opencode alone). +- Both refusals fire in `SetupFlow`'s constructor; `--ai all` is unusable at this scope. diff --git a/cli/aidd_docs/memory/internal/decisions/marketplace-identity-is-name-plus-plugins.md b/cli/aidd_docs/memory/internal/decisions/marketplace-identity-is-name-plus-plugins.md new file mode 100644 index 000000000..ee02020cb --- /dev/null +++ b/cli/aidd_docs/memory/internal/decisions/marketplace-identity-is-name-plus-plugins.md @@ -0,0 +1,27 @@ +# A marketplace's identity is its declared name plus its plugin set + +Read when touching `registerMarketplace`, `checkMarketplaceSources` or `nativeRegistrations.marketplaces`. + +## Alias and host name + +- A project's local alias may differ from what the catalog declares. +- Claude registers by the catalog's own name only. +- `nativeRegistrations.marketplaces` records both: `alias` (aidd's key), `hostName` (the catalog's name). +- Every host-facing call addresses `hostName`: the guard, `checkMarketplaceSources`, `clean`'s remove, `plugin remove`'s uninstall and cache purge. + +## What claude would accept + +- `claude plugin marketplace add` derives the name from the source's `marketplace.json`. +- A known name is silently repointed: no prompt, no error, exit 0, regardless of `--scope`. +- aidd refuses instead. + +## The check + +- `MarketplaceSyncSettingsUseCase.registerMarketplace` reads `known_marketplaces.json` first (`contexts/tools/domain/ports/host-marketplace-registry-reader.ts`, through `realpath`). +- Refuses only a `hostName` registered under a *different catalog* (`contexts/tools/domain/marketplace-source-conflict.ts`). +- Identity: declared name plus plugin set, from each side's `marketplace.json`. Never a path, never the version. +- A version bump under the same name and plugins is the host repointing to a newer build: no conflict. +- The same catalog from a differently resolved path: no conflict. Two projects auto-registering `aidd-framework` from their own builds measure exactly that; `pnpm smoke`'s shared-`$HOME` pattern surfaces it. +- A registered source whose catalog cannot be read: a dead entry a re-add repairs. +- `doctor` carries the same read as `checkMarketplaceSources`, `error`-severity, apart from the four registration states. +- The refusal is counted in `SyncFailedError`, printed the same way by `sync`, `plugin install | remove | update`, `marketplace add | remove | refresh`: all seven exit non-zero where they once passed silently or printed a false `registered.`/`removed.`. diff --git a/cli/aidd_docs/memory/internal/decisions/plugin-enablement-carries-its-scope.md b/cli/aidd_docs/memory/internal/decisions/plugin-enablement-carries-its-scope.md new file mode 100644 index 000000000..1fcb069a7 --- /dev/null +++ b/cli/aidd_docs/memory/internal/decisions/plugin-enablement-carries-its-scope.md @@ -0,0 +1,12 @@ +# Plugin enablement carries its own scope + +Read when touching `NativePluginActivator`, `plugin remove` or `clean`'s uninstall. + +- `NativePluginActivator.enablePlugin`/`uninstallPlugin` take an optional `MarketplaceScope`, default `"project"`. +- Separate from a registration's own `scope` (always `"user"` for the shared source). +- Decides `--scope local` (claude, project-bound) or `--scope user` (machine-wide). +- Before: no scope argument, so every claude enablement landed at its implicit `user` default whatever scope `aidd` ran at. +- A tool without `scopeArgs` (codex, copilot) is unaffected. +- `clean` and `plugin remove` resolve the scope to uninstall from the host's registry first (`HostPluginRegistryReader`, `contexts/tools/domain/ports/host-plugin-registry-reader.ts`). +- Fallback when the registry is silent: the manifest's recorded scope, then the other (`resolve-uninstall-scope.ts`). +- Measured against the real `claude`: a mismatched-scope uninstall is refused, never silently missed. diff --git a/cli/aidd_docs/memory/internal/decisions/self-update-version-source-npm.md b/cli/aidd_docs/memory/internal/decisions/self-update-version-source-npm.md index 76ec696c8..e1b26671f 100644 --- a/cli/aidd_docs/memory/internal/decisions/self-update-version-source-npm.md +++ b/cli/aidd_docs/memory/internal/decisions/self-update-version-source-npm.md @@ -21,5 +21,5 @@ Resolve the latest version from the public npm registry dist-tags endpoint (`reg - Version resolution works for every user regardless of GitHub repo visibility or token, and stays correct after the repo goes public. - The registry is hardcoded to `registry.npmjs.org`; a user with a custom npm mirror is checked against npmjs.org even though their `pnpm/yarn/bun add -g` may pull from the mirror. Same content for a public package, but a corporate-mirror audience would need this revisited. - The changelog is now optional: tokenless users get an empty changelog until the repo is public. -- Override hooks for tests: `AIDD_SELF_UPDATE_NPM_BASE` (npm base) and `AIDD_SELF_UPDATE_API_BASE` (GitHub base), wired in `deps.ts`. +- Override hooks for tests: `AIDD_SELF_UPDATE_NPM_BASE` (npm base) and `AIDD_SELF_UPDATE_API_BASE` (GitHub base), wired in `runtime/wiring/framework.ts`. - See PR #316, issue #315. diff --git a/cli/aidd_docs/memory/internal/smoke-real.md b/cli/aidd_docs/memory/internal/smoke-real.md new file mode 100644 index 000000000..af3b5d8e7 --- /dev/null +++ b/cli/aidd_docs/memory/internal/smoke-real.md @@ -0,0 +1,50 @@ +# `smoke:real` + +`scripts/smoke-real.sh`: the one check that reaches a real AI-tool binary's own registry. Opt-in, local-only, never CI, never lefthook. + +## Why it exists + +- `smoke-tools.sh` relocates `HOME`, so it proves only that this CLI *called* a host, never that the host registered anything. + +## Names + +- Never the reserved `aidd-framework`; `setup`'s auto-register always takes it. +- `aidd-smoke--` for project scope, `-user` for machine scope. +- One built under the project's `.aidd/cache/`, the other under `userConfigDir()/cache/built//`; one name at both scopes would collide on a host key. +- `--strict` refuses if a real `aidd-framework` registration exists anywhere in `$HOME`; default `--allow-existing` relies on the unique names. + +## Environment + +- `HOME` stays real. +- `AIDD_USER_CONFIG_DIR` relocated into the run's temp root, exported before the first `aidd` call. +- `identity.json` does not follow (`resolveAiddConfigDir()` refuses the variable); a phase relying on relocated identity reaches the real profile. +- Every `--scope user` call passes `--no-default-marketplace`, or `setup --scope user` registers the reserved name at every host. +- Skips per tool, never fails, for a binary absent from `PATH`. + +## Phases, in order + +1. Files-only `setup`. +2. Native registration and `plugin install` under the project-scope name. + Then each host's own inventory: `claude plugin details` lists the fixture's skills, hook events and MCP servers (never its agents: Claude counts none, measured 2026-09-09), `codex plugin list` marks it installed and enabled, `copilot plugin list` enabled. Cursor and opencode expose no inventory command. +3. `doctor`. +4. A host-side `claude plugin uninstall --scope local`, then the `sync --force` that repairs it. +5. Opencode's bridge. +6. Two `marketplace add` guards: a different catalog under one name, an alias diverging from the catalog's. +7. `setup --scope user`: project clean per `git status --porcelain`, `userConfigDir()/manifest.json` appears, `doctor --scope user` healthy. +8. Two projects sharing one machine-scope marketplace. +9. `clean --force` in the first. +10. `clean --scope user --force`, plus its no-user-manifest variant against its own config dir. + +## Trap + +- `clean --force` for every project created, then `clean --scope user --force`, however the run ends. +- Only the user manifest records a machine-scope registration; the trap recreates it first, or a run dead before `sync --scope user` strands the registration at every host. +- `copilot_purge_disabled_run_keys` removes only `$REF` and `$REF_USER` from `~/.copilot/settings.json`, and only while still `false`; a `true` one is reported `bad`. Copilot keeps a disabled ref at `false` rather than deleting it, and `aidd clean` never writes a host registry by hand. +- Cost: about twenty minutes with all five binaries; each codex round-trip pays its own marketplace refresh. + +## Measured facts + +- A project-scope `clean --force` protects a `scope: "user"` registration on the scope, not the name. The "left enabled, another project still references it" clause never fires here (`$MKT` is never the reserved name); it is asserted by `clean-shared-ref-guard.integration.test.ts` and `tests/e2e/clean-shared-ref-codex.e2e.test.ts`. +- What such a clean costs another project is its user-scope Cursor directory: `doctor` reports it, `aidd sync` repairs it. +- `clean --scope user --force` deletes the `aidd-framework` entry alone; another machine-scope entry survives, pointing at a removed `cache/built/`. +- `pnpm smoke`'s matrix registers fixtures under aliases their catalogs do not declare (`local`/`local-mkt`, `scoped`/`local-mkt`, `userscoped`/`user-mkt`): a supported divergence, so `smoke-tools.sh` needed no change and passes its whole matrix. diff --git a/cli/aidd_docs/memory/project-brief.md b/cli/aidd_docs/memory/project-brief.md index 20a7b2c9c..8f6238372 100644 --- a/cli/aidd_docs/memory/project-brief.md +++ b/cli/aidd_docs/memory/project-brief.md @@ -1,117 +1,37 @@ # Project Brief -## Executive Summary +What this package is, the problem it solves, and its domain language. -- **Package**: `@ai-driven-dev/cli` -- **Vision**: Distribute a canonical AI-Driven Development framework consistently across multiple AI coding assistants, eliminating manual tool-specific adaptation -- **Mission**: CLI that resolves the AIDD framework from remote/local sources, generates tool-specific file distributions with content rewriting and frontmatter conversion, and tracks every generated file in a hash-based manifest +## What it is -### Description +- The `aidd` binary, published as `@ai-driven-dev/cli`. It installs the AIDD framework into a project, per AI coding tool. +- For a developer already working with an assistant daily, who wants the same setup on every tool and every machine. -- Community product gated by GitHub authentication token -- CLI is the distribution backbone — not a generic scaffolding tool -- Framework assets: agents, commands, rules, skills, templates -- Supported tools: Claude Code, Cursor, GitHub Copilot, OpenCode, Codex (AI); VS Code (IDE) +## Why it exists -## Core Domain +- One canonical framework, five tools that each read a different shape. Written once, translated per tool, rather than one prompt library per assistant. +- Every file it writes is tracked, so drift is visible and repairable instead of discovered by hand. +- A session's cost is only knowable from the files the tools already wrote. Nothing else reads them. -- Framework resolved from remote (GitHub Releases) or local path/tarball -- Files are rewritten per tool conventions (path, frontmatter, content format) -- Every installed file tracked in `.aidd/manifest.json` via MD5 hash -- Drift = local modification vs. what was written at install time +## Domain language -## Ubiquitous Language +| Term | Meaning | +| ---- | ------- | +| Framework | the canonical set of agents, commands, rules, skills, templates | +| Distribution | what one tool gets: the framework rewritten to that tool's conventions | +| Manifest | `.aidd/manifest.json`, every installed file with its hash; each plugin also carries the `scope` (`project` \| `user`) its files were installed at | +| Drift | an installed file changed since it was written | +| Plugin | capability files grouped under one name, installed per tool from a marketplace | +| Marketplace | where plugins come from, registered per project or per machine | +| Context | a bounded area of this codebase; the unit that owns a concern | +| Capability | what a tool declares it can host — hooks, mcp, plugins, settings | +| Record | one measured figure for one session, stored per machine | +| Run journal | what a session did, written into the project by a hook | +| Attribution | how strongly a figure is tied to a person, a task or a step | -| Term | Definition | -| -------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| Framework | Canonical set of agents, commands, rules, skills, templates | -| Distribution | Tool-specific generated output (files rewritten per tool conventions) | -| Manifest | `.aidd/manifest.json` — hash-based tracking of every installed file | -| ToolConfig | Per-tool configuration: output paths, frontmatter conversion, merge rules. Tools: `claude` → `.claude/`, `cursor` → `.cursor/`, `copilot` → `.github/`, `opencode` → `.opencode/`, `codex` → `.codex/` | -| Plugin | Capability files (agents, commands, hooks, mcp, rules, skills) distributed per AI tool format via marketplace catalogs | -| Drift | Installed file modified locally vs. what was written at install time | -| Init | Bootstrap: CLI writes `.aidd/manifest.json` (+ `.aidd/cache` gitignore). The `aidd_docs/` memory bank is scaffolded by the `aidd-context` project-init skill, not the CLI binary | -| Install | Generates and writes tool-specific distribution files | +## Key features -## Commands - -### Bootstrap -| Command | Purpose | -|---|---| -| `aidd setup --source remote\|local [--path ] [--release ] [--ai ] [--ide ] [--plugins ] [--no-default-marketplace] [--yes]` | Initialize project: marketplace + tools + plugins (`--ai all` / `--ide all` for everything) | - -### AI tools (claude, cursor, copilot, codex, opencode) -| Command | Purpose | -|---|---| -| `aidd ai install [--force]` | Install AI tool runtime config | -| `aidd ai uninstall ` | Remove tool config | -| `aidd ai list / status / update / restore / doctor` | Per-tool ops | - -### IDE tools (vscode) -| Command | Purpose | -|---|---| -| `aidd ide install [--force]` | Install IDE config | -| `aidd ide uninstall / list / status / update / doctor` | Per-tool ops | - -### Plugins -| Command | Purpose | -|---|---| -| `aidd plugin install [name\|local-path] [--from ] [--tool ] [--scope ] [--token ] [--yes]` | Install from marketplace or local path; no arg → interactive pick | -| `aidd plugin create [name] / remove / list / update / search / doctor` | Plugin ops | - -### Marketplaces -| Command | Purpose | -|---|---| -| `aidd marketplace add [name] [source] [--scope ] [--yes] [--overwrite] [--token ]` | Register marketplace | -| `aidd marketplace list [--plugins] / remove / refresh [--force] / check` | Marketplace ops (cache cleared via `refresh --force`) | - -### Auth -| Command | Purpose | -|---|---| -| `aidd auth login [--gh] [--token ] [--level user\|project]` | GitHub auth | -| `aidd auth logout / status` | Auth ops | - -### Framework (authoring) -| Command | Purpose | -|---|---| -| `aidd framework build` | Build tool-specific framework distributions (5 targets × 2 modes). Maintainer/authoring command, not part of the consumer install flow | - -### Globals (chain unitaries) -| Command | Purpose | -|---|---| -| `aidd update / status / restore / doctor` | Run across AI + IDE + plugins (`status --json` for a machine-readable report) | -| `aidd clean [--force]` | Nuke .aidd + tracked files | -| `aidd self-update` | Update CLI binary | - -### Removed (architecture cleanup; current manifest = v6) -- `aidd sync` / `aidd ai sync` — removed with the build/install parity rework; `update` now re-materializes each tool from the built tree, so a separate sync step is gone (`sync-conflict-resolver` survives, reused by the update flow) -- `aidd cache list/clear` — removed; cache cleared via `aidd marketplace refresh --force` -- `aidd config list/get/set` — no remaining writable fields -- `aidd install [category] [tool]` — replaced by `aidd ai/ide install` -- `aidd uninstall [category] [tool]` — replaced by `aidd ai/ide uninstall` -- `aidd migrate [--dry-run] [--non-interactive]` — removed; manifests auto-upgrade to v6 on load (schema migration in `manifest.ts`), no explicit brownfield command -- Setup flags `--from / --switch-mode / --mode / --path` (path kept only with `--source local`) / `--release` -- Install flags `--path / --release / --plugins / --mcp / --all-plugins / --recommended-plugins / --no-plugins` -- Global `--repo` flag; `AIDD_REPO` env var gone from source -- `FrameworkResolver`, `FrameworkCache`, `ResolveFrameworkUseCase`, `InstallFrameworkPluginsUseCase`, `AdoptUseCase` — removed classes -- `MemoryCapability` — memory stubs moved to plugin ownership; no `memory-capability.ts` in source -- Manifest fields: `mode / scripts / repo / docsDir / docs / plugins(top-level)` — all removed - -## User Journey - -### Multi-Tool Developer - -```mermaid -journey - section Install - Run aidd ai install claude: 5: Multi-Tool Dev - Run aidd ai install cursor: 5: Multi-Tool Dev - Files generated in .claude/ and .cursor/: 5: CLI - section Drift - Modify some files locally: 3: Multi-Tool Dev - Run aidd status: 5: Multi-Tool Dev - Drift detected per tool: 5: CLI - section Restore - Run aidd ai restore claude --force: 4: Multi-Tool Dev - Files reverted to installed version: 5: CLI -``` +- Install, update and remove the framework for a tool, and repair what drifted. +- Install plugins from a marketplace, driving a tool's own CLI where its project files are inert. +- Translate an arbitrary source into a target-native plugin tree, recording nothing. +- Measure what sessions cost, from local files, with no service and no upload. diff --git a/cli/aidd_docs/memory/telemetry.md b/cli/aidd_docs/memory/telemetry.md new file mode 100644 index 000000000..2f7ca1240 --- /dev/null +++ b/cli/aidd_docs/memory/telemetry.md @@ -0,0 +1,39 @@ +# Telemetry + +What a session cost, measured from files each AI tool already wrote, stored per machine. No process runs to produce it; nothing leaves the machine. Boundary for a hosted destination: [`measurement-may-reach-a-hosted-destination.md`](../../../aidd_docs/memory/internal/decisions/measurement-may-reach-a-hosted-destination.md). + +## Sink + +- `TelemetrySinkAdapter` (`src/contexts/telemetry/infrastructure/telemetry-sink-adapter.ts`) resolves its root: `AIDD_TELEMETRY_DIR`, else `AIDD_USER_CONFIG_DIR` (legacy, never the variable a team shares), else the default config dir (`.config/aidd` on POSIX and on Windows with pre-existing data, `%APPDATA%/aidd` otherwise). +- One `.jsonl` day file per UTC day under `telemetry/`, append-only, `0600`/`icacls` unless the directory was user-named. +- Two record kinds (`TelemetrySinkRecordKind`, `contexts/telemetry/domain/telemetry-sink-record.ts`): `request`, `session`. Provenance: `export` or `local-read`. + +## Run journal + +- Written by the plugin hook, not the CLI: `plugins/aidd-telemetry/hooks/journal.cjs` reads stdin, detects the host, dispatches to `hooks/lib/`. +- `record.cjs` mints a run id, appends `session_start`/`turn_end`; `step-starts.cjs`, `step-ends.cjs`, `task-declared.cjs`, `file-writes.cjs` append the rest; `repo.cjs` resolves paths and tightens permissions; `trailer-repair.cjs` backs the commit trailer; `opencode-plugin.js` is OpenCode's own entry. +- Lives at the git root above the project: `kernel/paths.ts`'s `resolvedRunsDir` walks up via `repositoryRootAbove`. `AIDD_RUNS_DIR` overrides, read alike by `hooks/lib/repo.cjs` and the CLI. + +## Report + +- `aidd telemetry report` renders the axes in `ARTEFACT_AXES` (`src/presentation/display/cost-report-artefact.ts`). +- `--axis ` prints one markdown table; `--json` the envelope. Filters: `--from`, `--to`, `--days`, `--task`, `--project`, `--step`, `--model`, `--tool`. +- Envelope version `cost_report_version` (`COST_REPORT_ENVELOPE_VERSION`, `contexts/telemetry/domain/cost-report-envelope.ts`) bumps when a consumer must tell shapes apart, not per field. + +## Attribution + +- Person (`contexts/telemetry/domain/person-resolution.ts`): `mapped`, `unresolved`, `this-machine`. Identity is read and written from this machine's profile only; `aidd telemetry identity` never reads `AIDD_USER_CONFIG_DIR` or `.aidd/config.json`. +- Task, step, flow (`task-attribution.ts`, `step-attribution.ts`, `flow-attribution.ts`): declared-vs-inferred over the journal's closed intervals (`journal-intervals.ts`). +- Agent: `TelemetryRouteSupply.agentName` (`kernel/measurement.ts`); only Claude Code's reader sets it (`isSidechain`/`attributionAgent`, `contexts/telemetry/domain/formats/claude-code-transcript.ts`). + +## What a tool declares + +- `kernel/measurement.ts`'s `TelemetryLocalRead`: `declared` carries `TelemetryRouteSupply` (`tokenCounters`, `amount`, `toolStatedStep`, `agentName`), an optional `TranscriptLocation`, an optional `limitation`; `unsupported` carries a `reason`. +- `telemetry → tools` is the one allowed edge. Along it `telemetry` reuses seven `tools` public modules, listed here only: `registry.ts`, `marketplace-settings.ts`, `host-plugin-registration.ts`, `ports/host-plugin-registry-reader.ts`, and the `plugin-root-token`/`flat-hooks-merge`/`cursor-hooks-project-merge` format helpers. `domain/telemetry-setup.ts` crosses too. +- Declared: `claude`, `codex`, `copilot`, `opencode`, each in `contexts/tools/domain/profiles//profile.ts`. `unsupported`: `cursor`. Silent: `vscode`. + +## Gotchas + +- `AIDD_RUNS_DIR` answers where the journal lives; `AIDD_TELEMETRY_DIR`/`AIDD_USER_CONFIG_DIR` where the figures do. `cli.md` lists what else the latter moves. Read `plugins/aidd-telemetry/README.md` ("Share `AIDD_TELEMETRY_DIR`, never `AIDD_USER_CONFIG_DIR`") before pointing anyone at either. +- A relocated `HOME` does not relocate a real `codex` if `CODEX_HOME` is set (`testing.md`). +- A generated `prepare-commit-msg` (lefthook, husky) never calls the delegate until the printed job is added by hand; `on` and `check` name it, neither edits those files. diff --git a/cli/aidd_docs/memory/testing.md b/cli/aidd_docs/memory/testing.md index eb17cce22..f9b9a48d6 100644 --- a/cli/aidd_docs/memory/testing.md +++ b/cli/aidd_docs/memory/testing.md @@ -1,116 +1,48 @@ -# Testing Guidelines - -## Tools and Frameworks - -- Framework: `vitest` with workspace configuration (`vitest.workspace.ts`) -- Runner: `pnpm test` (runs `pnpm build` first, then `vitest run`) -- Test files: in `tests/` directory (not co-located with `src/`) -- Watch mode: `pnpm test:watch` -- Mutation testing: `pnpm test:mutation` (Stryker, scoped to `domain/models/manifest.ts`) - -## Test Pyramid — 3 Tiers - -Tier is identified by **file extension**, not folder: - -| Extension | Tier | Scope | -| ----------------------- | ----------- | ------------------------------------------ | -| `*.unit.test.ts` | Unit | Domain models, value objects, pure functions | -| `*.integration.test.ts` | Integration | Use-cases (application) + adapters (infra) | -| `*.e2e.test.ts` | E2E | Full CLI journeys — main happy paths only | - -### Tier 1 — Unit (`*.unit.test.ts`) - -- Scope: `src/domain/models/`, value objects, pure functions — exhaustive coverage -- No mocks, no I/O, no infrastructure dependencies -- `describe.concurrent()` forbidden -- Property tests: `tests/domain/models/manifest.property.unit.test.ts` (fast-check) - -### Tier 2 — Integration (`*.integration.test.ts`) - -Two sub-scopes: - -**Application** (`tests/application/`): -- Use-cases with real temp filesystem -- Mock all ports via in-memory implementations from `tests/helpers/ports/` -- Never mock: `FileSystem`, `ManifestRepository`, `Hasher` -- Covers specific cases NOT covered by E2E: conflict resolution, non-interactive branches, edge cases - -**Infrastructure** (`tests/infrastructure/`): -- Adapters tested in isolation with mock server responses or file fixtures -- One file per adapter -- Covers technical behaviors not visible in E2E (error parsing, retry logic, format transformation) - -### Tier 3 — E2E (`*.e2e.test.ts`) - -- Scope: main user journeys only — 5 to 10 scenarios per command max -- Full CLI invocation via `runCli()` from `tests/e2e/helpers.ts` -- `describe.concurrent()` required -- `try/finally` required for cleanup -- No edge cases (those belong in integration) - -E2E files live in `tests/e2e/*.e2e.test.ts` — one per journey (persona, greenfield setup, -clean, plugin install/create, update, command-surface matrices, -framework build). List them live: `ls tests/e2e/*.e2e.test.ts`. Each new command journey -adds one file here. - -## Test Fixtures - -- `tests/fixtures/framework/` — minimal synthetic fixture -- `tests/fixtures/framework-real/` — pinned real framework tag; used for E2E and integration tests requiring real plugin content (plugins: `aidd-async-dev`, etc.) -- `scripts/refresh-framework-fixture.sh` — updates pinned real fixture - -## Test Count - -Counts drift fast — read them live, don't trust a snapshot: - -```shell -find tests -name '*.unit.test.ts' | wc -l # unit files -find tests -name '*.integration.test.ts' | wc -l # integration files -find tests -name '*.e2e.test.ts' | wc -l # e2e files -pnpm test # total tests passing -``` - -Shape stays pyramid: unit ≫ integration > e2e. - -## Running Tests - -```shell -pnpm test:unit # domain models only -pnpm test:integration # use-cases + adapters -pnpm test:e2e # functional journeys -pnpm test # all tiers -pnpm test:mutation # Stryker mutation (slow) -``` - -## Naming Rule - -Test names must describe user-visible or system-level behaviour: - -- Banned: "calls execute()", "returns Y", "throws an error" -- Required: "installs tool when not present", "fails in non-interactive mode without --tools flag" - -`describe` blocks must not be named after the class under test — use a behavioral label. - -## Mocking and Stubbing - -- Never mock functional behavior -- Application integration: mock all ports via in-memory implementations from `tests/helpers/ports/` -- Infrastructure integration: mock only the HTTP/external layer -- E2E: no mocks — full real CLI binary invocation - -## Smoke / dogfood install isolation - -- Smoke harness: single `pnpm smoke` → `scripts/smoke-tools.sh`. Drives the real built binary across the full command matrix (all leaf commands × tools); robust `perl alarm` per-command timeout + `grep`-based content guards; coverage-gated. -- Smoke-tests and dogfood CLI installs (`ai install`, `marketplace add`, `plugin install`) MUST run in a fresh `/tmp/` dir with `git init` — NEVER in the repo root. -- In-repo installs leak tracked per-tool residue (`.codex/`, `.cursor/`, `.github/copilot/`, `.opencode/`, `opencode.json`, `.vscode/`) that gets committed by accident (cleaned in PR #276). -- This repo is Claude-only: only `.claude/` and `.aidd/` are legitimate in-repo install artifacts. -- If an in-repo per-tool install is unavoidable for a test, gitignore the non-Claude install dirs. -- A smoke case counts only once **executed** against the real binary — a plausible-looking guard can be silently dead (e.g. a filesystem-find heuristic that returns empty). Pick a tool's tracked file from the manifest (the source of truth), never by walking the filesystem. -- **Native-activation tools touch USER-GLOBAL state, not just the project dir.** `codex`/`copilot` plugin installs land in `~/.codex` / `~/.copilot` (`claude` in `~/.claude`). Sandbox them per run — `codex` honors `CODEX_HOME`, `copilot`/`claude` honor `HOME`, aidd's own user config honors `AIDD_USER_CONFIG_DIR` — or snapshot+restore the real dir. A fresh `/tmp` project dir alone does NOT isolate these. (This work polluted the repo + `~/.copilot` twice before the env-sandbox was right.) -- **Verify tool integrations against the real tool's CLI/IDE, not code+doc inference.** Whether a tool loads a project config is empirical: probe the real tool (`codex debug prompt-input`, `opencode debug skill`, `copilot plugin list`, the Cursor/VS Code plugins panel). Inference from the source + vendor docs was wrong twice here (Cursor assumed broken but works; Copilot assumed fully inert but registers the marketplace). Green unit/integration tests prove aidd's output shape, not that the tool consumes it. - -## Golden / snapshot machine-independence - -- Golden/snapshot tests MUST be machine-independent. Never snapshot a value derived from an absolute path — including content hashes computed over path-bearing content. -- Symptom of violation: passes locally, fails CI with a different hash (different absolute path on the runner). -- Fix pattern + full detail: `.claude/skills/test/references/golden-machine-independence.md` (recompute hash over normalized content). +# Testing + +How this package is tested: the layers, the tools, the conventions. + +## Strategy + +- Four vitest projects (`vitest.workspace.ts`): `unit`, `integration`, `e2e` select by extension anywhere under `tests/`; `architecture` selects `tests/architecture/**/*.arch.test.ts` only, so a stray `*.arch.test.ts` runs nowhere. +- Unit: pure logic. Integration: a use case or adapter, ports substituted. E2E: the real built binary. Architecture: ratchets over source text. +- A pyramid; read the split live. + +## Tools + +- `vitest`, `fast-check` for properties, `ink-testing-library` for terminal views. +- `stryker` for mutation, per scope, a gate in CI on the scopes a change touches. +- `knip`, `jscpd`, `biome`. + +## Conventions + +- `tests/` mirrors `src/`; where it does not, the mirror is wrong. +- Doubles from `tests/helpers/ports/`. Substitute at the seam; never mock functional behaviour. +- Fixtures in `tests/fixtures/`, read-only, excluded from JSON and link checks. Mutate a copy in a temp directory. +- `.gitattributes`'s `tests/fixtures/** text eol=lf` keeps byte-for-byte comparisons (`manifest-round-trip.unit.test.ts`) passing on Windows. +- A temp directory: `await mkdtemp(join(tmpdir(), "aidd--"))`, never a fixed name (a plantable symlink). The prefix is what `scripts/sweep-stale-test-dirs.cjs` reclaims. +- A test name is a phrase of observable behaviour; nested `describe` reads as a sentence. `E2E:` is a recurring legacy prefix, not one to copy. +- A golden snapshot is machine-independent, never derived from an absolute path: [`golden-machine-independence.md`](../../.claude/skills/test/references/golden-machine-independence.md). +- `describe.concurrent` appears in no unit or integration file; in e2e the golden and command-matrix suites use it, most `telemetry-*` suites do not. An observed split, not a rule. +- A bug fix's review reproduces the user's scenario against the built binary: [`bug-empirical-reproduction.md`](../../.claude/skills/test/references/bug-empirical-reproduction.md). +- A sandboxed run reaches no tool binary and no real profile: `tests/e2e/helpers.ts` narrows `PATH`, relocates `HOME`/`XDG_CONFIG_HOME`/`USERPROFILE`/`APPDATA`; `sandbox-reaches-no-tool-binary.e2e.test.ts` holds it. `CODEX_HOME` is never overridden: a machine setting it points a real `codex` at its own profile regardless. +- Records land under `AppData\Roaming` on Windows, `.config` elsewhere. Assert through the helper. +- Green unit and integration tests prove output shape, never that a tool consumes it: that is `pnpm smoke`. + +## Run + +- `pnpm test` runs every project; `test:unit`, `test:integration`, `test:e2e`, `test:arch` select one. +- `pnpm smoke` drives the real binary over the command matrix, hermetically. `pnpm smoke:full` adds the remote fetch. +- `pnpm smoke:real` reaches real host registries: [`smoke-real.md`](internal/smoke-real.md). +- `pnpm test:mutation:`; `mutation-scopes.json` declares each scope's globs (a leading `!` excludes) and the floor its score must hold. `tools` is split one scope per tool profile (`tools-claude`, `tools-codex`, …): a profile is a static declaration whose every mutant reruns each test that loads it, and the profiles together outlasted every other scope on a two-core runner. A weekly scheduled run replays every mutant with `--force`, so drift through a dependency an incremental run never replays is bounded to a week. `scripts/run-mutation.mjs` fails under the floor and keeps one incremental file per scope under `reports/mutation//`; `--force` reruns every mutant. Before a run the runner prunes the incremental file to kills alone: stryker reuses a result unless the mutant's file or a test that covered it changed, so a test written after the fact never reaches a mutant recorded as survived, uncovered or static, and a scope measured 74 read 67 in CI until it did. Raise a floor to the measured score after a run; never lower one without the reason in that file. +- A unit or integration test reads the repository through `tests/helpers/repository-root.ts`, never by climbing `../` or `process.cwd()`: a mutation run copies `cli/` into a sandbox, where a relative climb lands nowhere (`tests-reach-the-repository-through-one-helper.arch.test.ts`). +- Read counts live: a suite failing before producing a test contributes zero. + +## Gotchas + +- Concurrent runs are safe: each e2e run builds under `.e2e-build/`. Nothing under `tests/` resolves into `dist/`. +- `pnpm test` does not build; `dist/` is another run's output. +- A reproduction or manual smoke runs in a fresh temp directory, never at the repository root, which tracks `.codex/config.toml` and `.codex/environments/environment.toml` and gitignores `.vscode/`. +- `setup`'s auto-register always names the marketplace `aidd-framework` (`FRAMEWORK_MARKETPLACE_NAME`, `contexts/distribution/domain/marketplace.ts`); no flag overrides it. Claude's own `add` silently overwrites a known name, which is why `smoke:real` never drives auto-register. +- Alias versus `hostName`, and why two projects sharing one build are no conflict: [`marketplace-identity-is-name-plus-plugins.md`](internal/decisions/marketplace-identity-is-name-plus-plugins.md). +- `pnpm smoke` shares one relocated `$HOME` while `new_project()` spins a fresh directory per cell; several cells auto-register from different paths. Identity by name plus plugin set is what makes that pass. diff --git a/cli/aidd_docs/memory/vcs.md b/cli/aidd_docs/memory/vcs.md index ca21ba14f..a2fa33ecf 100644 --- a/cli/aidd_docs/memory/vcs.md +++ b/cli/aidd_docs/memory/vcs.md @@ -1,28 +1,22 @@ -# Versioning Control System (VCS) Guidelines +# VCS -- Main Branch: `main` -- Platform: `github` -- CLI or MCP: `gh` +What version control means for this package. The repository's own conventions — branches, targets, the release model — are in the repo bank's `vcs.md`; this page holds only what differs here. -## Branch Naming Convention +## Setup -Format: `type/ticket-short-description` (kebab-case) +- Same platform, same branches, same routing as the repository. Read that page first. -Types: `feat/`, `fix/`, `docs/`, `refactor/`, `chore/`, `test/`, `hotfix/` +## Branches -Example: `feat/001-init-project` +- Nothing package-specific. A `cli/` change follows the repository's prefixes. -## Commit Convention +## Commits -Format: `type(scope): description` (Conventional Commits) +- `cli` is this package's only scope in `commitlint.config.cjs`. `domain`, `infra` and `install` are not in the enum and warn. +- A `feat` or `fix` under `cli/` releases `@ai-driven-dev/cli` alone, under its own `cli-v` tag. +- A commit made by an AI session carries an `AIDD-Session-Id` trailer, appended by a `prepare-commit-msg` line that `aidd telemetry on` installs — except where lefthook or husky owns that hook and regenerates it, in which case `telemetry on` installs the delegate and prints the job to add by hand; this repository's own `lefthook.yml` carries that job. `telemetry off` removes what it installed. It is what lets a session's cost be read per commit. +- The enforced header limit is 100, whatever a page says — the repository-root `commitlint.config.cjs`, which overrides nothing and so takes `config-conventional`'s default. That is the one the hook runs: `lefthook.yml`'s `commit-msg` job is `pnpm exec commitlint --edit {1}` from the repository root, and `ci.yml` passes the same file as `configFile`. The package-local `cli/commitlint.config.cjs` sets `header-max-length` to 120 and is not what either of them reads. -- Types: `feat`, `fix`, `docs`, `refactor`, `perf`, `test`, `chore`, `style`, `ci`, `revert` -- Scope: optional — `cli`, `domain`, `infra`, `install` -- Imperative mood, lowercase, max 72 chars +## Commit Strategy -Example: `feat(install): add plugin registry support` - -## Releases - -- Driven by **release-please** (Conventional Commits → semver). A `!` or `BREAKING CHANGE` footer forces a major bump. -- The bot keeps one open release PR off `main`; merging it tags `vX.Y.Z` and triggers publish (see `deployment.md`). Never bump the version or tag by hand. +AI should auto commit: `never`. diff --git a/cli/aidd_docs/tasks/2026_08/2026_08_20_refactor-contextes-cli/README.md b/cli/aidd_docs/tasks/2026_08/2026_08_20_refactor-contextes-cli/README.md new file mode 100644 index 000000000..bc7b2d1b9 --- /dev/null +++ b/cli/aidd_docs/tasks/2026_08/2026_08_20_refactor-contextes-cli/README.md @@ -0,0 +1,171 @@ +# Refactor du CLI par contextes fonctionnels + +Sept documents, produits par une session de cadrage adossée à des mesures sur le code. +Chaque affirmation chiffrée y est reproductible. + +| Document | Contenu | +|---|---| +| `brainstorm.md` | l'intention affinée : mission du CLI, quatre contextes, décisions et invariants | +| `findings.md` | toutes les mesures : volumétrie, glissements de sens, cycles, code mort, comparaison Superpowers | +| `arborescence.md` | l'arbre cible fichier par fichier, avec les règles de dépendance | +| `commandes.md` | la surface de commandes cible et sa grammaire | +| `domaine.md` | critique du domaine et sa cible, avec le test d'acceptation | +| `harnais.md` | les garde-fous déterministes, leur état et ce qui reste | +| `plan.md` | le plan exécutable : 19 phases, objectif, ressources, décisions | +| `marketplaces-heberges.md` | note de conception : héberger les distributions générées, ce qui débloque la phase 5 | +| `phase-1.md` … `phase-19.md` | une fiche par phase : projection, parcours, portée de test, tâches, critères | + +`migration.md` a été supprimé : sa numérotation en treize phases contredisait les dix-neuf du plan, +et deux systèmes de numéros dans le même dossier sont un piège. Ses deux sections propres sont +reprises plus bas ; tout le reste vit dans `plan.md` et les fiches de phase. + +## Décisions structurantes + +- Le CLI lance et rend cohérent l'écosystème. Sa valeur propre est **la translation**. +- Quatre contextes en chaîne : `framework` → `translate` → `tools` → `kernel`, plus `framework` → `distribution`. +- Deux régimes de propriété des fichiers : possédés donc régénérés, co-possédés donc fusionnés. +- Grammaire des commandes : verbe nu pour une action, nom puis verbe pour une ressource. +- `translate` absorbe `framework build`. `sync` remplace `restore`. `doctor` absorbe `status`. + `ai` et `ide` deviennent le flag `--tool`. `update` sans sujet met à jour le CLI. +- kanban, telemetry et governance sont lancés, pas contenus. + +## Test d'acceptation + +**Ajouter un sixième outil doit toucher un fichier et une ligne d'enregistrement.** +Aujourd'hui : huit endroits. Vérifié en continu par `tests/architecture/tool-addition-cost.arch.test.ts`. + +## Livré pendant le cadrage + +- Six règles d'architecture auto-porteuses, scopées, plus le non-ré-export dans `1-exports.md`. +- Cinq tests d'architecture avec cliquet, vérifiés, 238 ms, en pre-commit et en CI. +- Quatre règles Biome activées, dont la frontière du domaine, vérifiée dans les deux sens. +- `continue-on-error` retiré de knip et jscpd ; seuil jscpd à 3,5 % pour 3,43 % mesurés. +- Trois mensonges de documentation corrigés : `aidd sync` annoncé et inexistant, six dossiers absents + de `codebase-map.md`, et le manifest v6 prétendant porter les marketplaces. +- Un ré-export supprimé : `doctor-use-case.ts` réexportait du domaine pour un test. + +## Les filets, et ce que chacun attrape + +Un refactor de cette taille ne tient pas sur la relecture. Chaque phase s'appuie sur un filet qui +échoue tout seul, et aucun filet ne couvre tout — d'où la liste. + +| Filet | Attrape | Depuis | +|---|---|---| +| golden baseline | un changement de comportement sur un scénario complet, dérive comprise | phase 1, étendu | +| instantané de l'aide | un changement de surface utilisateur pendant un déplacement | phase 1, nouveau | +| golden du build | une sortie de build différente, cellule par cellule | existant, réduit en phase 4 | +| e2e, 15 fichiers | les parcours réels, binaire compris | existant | +| tests d'architecture | les invariants : partage, orchestration, coût d'un outil, doc, carte | livrés | +| smoke, 98 vérifications, 100% des commandes feuilles | une commande qui casse avec ses vrais arguments, binaire compris | rendu hermétique et branché en CI en phase 2 | +| non-ré-export | un module qui se met à publier un symbole qu'il ne définit pas | phase 6, nouveau — Biome est aveugle à cette forme | +| graphe des contextes | une arête latérale entre contextes | phase 12, nouveau | +| aller-retour du manifest | un modèle qui change et une sortie qui bouge | phase 13, nouveau | +| équivalence des surfaces | un renommage qui change autre chose que le nom | phase 17, nouveau, temporaire | +| Biome | cycles d'exécution, ré-exports, barrels, frontière du domaine | livré | +| knip, jscpd | code mort, duplication en hausse | livrés, bloquants | +| seuils de couverture | un test perdu pendant un déplacement (85 / 80 / 90 / 85) | existant | +| mutation sur `manifest.ts` | des tests qui passent sans rien vérifier, sur l'agrégat que la phase 14 redécoupe | existant, **cassé** — phase 14, tâche 0 | + +Trois de ces filets n'existaient pas quand le plan a été écrit la première fois. Ils répondent aux +trois faiblesses qui avaient été signalées : une phase trop grosse, une phase sans filet propre, et +onze déplacements sans preuve que la surface utilisateur n'avait pas bougé. + +## Ce que la session a trouvé en exécutant plutôt qu'en lisant + +- **Le smoke est rouge et personne ne le sait.** 73 succès, 4 échecs, 7 min 11 s. Aucun job de CI, + aucun hook. Les quatre échecs sont un seul scénario qui a cessé de tester ce qu'il annonce le jour + où `aidd-dev` est entré dans les plugins recommandés : `setup --plugins recommended` l'installe, + donc `plugin install aidd-dev` échoue sur « already installed » avant même de lire le catalogue + corrompu qu'il vient d'injecter. Défaut de test, pas de produit. +- **La couverture du smoke dépend de l'état d'authentification de la machine.** Ligne 106 : + `TOKEN="${AIDD_TOKEN:-$(gh auth token 2>/dev/null || true)}"`, et tout ce qui compte est derrière + `if [[ -z "$TOKEN" ]]`. Compté statiquement : **11 invocations hermétiques contre 30 derrière le + jeton** — la matrice de setup, les commandes globales, restore, les commandes par outil et par + plugin, le garde-fou de conflit et l'injection de faute sont toutes dans le bloc gardé. Sur une + machine où `gh` est connecté, la suite couvre 41 invocations et annonce 100 % ; ailleurs elle en + couvre 11. Même commande, même dépôt, deux filets différents. +- **Et une commande pend** : `plugin update (all)` a dépassé le plafond de 180 s du script et a été + tuée. Vu une fois, non diagnostiqué. +- **11 des 24 options déclarées n'ont jamais été passées**, dont `--flat`, que la phase 5 supprime + pour quatre outils, et `--scope`, qui décide où les fichiers atterrissent. +- **Stryker est cassé, pas seulement dormant.** `stryker.conf.json` mute exactement un fichier, + `src/domain/models/manifest.ts`, avec un seuil de rupture à 50 — le filet le plus pertinent qui + soit pour la phase la plus risquée. `stryker run` plante sur + `TypeError: ts.parseConfigFileTextToJson is not a function` : Stryker 9.6.1 appelle une API que + TypeScript 7.0.2, le portage natif, n'expose plus. Aucun job, aucun hook, donc personne ne l'a vu + se casser à la montée de version. +- **Deux de mes propres mesures étaient fausses** avant exécution : le smoke couvre 100 % des + commandes feuilles, pas 23 sur 27 ; et il fait 77 vérifications, pas 44. L'analyse par regex + ratait les invocations en boucle. + +## Les tests pendant la migration + +Il n'y a pas de phase de coupe : la mesure ne la justifie pas (voir `brainstorm.md`). Les tests ont +en revanche deux besoins concrets. + +**Réécriture de chemins.** 157 fichiers de test importent `src/`. Chaque phase d'extraction les +casse par le chemin, pas par le comportement. C'est mécanique, et c'est le signe qu'un lot est bien +neutre : si un test échoue autrement que par un chemin, le lot ne l'était pas. + +**Extension du filet, en phase 1.** Le golden ne couvre que cinq invocations. Tout le reste du plan +en dépend. + +Repères de durée mesurés avant la migration, à surveiller : unit 4,65 s pour 1 520 tests, +integration 2,91 s pour 510, e2e 15,5 s pour 128 après build. Une phase qui fait franchement gonfler +l'un de ces chiffres mérite d'être regardée. + +## Ce qui peut être fait en parallèle + +Les phases 1 et 2 sont indépendantes l'une de l'autre. Les phases 5 à 9 sont séquentielles par +construction (chaque contexte dépend de celui d'en dessous). La phase 13 suit chaque phase qu'elle +documente, plutôt que d'attendre la fin. + +## Télémétrie, développée en parallèle + +Elle arrive pendant ce refactor, et elle atterrit dans la structure actuelle : elle suivra ses +couches comme le reste. Trois conséquences à ne pas perdre. + +- **Les projections de phase ne la connaissent pas.** Chaque fiche liste des fichiers nommés ; + ceux que la télémétrie ajoutera devront être intégrés à la projection de la phase qui déplace + leur couche, sinon ils seront oubliés au déplacement. +- **`docs/FAQ.md:44` promet aujourd'hui le contraire** : « No hosted service. AIDD is prompt content + you install into your own tool; there is no AIDD server, account, or telemetry. » C'est le seul + endroit du dépôt qui porte cette promesse — le README ne la contient pas. Elle doit être réécrite + avant qu'une release embarque de la télémétrie, faute de quoi l'engagement est faux le temps d'une + version. +- **« Activer la télémétrie pour tel outil » lit l'état que possède `framework`.** L'invariant + « seul `framework` importe un autre contexte » ne le permet pas. Soit la télémétrie s'active + globalement en attendant, soit `framework` expose une lecture publique de son état — décision à + prendre au moment où ce besoin devient réel, pas avant. + +## Points encore ouverts + +| Sujet | État | +|---|---| +| Skills, une par contexte | à écrire après le déplacement du code, ordre choisi | +| `doctor` doit gagner l'inventaire des outils | ajout à concevoir, et il est cassé (#465) | +| `enable`/`disable` distinct d'`install`/`remove` | existe chez Claude, à évaluer pour AIDD | +| Placement d'`errors.ts` (457 loc) | kernel, ou découpé par contexte avec la base en commun | +| Découpage de `framework/application` entre `flows/` et `cases/` | validable après la phase 3 | +| Conflit `1-exports.md` vs `index.ts` de contexte | **tranché** : pas d'`index.ts`. La frontière est un cliquet listant les modules publics, pas un baril de ré-exports — voir `arborescence.md`, invariant 4 | +| Gouvernance | définie comme un sas recevant la télémétrie, pas davantage | + +## Corrections faites en cours de route + +- **La phase 5 est annulée** : elle voulait supprimer le mode flat pour les quatre outils natifs, en + croyant qu'il faisait doublon. Vérifié avant exécution : pour Claude, le mode marketplace produit + 198 fichiers sous `.claude-plugin/` et `plugins/`, le mode flat 189 sous `.claude/agents/`, + `.claude/skills/`, `.claude/hooks/`. Deux livrables différents, et `cli/README.md` documente le + second. L'erreur venait d'une confusion entre `PluginsCapability.mode`, qui décrit l'installation + d'un *plugin*, et `FrameworkBuildMode`, qui décrit la construction du *framework*. + + +Elles sont conservées parce qu'elles disent où le raisonnement a dérapé. + +- La matérialisation n'est pas la cause de la moitié du CLI : 3 outils sur 5 pointent déjà. +- `noImportCycles` n'aurait pas attrapé nos cycles : ils se referment par des `import type`, donc + il n'y a pas de cycle à l'exécution. +- La coupe du volume de tests est abandonnée : la suite tourne en 25 s pour 2 158 tests, aucun sujet + n'est testé à deux niveaux, un seul fichier sur 139 est lourdement doublé. +- `aidd kanban` ne violait pas la grammaire : il a déjà des sous-commandes avec un défaut. +- `plugin create` sort bien : personne n'écrit de plugin tiers, et la commande n'est documentée nulle part. diff --git a/cli/aidd_docs/tasks/2026_08/2026_08_20_refactor-contextes-cli/arborescence.md b/cli/aidd_docs/tasks/2026_08/2026_08_20_refactor-contextes-cli/arborescence.md new file mode 100644 index 000000000..deb028281 --- /dev/null +++ b/cli/aidd_docs/tasks/2026_08/2026_08_20_refactor-contextes-cli/arborescence.md @@ -0,0 +1,144 @@ +# Arborescence cible — CLI orienté contextes + +## Graphe + +``` +presentation ──> contextes ──> kernel +runtime ────────> (câblage uniquement) + +framework ──> translate ──> tools ──> kernel + └───────> distribution ────────> kernel + +kanban · telemetry · governance : lancés par le CLI, pas contenus en lui +``` + +## Arbre + +``` +cli/src/ + cli.ts + + kernel/ ~950 langage commun + tool.ts identité des outils (ex tool-ids.ts) + source.ts localisation d'une source (ex plugin-source.ts) + paths.ts chemins projet + file.ts fichier et empreinte + merge.ts stratégies de fusion + errors.ts erreurs de domaine + ports/ file-reader file-writer hasher logger asset-provider + + contexts/ + tools/ ~2960 ce que le projet cible + domain/ + profiles/ claude cursor copilot codex opencode vscode + chemins, formats natifs, capacités déclarées + registry.ts contracts.ts build-contract.ts + settings-capability.ts mcp-capability.ts config-capability.ts + mcp-exclusion.ts tool-recommendations.ts + ports/ native-plugin-activator file-merger + application/ + install-tool uninstall-tool install-config + install-ide-config install-runtime-config detect-tools + infrastructure/ + abstract-native-plugin-cli codex-cli copilot-cli + + translate/ ~4000 LE CŒUR + domain/ + capabilities/ agents skills commands rules hooks + formats/ markdown command placeholders toml jsonc + chemins par outil, fusions mcp et hooks, + réécritures de liens et de tokens + content-translator.ts + canon.ts sections, templates, placeholders (ex framework.ts) + build-target.ts cibles et modes (ex framework-build.ts) + application/ + translate-source source canonique -> natif de N cibles, + en place ou vers un arbre de distribution + (absorbe l'ancien framework build) + infrastructure/ + schema-validator + + distribution/ ~2400 d'où vient le contenu + domain/ + marketplace.ts cache-entry.ts source-mode.ts + catalog.ts catalog-parsers/ (dont copilot natif) + ports/ registry cache trust-store catalog-repository + fetcher raw-fetcher + application/ + add list refresh register-framework resolve fetch-source + publish-to-registry (nouveau : publier, pas consommer) + infrastructure/ + registry catalog-repository fetcher cache trust raw-fetcher + + framework/ ~4800 ce qui est posé ici + domain/ + manifest.ts l'enregistrement, proche d'un lockfile + plugin.ts enregistrement installé + doctor.ts install-scope.ts setup-flow.ts project-context.ts + semver.ts + ports/ manifest-repository plugin-distribution-reader + application/ + flows/ setup update regenerate sync-settings + marketplace-check marketplace-remove + cases/ install-plugin remove-plugin list search + materialize status diagnose clean init + infrastructure/ + manifest-repository plugin-distribution-reader + + launchers/ petit lance l'écosystème + kanban.ts localise et lance le binaire kanban + telemetry.ts active, désactive, gère la config (user-scope) + governance.ts à venir + + presentation/ ~2600 + commands/ enregistrement et parsing par contexte + display/ rendu des résultats + prompts/ setup-tools setup-plugins plugin-pick menu + conflict-resolution + output.ts error-handler.ts + + runtime/ ~900 + wiring/ un câblage par contexte, remplace deps.ts (733) + auth/ http/ git/ platform/ project-root/ self-update/ +``` + +## Invariants + +1. `presentation` → contextes → `kernel`. Aucune flèche inverse. +2. Chaîne unique : `framework` → `translate` → `tools` → `kernel`, plus `framework` → `distribution`. Aucune autre arête entre contextes. +3. `kernel` n'importe aucun contexte et ne porte aucune logique métier. +4. Rien n'importe l'intérieur d'un contexte : une importation venue d'ailleurs ne vise qu'un module + que ce contexte déclare public. + + > La valeur est la frontière, pas le fichier. Un `index.ts` de contexte a d'abord été écrit ici + > comme mécanisme, avant d'être retiré : c'est un baril de ré-exports, donc il contredit + > l'invariant 5, la règle Biome `noBarrelFile` et le cliquet `no-re-export` dont la base est vide + > et éprouvée par injection. La frontière est donc tenue par un cliquet d'architecture qui liste + > les modules publics de chaque contexte — aucun fichier de ré-export n'existe, donc il n'y a rien + > à exempter. + +5. Aucun barrel de ré-export, nulle part. +6. Un module n'est partagé que s'il a des appelants dans au moins deux contextes. +7. Un chapeau ne dépend pas de plus de contextes qu'il n'en traverse. +8. Deux régimes de propriété, deux traitements : + - fichiers **possédés** par le CLI (contenu généré, gitignoré) → on régénère, pas de machinerie d'empreinte ; + - fichiers **co-possédés** avec l'utilisateur (`settings.json`, `.mcp.json`, `.vscode/`) → fusion, diagnostic, conflits. +9. Les lanceurs ne contiennent pas l'applicatif : ils le localisent et l'exécutent. + +## Conséquences concrètes du choix « lancé, pas contenu » + +- `src/application/commands/kanban.ts` importe aujourd'hui + `../../../../kanban/src/presentation/...` — un import profond hors du package. Le lanceur + n'importe plus rien : il localise le binaire et l'exécute. +- `ink` (7.1.1) et `react` (19.2.8) quittent les dépendances de `cli/package.json`. Ils n'y + servent que kanban, et `knip.json` les liste en `ignoreDependencies` pour cette raison. + Au passage, les versions divergent déjà : React 19.2.8 côté CLI, 19.2.7 côté kanban. +- `cli-table3` et `gray-matter` sont dans le même cas, à vérifier avant retrait. +- Le budget de `scripts/check-bundle-size.mjs` baisse d'autant ; c'est un gain vérifiable. + +## Suppressions actées + +- branche catalogues étrangers : `loadForeign()` + 4 parseurs + `normalized-plugin.ts` (code mort, aucun appelant en production) +- `domain/models/marketplace-entry.ts` (103 loc, inatteignable, ignoré par knip.json) +- 4 exports morts de `mcp-exclusion.ts`, `buildMergeFileEntries`, `UpdateAiToolsInput/Result`, `UpdateIdeToolsInput/Result` +- `plugin create` et `plugin-scaffold.ts` (personne n'écrit de plugin tiers) diff --git a/cli/aidd_docs/tasks/2026_08/2026_08_20_refactor-contextes-cli/brainstorm.md b/cli/aidd_docs/tasks/2026_08/2026_08_20_refactor-contextes-cli/brainstorm.md new file mode 100644 index 000000000..72db7b78d --- /dev/null +++ b/cli/aidd_docs/tasks/2026_08/2026_08_20_refactor-contextes-cli/brainstorm.md @@ -0,0 +1,88 @@ +# Réorganiser le CLI par contextes fonctionnels + +L'architecture clean tient sur le papier : aucun import du domaine vers l'extérieur. Ce qui a dérivé, ce sont les frontières fonctionnelles. Les dossiers rangent par couche technique, donc chaque capacité produit est éparpillée sur trois niveaux ; `use-cases/shared/` est devenu un dépotoir dont 2 fichiers sur 14 sont réellement partagés ; `deps.ts` pèse 733 lignes ; et le même mot désigne cinq choses différentes selon l'endroit — « plugin » est tour à tour ce qu'on écrit, une offre de catalogue, une charge utile téléchargée et un enregistrement installé, chacun avec déjà son propre type. + +La cible réorganise le premier niveau par contexte fonctionnel. Un contexte contient plusieurs cases ; la case reste l'unité technique. + +## Mission du CLI + +Le CLI lance et rend cohérent l'écosystème AIDD : installer le framework, lancer le kanban, activer la télémétrie pour un outil donné, et bientôt la gouvernance — un sas vers lequel les informations de télémétrie seront envoyées. + +Sa valeur propre est la **translation**. Un utilisateur sous Claude Code peut déjà ajouter le marketplace lui-même ; le CLI ne lui apporte rien là. Ce qu'il ne peut pas faire seul, c'est convertir un même contenu en `.mdc` Cursor, en TOML Codex et en `.github/instructions` Copilot. C'est le seul endroit où le CLI est irremplaçable, et c'est donc le cœur. + +## Ce qui est clair + +- **Quatre contextes, en chaîne.** `framework` → `translate` → `tools` → `kernel`, plus `framework` → `distribution`. Aucune autre arête entre contextes. + - **translate** — convertir une source canonique vers le format natif de N cibles. Clients : le framework, `.aidd/agents` (#592), demain d'autres sources. + - **tools** — quels outils le projet cible, et leur configuration. + - **distribution** — d'où vient le contenu. + - **framework** — quel framework et quels plugins sont posés, à quelle version. Cycle de vie de dépendance, proche d'un gestionnaire de paquets, ce qui explique que `manifest.json` ressemble à un lockfile. +- **Deux régimes de propriété, et c'est la distinction structurante.** Les fichiers de contenu générés appartiennent au CLI : gitignorés selon #592, jetables, donc on les **régénère**. Les fichiers de configuration (`settings.json`, `.mcp.json`, `.vscode/`) sont **co-possédés** avec l'utilisateur, qui a le droit d'y mettre ses propres choses : là, fusion, diagnostic et conflits ont une vraie valeur. La suringénierie n'est pas `doctor` ni `restore` en soi, c'est d'appliquer le même appareillage d'empreintes et de fusion aux deux régimes. +- **`restore` est le régénérateur, mal nommé.** `sync/` ne fait que 79 lignes et n'est qu'une politique de conflit. C'est `restore` (~800 loc avec ses satellites) qui réécrit depuis la source, ce que #592 décrit comme le geste quotidien (« CI/dev runs sync after checkout »). La commande porte le nom du cas de secours alors qu'elle fait le travail courant. +- **Le framework AIDD est le sujet privilégié.** Le code le dit déjà : `FRAMEWORK_MARKETPLACE_NAME` est un nom réservé que `marketplace-add-use-case.ts:42` refuse, `Marketplace.isFramework()` existe comme prédicat, `setup` porte un `--skip-framework`. La cible en fait une règle assumée au lieu d'une exception subie. +- **Les chapeaux vivent dans `framework`** et dépendent des entrées publiques des autres contextes, pas de use cases individuels. Les 13 dépendances au constructeur de `SetupUseCase` mesurent l'écart actuel. +- **Ni saga, ni event sourcing, ni CQRS.** Zéro rollback, zéro compensation, zéro bus d'événements dans 22 800 lignes. Une exécution ratée est réparée par l'utilisateur via un diagnostic ou une régénération. Le couple « chapeau plus sous-use-cases » est le bon pattern ; c'est son rangement dans `shared/` qui était faux. +- **Le partage se mérite** : appelants dans au moins deux contextes. Sur les 14 fichiers de `use-cases/shared/`, deux seulement passent la règle (`resolve-marketplace`, `ensure-built-marketplace`) et cinq n'ont qu'un seul appelant. +- **kanban, telemetry et governance sont lancés, pas contenus.** Le CLI les localise et les exécute. Cela évite de faire entrer `ink` et `react` — déjà dans les dépendances, ignorés par `knip.json` parce que seul kanban les utilise — dans le bundle de tous les utilisateurs, alors qu'un budget de taille est vérifié par `scripts/check-bundle-size.mjs`. +- **Télémétrie : user-scope, sans override projet.** Décision de confiance avant d'être une décision d'architecture : si un projet pouvait l'activer, cloner un dépôt déclencherait l'envoi de données à l'insu de celui qui clone. Le projet peut demander, la personne décide. +- **Les neuf cellules de build sont conservées.** La décision inverse avait été prise puis annulée : elle reposait sur une confusion entre deux axes. `PluginsCapability.mode` décrit comment un *plugin* s'installe dans un outil ; `FrameworkBuildMode` décrit comment le *framework* est construit pour une cible. Le constat « quatre outils sur cinq sont en `native` » portait sur le premier et ne disait rien du second. Vérifié dans le golden de build : pour Claude, le mode marketplace produit 198 fichiers sous `.claude-plugin/` et `plugins/`, le mode flat en produit 189 sous `.claude/agents/`, `.claude/skills/`, `.claude/hooks/`. Deux livrables différents, et `cli/README.md` documente le second — « or when you want files on disk in the project ». +- **Publier plutôt que consommer.** Lire les catalogues cursor/copilot/codex disparaît (code mort) ; publier le framework dans les registres tiers devient une capacité côté auteur, aux côtés de `build-distribution`. +- **Ports et adapters par contexte** ; chaque contexte expose un seul `index.ts`. `deps.ts` éclate en un câblage par contexte. +- **Présentation et runtime sont deux couches**, pas une coquille. La présentation (commandes 1736, affichage 139, menu 366, prompts ~300) inclut des fichiers aujourd'hui rangés en `use-cases/`. Le runtime porte le câblage, http, git, plateforme, auth, self-update. +- **Filet de comportement gelé** : 13 fichiers `tests/e2e/` plus 2 `tests/golden/`. `tests/e2e/helpers.ts` importe trois symboles de `src`, donc le filet n'est pas totalement indépendant des chemins. +- **Arbre de tests miroir conservé**, pour ne pas ajouter de bruit dans `src`. Contrepartie assumée : chaque extraction future de contexte sera un déplacement à deux arbres. +- **La coupe du volume de tests est abandonnée, faute de justification mesurée.** Les trois motifs retenus au départ ne résistent pas aux chiffres : la suite complète tourne en ~25 s pour 2 158 tests (unit 4,65 s / 1 520, integration 2,91 s / 510, e2e 15,5 s / 128) ; aucun sujet n'est testé à deux niveaux d'après les noms de fichiers ; et un seul fichier sur 139 dépasse dix doublures, pour 84 occurrences au total. Le ratio de 1,44:1 entre tests et source décrit une suite saine, pas une suite obèse. Ce dont les tests ont réellement besoin est ailleurs : réécrire les chemins des 157 fichiers qui importent `src/` lors des déplacements, et étendre le filet golden qui ne couvre que cinq invocations. +- **Atterrissage incrémental**, les deux dispositions coexistent, feuilles d'abord. + +## Invariants + +1. `presentation` → contextes → `kernel`. Aucune flèche inverse. +2. Chaîne unique : `framework` → `translate` → `tools` → `kernel`, plus `framework` → `distribution`. +3. `kernel` n'importe aucun contexte et ne porte aucune logique métier. +4. Un contexte expose un seul `index.ts` ; rien n'importe son intérieur. +5. Aucun barrel de ré-export dans un contexte. +6. Un module n'est partagé que s'il a des appelants dans au moins deux contextes. +7. Un chapeau ne dépend pas de plus de contextes qu'il n'en traverse. +8. Fichiers possédés → régénération. Fichiers co-possédés → fusion et diagnostic. +9. Les lanceurs ne contiennent pas l'applicatif : ils le localisent et l'exécutent. + +## Suppressions actées + +- Branche catalogues étrangers : `loadForeign()`, les 4 parseurs `{cursor,codex,copilot,opencode}-marketplace.ts` et `normalized-plugin.ts`. Aucun appelant en production ; seuls le port la déclare et trois tests la bouchonnent. +- `domain/models/marketplace-entry.ts` (103 loc) : seul fichier inatteignable depuis `src/cli.ts`, et `knip.json` l'ignore explicitement au lieu qu'il soit supprimé. L'homonyme vivant est `domain/capabilities/marketplace-entry.ts` (25 loc). +- Quatre exports morts de `mcp-exclusion.ts` (`extractMcpKeys`, `filterMcpExclusions`, `computeMcpExclusions`, `detectNewMcpEntries`), plus `buildMergeFileEntries` et `Update{Ai,Ide}Tools{Input,Result}`. +- `plugin create` et `plugin-scaffold.ts` : personne n'écrit de plugin tiers aujourd'hui. + +## Encore ouvert + +- **Nommage des commandes.** Le découpage en quatre ne se reflète plus dans la surface actuelle. À revoir entièrement. +- **`translate` comme commande publique générique** — « ce que tu passes en IN, il le met en OUTPUT selon la cible » — est à décider indépendamment du fait que le contexte est au cœur. +- **`doctor` est cassé** : issue #465, il rapporte « healthy » sur un projet jamais installé. À reconstruire autour de la question « pourquoi mon outil ne voit pas le framework ». +- **Découpage de `framework/application`** entre `flows/` et `cases/`, validable seulement une fois les 14 fichiers de `shared/` redescendus. +- **Placement de `errors.ts`** (457 loc) dans le kernel, ou découpé par contexte avec la seule classe de base en commun. +- **`ARCHITECTURE.md` est périmé** : il documente `marketplaces` dans le manifest v6 alors que `manifest.ts:142` indique que le registre vit dans `.aidd/marketplaces.json`. +- **Répercussions sur les rules, skills et `aidd_docs`**, non traitées. + +## Prochain pas + +Revoir le nommage des commandes sur le découpage en quatre, puis répercuter sur les rules, les skills et `aidd_docs`. + +## Répercussion sur les règles, skills et mémoire + +### Ce qui est fait +- Les invariants applicables aujourd'hui sont devenus des règles auto-porteuses, une par sujet, scopées à des paths logiques : `0-dependency-direction`, `0-ports-adapters`, `0-use-case`, `0-domain-model`, `0-orchestration`, `0-shared-modules`. Le non-ré-export a rejoint `01-standards/1-exports.md`, sa catégorie. +- `0-layer-responsibilities.md` couvrait quatre sujets et légitimait le dépotoir (*« Shared Use Cases: only called from other use-cases »*). Scindé en `0-use-case` et `0-domain-model` ; sa section Sub-use-cases est remplacée par `0-shared-modules`, ses sections Port et Adapter par `0-ports-adapters`, son « Methods ≤ 20 lines » retiré car `06-design-patterns/6-method-size.md` le portait déjà. +- `0-hexagonal.md` supprimé : c'était une carte, et `aidd_docs/memory/codebase-map.md` en contient déjà une plus riche. +- `0-file-ownership` déplacé en mémoire (`architecture.md`) : c'est une décision de conception, pas une contrainte d'écriture, et elle était chargée sur tout `src` pour une poignée de fichiers. +- `0-launchers` supprimé des règles : un seul lanceur existe. Le sujet ira dans la skill du contexte concerné. + +Test appliqué : une règle empêche une violation au moment où on écrit ; ce qui décrit l'existant va en mémoire ; ce qui est une marche à suivre va en skill. + +Règles chargées sur `src/**/*.ts` : 7, contre 9 avant l'opération. + +### Reste à faire +- Les skills, une par contexte (`translate`, `tools`, `distribution`, `framework`) plus les transversales `test` et `audit-remediate`. Elles décrivent la cible, donc elles attendent que le code ait bougé. Les dix skills actuelles encodent la taxonomie par couche et seront remplacées, pas mises à jour. +- Le sujet « lanceur » (localiser puis exécuter, ne pas embarquer) rejoindra la skill du contexte qui portera kanban, telemetry et governance. +- `1-exports.md` interdit tout `index.ts` ; cela contredira l'invariant cible « un contexte expose une seule entrée publique ». Distinguer le barrel de confort de la frontière de contexte au moment du déplacement. +- `codebase-map.md` (93 l., 32 réfs) et `architecture.md` (143 l., 16 réfs) se réécrivent une fois le code déplacé, pas avant. +- `ARCHITECTURE.md` est faux dès aujourd'hui sur le manifest v6. diff --git a/cli/aidd_docs/tasks/2026_08/2026_08_20_refactor-contextes-cli/commandes.md b/cli/aidd_docs/tasks/2026_08/2026_08_20_refactor-contextes-cli/commandes.md new file mode 100644 index 000000000..e538d760e --- /dev/null +++ b/cli/aidd_docs/tasks/2026_08/2026_08_20_refactor-contextes-cli/commandes.md @@ -0,0 +1,93 @@ +# Surface de commandes cible + +## Grammaire + +Règle unique, observée sans exception chez Claude Code et Codex : + +- **Verbe nu** = une action exécutée maintenant. Le sujet implicite est le CLI ou le projet courant. +- **Nom puis verbe** = le cycle de vie d'une ressource gérée. + +Claude : `doctor`, `update`, `install`, `import` sont des actions ; `plugin install`, +`plugin marketplace add`, `mcp`, `agents` sont des ressources. +Codex : `exec`, `review`, `apply`, `update`, `doctor`, `login` sont des actions ; +`plugin add`, `plugin marketplace` sont des ressources. + +## Surface + +``` +# ACTIONS — verbe nu +aidd setup installe AIDD dans le projet +aidd clean retire AIDD du projet +aidd doctor [--tool ...] outils détectés, équipés, plugins, problèmes +aidd sync [--tool ...] régénère les fichiers possédés, piloté par le manifest +aidd translate --to convertit une source, sans cycle de vie + [--out ] [--as marketplace|flat] +aidd update|upgrade met à jour le CLI lui-même +aidd login | aidd logout + +# RESSOURCES — nom puis verbe +aidd framework install | update | remove [--tool ...] +aidd plugin install | update | remove | list | search [--tool ...] +aidd marketplace add | refresh | remove | list + +# APPLICATIONS DE L'ÉCOSYSTÈME — ressources, verbes selon leur nature +aidd kanban open | list open par défaut +aidd telemetry enable | disable | status +``` + +Environ 22 commandes feuilles contre 34 aujourd'hui, et `--tool` est le flag unique de portée. + +## Suppressions et fusions + +| Aujourd'hui | Devient | Pourquoi | +|---|---|---| +| `ai` et `ide` (7 verbes identiques chacun) | flag `--tool` | Un outil n'est pas une ressource gérée, c'est la dimension de portée. `tool add cursor` était déjà `framework install --tool cursor` : `InstallAiToolUseCase` fait config runtime + plugins + settings + manifest. | +| `status`, `ai status`, `ide status`, `ai doctor`, `ide doctor`, `plugin doctor` | `aidd doctor` | Les deux appelaient `detect-plugin-drift` sur les mêmes fichiers, avec deux vocabulaires. Ni Claude ni Codex n'ont de `status`. | +| `restore`, `ai restore`, `ide restore` | `aidd sync` | `restore` portait le nom du cas de secours pour le geste quotidien. `sync` est déjà le mot de `ARCHITECTURE.md` et de #592. | +| `self-update` | `aidd update` | `update` sans sujet signifie « le CLI » chez Claude comme chez Codex. | +| `framework build` | `aidd translate` | Mesuré identique : `build` prend un `sourceDir`, un `outDir` et un mode (`--flat` = *materialize directly into project workspace*). C'est `translate` avec la source figée. | +| `plugin create` | supprimé | Personne n'écrit de plugin tiers ; la commande n'est documentée nulle part. | +| `aidd sync` (documenté, inexistant) | existe enfin | `ARCHITECTURE.md:58` l'annonce, aucune déclaration ne correspond. | + +## Kanban et telemetry ne sont pas une catégorie à part + +Ce sont deux ressources dont la nature appelle des verbes différents : telemetry est un réglage +persistant (`enable`/`disable`), kanban est une application qu'on ouvre (`open`). + +`aidd kanban` respectait déjà la grammaire : `commands/kanban.ts` enregistre `list` et +`interactive` avec `isDefault: true`. Le verbe est simplement rendu explicite et renommé `open`. + +Pas de `start`/`stop` : vérifié, `kanban/src` ne contient ni `listen`, ni `server`, ni `daemon`, +ni `spawn`, ni `pid`. C'est un `render()` d'ink au premier plan, que l'on quitte. Une commande +`stop` n'aurait jamais rien à arrêter. Le couple `start`/`stop` sera en revanche le bon pour la +gouvernance si son sas est un service qui tourne — c'est le cas que Codex traite avec +`remote-control`, « Manage the app-server daemon ». + +## Divergences assumées avec l'écosystème + +- **`marketplace` reste au niveau racine**, alors que Claude et Codex l'imbriquent sous `plugin`. + Raison de domaine : chez eux un marketplace ne sert que des plugins ; ici il porte **aussi le + framework** (`FRAMEWORK_MARKETPLACE_NAME` y est enregistré). L'imbriquer mentirait sur son contenu. +- **Alias systématiques**, comme chez eux : `install|i`, `remove|rm`, `update|upgrade`, + `plugin|plugins`. Évite d'avoir à trancher le débat du bon mot. + +## Adjacences à documenter d'une phrase chacune + +Elles ne sont pas des doublons, mais elles se ressemblent assez pour être confondues. + +- `marketplace refresh` re-télécharge les catalogues. +- `framework update` passe à une nouvelle version. +- `sync` réécrit les fichiers possédés à partir de ce qui est déjà là. +- `translate` convertit une source arbitraire sans rien enregistrer ; `sync` fait la même + conversion mais pilotée par le manifest, donc avec cycle de vie. +- `setup` amorce le projet entier (marketplace + framework + outils + plugins) ; + `framework install` n'agit que sur le framework. +- `clean` retire tout AIDD du projet ; `framework remove` ne retire que le framework. + +## Encore ouvert + +- **`doctor` doit gagner l'inventaire des outils**, ce qu'il ne fait pas aujourd'hui. C'est un + ajout, pas un renommage — et il est de toute façon à refaire (#465 : il rapporte « healthy » + sur un projet jamais installé). +- **`enable`/`disable` distinct d'`install`/`remove`** existe chez Claude : un plugin installé + mais désactivé est un état réel qu'AIDD ne modélise pas. À évaluer. diff --git a/cli/aidd_docs/tasks/2026_08/2026_08_20_refactor-contextes-cli/domaine.md b/cli/aidd_docs/tasks/2026_08/2026_08_20_refactor-contextes-cli/domaine.md new file mode 100644 index 000000000..484c79685 --- /dev/null +++ b/cli/aidd_docs/tasks/2026_08/2026_08_20_refactor-contextes-cli/domaine.md @@ -0,0 +1,74 @@ +# Domaine — état actuel et cible + +## Test d'acceptation + +**Ajouter un sixième outil doit toucher un fichier et une ligne d'enregistrement.** +Mesurable avant et après. Aujourd'hui : huit endroits. + +| # | Fichier | Ce qu'on y ajoute | +|---|---|---| +| 1 | `domain/tools/ai/.ts` | le profil, avec `registerTool()` en bas | +| 2 | `domain/models/tool-ids.ts` | union `AiToolId` + tableau `AI_TOOL_IDS` | +| 3 | `domain/models/plugin-format.ts` | union `PluginFormat` | +| 4 | `domain/models/framework-build.ts` | union `FrameworkBuildTarget` + `FRAMEWORK_BUILD_TARGET_MODES` | +| 5 | `strategies/tool-contracts.ts` | `buildContract()` et sa variante flat | +| 6 | `infrastructure/deps.ts` | import à effet de bord + entrée du registre de build | +| 7 | `infrastructure/assets/asset-loader.ts` | import de la config embarquée | +| 8 | `assets/configs//` | le fichier de config | + +## Défauts mesurés + +### Manifest est une façade, pas un agrégat +529 lignes, 28 méthodes publiques, six responsabilités : outils, fichiers tracés, fichiers +fusionnés, exclusions MCP, plugins, sérialisation. Aucune ne peut évoluer sans rouvrir le +même fichier. + +### L'évolution du format de persistance vit dans l'entité +Cinq fonctions `migrateV1toV2` … `migrateV5toV6`, plus des champs conservés pour le seul +aller-retour legacy. Commentaire ligne 89 : « This migration block must remain until all +users have upgraded past v1. » **Décision : les migrations par version sont supprimées, pas +déplacées.** + +### Obsession du primitif là où l'objet-valeur existe déjà +`FileHash` est un vrai objet-valeur avec `equals()`. `Plugin` porte pourtant trois +`ReadonlyMap` de sens différents, distingués par un commentaire : +chemin → empreinte, chemin installé → chemin de composant, nom de serveur MCP → MD5. +Le compilateur voit le même type dans les trois cas. + +### Trois unions parallèles, membres identiques +Mesuré : `AiToolId`, `PluginFormat` et `FrameworkBuildTarget` ont exactement les mêmes cinq +membres, dans un ordre différent. `vscode` n'est dans aucune des deux dernières, ce qui est +correct. Aucune divergence réelle ; trois listes synchronisées à la main, sans vérification. + +### Duplication confirmée et déjà dérivée (issue #468) +Quatre `install-*-use-case` (325 loc) implémentent le même pipeline ; quatre classes de +capacité dupliquent la même surface de huit méthodes. La dérive est arrivée : +`AgentsCapability.acceptsFileName` reçoit sa liste de suffixes de l'extérieur là où les trois +autres la calculent en interne — même contrat, deux implémentations incompatibles. + +### Un seul vrai cas particulier en dur +Sur 7 comparaisons d'identifiant d'outil, 5 sont dans la branche morte `loadForeign`. +Restent `cursor-hooks.ts:11` et surtout +`built-tree-materialization-translator.ts:62` : `toolId === "opencode" ? "flat" : "marketplace"`, +qui redérive par le nom ce que le profil déclare déjà (`mode: "flat"`). + +## Cible + +- **Un outil, un fichier.** Le profil porte ses capacités **et** son contrat de build. + `tool-contracts.ts` (820 loc, 9 fonctions) disparaît, réparti sur les profils. +- **Un mode par outil**, déclaré dans le profil. `FRAMEWORK_BUILD_TARGET_MODES` et ses neuf + cellules deviennent dérivés ; `framework-build.ts` ne garde que `FrameworkBuildMode`. +- **Une union source.** `PluginFormat` et `FrameworkBuildTarget` deviennent des alias ou des + sous-ensembles explicites d'`AiToolId`, gardant le vocabulaire sans dupliquer les valeurs. +- **Manifest devient un agrégat racine à membres séparés** : `ToolEntry` porte `TrackedFiles`, + `MergeFiles`, `McpExclusions`, `InstalledPlugin[]`. Une sauvegarde, un invariant, un fichier + par responsabilité. À faire pendant le déplacement vers le contexte `framework`. +- **Les trois maps sont typées** : `Map`, + `Map`, `Map`. +- **Renommage par l'intention** : `InstalledPlugin` pour l'enregistrement, `PluginOffer` pour + l'entrée de catalogue, `PluginPayload` pour la charge utile téléchargée. Chaque contexte + parle alors de son propre « plugin » sans ambiguïté. +- **Le domaine de chaque contexte est non anémique** : les invariants sont validés dans le + modèle, pas dans les use cases. +- **Suppression du dernier cas particulier** : lire `mode` sur le profil au lieu de comparer + l'identifiant à `"opencode"`. diff --git a/cli/aidd_docs/tasks/2026_08/2026_08_20_refactor-contextes-cli/findings.md b/cli/aidd_docs/tasks/2026_08/2026_08_20_refactor-contextes-cli/findings.md new file mode 100644 index 000000000..47c4de818 --- /dev/null +++ b/cli/aidd_docs/tasks/2026_08/2026_08_20_refactor-contextes-cli/findings.md @@ -0,0 +1,376 @@ +# Mesures — état actuel du CLI + +Toutes les mesures ci-dessous sont reproductibles sur la base de code au 2026-08-20. + +## Volumétrie + +| Ensemble | Fichiers | Lignes | +|---|---|---| +| `src/` | 253 | 22 806 | +| tests | 201 | 32 811 | +| dont unit | 139 | 20 281 | +| dont integration | 47 | 8 952 | +| dont e2e | 15 | 3 578 | + +Aucun test colocalisé dans `src/` ; l'arbre `tests/` est un miroir des chemins. + +## Localité par contexte candidat + +1 252 arêtes d'import internes, 45 % intra-contexte. + +| Candidat | intra | sortantes | entrantes | +|---|---|---|---| +| framework (build) | 8 | 51 | 5 | +| marketplace | 1 | 56 | 12 | +| tools | 14 | 68 | 55 | +| plugin | 96 | 136 | 146 | +| install et satellites | 34 | 259 | 36 | +| noyau (models, ports, adapters, commands) | 418 | 111 | 427 | + +## Glissements de sens — un mot, plusieurs types + +| Mot | Sens | Type | +|---|---|---| +| plugin | ce qu'on écrit | `plugin-scaffold.ts` | +| plugin | offre de catalogue | `PluginCatalogEntry` | +| plugin | offre d'un écosystème étranger | `NormalizedPlugin` | +| plugin | charge utile téléchargée | `PluginDistribution` | +| plugin | enregistrement installé | `Plugin` | +| marketplace | source enregistrée | `Marketplace` / `MarketplaceEntry` | +| marketplace | fetch caché | `MarketplaceCacheEntry` | +| marketplace | format de fichier émis | `formats/*-marketplace.ts` | +| tool | cible de build | `FrameworkBuildTarget` | +| tool | surface installée | `AiToolId` + `tools/registry.ts` | +| scope | project/user pour installer | `InstallScope` | +| scope | project/user pour le registre | `MarketplaceScope` | + +Trois erreurs distinctes coexistent pour le dernier cas : `InvalidInstallScopeError`, `InvalidPluginScopeError`, `InvalidMarketplaceScopeError`. + +## Propriété des états persistés + +| Store | Propriétaire | +|---|---| +| `.aidd/manifest.json` | `ManifestRepositoryAdapter` | +| `.aidd/marketplaces.json` | `MarketplaceRegistryAdapter` | +| `.aidd/cache/trusted-marketplaces.json` | `MarketplaceTrustStoreAdapter` | +| `.aidd/cache/marketplaces/` | `MarketplaceCacheAdapter` | +| `.aidd/cache/built//` | cache de build | +| `.claude/ .cursor/ .github/ …` | adapter fichier, tracé via le manifest | + +`manifest.ts:142` : le registre marketplace a quitté le manifest v6 et vit dans `.aidd/marketplaces.json`. `ARCHITECTURE.md` documente encore l'ancienne forme. + +## Cycles internes — les deux sont cassables + +- `formats/command.ts` → `tools/contracts.ts` : deux types seulement, `UserFileSection` (ligne 11) et `UserFileSectionKey` (ligne 13). Import type-only. +- `capabilities/{rules,commands,skills}` → `tools/registry` : cycle accidentel via ré-export barrel. `AI_TOOL_IDS` est défini dans `models/tool-ids.ts` et ré-exporté par `registry.ts` ligne 21. Trois imports à repointer sur la source. + +## `use-cases/shared/` — 2 fichiers sur 14 sont partagés + +| Fichier | Appelants | Verdict | +|---|---|---| +| `resolve-marketplace` | 9 | partagé | +| `ensure-built-marketplace` | 5 | partagé | +| `resolve-update-decision` | 4 | interne | +| `update-one-tool` | 4 | interne | +| `post-install-pipeline` | 3 | interne | +| `gitignore` | 3 | interne | +| `apply-plugin-files` | 3 | interne | +| `detect-plugin-drift` | 2 | interne | +| `restore-drift-entries` | 2 | non partagé | +| `fetch-marketplace-source` | 1 | non partagé | +| `generate-tool-distribution` | 1 | non partagé | +| `resolve-restore-decision` | 1 | non partagé | +| `restore-merge-files` | 1 | non partagé | +| `restore-regular-files` | 1 | non partagé | + +## Use cases — 78 fichiers pour 34 commandes feuilles + +45 déclarations `.command()` dont 11 groupes parents. Trois natures sous un seul suffixe : vrais use cases adossés à une commande ; étapes et politiques que personne ne demande (~15) ; interaction, qui est de la présentation (`setup-tools-prompt`, `setup-plugins-prompt`, `plugin-pick`, `sync-conflict-resolver`, `project-context-detector`, `menu-use-case` 366 loc). + +Six dépassent la règle d'une responsabilité : `marketplace-sync-settings` 479, `menu` 366, `plugin-add` 307, `status` 219, `restore` 218, `uninstall-tools` 214. + +## Frontières mal placées + +- `use-cases/plugin/translator/` : 4 fichiers sur 6 importent `Manifest` et `Plugin`. C'est l'application de la traduction qui enregistre, donc `framework`, pas `translate`. +- `marketplace-check-use-case` diffe les catalogues contre `manifest.getPlugins(toolId)`. +- `marketplace-remove-use-case` supprime les fichiers de plugins puis appelle `manifest.removePlugin` et `manifestRepo.save`. +- `marketplace-sync-settings-use-case` (479 loc) écrit dans les fichiers de config d'outils. + +Ces trois derniers sont des chapeaux, pas des use cases de `distribution`. + +## Matérialiser ou pointer + +`plugin-translator-factory.ts:35` décide : `installScope === "user" || translationMode === "flat"` → matérialisation. + +| Outil | Mode | Mécanisme natif | +|---|---|---| +| claude | native, `.claude/plugins/`, translationMode marketplace | `extraKnownMarketplaces` | +| cursor | native, découverte plugin-locale, installScope user | plugin-local | +| copilot | native, `.github/plugins/`, `nativeActivation` | oui, avec réserves (copilot-cli#2249, #3088) | +| codex | native, `.codex/plugins/`, `nativeActivation` | user-global seulement | +| opencode | **flat**, préfixe `aidd-` | aucun | + +Code spécifique au mode flat : 831 loc (`flat-build-strategy` 335, `flat-hooks-merge` 227, `mode-b-flat-materialization-translator` 186, `flat-paths` 83). +Coût de la surveillance d'écart : ~1 544 loc (`doctor` 457, `restore` 472, `restore-*` partagés 326, `status` 219, `detect-plugin-drift` 70). + +## Découpage des capacités + +| Capacité | Consommateurs | Contexte | +|---|---|---| +| agents, skills, commands, rules | `install-*-use-case` de contenu + profils d'outil | translate | +| hooks | `tools/contracts`, `codex`, `config-capability` | translate | +| settings | `install-ide-config`, `install-config`, `install-ide-tool`, `install-runtime-config`, `vscode`, `copilot` | tools | +| mcp | `install-config`, helpers de plugin, translator flat | tools | +| plugins | translator + `marketplace-sync-settings` | scindée | + +## Code mort + +- Un seul fichier inatteignable depuis `src/cli.ts` : `domain/models/marketplace-entry.ts` (103 loc), ignoré explicitement par `knip.json`. +- `loadForeign()` : atteignable, jamais appelée en production ; déclarée par le port, bouchonnée par trois tests. +- `mcp-exclusion.ts` : 3 exports utilisés sur 7. Les 4 autres sont couverts par `tests/domain/models/mcp.unit.test.ts` — du comportement mort protégé par des tests vivants. +- `buildMergeFileEntries`, `UpdateAiToolsInput/Result`, `UpdateIdeToolsInput/Result` : zéro usage, même interne. + +## Comparaison Superpowers (obra/superpowers v6.3.0) + +Un dossier `skills/` de markdown partagé, dix manifestes par hôte de 500 à 1 700 octets qui pointent dessus : `.claude-plugin`, `.cursor-plugin`, `.codex-plugin`, `.opencode`, `.devin-plugin`, `.hermes-plugin`, `.kimi-plugin`, `.pi/extensions`, `.agents/plugins`, `gemini-extension.json`. La ligne utile du manifeste Cursor est `"skills": "./skills/"`. Les scripts de 15 et 10 Ko ne traduisent rien : `sync-to-codex-plugin.sh` est un rsync avec exclusions qui pousse dans le registre d'OpenAI et ouvre une PR. + +Limites de la comparaison : ils ne livrent que des skills et des hooks, soit les capacités qui ont convergé entre outils. AIDD livre huit natures, dont celles qui n'ont pas convergé. Et leur README impose une installation séparée par hôte, là où `setup` installe sur N outils d'un coup. + +## Issue #592 — la direction produit + +`feat(cli): project agents under .aidd/agents with materialize into tool trees` dit deux choses décisives : +- « Symlinking one file into every host tree fails when formats diverge and breaks drift/hash restore » — la matérialisation est la réponse assumée à la divergence des formats ; +- « Generated trees gitignored by default; CI/dev runs sync after checkout » — l'arbre généré est jetable, donc régénérable. + +C'est le même mécanisme que le `translate` générique : une source canonique, convertie vers chaque cible installée. + +## Enregistrer un marketplace : la CLI de l'outil ou son fichier de config ? + +Trois façons de faire coexistent, pour la même opération. + +| outil | mécanisme | +|---|---| +| copilot | `nativeActivation: { binary: "copilot" }` — pilote la CLI de l'outil | +| codex | `nativeActivation: { binary: "codex" }` — pilote la CLI de l'outil | +| **claude** | **écrit `.claude/settings.json` à la main** (`extraKnownMarketplaces`, `enabledPlugins`) | +| cursor | découverte plugin-locale, rien à enregistrer | + +`claude plugin marketplace add|list|remove|update` existe, vérifié dans l'aide de Claude Code. AIDD +édite donc à la main le fichier de configuration privé d'un outil qui expose une commande officielle +pour ça — et ce fichier est **co-possédé** : c'est le seul fichier tracé du profil Claude, celui +dont la dérive est apparue en phase 1. + +Le profil Copilot documente pourquoi il pilote la CLI : « Copilot treats enabledPlugins in +settings.json as a recommendation, not an auto-install (github/copilot-cli#2249) ». Autrement dit, +on est passé par la CLI **parce que le fichier ne suffisait pas**, pas par principe. Le profil Claude +ne porte aucun commentaire : le choix n'a pas été questionné. + +**L'arbitrage.** Écrire le fichier fonctionne sans que l'outil soit installé ; piloter sa CLI exige +sa présence mais s'appuie sur un contrat public plutôt que sur un format de fichier privé qui peut +changer sans préavis. La dépendance est déjà acceptée pour deux outils sur quatre. + +Décision produit, non tranchée. + +### Uniformiser sur la CLI de l'outil : tenté, mesuré, abandonné pour Claude + +Piloter `claude plugin marketplace add --scope project` a été implémenté puis retiré. Le golden a +dit pourquoi : l'empreinte de `.claude/settings.json` change et `status` rapporte le fichier +**modifié** là où il était en phase. + +La cause est nette. Cette commande **écrit elle-même dans `.claude/settings.json`**, après qu'AIDD +l'a écrit et a enregistré son empreinte au manifest. Deux écrivains, un seul qui enregistre : le +projet signale une dérive permanente. Sans `--scope project` c'est pire encore — la commande vise le +**user scope par défaut** et enregistrerait le marketplace globalement, pour tous les projets de la +machine. + +Codex et Copilot n'ont pas ce problème : leurs profils déclarent `marketplaceSettings: null` ou un +fichier que leur CLI ne réécrit pas. Ils sont pilotés parce que leur fichier de config **ne suffit +pas**, et le pilotage n'entre pas en conflit avec le suivi d'empreinte. + +Ce que ça révèle, au-delà du cas : `.claude/settings.json` n'est pas co-possédé avec *l'utilisateur* +mais avec *l'outil*. Suivre l'empreinte d'un fichier qu'un autre programme réécrit légitimement +fabrique de la fausse dérive. C'est une troisième catégorie, à côté des fichiers possédés et +co-possédés, et le régime à lui appliquer n'est tranché nulle part. + +### Cursor : sa CLI a évolué, mais pas dans le sens utile + +`cursor-agent plugin marketplace add|list|remove|update` existe désormais. Vérifié : `add` prend une +**URL de dépôt git** et `list` liste ce qui est « visible to this account » — un concept hébergé, +indexé côté serveur. AIDD construit un marketplace **local** ; cette commande ne peut pas le +prendre. La matérialisation plugin-locale actuelle de Cursor reste la bonne approche. + +## Activation native déclenchée pour un outil qui ne la déclare pas (2026-08-22) + +`aidd marketplace add cc anthropics/claude-code` sur un projet claude affiche : + +``` +Warning: Native plugin activation — build 'cc' for claude skipped: ENOENT: no such file or directory, +open '…/.aidd/cache/marketplaces/cc/github-anthropics-claude-code-HEAD/plugins/plugin-dev/.claude-plugin/plugin.json' +``` + +Le profil claude n'a pas de `nativeActivation` — l'activation native ne devrait pas s'exécuter pour +lui. Le `bestEffort` l'a rattrapée, donc rien n'a cassé, mais la branche prise n'est pas la bonne. +Repéré en instruisant la phase 5, qui touche exactement ce chemin. Non corrigé : hors du périmètre +tranché ce jour. + +## La suite smoke laisse des marketplaces derrière elle (2026-08-22) + +`copilot plugin marketplace list`, hors de tout projet : + +``` +Registered marketplaces: + • aidd-framework (Local: /private/var/folders/…/aidd-smoke-tools-XXXXXXXX.5XlhflGviM/proj.sxDyTW/.aidd/cache/built/aidd-framework/copilot) +``` + +Le répertoire n'existe plus. Les enregistrements de copilot sont **globaux à l'utilisateur**, pas au +projet, donc chaque exécution de la suite smoke en dépose un qui survit à la suppression du projet +temporaire. La suite est hermétique pour ce qu'elle écrit sous le projet, pas pour ce qu'elle fait +écrire aux outils. À corriger dans `scripts/smoke-tools.sh` : désenregistrer en fin de course. + +## Copilot porte le même défaut de partage que claude (2026-08-22) + +`.github/copilot/settings.json` reçoit lui aussi `extraKnownMarketplaces` avec des chemins absolus, +et il est committé. Mais le correctif n'a pas la même forme que pour claude : copilot n'a pas de +convention `settings.local.json` documentée, et la liste ci-dessus montre que son enregistrement +réel vit dans son magasin global — l'écriture du fichier projet est probablement redondante. Question +distincte, non traitée par la phase 5a. + +## `update` ne synchronise pas les marketplaces (2026-08-22, corrigé) + +`MarketplaceSyncSettingsUseCase` était appelée par `setup`, `install`, `marketplace add/remove/refresh` +et `plugin install` — pas par `update`, qui rafraîchissait le cache des marketplaces sans jamais en +informer les outils. Les deux vont ensemble, comme elles le sont déjà dans la commande +`marketplace refresh`. Un projet dont le fichier de réglages de l'outil a dérivé +n'est donc pas remis d'aplomb par la commande que l'utilisateur associe naturellement à « remets-moi +à jour ». Antérieur à la phase 5, repéré en la vérifiant. + +## Deux projets ne peuvent pas cohabiter dans le registre de copilot (2026-08-22) + +Les enregistrements de copilot sont **globaux à l'utilisateur et clés par nom**, alors qu'AIDD +enregistre un arbre construit qui vit **dans un projet**. Un seul emplacement pour le nom +`aidd-framework`, donc le premier projet le prend et le garde. Quand son répertoire disparaît, tous +les autres projets cassent : + +``` +Native plugin activation — enable plugin 'aidd-vcs@aidd-framework' skipped: + copilot plugin install aidd-vcs@aidd-framework failed: Failed to fetch marketplace: + Local marketplace path does not exist: …/aidd-smoke-tools-XXXXXXXX…/built/aidd-framework/copilot +``` + +La logique de reprise existe pourtant — `registerMarketplace` tente `add`, et sur conflit +désenregistre puis réenregistre. Elle est bloquée un cran plus loin : + +``` +Cannot remove marketplace "aidd-framework". +Installed plugins from this marketplace: aidd-context, aidd-vcs, aidd-pm, … +Use --force to remove the marketplace and uninstall all its plugins. +``` + +Copilot refuse de désenregistrer un marketplace dont des plugins sont installés. Le `--force` qu'il +propose **désinstalle tous ces plugins**, y compris ceux que l'utilisateur aurait installés +lui-même depuis ce marketplace. C'est pour ça que le correctif n'est pas pris ici : il détruit +quelque chose qui ne nous appartient pas. + +Forme proposée, à valider : lire le chemin actuellement enregistré, et ne reprendre l'emplacement +que s'il pointe vers un répertoire **qui n'existe plus** — un pointeur mort ne détruit rien. S'il +pointe vers un autre projet vivant, avertir avec la commande, ne pas voler. Cela demande une lecture +sur le port `NativePluginActivator`, ce que la tâche 2 de la phase 5 avait déjà anticipé. + +Au passage, une affirmation du code était fausse et a été corrigée : le commentaire de +`registerMarketplace` disait que la CLI ne rejette `add` que pour une source différente. Mesuré, +copilot rejette tout doublon de nom : `Marketplace "aidd-framework" already registered`. + +## Un marketplace de scope user atterrit dans le fichier d'un projet (2026-08-22) + +`aidd marketplace add usr … --scope user` l'enregistre dans le registre utilisateur d'AIDD, puis la +synchronisation écrit son entrée dans `.claude/settings.local.json` **du projet courant** — vérifié. +Le scope d'AIDD décrit donc où AIDD s'en souvient, pas où l'outil l'apprend. + +Claude accepte `--scope user` et écrit alors `~/.claude/settings.json` ; les trois autres n'ont pas +de scope du tout. Une réponse cohérente existe donc, mais elle ferait écrire AIDD dans le répertoire +personnel de l'utilisateur — exactement le genre d'écriture qui vient d'être retirée de la suite +smoke. Non prise sans arbitrage. + +## Le port `listDirectory` ne tenait pas sa forme sous Windows (2026-08-22, corrigé) + +`FileAdapter.listDirectory` renvoyait la sortie brute de `relative()`, donc séparée par des +antislashs sous Windows, alors que ses appelants comparent ces chemins à des chemins écrits avec des +`/` dans les profils et le manifest. Aucun test ne pouvait l'attraper : l'adaptateur en mémoire, lui, +a toujours produit des `/`, donc les deux implémentations divergeaient exactement là où personne ne +regardait. Le port déclare maintenant sa forme et l'adaptateur réel s'y tient. + +## L'outil clé son registre par le nom du manifeste, pas par le nôtre (2026-08-22) + +Deux marketplaces AIDD qui pointent sur la même source produisent deux arbres construits déclarant +le même `name` dans leur `marketplace.json`. L'outil les voit donc comme un seul, quel que soit le +nom qu'AIDD leur a donné et quel que soit leur scope. + +Mesuré, et c'est pire qu'un refus : le second **écrase** silencieusement le premier. Après +`marketplace add doublon `, la déclaration nommée `aidd-framework` pointe vers l'arbre +construit de `doublon`. Le dernier synchronisé gagne, sans un mot. + +**Arbitré : hors périmètre.** L'outil garde l'existante, c'est à l'utilisateur de ne pas déclarer +deux fois la même source. Une détection a été écrite puis retirée : elle lit le nom du catalogue +depuis `.aidd/cache/marketplaces/`, qui n'existe que pour les sources distantes, donc elle ne se +serait déclenchée qu'à moitié — silencieuse précisément dans le cas local où la collision arrive. +Une règle qui ment par omission est pire que pas de règle. La rendre fiable demanderait d'amener la +résolution de source dans la synchronisation. + +## Un marketplace de scope user se construit dans le projet qui l'enregistre (2026-08-22) + +`aidd marketplace add … --scope user` construit son arbre sous +`/.aidd/cache/built//`, et c'est ce chemin que la déclaration globale de l'outil +désigne. Supprimer ce projet tue donc une déclaration censée valoir pour tous. C'est la même maladie +que le registre global de copilot, un cran plus bas : un scope global qui pointe vers du local. +Un marketplace de scope user devrait se construire sous le répertoire de configuration utilisateur +d'AIDD. **Corrigé le 2026-08-22** : il s'y construit désormais, et la déclaration globale de l'outil +y pointe, indépendamment de tout projet. + +## `marketplace refresh` ne revoit pas une source locale modifiée (2026-08-22, corrigé) + +Après édition du `marketplace.json` d'une source locale, `refresh` affichait `Fetching marketplace …` +puis `ok`, mais l'arbre construit gardait l'ancien contenu ; il fallait supprimer +`.aidd/cache/built/` à la main. + +Cause : la fraîcheur se juge sur `:`. Pour une source publiée c'est +valable — un contenu différent porte une version différente. Pour un répertoire de cette machine, +non : on édite un fichier et la version ne bouge pas, ce qui est exactement le quotidien du +développement du framework. La version d'une source locale n'est donc plus crue, et un `refresh` +explicite ne l'est plus non plus. Une construction réelle coûte 0,4 s pour 434 fichiers, démarrage +de node compris, donc la réponse sûre est aussi la moins chère. + +Effet de bord traité au passage : les diagnostics de construction remontaient dès lors sur chaque +commande. Ils appartiennent à `aidd framework build`, où l'utilisateur a demandé une construction ; +la reconstruction de cache les trace désormais en `--verbose`. + +## Ce que le découpage doit faire bouger, mesuré (2026-09-01) + +245 fichiers, 22 326 lignes. Les six dossiers qui dépassent dix fichiers source directs : + +| dossier | fichiers | +|---|---| +| `domain/models` | 29 | +| `domain/ports` | 25 | +| `infrastructure/adapters` | 23 | +| `domain/formats` | 21 | +| `application/commands` | 16 | +| `use-cases/shared` | 14 | + +Et les quatre fichiers qui portent trop : + +| fichier | lignes | +|---|---| +| `use-cases/framework/strategies/tool-contracts.ts` | 820 | +| `infrastructure/deps.ts` | 743 | +| `use-cases/marketplace/marketplace-sync-settings-use-case.ts` | 543 | +| `domain/models/manifest.ts` | 529 | + +`tool-contracts.ts` est aussi dans la base du cliquet « coût d'un outil » : les deux mesures +désignent le même fichier, ce qui en fait la cible la plus rentable des extractions. + +## Le déterminisme des e2e, vérifié (2026-09-01) + +Aucun appel réseau, aucune dépendance à l'ordre, aucune horloge dans le golden. Les deux usages de +`Date.now()` sont légitimes : un nom de fichier temporaire unique, et un `checkedAt` que le test +fournit lui-même comme donnée d'entrée. Le seul vrai risque était la dépendance aux binaires +d'outils installés sur la machine, retirée en filtrant le `PATH` des runs bac à sable. diff --git a/cli/aidd_docs/tasks/2026_08/2026_08_20_refactor-contextes-cli/harnais.md b/cli/aidd_docs/tasks/2026_08/2026_08_20_refactor-contextes-cli/harnais.md new file mode 100644 index 000000000..7cc03c6d2 --- /dev/null +++ b/cli/aidd_docs/tasks/2026_08/2026_08_20_refactor-contextes-cli/harnais.md @@ -0,0 +1,205 @@ +# Harnais et garde-fous déterministes + +## Diagnostic + +Les garde-fous existent déjà et n'ont jamais bloqué. + +| Constat | Preuve | +|---|---| +| `knip` ne bloque pas | `cli-ci.yml` : `continue-on-error: true` sur le job `cli-knip` | +| `jscpd` ne bloque pas | idem sur `cli-jscpd`, et `pnpm jscpd` tourne sans seuil configuré | +| L'échappatoire a servi | `domain/models/marketplace-entry.ts` (103 loc, inatteignable) est listé dans `knip.json` `ignore` | +| La duplication est connue et tolérée | `jscpd` l'a détectée, elle est devenue l'issue #468, rien ne l'a bloquée | +| Aucun test d'architecture | aucun fichier de test ne vérifie direction de dépendance, frontière, cycle ou ré-export | +| Biome tourne à vide | `biome.json` n'active que `recommended: true` | + +Une règle est un conseil, un test est une barrière. La dérive s'est produite **alors que les règles +existaient** : la politique `shared/` était écrite et a été suivie fidèlement jusqu'au dépotoir. + +## Niveau 1 — Biome, sans dépendance ajoutée + +Quatre règles natives couvrent quatre invariants. + +| Règle | Groupe | Invariant couvert | Aurait attrapé | +|---|---|---|---| +| `noImportCycles` | suspicious | pas de cycle d'exécution | un vrai cycle, vérifié en en fabriquant un | +| `noReExportAll` | performance | un fichier n'exporte que ce qu'il définit | les 6 sites de ré-export, dont `registry.ts` et ses 8 symboles | +| `noBarrelFile` | performance | idem | idem | +| `noRestrictedImports` | style | frontières de contexte | toute arête latérale entre contextes | + +**Correction mesurée.** `noImportCycles` **n'aurait pas attrapé** les deux cycles trouvés à la main. +Vérifié dans les deux sens : il signale un cycle fabriqué exprès, et ne dit rien du code réel. La +raison est que ces cycles se referment par des `import type` — `tools/contracts.ts` importe les +capabilities en type seulement, `registry.ts` importe `contracts` en type. Il n'y a donc **pas de +cycle à l'exécution**, et Biome a raison de se taire. Ce sont des cycles de conception, pas de +runtime : leur gravité avait été surestimée. La règle reste utile pour les vrais cycles ; elle ne +garde pas nos frontières. + +Ce qui garde les frontières, c'est `noRestrictedImports` — stable depuis la 1.6, motifs façon +gitignore avec négation, message personnalisable, appliquée par dossier via `overrides`. Vérifiée +dans les deux sens : rien sur le domaine actuel, échec immédiat sur une violation volontaire. + +Conflit à traiter le moment venu : `noBarrelFile` interdit tout barrel, alors que la cible veut un +`index.ts` par contexte. Résolution par `overrides` — règle active partout sauf +`src/contexts/*/index.ts`. + +## Niveau 2 — Tests d'architecture + +Cinq tests que ne couvre aucun outil du marché, parce qu'ils sont propres à ce domaine. + +| Test | Ce qu'il assied | Aurait attrapé | +|---|---|---| +| `earned-sharing` | tout module partagé a des appelants dans ≥ 2 contextes | 12 des 14 fichiers de `use-cases/shared/` | +| `orchestrator-deps` | un chapeau ne dépend pas de plus de contextes qu'il n'en traverse | les 13 dépendances de `SetupUseCase` | +| `tool-addition-cost` | un identifiant d'outil n'apparaît que dans son profil et le kernel | les 3 unions parallèles et le `toolId === "opencode"` en dur | +| `docs-do-not-lie` | toute commande citée dans `ARCHITECTURE.md` et le README existe | `aidd sync` documenté et jamais déclaré ; `status --json` (#464) | +| `map-matches-tree` | l'arborescence de `codebase-map.md` correspond à `find src -type d` | la carte périmée, et la discipline manuelle qu'elle exige | + +Les trois derniers transforment de la documentation en assertion exécutable. C'est ce qui empêche +la doc de redevenir fausse sans qu'on s'en aperçoive. + +## État : niveau 2 livré + +Les cinq tests existent, passent, et le cliquet a été vérifié en introduisant une violation +volontaire (un fichier neuf nommant `"cursor"` fait échouer `tool-addition-cost` ; son retrait +rend le vert). + +``` +tests/architecture/ + graph.ts lecture du source comme texte, graphe d'imports, cliquet + earned-sharing.arch.test.ts 7 violations au cliquet + orchestrator-deps.arch.test.ts 2 violations (setup 6 use cases, doctor 5), seuil > 4 + tool-addition-cost.arch.test.ts 20 fichiers nomment un outil hors profil + docs-do-not-lie.arch.test.ts 0 violation après correction + codebase-map.arch.test.ts 0 violation après correction +``` + +Projet vitest dédié `architecture`, script `pnpm test:arch`, exécuté en pre-commit via lefthook +(`cli-architecture`). Durée mesurée : **238 ms** pour les cinq fichiers. Les tests ne font que lire +des fichiers, ils n'importent jamais le code sous test, donc rien ne peut les casser par câblage. + +### Deux mensonges corrigés au passage, trouvés par les tests eux-mêmes + +- `ARCHITECTURE.md` annonçait `aidd sync` dans sa surface de commandes. Ligne retirée. +- `codebase-map.md` omettait six dossiers réels : `display`, `translator`, `auth`, `git`, `http`, + et surtout `use-cases/framework/` avec son `strategies/` — soit 1 819 lignes, le plus gros dossier + de use cases, absent de la carte. Ajoutés. + +Le test `docs-do-not-lie` accepte une citation quand sa ligne marque la commande comme retirée, +en nie l'existence, ou est une ligne de tableau de migration associant l'ancienne à la nouvelle. +Pas de liste de noms à ignorer, qui deviendrait périmée le jour où une commande revient. + +### Non vérifié + +`pnpm lint` et `pnpm exec biome` échouent dans cet environnement avec « Linter process terminated +abnormally », y compris sur `--version` et hors bac à sable. Le binaire direct +(`./node_modules/.bin/biome`, version 2.5.8) fonctionne et ne remonte rien sur les nouveaux fichiers. +Le wrapper `pnpm exec` est en cause, pas Biome ni le code. + +### Effet de bord révélateur + +`pnpm typecheck` échouait sur `../kanban/src/**` tant que les dépendances de kanban n'étaient pas +installées : le typecheck du CLI dépend du `node_modules` d'un autre package, à cause de l'import +profond `../../../../kanban/src/…`. `lefthook.yml` documente déjà ce contournement dans +`cli-typecheck`. Argument supplémentaire pour le passage en lanceur. + +## Niveau 3 — Politique d'échappatoire + +- `continue-on-error` retiré de `cli-knip` et `cli-jscpd`. +- `jscpd` reçoit un seuil explicite et bloque au-delà. +- `knip.json` : `ignore` vide pour `src/`. Toute exception porte une raison et un numéro d'issue, + et un test vérifie que chaque entrée est justifiée. + +Une exception non justifiée fait échouer la CI. C'est le point qui manquait : les barrières +existaient, les exceptions n'étaient jamais relues. + +## État : niveaux 1 et 3 livrés + +### Niveau 1 — Biome + +`biome.json` active `noBarrelFile`, `noReExportAll`, `noImportCycles` et `noUnresolvedImports`, plus +un `override` qui interdit au domaine d'importer `application` ou `infrastructure`. Version alignée +sur celle installée (2.5.8, le `$schema` annonçait encore 2.4.7). + +506 fichiers vérifiés, zéro erreur. Une seule exception sanctionnée : `tests/helpers/**` est exempté +de `noBarrelFile` — c'est de l'infrastructure de test importée par 78 fichiers, délibérée et stable. + +**Un vrai ré-export trouvé et supprimé** : `doctor-use-case.ts` réexportait deux fonctions de +`domain/formats/markdown-references.js`, uniquement pour qu'un test les importe à travers le use +case. Le code de production les importait déjà directement. Le test pointe désormais la source ; le +ré-export a disparu. Un test qui déformait la production. + +### Niveau 3 — Échappatoires + +- `continue-on-error: true` retiré des jobs `cli-knip` et `cli-jscpd`. Ils bloquent désormais. +- `jscpd` reçoit un seuil : `--threshold 3.5`, pour une mesure actuelle de **3,43 %** (71 clones, + 772 lignes dupliquées sur 22 507). Vérifié : échec à 3.0, succès à 3.5. Toute augmentation bloque. +- `knip` ne signale plus rien. Le helper des tests d'architecture a été nommé `helpers.ts` pour + entrer dans le motif `tests/**/helpers.ts` déjà présent, plutôt que d'ajouter une exception. +- Reste dans `knip.json` `ignore` : `src/domain/models/marketplace-entry.ts`, qui disparaît en + phase 1 du plan de migration. C'est la seule entrée, et elle a une date de péremption. + +### Câblage + +- CI : nouveau job `cli / Architecture invariants` lançant `pnpm test:arch`. +- pre-commit : `cli-architecture`, restreint aux chemins qui peuvent invalider un invariant. + +## État de la mesure, vérifié le 2026-08-21 + +| Filet | Volume | Ce qu'il attrape | +|---|---|---| +| unitaire | 1 380 tests | domaine et use cases | +| intégration | 465 tests | adapters sur un vrai système de fichiers | +| e2e | 126 tests, 15 fichiers | le binaire réel | +| architecture | 6 tests, 238 ms | invariants, doc, carte, coût d'ajout d'un outil | +| smoke | 98 vérifications, 92 s | 36/36 commandes feuilles, hermétique | + +**Couverture de code : 91,3 % / 88,1 % / 91,0 % / 91,3 %**, seuils configurés 85/80/90/85. + +### Pourquoi la couche commandes est exclue de la couverture + +Elle l'était sans raison écrite. Vérifié : l'inclure fait tomber le total de 91,3 % à 82,0 % et +affiche `cli.ts` à **0 %** et `commands/` à **0,69 %** — alors que 126 tests e2e et 98 vérifications +smoke les exercent. Les deux lancent `dist/cli.js` en **sous-processus**, et la couverture v8 ne +traverse pas une frontière de processus. Les inclure produit un faux zéro, pas une mesure. +L'exclusion est conservée, avec cette raison désormais écrite dans `vitest.config.ts`. Leur filet +réel est l'e2e et le smoke, comptés à part. + +### Stryker : le premier blocage est levé, le second est diagnostiqué + +`tsconfigFile: ""` supprime le crash `TypeError: ts.parseConfigFileTextToJson is not a function` : +c'est le préprocesseur TSConfig de Stryker qui appelait une API que TypeScript 7 n'expose plus. + +Il atteint désormais son run initial et échoue plus loin, pour une autre raison : son runner lance +vitest, qui prend `vitest.workspace.ts` et exécute donc l'e2e — et le golden de build ne survit pas +au bac à sable de Stryker, où les chemins absolus diffèrent. Les options `vitest.dir`, +`vitest.related` et un fichier de configuration dédié ont été essayés : aucune ne restreint le run +initial. Le déblocage demande d'empêcher Stryker d'utiliser le workspace, ce qui n'a pas été fait. + +Reste donc utile pour la phase 14, avec un obstacle nommé au lieu d'un « cassé ». + +## Placement + +| Moment | Ce qui tourne | Pourquoi | +|---|---|---| +| pre-commit (lefthook) | biome (lint + format) et les tests d'architecture | ils ne font que lire des fichiers, donc c'est rapide, et le retour arrive là où il coûte le moins cher | +| CI | typecheck, lint, unit, integration, e2e, golden, knip, jscpd, budget de bundle | le complet, y compris ce qui est lent | + +Le pre-commit doit rester rapide : un hook lent finit contourné par `--no-verify`. + +## Le reste du harnais + +- **Règles** : les six règles d'architecture issues des invariants, plus les trois invariants cibles + ajoutés une fois qu'ils sont vrais (chaîne des contextes, `kernel`, entrée publique unique). +- **Mémoire** : `codebase-map.md` et `architecture.md` réécrits, et la carte devient vérifiée par + test plutôt que maintenue à la main. +- **Skills** : une par contexte, qui répond à « où ça va » en s'appuyant sur les invariants plutôt + qu'en les répétant. +- **Hooks** : lefthook porte le niveau 1 et le niveau 2 rapides. + +## Sources + +- https://biomejs.dev/linter/rules/no-restricted-imports/ +- https://biomejs.dev/linter/rules/no-re-export-all/ +- https://biomejs.dev/linter/rules/no-barrel-file/ +- https://biomejs.dev/linter/rules/no-import-cycles/ diff --git a/cli/aidd_docs/tasks/2026_08/2026_08_20_refactor-contextes-cli/marketplaces-heberges.md b/cli/aidd_docs/tasks/2026_08/2026_08_20_refactor-contextes-cli/marketplaces-heberges.md new file mode 100644 index 000000000..433e6df0d --- /dev/null +++ b/cli/aidd_docs/tasks/2026_08/2026_08_20_refactor-contextes-cli/marketplaces-heberges.md @@ -0,0 +1,76 @@ +# Héberger les marketplaces générées par outil + +Note de conception, pas une phase du refactor. Elle débloque la décision ouverte de la phase 5 et +donne sa forme au test d'acceptation de la phase 10. + +## Ce qui existe déjà + +**La CI publie les neuf distributions à chaque release.** `ci.yml` construit la matrice +outil × mode et attache `aidd-framework---.zip` à la release GitHub via +`gh release upload`. Les artefacts existent, sont versionnés, et sont accessibles publiquement. + +**Le CLI ne les consomme pas.** `setup --release ` choisit une release du *framework source*, +puis reconstruit localement dans `.aidd/cache/built//`. Le même build est donc fait deux +fois : une fois en CI pour publier, une fois chez chaque utilisateur pour installer. + +**Et l'intention est déjà écrite.** `docs/FAQ.md:42` : « Other tools install via their native +mechanism from the release archives; public-marketplace publishing is on the way, native parity is a +roadmap item. » + +## Pourquoi ça compte maintenant + +Les marketplaces construites sont des **chemins locaux**. C'est la seule raison pour laquelle les +outils ne peuvent pas être pilotés uniformément. + +| outil | ce que sa commande accepte | utilisable avec un chemin local | +|---|---|---| +| claude | URL, chemin, ou dépôt GitHub | oui | +| codex | snapshot de marketplace | oui (déjà piloté) | +| copilot | snapshot de marketplace | oui (déjà piloté) | +| cursor | **URL de dépôt git**, indexée par compte | **non** | + +Vérifié contre les CLI installées. Héberger les marketplaces sous une forme que les quatre commandes +acceptent rend l'enregistrement uniforme — et les quatre profils d'outil ne diffèrent plus que par +leurs chemins et leurs formats, ce que vise le test d'acceptation de la phase 10. + +## La question de forme + +Ce qui est publié aujourd'hui, ce sont des **zips**. Ce que les commandes veulent, c'est une **URL de +dépôt git** — Cursor l'exige, et c'est ce que Superpowers fait : `sync-to-codex-plugin.sh` pousse par +rsync dans `prime-radiant-inc/openai-codex-plugins` et ouvre une PR. + +Trois formes possibles, à trancher : + +1. **Un dépôt git par outil**, poussé à chaque release. Ce que les commandes attendent, ce que fait + Superpowers. Coût : quatre à neuf dépôts à créer, alimenter et versionner. +2. **Des branches d'un seul dépôt**, une par couple outil/mode. Un seul dépôt à gérer, mais toutes + les commandes n'acceptent pas une branche arbitraire — à vérifier outil par outil. +3. **Garder les zips et ne pas piloter les commandes.** Le CLI télécharge l'archive et enregistre + localement, comme aujourd'hui mais sans rebuild. Ne débloque pas Cursor, ne débloque pas la + phase 5. + +## Ce que ça débloquerait + +- **La décision ouverte de la phase 5.** Avec une URL, il n'y a plus de chemin local à pointer, donc + plus de dilemme entre « exiger le binaire » et « écrire le fichier en repli ». +- **Cursor piloté**, pour la première fois. +- **Un build au lieu de deux.** Le CLI cesse de reconstruire ce que la CI a déjà publié, ce qui + supprime `.aidd/cache/built/` du chemin d'installation courant. +- **La preuve du coût d'ajout d'un outil** (phase 10) devient réelle : un profil, une entrée de + publication, rien d'autre. + +## Ce que ça coûte, et ce qui reste ouvert + +- **Un projet hors ligne ne peut plus s'installer** sans réseau, là où un chemin local le permettait. + C'est la même question que la phase 5 pose, déplacée : garder un chemin local en repli, ou pas. +- **Le contenu devient public** par construction. Un framework privé ou d'entreprise ne peut pas + passer par un dépôt public — il faudrait alors les deux voies, pas une. +- **La révocation** : un marketplace enregistré par URL vit dans la config de l'outil, pas dans le + projet. Le retirer demande la commande de l'outil, pas un `rm`. +- **Qui publie, et quand** : à chaque release, ou seulement sur les versions stables ? Les neuf + cellules, ou seulement celles qu'un outil sait consommer ? + +## Prochain pas + +Trancher la forme (dépôt par outil, branches, ou zips), puis dimensionner. La phase 5 attend cette +réponse ; le reste du refactor n'en dépend pas. diff --git a/cli/aidd_docs/tasks/2026_08/2026_08_20_refactor-contextes-cli/phase-1.md b/cli/aidd_docs/tasks/2026_08/2026_08_20_refactor-contextes-cli/phase-1.md new file mode 100644 index 000000000..b757efefb --- /dev/null +++ b/cli/aidd_docs/tasks/2026_08/2026_08_20_refactor-contextes-cli/phase-1.md @@ -0,0 +1,168 @@ +--- +status: done +--- + +# Instruction: Extend the golden net + +The sixteen phases that follow are only verifiable if a behavior snapshot covers the surface they +touch. Today `snapshots/phase0/snapshot.json` holds **five invocations** — `setup`, `status`, +`restore --force`, `clean --force`, `status` — while the test's own docstring claims "each public +CLI command". Nothing else in this plan is safe until that gap closes. + +`captureMatrix` runs commands **sequentially in one project directory**. It is a scenario, not a +list of independent invocations: state accumulates and `clean --force` is terminal. Extending it is +a scenario design. + +Already normalized: absolute paths, the built-cache directory, version strings, CRLF, and manifest +file hashes recomputed over normalized content. Already covered elsewhere: `framework build`, whose +own golden spans the nine target/mode cells. + +## Architecture projection + +> Tree of the final files. ✅ create · ✏️ modify · ❌ delete + +```txt +. +└── cli/tests/golden/ + ├── golden-baseline.e2e.test.ts ✏️ modify (honest docstring, extended scenario, error scenario) + ├── help-surface.e2e.test.ts ✅ create (--help for every command and subcommand) + └── snapshots/ + ├── phase0/snapshot.json ✏️ modify (recaptured with UPDATE_GOLDEN=1) + └── help/surface.json ✅ create (the user-visible surface, frozen) +``` + +## User Journey + +```mermaid +flowchart TD + A[A contributor changes the CLI] --> B{Does the snapshot move?} + B -->|No| C[The change is behavior-neutral] + B -->|Yes| D[The diff shows exactly what changed] + D --> E[Reviewer accepts or rejects that behavior change] +``` + +## Test Scope + +```mermaid +--- +title: Test scope +--- +journey + section Setup + create a temp project and a fake home => hermetic project directory: 5: system + point setup at the local framework fixture => no network involved: 5: system + section Happy path + run setup then doctor, marketplace list, plugin list => each invocation recorded: 5: cli + install the local aidd-test plugin then list again => catalog and manifest recorded: 5: cli + install a second tool then read status => two equipped tools recorded: 5: cli + remove the plugin then clean the project => teardown path recorded: 5: cli + section Edge case - drifted project + a tracked file is overwritten => run status and doctor => drift reported in both: 1: cli + the same drift => run restore --force then status => project back in sync: 1: cli + section Edge case - no manifest + a directory was never set up => run doctor and status => non-zero exit with a clear message: 1: cli + section Edge case - malformed catalog + the marketplace-malformed fixture => add it as a marketplace => non-zero exit naming the file: 1: cli + section Teardown + capture twice in a row => the two snapshots are byte-identical: 5: system +``` + +## Tasks to do + +### `1)` Make the docstring honest + +> The file must not promise more than it holds. + +1. Replace "Each public CLI command is exercised" with what it does: one scenario over a hermetic + fixture project, plus an error scenario. +2. Name what it deliberately leaves out — see task 6. + +### `2)` Extend the main scenario + +> Keep the existing order; insert around it. + +1. After `setup`, capture `doctor`, `marketplace list` and `plugin list` on the fresh project. +2. Capture `plugin install aidd-test`, then `plugin list` again. The fixture serves it from + `./plugins/aidd-test`, a local source, so this stays offline. +3. Capture a second tool install, then `status` with two tools equipped. +4. Capture `plugin remove aidd-test` before the existing `restore --force`. +5. Leave `clean --force` and the post-clean `status` last: `clean` ends the scenario. + +### `3)` Capture drift + +> The mechanism `status` and `doctor` share is the one never captured. + +1. Between two captures, overwrite one tracked file with fixed content. It is not a command, so it + produces no entry; its effect shows in the next one. +2. Capture `status` and `doctor` on the drifted project. +3. Capture `restore --force`, then `status` again. + +### `4)` Add an error scenario in a second directory + +> `clean --force` is terminal, so error paths need their own project. + +1. Capture `doctor` and `status` on a directory with no manifest. +2. Capture a plugin install with no marketplace registered. +3. Capture a marketplace add pointing at `tests/fixtures/framework/marketplace-malformed`. +4. Capture an unknown tool id and an unknown command. +5. Prefix each entry's `command` with its scenario so both live in one snapshot file. + +### `5)` Freeze the user-visible surface + +> The eleven relocation phases have no other net for "nothing the user sees moved". + +1. Add `help-surface.e2e.test.ts`: walk the command tree from the root, capture `--help` for every + command and every subcommand, and store it as one snapshot. +2. It needs no fixture project and no network, so it is fast and runs everywhere. +3. From then on, a move that changes a description, a flag, an argument or an order fails + immediately, with the diff naming the command. + +### `6)` Prove the capture is deterministic + +> Reproducible at capture time is not the same as stable. + +1. Capture twice in a row and compare byte for byte. +2. Inspect the new entries for values `normalize()` does not handle. Timestamps are the likely leak, + since marketplace entries carry `addedAt` and `lastFetched`. Extend `normalize()` rather than + dropping the field. +3. Run the suite twice without `UPDATE_GOLDEN`. + +### `7)` Record what stays out of reach + +> An honest net names its holes. + +1. In the docstring, one line each: anything hitting the network, anything interactive, and + `framework build`, covered by its own golden. + +## Test acceptance criteria + +| Task | Acceptance criteria | +| ---- | ------------------- | +| 1, 7 | The docstring describes what the file covers and names what it does not; no claim exceeds the content | +| 2 | The snapshot holds an entry for `doctor`, `marketplace list`, `plugin list`, `plugin install`, `plugin remove` and a second tool install, on top of the existing five | +| 3 | The snapshot holds a `status` and a `doctor` taken on a drifted project, and a `status` after `restore --force` showing it back in sync | +| 4 | The snapshot holds at least four entries with a non-zero exit code, captured in a directory the main scenario never touched | +| 5 | The help snapshot holds an entry per command and per subcommand; changing one description fails the test, verified by changing one | +| 6 | Two consecutive captures are byte-identical, two consecutive verification runs pass, and no absolute path, version string or timestamp survives in the snapshot | +| all | The snapshot diff of this phase is pure addition, **or** an entry changed for a reviewed reason recorded here. A change with no such reason means the capture is not deterministic | + +## What this phase found + +Extending the net immediately produced two results the plan did not anticipate. + +**`aidd restore --force` was inert.** The command folded the flag into +`interactive = !force && isTTY`, and `RestoreAllUseCase` only took `interactive`, so a non-TTY run +always decided with `force: false`. A modified tracked file raised `InputRequiredError`, swallowed +into a warning telling the user to pass `--force` — which they had — while the command reported +"all files are unmodified" and `status` reported the same file modified. Fixed in its own commit; +that is why one existing snapshot entry changed, and its diff is the review. + +**Two invocations this phase wrote were wrong, and the capture said so.** +`plugin remove --yes` recorded `error: unknown option`, not a removal. Worth noting while fixing it: +`plugin install` accepts `--yes` silently and `plugin remove` rejects it, though neither declares it +and it is not a global option. + +**Every tracked file in this scenario is co-owned.** With claude and cursor installed, the manifest +tracks exactly one file per tool — their `settings.json`. `plugin install` writes no tracked file at +all: for both tools AIDD registers a locally built marketplace rather than copying. So this scenario +cannot exercise the CLI-owned regeneration regime; a flat-mode tool would be needed for that. diff --git a/cli/aidd_docs/tasks/2026_08/2026_08_20_refactor-contextes-cli/phase-10.md b/cli/aidd_docs/tasks/2026_08/2026_08_20_refactor-contextes-cli/phase-10.md new file mode 100644 index 000000000..1f0229e0f --- /dev/null +++ b/cli/aidd_docs/tasks/2026_08/2026_08_20_refactor-contextes-cli/phase-10.md @@ -0,0 +1,158 @@ +--- +status: done +--- + +# Instruction: Extract the tools context + +What the project targets, and how each target is configured. This is the phase that settles the +plan's acceptance test: adding a sixth tool must touch one file. + +Today it touches eight, and three of them are parallel unions of the same five values. Measured: +`AiToolId`, `PluginFormat` and `FrameworkBuildTarget` have exactly the same members, in different +order, with nothing checking that they agree. + +## Architecture projection + +> Tree of the final files. ✅ create · ✏️ modify · ❌ delete + +```txt +. +└── cli/src/contexts/tools/ ✅ create + ├── domain/ + │ ├── profiles/ ✅ create (claude, cursor, copilot, codex, opencode, vscode) + │ ├── registry.ts ✏️ modify (from domain/tools/) + │ ├── contracts.ts ✏️ modify (from domain/tools/) + │ ├── settings-capability.ts ✏️ modify (co-owned files) + │ ├── mcp-capability.ts ✏️ modify (co-owned files) + │ ├── mcp-exclusion.ts ✏️ modify (from domain/models/) + │ └── ports/ ✅ create (native-plugin-activator, file-merger) + ├── application/ ✏️ modify (install-tool, uninstall-tool, the three config installs) + └── infrastructure/ ✏️ modify (native-plugin-cli, codex-cli, copilot-cli) + +cli/src/application/use-cases/framework/strategies/tool-contracts.ts ❌ delete (820 l., split across profiles) +cli/src/domain/models/plugin-format.ts ✏️ modify (becomes derived) +cli/src/domain/models/framework-build.ts ✏️ modify (keeps only the mode type) +``` + +> **Frontière sans baril (tranché en phase 7).** Ce contexte n'a pas d'`index.ts`. La valeur de +> l'invariant est « rien n'importe l'intérieur d'un contexte », et un fichier de ré-exports n'est +> qu'un mécanisme — celui-là contredit `noBarrelFile` et le cliquet `no-re-export` à base vide. La +> frontière est tenue par un cliquet d'architecture qui liste les modules publics du contexte : une +> importation venue d'un autre contexte ne vise que cette liste. Voir `arborescence.md`, invariant 4. + +## User Journey + +```mermaid +flowchart TD + A[A sixth tool is supported] --> B[One profile file is written] + B --> C[It declares paths, formats, capabilities and its build contract] + C --> D[One registration line] + D --> E[Nothing else is edited] +``` + +## Test Scope + +```mermaid +--- +title: Test scope +--- +journey + section Setup + the tool-addition-cost ratchet lists twenty files => the target is measurable: 5: system + section Happy path + install and uninstall each supported tool => unchanged behavior: 5: cli + build for each surviving target => output byte-identical: 5: cli + merge settings and mcp into a project that already has its own => user entries preserved: 5: cli + section Edge case - a seventh tool, on paper + add a profile in a scratch branch => nothing outside it needs an edit => the ratchet stays empty: 1: system + section Teardown + the three parallel unions are gone => one source, two derived types: 5: system +``` + +## Tasks to do + +### `1)` Give each profile its build contract + +1. `tool-contracts.ts` holds nine `build*Contract()` functions for five tools. A tool's build + contract is a property of that tool: move each into its profile. +2. The 820-line file disappears. + +### `2)` Derive the unions + +1. `PluginFormat` and `FrameworkBuildTarget` have the same members as `AiToolId`. Make them aliases + or explicit subsets so the values are written once. +2. `FRAMEWORK_BUILD_TARGET_MODES` becomes derived: each profile declares its mode, since phase 5 + made the mode a property of the tool. + +### `3)` Move the co-owned configuration + +1. `settings-capability`, `mcp-capability` and `mcp-exclusion` describe files the user also owns. + They belong here, with the merge strategies that keep the user's entries. + +### `4)` Close the context + +1. Declare the context's public modules in the boundary ratchet, and add the biome `override` refusing imports into the interior. +2. Shrink the `tool-addition-cost` baseline to empty, or record what is left and why. + +## Test acceptance criteria + +| Task | Acceptance criteria | +| ---- | ------------------- | +| 1 | Building for each surviving target produces the same tree; no file outside the profiles names a tool | +| 2 | Changing the tool list in one place is enough; the derived types follow without a second edit | +| 3 | Installing into a project that already has its own `settings.json` and `.mcp.json` preserves the user's entries | +| 4 | An import into `contexts/tools/` interior fails the lint; the `tool-addition-cost` baseline is empty or justified line by line | +| all | Golden, build golden and e2e pass **unmodified** | + +## Livrée (2026-09-02) + +Les tâches 1, 3 et 4 étaient faites depuis `c67bcd6a` : les neuf contrats de build sont dans un +`build.ts` par outil, `tool-contracts.ts` a disparu, `settings`, `mcp` et `mcp-exclusion` sont dans +`tools`, et la frontière du contexte est déclarée et prouvée par injection. + +La tâche 2, elle, ne l'était pas. Les trois unions parallèles existaient toujours, écrites à la +main, avec un test de conformité qui vérifiait qu'elles s'accordaient — un détecteur, pas une +dérivation : ajouter un sixième outil demandait encore quatre éditions. + +Ce qui a changé : + +- `FrameworkBuildTarget` et `PluginFormat` sont des alias de `AiToolId`. Une cible de build est un + outil, un format est la mise en page qu'un outil donne à un plugin ; les réécrire créait une + deuxième liste à tenir. +- `FRAMEWORK_BUILD_TARGET_MODES` devient `frameworkBuildTargetModes()`, lue sur les profils : un + outil supporte un mode quand son profil déclare un contrat de build pour ce mode. Une fonction et + pas une constante, parce que le registre se remplit au câblage — une constante évaluée à l'import + aurait capturé un registre vide. Le câblage de `runtime` itère la même liste, donc les deux ne + peuvent plus diverger. +- Les emplacements de manifeste et de catalogue sont déclarés par chaque profil + (`distributionProbes`) et collectés par `translate`. + +L'ordre des sondes est un comportement, pas une présentation : le lecteur prend la première qui +résout, et copilot accepte un `plugin.json` nu à la racine, que n'importe quel répertoire peut +porter. Les sondes sont donc triées du chemin le plus profond au moins profond — la raison pour +laquelle l'ordre écrit à la main fonctionnait, dite explicitement. Un répertoire codex portant un +`plugin.json` racine était le cas discriminant : sans le tri il se lit `copilot`, et le test +d'intégration échoue exactement là. + +Deux tests changeaient de nature en devenant tautologiques. « chaque cible est un outil enregistré » +et « chaque format de sonde est un outil enregistré » ne peuvent plus être faux : ils sont remplacés +par une éprouvette de chaque dérivation sur des profils synthétiques, dont le cas qu'un registre +réel ne présentera jamais — un outil enregistré qui ne déclare aucun contrat de build. + +## Ce qui reste dans le socle, et pourquoi + +Sept fichiers nommaient un outil hors de son profil, il en reste trois, chacun pour une raison +différente et une seule est une dette : + +| Fichier | Pourquoi il reste | +| ------- | ----------------- | +| `tool-recommendations.ts` | Recommande des outils à un utilisateur par leur nom. Il n'y a pas de profil où lire « quel outil convient à quelle stack » : ce n'est la propriété d'aucun outil. | +| `config-refs.ts` | `CONFIG_OPENCODE = "opencode"` nomme un artefact de configuration, pas un outil. Il s'écrit comme un outil parce que l'artefact est son fichier de config ; c'est le profil d'opencode qui déclare le consommer. | +| `plugins-capability.ts` | `NativeActivation.binary` liste les trois CLI que ce dépôt a mesurées et pour lesquelles il a écrit un activateur. C'est une liste blanche assumée : un quatrième outil pilotant sa CLI devra de toute façon enregistrer un activateur pour ce binaire. | + +## Vérifié + +- 1987 tests, 982 suites, 0 échec — suites comptées, pas seulement les tests +- smoke : 98 pass, 0 fail, 22 / 22 commandes feuilles +- `aidd translate --to nope` répond `claude, cursor, copilot, opencode, codex`, dérivé des profils +- tsc 0, biome 0, build ok diff --git a/cli/aidd_docs/tasks/2026_08/2026_08_20_refactor-contextes-cli/phase-11.md b/cli/aidd_docs/tasks/2026_08/2026_08_20_refactor-contextes-cli/phase-11.md new file mode 100644 index 000000000..3fb5cfc1b --- /dev/null +++ b/cli/aidd_docs/tasks/2026_08/2026_08_20_refactor-contextes-cli/phase-11.md @@ -0,0 +1,160 @@ +--- +status: done +--- + +# Instruction: Extract the translate context + +The core. Converting one canonical source into what each tool expects, at every level: plugin +content into a tool's format, a framework source into a target-native distribution, paths, merges +and rewrites. + +It is the only thing the CLI does that a user cannot do without it, which is why it is a context and +not a service. + +## Architecture projection + +> Tree of the final files. ✅ create · ✏️ modify · ❌ delete + +```txt +. +└── cli/src/contexts/translate/ ✅ create + ├── domain/ + │ ├── capabilities/ ✏️ modify (agents, skills, commands, rules, hooks) + │ ├── formats/ ✏️ modify (markdown, command, placeholders, toml, paths, merges, rewrites) + │ ├── content-translator.ts ✏️ modify (from domain/models/plugin-content-translator.ts) + │ ├── canon.ts ✏️ modify (from domain/models/framework.ts) + │ └── build-target.ts ✏️ modify (what remains of framework-build.ts) + ├── application/ + │ └── translate-source.ts ✏️ modify (from use-cases/framework/, in place or to a distribution tree) + └── infrastructure/schema-validator.ts ✏️ modify +``` + +> **`jsonc` reste dans le noyau (phase 9).** La projection le listait ici. Il n'y va pas : +> `kernel/merge.ts` appelle `stripJsonComments`, donc le laisser dans un contexte rendrait +> insatisfiable la règle « le noyau n'importe aucun contexte », et le dupliquer serait pire que de +> le déplacer. Trente lignes pures, sans import. + +> **Frontière sans baril (tranché en phase 7).** Ce contexte n'a pas d'`index.ts`. La valeur de +> l'invariant est « rien n'importe l'intérieur d'un contexte », et un fichier de ré-exports n'est +> qu'un mécanisme — celui-là contredit `noBarrelFile` et le cliquet `no-re-export` à base vide. La +> frontière est tenue par un cliquet d'architecture qui liste les modules publics du contexte : une +> importation venue d'un autre contexte ne vise que cette liste. Voir `arborescence.md`, invariant 4. + +## User Journey + +```mermaid +flowchart TD + A[A canonical source] --> B[translate] + B --> C[Cursor .mdc] + B --> D[Codex TOML] + B --> E[Copilot .github/instructions] + B --> F[A distribution tree, or files written in place] +``` + +## Test Scope + +```mermaid +--- +title: Test scope +--- +journey + section Setup + the framework fixture and an installed project => both call sites exercised: 5: system + section Happy path + build a framework for every surviving target => output byte-identical: 5: cli + install a plugin into each tool => translated content identical to before: 5: cli + section Edge case - a format with no equivalent + a capability a target cannot represent => translate for that target => skipped with a clear message: 1: cli + section Teardown + the context imports tools and the kernel, nothing else => the chain holds: 5: system +``` + +## Tasks to do + +### `1)` Les capacités de contenu vont dans `tools`, pas ici — et voici pourquoi + +> La projection les envoyait dans `translate`. Mesuré, c'est ce qui créait l'inversion que la +> tâche 4 interdit. + +Une capacité de contenu chevauche la couture entre les deux contextes : `buildOutputPath` dit **où** +un outil range ses agents, savoir d'outil ; `convertFrontmatter` dit **comment** le contenu change de +forme, savoir de traduction. La mettre dans `translate` force `tools/domain/contracts.ts`, qui la +compose, à importer `translate`. La mettre dans `tools` semblait la forcer à importer `formats/`, +donc `translate`. Les deux placements paraissaient produire la même arête interdite. + +Le blocage n'était pas réel. Ce que ces capacités tirent de `formats/`, mesuré symbole par symbole : + +| capacité | ce qu'elle importe de `formats/` | +|---|---| +| agents | `parseFrontmatter`, `serializeFrontmatter` | +| skills, commands, rules | `serializeFrontmatter` | +| hooks | rien | + +Deux transformations pures sur du frontmatter, sans connaissance d'outil ni de cible. Et +`formats/markdown.ts` fait 139 lignes **sans un seul import**. C'est du vocabulaire partagé, pas de +la traduction — exactement l'argument qui a mis `jsonc.ts` dans le noyau en phase 9, et le précédent +vient de ce dépôt. + +1. `markdown.ts` va dans le noyau. Ses consommateurs sont déjà des deux côtés de la future frontière. +2. `agents`, `skills`, `commands`, `rules` et `hooks` rejoignent `settings` et `mcp` dans `tools` : + un outil déclare ce qu'il accepte et où il le range. `translate` lit ces déclarations. +3. La chaîne `translate → tools → kernel` tient alors sans découper `AiTool` ni rouvrir la phase 10. + +### `2)` Move the formats and the translator + +1. Everything under `domain/formats/` that survived phase 3, plus `plugin-content-translator.ts`. +2. `framework.ts` becomes `canon.ts`: it describes the canonical source shape, not a product. + +### `3)` Move the build, renamed for what it does + +1. `use-cases/framework/` becomes `translate-source`: one source, N targets, written in place or to + a distribution tree. The command keeps its current name until phase 18. + +### `4)` Close the context + +1. Declare the context's public modules in the boundary ratchet, and add the biome `override`. + Verify it depends on `tools` and the kernel and on nothing else. +2. Ajouter aussi l'override inverse : `src/contexts/tools/**` ne peut pas importer + `src/contexts/translate/**`. C'est l'arête que la phase 10 a laissée debout en promettant qu'elle + se résoudrait ici ; une promesse que rien ne vérifie n'est pas une garantie. L'éprouver par + injection, comme celui du noyau. + +## Test acceptance criteria + +| Task | Acceptance criteria | +| ---- | ------------------- | +| 1 | Installing a plugin produces the same files for every tool | +| 2 | Every format transform behaves as before; the build golden is unchanged | +| 3 | `framework build` still works, unchanged, under its current name | +| 4 | The context imports only `tools` and the kernel; an import into its interior fails the lint | +| all | Golden, build golden and e2e pass **unmodified** | + +## Livrée (2026-09-02) + +Les trois premières tâches étaient faites depuis le commit `77a8c6bf` : `markdown.ts` est dans le +noyau, les formats et le traducteur sont dans `translate`, `framework.ts` s'appelle `canon.ts`, et +`translate` n'importe que le noyau et `tools` (vérifié : toutes ses importations relatives pointent +vers `kernel/`, `tools/domain/` ou son propre domaine). + +La tâche 4 demandait d'éprouver les deux règles par injection plutôt que de les lire. C'est ce qui a +trouvé le défaut. L'override `tools` ne peut pas importer `translate` mordait bien. Celui de +`translate` ne mordait pas du tout : sa liste nommait `**/domain/models/**`, +`**/application/use-cases/**`, `**/infrastructure/adapters/**` et trois autres chemins que le +refactor avait déjà supprimés. La règle se lisait comme une frontière, ne correspondait à rien, et +laissait `translate` importer `framework`, `distribution`, `presentation` ou `runtime` sans un mot. + +Elle nomme désormais les quatre destinations interdites qui existent. Les six overrides sont +prouvés un par un, en écrivant l'import interdit et en regardant biome refuser : + +| Depuis | Import injecté | Message | +| ------ | -------------- | ------- | +| `translate/domain` | `../../framework/domain/manifest.js` | translate may import only the kernel and contexts/tools | +| `translate/domain` | `../../../runtime/wiring/translate.js` | idem | +| `tools/domain` | `../../translate/domain/plugin-format.js` | tools may not import translate | +| `distribution/domain` | `../../tools/domain/registry.js` | distribution knows no tool… | +| `kernel` | `../contexts/tools/domain/registry.js` | kernel must not import any context | +| `framework/domain` | `../application/restore/restore-use-case.js` | domain must not import application… | + +Et `tests/architecture/import-rules-bite.arch.test.ts` empêche la panne de revenir : chaque motif de +chaque override doit encore désigner un chemin présent sous `src/`. Le bug d'origine réinjecté le +fait échouer en nommant la ligne fautive. diff --git a/cli/aidd_docs/tasks/2026_08/2026_08_20_refactor-contextes-cli/phase-12.md b/cli/aidd_docs/tasks/2026_08/2026_08_20_refactor-contextes-cli/phase-12.md new file mode 100644 index 000000000..0c41cf680 --- /dev/null +++ b/cli/aidd_docs/tasks/2026_08/2026_08_20_refactor-contextes-cli/phase-12.md @@ -0,0 +1,94 @@ +--- +status: done +--- + +# Instruction: Extract the distribution context + +Where content comes from: registered marketplaces, their catalogs, their caches, and whether they +are trusted. After phase 8 moved the three cross-area flows out, it knows nothing about tools and +nothing about what is installed — it is a leaf, and this phase proves it. + +Its state left the manifest a while ago: `manifest.ts:142` records that the registry lives in +`.aidd/marketplaces.json`. + +## Architecture projection + +> Tree of the final files. ✅ create · ✏️ modify · ❌ delete + +```txt +. +└── cli/src/contexts/distribution/ ✅ create + ├── domain/ + │ ├── marketplace.ts ✏️ modify (entry, scope, staleness) + │ ├── cache-entry.ts ✏️ modify + │ ├── source-mode.ts ✏️ modify + │ ├── catalog.ts ✏️ modify (from domain/models/plugin-catalog.ts) + │ ├── catalog-parsers/ ✅ create (the Copilot-native reader from phase 8) + │ └── ports/ ✅ create (registry, cache, trust-store, catalog-repository, fetcher, raw-fetcher) + ├── application/ ✏️ modify (add, list, refresh, register-framework, resolve, fetch-source) + └── infrastructure/ ✏️ modify (registry, catalog-repository, fetcher, cache, trust, raw-fetcher) +``` + +> **Frontière sans baril (tranché en phase 7).** Ce contexte n'a pas d'`index.ts`. La valeur de +> l'invariant est « rien n'importe l'intérieur d'un contexte », et un fichier de ré-exports n'est +> qu'un mécanisme — celui-là contredit `noBarrelFile` et le cliquet `no-re-export` à base vide. La +> frontière est tenue par un cliquet d'architecture qui liste les modules publics du contexte : une +> importation venue d'un autre contexte ne vise que cette liste. Voir `arborescence.md`, invariant 4. + +## User Journey + +```mermaid +flowchart TD + A[A user names a source] --> B[Registered, with a scope] + B --> C[Fetched and cached] + C --> D[Trusted or refused] + D --> E[Its catalog is offered to whoever asks] +``` + +## Test Scope + +```mermaid +--- +title: Test scope +--- +journey + section Setup + a project and the local framework fixture => a source that needs no network: 5: cli + section Happy path + add, list and refresh a marketplace => unchanged behavior: 5: cli + resolve a catalog twice => the second read comes from cache: 5: cli + section Edge case - a malformed catalog + the marketplace-malformed fixture => refresh it => non-zero exit naming the file: 1: cli + section Edge case - an untrusted source + a source not yet trusted => resolve it => the trust decision is asked before any read: 1: cli + section Teardown + the context imports only the kernel => no tool profile, no manifest: 5: system +``` + +## Tasks to do + +### `1)` Move the sourcing domain and its ports + +1. The marketplace models, the catalog model and the Copilot-native parser. +2. The six ports it owns: `marketplace-registry`, `marketplace-cache`, `marketplace-trust-store`, + `plugin-catalog-repository`, `plugin-fetcher`, `raw-catalog-fetcher`. + +### `2)` Move the six use cases that stayed + +1. `add`, `list`, `refresh`, `register-framework`, `resolve`, `fetch-source`. The three that crossed + into the installation record left at phase 8. + +### `3)` Close the context and prove the leaf + +1. Declare the context's public modules in the boundary ratchet, and add the biome `override`. +2. Verify by import graph, not by reading: nothing under the context imports a tool profile or + `Manifest`. + +## Test acceptance criteria + +| Task | Acceptance criteria | +| ---- | ------------------- | +| 1 | Adding, listing, refreshing and removing a marketplace behave as before, including the trust prompt | +| 2 | A malformed catalog still fails with a message naming the file, and one bad catalog does not abort a multi-marketplace report | +| 3 | The context imports only the kernel; an import into its interior fails the lint | +| all | Golden and e2e pass **unmodified** | diff --git a/cli/aidd_docs/tasks/2026_08/2026_08_20_refactor-contextes-cli/phase-13.md b/cli/aidd_docs/tasks/2026_08/2026_08_20_refactor-contextes-cli/phase-13.md new file mode 100644 index 000000000..e2bce48cb --- /dev/null +++ b/cli/aidd_docs/tasks/2026_08/2026_08_20_refactor-contextes-cli/phase-13.md @@ -0,0 +1,111 @@ +--- +status: done +--- + +# Instruction: Extract the framework context + +What is installed here, at which version, and whether it is still true. It is the only context +allowed to call another, and it owns `manifest.json` and the tool files. + +This phase **moves only**. The aggregate keeps the shape it has today, defects included: 529 lines, +28 public methods, six responsibilities. Splitting it is phase 14, on its own, because a move and a +domain redesign in the same pass cannot both be reviewed. + +## Architecture projection + +> Tree of the final files. ✅ create · ✏️ modify · ❌ delete + +```txt +. +└── cli/src/contexts/framework/ ✅ create + ├── domain/ + │ ├── manifest.ts ✏️ modify (moved as-is, not yet split) + │ ├── plugin.ts ✏️ modify (moved as-is, renamed in phase 14) + │ ├── doctor.ts ✏️ modify + │ ├── install-scope.ts ✏️ modify + │ ├── setup-flow.ts ✏️ modify + │ ├── project-context.ts ✏️ modify + │ ├── semver.ts ✏️ modify + │ └── ports/ ✅ create (manifest-repository, plugin-distribution-reader) + ├── application/ + │ ├── flows/ ✏️ modify (setup, sync, update, and the three from phase 8) + │ └── cases/ ✏️ modify (install, uninstall, plugin *, materialize, status, doctor, clean, init) + └── infrastructure/ ✏️ modify (manifest-repository, plugin-distribution-reader, native plugin CLIs) +``` + +> **Frontière sans baril (tranché en phase 7).** Ce contexte n'a pas d'`index.ts`. La valeur de +> l'invariant est « rien n'importe l'intérieur d'un contexte », et un fichier de ré-exports n'est +> qu'un mécanisme — celui-là contredit `noBarrelFile` et le cliquet `no-re-export` à base vide. La +> frontière est tenue par un cliquet d'architecture qui liste les modules publics du contexte : une +> importation venue d'un autre contexte ne vise que cette liste. Voir `arborescence.md`, invariant 4. + +## User Journey + +```mermaid +flowchart TD + A[A developer sets up a project] --> B[The framework is installed into the chosen tools] + B --> C[The manifest records every file it wrote] + C --> D{Later: is it still true?} + D -->|Yes| E[Nothing to do] + D -->|No| F[Regenerate what the CLI owns, report what the user also owns] +``` + +## Test Scope + +```mermaid +--- +title: Test scope +--- +journey + section Setup + a project set up from the local fixture => manifest and tool files written: 5: cli + section Happy path + run setup, status, update, install and remove a plugin => unchanged behavior: 5: cli + section Edge case - a drifted generated file + a tracked file was edited => run restore --force => regenerated, no prompt: 1: cli + section Edge case - a drifted co-owned file + settings.json was edited by the user => run restore => the edit is reported, not overwritten: 1: cli + section Teardown + the context graph test passes => framework reaches translate and distribution, neither reaches back: 5: system +``` + +## Tasks to do + +### `1)` Move what is left + +> After four contexts leave, this context is what remains. + +1. The installation domain, its two ports, the flows and the cases. +2. Change no signature and no method. Anything tempting to fix here belongs to phase 14. + +### `2)` Close the context + +1. Declare the context's public modules in the boundary ratchet. This context is the only one + allowed to import another context's public modules. +2. Add the biome `override` refusing imports into the interior. + +### `3)` Turn the chain into a test + +> The invariant that carries the whole plan deserves more than a lint pattern. + +1. Add `tests/architecture/context-graph.arch.test.ts`: build the import graph, map each file to its + context, and assert the only edges are those `arborescence.md` invariant 2 allows — + `framework → translate`, `translate → tools`, `framework → distribution`, and every context to + the kernel. + + > Une première rédaction de cette tâche omettait `translate → tools`, l'arête que la phase 11 + > établit précisément. Un test écrit sur cette liste-là aurait refusé la structure voulue. + +2. Il remplace les `override` biome par une seule liste lisible d'arêtes autorisées. Les deux ne + doivent pas coexister en disant des choses différentes : soit le test devient la source unique et + les overlays partent, soit ils restent et le test se contente de ce qu'ils ne savent pas exprimer. + Trancher ici, et l'écrire. + +## Test acceptance criteria + +| Task | Acceptance criteria | +| ---- | ------------------- | +| 1 | Every command touching the installation record behaves as before; no public method changed | +| 2 | An import into `contexts/framework/` interior fails the lint | +| 3 | The context graph test lists the allowed edges and fails when a new one appears, verified by adding one | +| all | Golden, help snapshot and e2e pass **unmodified** | diff --git a/cli/aidd_docs/tasks/2026_08/2026_08_20_refactor-contextes-cli/phase-14.md b/cli/aidd_docs/tasks/2026_08/2026_08_20_refactor-contextes-cli/phase-14.md new file mode 100644 index 000000000..231bf979e --- /dev/null +++ b/cli/aidd_docs/tasks/2026_08/2026_08_20_refactor-contextes-cli/phase-14.md @@ -0,0 +1,113 @@ +--- +status: done +--- + +# Instruction: Split the Manifest aggregate + +`Manifest` is 529 lines and 28 public methods covering six responsibilities: tools, tracked files, +merge files, mcp exclusions, plugins, serialization. None can change without reopening the same +file. It is a facade over a JSON document, not an aggregate. + +This phase changes the domain and moves nothing. It is separate from phase 13 so its diff is +readable: one shows files arriving, the other shows a model changing shape. + +Two smaller defects go with it. `FileHash` exists as a proper value object with `equals()`, and yet +the installed record carries three `ReadonlyMap` of different meanings, told apart +only by a comment — the compiler sees the same type in all three. And `Plugin` alone does not say +which of the five plugins it is. + +## Architecture projection + +> Tree of the final files. ✅ create · ✏️ modify · ❌ delete + +```txt +. +└── cli/src/contexts/framework/domain/ + ├── manifest.ts ✏️ modify (aggregate root: identity and consistency only) + ├── tool-entry.ts ✅ create (one tool's slice of the record) + ├── tracked-files.ts ✅ create (paths and hashes) + ├── merge-files.ts ✅ create (co-owned file entries) + ├── mcp-exclusions.ts ✅ create (from the manifest's four methods) + ├── installed-plugin.ts ✏️ modify (from plugin.ts, renamed and typed) + └── manifest-serialization.ts ✅ create (toJSON / fromJSON, out of the entity) +``` + +## User Journey + +```mermaid +flowchart TD + A[A command changes what is installed] --> B[It asks the aggregate root] + B --> C[The root delegates to the member that owns it] + C --> D[One save, one consistent document] +``` + +## Test Scope + +```mermaid +--- +title: Test scope +--- +journey + section Setup + a project with two tools, plugins, merge files and an mcp exclusion => every member populated: 5: cli + section Happy path + run every command that reads or writes the record => unchanged behavior: 5: cli + write the manifest twice with no change between => byte-identical output: 5: system + section Edge case - a partial failure + a write fails mid-flow => read the manifest => it is the last consistent state, not a half-written one: 1: system + section Edge case - the three maps + pass a component-path map where a hash map is expected => it does not compile: 1: system + section Teardown + the aggregate exposes fewer than ten methods => the six responsibilities live in their own files: 5: system +``` + +## Tasks to do + +### `0)` Mesurer avant de toucher + +> `stryker.conf.json` mute exactement un fichier, `src/domain/models/manifest.ts`, avec un seuil de +> rupture à 50. C'est la meilleure preuve disponible que les tests de cet agrégat attrapent un +> changement — ce dont cette phase a besoin avant de le redécouper. + +1. La réparation de Stryker appartient à la phase 9, avec le reste du harnais : une mesure prise + après le redécoupage ne prouverait rien sur le redécoupage. Ici on l'utilise. +2. Enregistrer le score **avant** le découpage, puis après. L'écart entre les deux est la revue. +3. Si la phase 9 a conclu que la réparation est impossible, elle l'a écrit et a nommé ce qui la + remplace. Le test d'aller-retour de la tâche 4 est ce remplacement, et il est plus faible : il + prouve que la sortie est stable, pas que les tests remarqueraient un changement de comportement. + +### `1)` Separate the members + +> One save, one invariant, one file per responsibility. + +1. `Manifest` keeps identity, consistency and the entry point to its members. +2. `ToolEntry` carries tracked files, merge files, mcp exclusions and installed plugins. +3. Serialization leaves the entity: `toJSON` and `fromJSON` become their own module. + +### `2)` Type the three maps + +1. Path to hash, installed path to component path, mcp server name to digest. Three distinct types, + so one can no longer be passed where another is expected. `FileHash` already shows the shape. + +### `3)` Rename by intention + +1. `Plugin` becomes `InstalledPlugin`. The catalog entry and the fetched payload keep their own + names, so each context speaks of its own plugin without ambiguity. + +### `4)` Prove the round-trip did not move + +> The strongest available net for a model change: the document on disk must be identical. + +1. Add a test that loads every manifest fixture, writes it back, and asserts the bytes are unchanged. +2. Run it before and after the split. This is what makes the phase reviewable. + +## Test acceptance criteria + +| Task | Acceptance criteria | +| ---- | ------------------- | +| 0 | Either a mutation score for `manifest.ts` is recorded before the split, or the phase records why it cannot be and what stands in its place | +| 1 | Every command touching the record behaves as before; one save still writes one consistent document | +| 2 | Passing one of the three maps where another is expected fails to compile, verified by trying | +| 3 | No type named `Plugin` alone remains | +| 4 | Loading and rewriting every manifest fixture produces byte-identical output, before and after | +| all | Golden, help snapshot and e2e pass **unmodified** | diff --git a/cli/aidd_docs/tasks/2026_08/2026_08_20_refactor-contextes-cli/phase-15.md b/cli/aidd_docs/tasks/2026_08/2026_08_20_refactor-contextes-cli/phase-15.md new file mode 100644 index 000000000..a90945bad --- /dev/null +++ b/cli/aidd_docs/tasks/2026_08/2026_08_20_refactor-contextes-cli/phase-15.md @@ -0,0 +1,119 @@ +--- +status: done +--- + +# Instruction: Drop the manifest version migrations + +`manifest.ts` carries five migration functions, `migrateV1toV2` through `migrateV5toV6`, plus fields +kept only so a legacy manifest round-trips. A comment at line 89 says the block must stay "until all +users have upgraded past v1". + +A domain entity that knows every past shape of its own JSON is carrying a persistence concern. The +decision is to remove them, not relocate them: the reachable versions are behind us. + +This is the one deletion that changes what the CLI **accepts**, not just what it contains. It is +therefore placed late and deliberately: nothing in this plan depends on it, so it can be postponed +by its own opening check without holding anything back. + +It also comes after phase 14, so the migrations are removed from an aggregate that has already been +split — a smaller file, a smaller diff, and the round-trip test written in phase 14 is available to +prove the removal changed no output for a supported manifest. + +## Architecture projection + +> Tree of the final files. ✅ create · ✏️ modify · ❌ delete + +```txt +. +└── cli/ + ├── src/domain/models/manifest.ts ✏️ modify (drop 5 migrations, legacy fields, VSCODE_MIGRATION_PATHS) + ├── tests/domain/models/manifest.unit.test.ts ✏️ modify (drop the legacy round-trip cases) + └── README.md ✏️ modify (state the minimum manifest version accepted) +``` + +## User Journey + +```mermaid +flowchart TD + A[A project has a .aidd/manifest.json] --> B{Is it version 6?} + B -->|Yes| C[Loaded] + B -->|No| D[Refused with a message naming the version and the way out] +``` + +## Test Scope + +```mermaid +--- +title: Test scope +--- +journey + section Setup + a project set up by the current CLI => manifest is v6: 5: cli + section Happy path + run status, doctor and restore => manifest loads and behaves as before: 5: cli + section Edge case - an older manifest + a v5 manifest on disk => run any command that reads it => refused, message names the version: 1: cli + the same project => run setup again => a fresh v6 manifest is written: 1: cli + section Teardown + manifest.ts holds one shape => no migration function remains: 5: system +``` + +## Tasks to do + +### `0)` Check before removing + +> The only task in this plan that can lose user data if skipped. + +1. **Répondu le 2026-09-02.** La version 6 est arrivée le **2026-05-09**, commit `273573fc` + « drop dead marketplaces aggregate (v5→v6 migration) », embarquée dans **4.1.0-beta.25**. La + version publiée aujourd'hui est **5.2.1** : bientôt quatre mois et une version majeure entière. + + Un manifest antérieur appartient donc à un projet qui n'a pas vu AIDD depuis quatre mois. + +2. **Mais l'ancienneté n'est pas la question, et le risque n'est pas où la tâche le cherchait.** + Le porteur d'un vieux manifest a aussi un vieux CLI, qui sait encore migrer. Le danger apparaît + quand il met le CLI à jour **d'abord** : `self-update` l'amène en 5.x, il ouvre son projet, et le + CLI qui vient d'arriver ne sait plus lire ce qu'il aurait su lire une minute plus tôt. + + La garde de version qu'il faut conserver ne doit donc pas seulement refuser : elle doit nommer + **la dernière version capable de migrer**, pour que l'utilisateur redescende, migre, puis remonte. + Un message qui dit « version non supportée » sans dire par quoi la supporter transforme un + problème réversible en impasse. + +3. Si ce message ne peut pas être écrit avec certitude, ne pas supprimer les migrations. +2. If any doubt remains, stop and report. Postponing costs nothing: this phase is the only one no + other phase waits for, which is why it sits here. + +### `1)` Remove the migrations + +1. Delete `migrateV1toV2` through `migrateV5toV6`, `VSCODE_MIGRATION_PATHS`, and the fields retained + only for legacy round-trip. +2. Keep the version guard, et son message nomme la dernière version qui savait migrer — voir la + tâche 0. Refuser sans dire par quoi remplacer le refus est une impasse, pas un garde-fou. +3. Drop the legacy round-trip cases from the manifest unit test, keep the version-guard ones. + +### `2)` Say it in the README + +1. One line: the minimum manifest version the CLI reads, and what to run when an older one is found. + +## Test acceptance criteria + +| Task | Acceptance criteria | +| ---- | ------------------- | +| 0 | The check is recorded in the phase or the phase is postponed with a reason | +| 1 | A v6 manifest loads and every command behaves as before; a v5 manifest is refused with a message naming the version | +| 1 | `manifest.ts` contains no function whose name starts with `migrate` | +| 2 | The README states the minimum version and the way out | +| all | Golden and e2e pass unmodified: no fixture carries a manifest below v6 | + +## Ce que la mutation dit de ce code (2026-09-02) + +Mesuré après le découpage de la phase 14 : sur 109 mutants survivants, **82 sont dans +`manifest.ts`**, et leurs plus gros amas sont exactement les fonctions que cette phase supprime — +`migrateV3toV4` (10), `migrateV4toV5` (5), `migrateV2toV3` (4), plus les gardes qui les entourent. + +Deux conséquences. D'abord, ce n'est pas une dette à rembourser avant de supprimer : écrire des tests +pour du code qui part serait du travail perdu. Ensuite, le score de mutation devrait monter +nettement après cette phase **sans qu'un seul test soit écrit** — et si ce n'est pas le cas, c'est +que la suppression a emporté autre chose que les migrations. C'est le contrôle le moins cher de +cette phase. diff --git a/cli/aidd_docs/tasks/2026_08/2026_08_20_refactor-contextes-cli/phase-16.md b/cli/aidd_docs/tasks/2026_08/2026_08_20_refactor-contextes-cli/phase-16.md new file mode 100644 index 000000000..45500cf47 --- /dev/null +++ b/cli/aidd_docs/tasks/2026_08/2026_08_20_refactor-contextes-cli/phase-16.md @@ -0,0 +1,87 @@ +--- +status: done +--- + +# Instruction: Separate presentation from runtime + +What was called the shell mixed two layers. Presentation is not a technical leftover: commands +(1746 l.), display (139 l.), the interactive menu (366 l.) and the prompts add up to roughly 2 600 +lines — and part of it currently sits under `use-cases/`, where a prompt was called a use case. + +Runtime is the other half: wiring, http, git, platform, auth, self-update. `deps.ts` alone is 733 +lines and becomes one wiring module per context. + +## Architecture projection + +> Tree of the final files. ✅ create · ✏️ modify · ❌ delete + +```txt +. +└── cli/src/ + ├── presentation/ ✅ create + │ ├── commands/ ✏️ modify (from application/commands/) + │ ├── display/ ✏️ modify (from application/display/) + │ ├── prompts/ ✅ create (setup-tools, setup-plugins, plugin-pick, conflict, menu) + │ ├── output.ts ✏️ modify + │ └── error-handler.ts ✏️ modify + └── runtime/ ✅ create + ├── wiring/ ✅ create (one module per context) + ├── auth/ ✏️ modify (credential-store, oauth-provider, token-provider) + ├── prompter/ ✏️ modify (the prompter port and its adapter) + ├── http/ git/ platform/ project-root/ self-update/ ✏️ modify + └── deps.ts ❌ delete (733 l., split across wiring/) +``` + +## User Journey + +```mermaid +flowchart TD + A[A user runs a command] --> B[Presentation parses and asks] + B --> C[A context does the work] + C --> D[Presentation renders the result] + E[Runtime wires the two together] --> C +``` + +## Test Scope + +```mermaid +--- +title: Test scope +--- +journey + section Setup + a terminal without a TTY => the non-interactive path is exercised: 5: cli + section Happy path + run every command with --yes => same stdout, same exit codes: 5: cli + run the interactive menu with a TTY => same choices, same outcomes: 5: cli + section Edge case - a conflict during install + a co-owned file was edited => install the same content => the conflict is asked, not assumed: 1: cli + section Teardown + no prompt lives under a context => interaction is presentation only: 5: system +``` + +## Tasks to do + +### `1)` Move the interaction out of the contexts + +1. `setup-tools-prompt`, `setup-plugins-prompt`, `plugin-pick`, `sync-conflict-resolver` and + `menu-use-case` ask the user. They are presentation, not use cases. +2. What remains in a context is the decision the answer feeds. + +### `2)` Split the wiring + +1. `deps.ts` becomes one wiring module per context, each assembling only what its context needs. +2. `createMenuDeps` keeps its role: the pre-parse subset, which the current rule already describes. + +### `3)` Gather the runtime + +1. auth, http, git, platform, project-root and self-update are technical services, not a context. + +## Test acceptance criteria + +| Task | Acceptance criteria | +| ---- | ------------------- | +| 1 | Every interactive flow behaves as before, with and without a TTY; no context contains a prompt | +| 2 | Each context can be wired without pulling another's adapters; the pre-parse path still does no extra I/O | +| 3 | `presentation` and `runtime` import contexts; no context imports either | +| all | Golden and e2e pass **unmodified**, including the TTY persona test | diff --git a/cli/aidd_docs/tasks/2026_08/2026_08_20_refactor-contextes-cli/phase-17.md b/cli/aidd_docs/tasks/2026_08/2026_08_20_refactor-contextes-cli/phase-17.md new file mode 100644 index 000000000..65d7e5697 --- /dev/null +++ b/cli/aidd_docs/tasks/2026_08/2026_08_20_refactor-contextes-cli/phase-17.md @@ -0,0 +1,140 @@ +--- +status: done +--- + +# Instruction: Turn kanban into a launcher + +`commands/kanban.ts` imports `../../../../kanban/src/presentation/…`, a deep path into another +package. The consequences are measured: `cli/package.json` declares `ink`, `react`, `cli-table3` and +`gray-matter`, none of which `cli/src` imports — they are listed in `knip.json` as ignored +dependencies for exactly that reason. And `pnpm typecheck` fails on `../kanban/src/**` unless +kanban's own dependencies are installed, which `lefthook.yml` already documents as a workaround. + +kanban only ever needed `DOCS_DIR`. The CLI should locate and run it, not contain it. + +## Architecture projection + +> Tree of the final files. ✅ create · ✏️ modify · ❌ delete + +```txt +. +└── cli/ + ├── src/launchers/kanban.ts ✅ create (locate the binary, execute it) + ├── src/presentation/commands/kanban.ts ✏️ modify (no deep import) + ├── package.json ✏️ modify (drop ink, react, cli-table3, gray-matter) + ├── knip.json ✏️ modify (drop the four ignored dependencies) + └── ../lefthook.yml ✏️ modify (cli-typecheck no longer needs kanban's node_modules) +``` + +## User Journey + +```mermaid +flowchart TD + A[aidd kanban] --> B{Is the binary reachable?} + B -->|Yes| C[It runs, the board opens] + B -->|No| D[A message names the path that was tried] +``` + +## Test Scope + +```mermaid +--- +title: Test scope +--- +journey + section Setup + a project with aidd_docs => there are tasks to show: 5: cli + section Happy path + run aidd kanban list => the same rows as before: 5: cli + section Edge case - the binary is missing + kanban is not installed => run aidd kanban => a message names the path that was tried: 1: cli + section Teardown + typecheck the CLI without kanban's node_modules => it passes: 5: system +``` + +## Livrée autrement que prévu (2026-09-02) + +La tâche 1 disait « remplacer l'import profond par un lanceur qui trouve le binaire et l'exécute ». +Il n'y a pas de binaire : `@ai-driven-dev/kanban-source` est `private`, sans version, sans `main`, +sans `exports`, sans `bin`, sans build, et `kanban/src/` ne contient aucun fichier d'entrée — +seulement des fonctions qui enregistrent des commandes dans un programme hôte. Kanban est une +bibliothèque, pas un programme. + +### Ce que la phase voulait vraiment + +Que le CLI cesse de porter les dépendances d'une interface texte. `tsup` déclare +`skipNodeModulesBundle: true`, donc elles ne sont pas empaquetées : elles étaient chargées à chaque +invocation d'`aidd`, pour une commande `hidden`. + +| dépendance | poids direct | +|---|---| +| `ink` | 1,1 Mo | +| `react` | 252 Ko | +| `gray-matter` | 80 Ko | +| `cli-table3` | 68 Ko | + +### Ce qui a été fait, et pourquoi pas ailleurs + +Différer **dans kanban**, pas dans le CLI. Ses deux fichiers de commandes et son dépôt de documents +chargent maintenant `ink`, `react`, `cli-table3` et `gray-matter` dans le corps de leurs actions. +Les fonctions d'enregistrement restent importables immédiatement, donc commander connaît ses +sous-commandes au parsing. + +Deux tentatives ont échoué avant celle-là, et chacune apprend quelque chose : + +1. **Différer côté CLI, par un hook `preSubcommand`.** Impossible : commander parse avant que le + hook ne se déclenche, et `aidd kanban list` répond `too many arguments for 'kanban'`. +2. **Différer sans activer le découpage.** Silencieusement inefficace : avec `splitting: false`, + esbuild replie un `import()` en import statique. Le code semble paresseux et ne l'est pas. + `splitting: true` est donc nécessaire, et son commentaire dans `tsup.config.ts` dit pourquoi. + +### Vérifié par profil, pas par lecture + +Un profil CPU d'`aidd --help` montre les quatre absentes du démarrage, là où `gray-matter` y était +encore après la première passe. Bundle principal de 402,9 à 389,8 Ko, `aidd --help` à 133 ms, et les +trois chemins de la commande répondent : `kanban --help` liste ses deux sous-commandes, `kanban list` +et `kanban list --json` fonctionnent. Kanban : 68 tests, 25 suites. + +### Ce qui reste, et qui t'appartient + +Les quatre restent **déclarées** dans `cli/package.json`, donc encore téléchargées à l'installation. +Les en sortir demande de décider ce qu'il advient d'`aidd kanban` chez quelqu'un qui ne les a pas — +message clair et commande indisponible, ou kanban publié à part avec son propre `bin`. Kanban +déclare déjà les quatre de son côté, donc la duplication est prête à disparaître le jour où la +question est tranchée. + +Le coût de démarrage, lui, est payé une fois pour toutes. + +## Tasks to do + +### `1)` Locate and execute + +1. Replace the deep import with a launcher that finds the binary and runs it. +2. On failure, name the path that was tried — a launcher that fails silently is worse than none. + +### `2)` Drop the four dependencies + +1. `ink`, `react`, `cli-table3` and `gray-matter` leave `cli/package.json`, and their entries leave + `knip.json`. + + > Knip signale déjà `@types/react` et `ink-testing-library` comme inutilisées : cette tâche est + > ce qui les fait disparaître. Il signale aussi `@commitlint/cli`, et c'est un **faux positif** — + > `lefthook.yml` l'appelle, fichier que knip ne lit pas. Ne pas la supprimer : lui apprendre où + > regarder. La CI masque les trois aujourd'hui derrière `--exclude exports,types`, ce qui rend + > l'outil aveugle à ce qu'il devrait garder ; retirer l'exclusion une fois les vraies mortes + > parties. +2. Note the drop in the bundle budget: it is a verifiable gain, not a claim. + +### `3)` Simplify the hook + +1. `cli-typecheck` no longer needs to install kanban's dependencies. Remove the workaround and its + comment. + +## Test acceptance criteria + +| Task | Acceptance criteria | +| ---- | ------------------- | +| 1 | `aidd kanban` and `aidd kanban list` behave as before; a missing binary gives a message naming the path | +| 2 | `cli/src` imports none of the four packages, and `knip.json` ignores no dependency | +| 3 | `pnpm typecheck` passes with `kanban/node_modules` absent | +| all | The bundle is smaller than before, measured by `check-bundle-size.mjs` | diff --git a/cli/aidd_docs/tasks/2026_08/2026_08_20_refactor-contextes-cli/phase-18.md b/cli/aidd_docs/tasks/2026_08/2026_08_20_refactor-contextes-cli/phase-18.md new file mode 100644 index 000000000..7ce456c66 --- /dev/null +++ b/cli/aidd_docs/tasks/2026_08/2026_08_20_refactor-contextes-cli/phase-18.md @@ -0,0 +1,141 @@ +--- +status: done +--- + +# Instruction: Move the command surface, by alias + +Last, and by alias, for one reason: the e2e net invokes the CLI. Renaming breaks it at the moment it +is most needed. The new surface arrives beside the old, the tests move, the snapshot is recaptured, +then the old spelling goes. + +The grammar is not invented: it is what Claude Code and Codex both follow without exception. A bare +verb performs an action; a noun then a verb manages a resource. `claude doctor` and `codex update` +act on the CLI; `claude plugin install` and `codex plugin add` manage a resource. + +Today the same verb is declared four times — `update`, `status`, `list`, `doctor` — because the +grouping is by object. And `ai` and `ide` expose the same seven verbs for what is one subject. + +## Architecture projection + +> Tree of the final files. ✅ create · ✏️ modify · ❌ delete + +```txt +. +└── cli/ + ├── src/presentation/commands/ + │ ├── ai.ts ide.ts ❌ delete (become the --tool flag) + │ ├── status.ts restore.ts self-update.ts ❌ delete (folded into doctor, sync, update) + │ ├── framework.ts ✏️ modify (install/update/remove; build becomes translate) + │ ├── translate.ts ✅ create (the core, visible in --help at last) + │ ├── sync.ts ✅ create (the command ARCHITECTURE.md announced and never had) + │ ├── doctor.ts ✏️ modify (absorbs status, gains the tool inventory) + │ ├── plugin.ts marketplace.ts ✏️ modify (aliases, no create) + │ └── kanban.ts telemetry.ts ✏️ modify (open; enable/disable) + └── tests/golden/ + ├── surface-equivalence.e2e.test.ts ✅ create (old spelling and new produce the same outcome) + ├── snapshots/phase0/snapshot.json ✏️ modify (recaptured on the new surface) + └── snapshots/help/surface.json ✏️ modify (recaptured: this phase is the surface change) +``` + +## User Journey + +```mermaid +flowchart TD + A[A user types a command] --> B{Bare verb or noun?} + B -->|Bare verb| C[An action now: setup, doctor, sync, translate, clean, update] + B -->|Noun then verb| D[A resource's lifecycle: framework, plugin, marketplace] + E[--tool scopes any of them] --> C + E --> D +``` + +## Test Scope + +```mermaid +--- +title: Test scope +--- +journey + section Setup + both surfaces registered => old and new spellings answer: 5: cli + section Happy path + run each new command => same outcome as its old spelling: 5: cli + run doctor without --tool => every tool reported, with what is wrong: 5: cli + run sync on a drifted project => generated files regenerated: 5: cli + section Edge case - the ambiguous verb + a user types update with no subject => the CLI updates itself, and says so: 1: cli + section Edge case - an old spelling + a user types ai install cursor => it still works => a deprecation line names the new form: 1: cli + section Teardown + remove the aliases => only the new surface answers => the snapshot is recaptured once: 5: cli +``` + +## Tasks to do + +### `1)` Add the new surface beside the old + +1. `sync` first: it never existed, so nothing is replaced. Then `doctor` enriched with the tool + inventory. Then `translate`, before `framework build` is retired. +2. Every old spelling keeps working and prints one line naming its replacement. + +### `2)` Prove the two surfaces are equivalent + +> This is the one phase that changes the net and the subject at once. Recapturing the golden cannot +> tell a successful rename from a behavior change, because the command string moved too. So the net +> for this phase is not the snapshot — it is equivalence, and it only exists while both spellings do. + +1. Add `surface-equivalence.e2e.test.ts`: for each pair, run the old spelling and the new one on two + freshly created identical projects, and assert the same exit code, the same files written and the + same manifest. + + > **Deux sortes de paires, deux exigences.** Une rédaction antérieure demandait aussi la même + > sortie standard pour toutes. Impossible : la tâche 1 enrichit `doctor` de l'inventaire des + > outils, donc sa sortie ne peut pas égaler celle de `status`, et six commandes repliées en une + > ne peuvent pas toutes imprimer la même chose. + > + > - **Renommage pur** (`restore` → `sync`, `self-update` → `update`, `framework build` → + > `translate`) : mêmes effets **et** même sortie, l'écho de la commande retiré. Une sortie qui + > bouge ici est une régression. + > - **Repli** (`status`, `ai status`, `ide status`, `ai doctor`, `ide doctor`, `plugin doctor` → + > `doctor`) : mêmes **effets** seulement. La sortie change par construction, et exiger qu'elle + > ne change pas reviendrait à interdire l'enrichissement que la tâche 1 demande. + > + > Dire lequel des deux régimes s'applique à chaque paire, dans le test. Une paire sans régime + > déclaré est une paire que personne n'a examinée. +2. Cover every pair the phase introduces, including the ones that fold several commands into one: + `status` and `ai status` against `doctor`, `restore` against `sync`, `ai install ` against + `framework install --tool `, `self-update` against `update`, `framework build` against + `translate`. +3. The test lives only as long as the aliases. It is deleted with them in task 4, and its passing + run is what licenses the deletion. + +### `3)` Move the tests + +1. e2e and golden invoke the new spellings. Recapture once, and review the diff as the behavior + change it is. + +### `4)` Retire the old surface + +1. Remove `ai`, `ide`, `status`, `restore`, `self-update` and the aliases. +2. `--tool` is the single scope flag everywhere. + +### `5)` Say what each adjacent command does + +> Six pairs are close enough to be confused. One line each, in `--help`. + +1. `marketplace refresh` re-fetches catalogs; `framework update` moves to a new version; `sync` + rewrites owned files from what is already there. +2. `translate` converts an arbitrary source and records nothing; `sync` does the same conversion, + driven by the manifest. +3. `setup` bootstraps the whole project; `framework install` acts on the framework alone. +4. `clean` removes AIDD from the project; `framework remove` removes the framework. + +## Test acceptance criteria + +| Task | Acceptance criteria | +| ---- | ------------------- | +| 1 | Every new command produces the same outcome as the old spelling it replaces; every old spelling still works and names its replacement | +| 2 | For every pair, the old and the new spelling produce the same exit code, files, manifest and output on identical projects | +| 3 | The golden diff shows the invocation strings changing and nothing else | +| 4 | No verb is declared twice for the same subject; `--tool` scopes every command that accepts a scope. The equivalence test is deleted with the aliases, after a passing run | +| 5 | `--help` distinguishes the six adjacent commands in one line each | +| all | A user coming from Claude Code or Codex finds `update`, `doctor` and the noun groups where those CLIs put them | diff --git a/cli/aidd_docs/tasks/2026_08/2026_08_20_refactor-contextes-cli/phase-19.md b/cli/aidd_docs/tasks/2026_08/2026_08_20_refactor-contextes-cli/phase-19.md new file mode 100644 index 000000000..d5afa550c --- /dev/null +++ b/cli/aidd_docs/tasks/2026_08/2026_08_20_refactor-contextes-cli/phase-19.md @@ -0,0 +1,109 @@ +--- +status: done +--- + +# Instruction: Rewrite the documentation and the skills + +The last phase, because until now the documentation described a tree that had not moved. + +Two files are rewritten rather than corrected: `codebase-map.md` (32 structural references) and +`memory/architecture.md` (16). The ten skills are replaced rather than updated: they encode the +layer taxonomy, answering "how do I create an adapter" when the first question becomes "which +context does this belong to". + +Three target invariants also become rules here, now that they are true. + +## Architecture projection + +> Tree of the final files. ✅ create · ✏️ modify · ❌ delete + +```txt +. +└── cli/ + ├── ARCHITECTURE.md ✏️ modify (four contexts, the chain, the two ownership regimes) + ├── aidd_docs/memory/ + │ ├── codebase-map.md ✏️ modify (rewritten; the map test keeps it honest) + │ └── architecture.md ✏️ modify (rewritten) + ├── .claude/skills/ + │ ├── {adapter,capability,command,domain-model,feature,format,tool,use-case}/ ❌ delete + │ ├── {translate,tools,distribution,framework}/ ✅ create (one per context) + │ └── {test,audit-remediate}/ ✏️ modify (cross-cutting, kept) + └── .claude/rules/ + ├── 00-architecture/0-contexts.md ✅ create (the chain, the kernel, one public entry) + └── 01-standards/1-exports.md ✏️ modify (barrels forbidden, context entry allowed) +``` + +## User Journey + +```mermaid +flowchart TD + A[A contributor adds something] --> B[Which context does it serve?] + B --> C[That context's skill says what to write and where] + C --> D[The rules say what may not be done] + D --> E[The architecture tests refuse what slipped through] +``` + +## Test Scope + +```mermaid +--- +title: Test scope +--- +journey + section Setup + the code has moved => the documentation can describe what exists: 5: system + section Happy path + read codebase-map => every directory under src is listed: 5: system + read ARCHITECTURE.md => every command it presents exists: 5: system + follow a context skill to add a format => it lands in the right place: 5: system + section Edge case - a stale map + a directory is added without updating the map => the map test fails: 1: system + section Teardown + the three target invariants are rules => the plan leaves nothing in a task folder: 5: system +``` + +## Tasks to do + +### `1)` Rewrite the two memory files + +1. `codebase-map.md` describes the four contexts, the kernel, presentation and runtime. The + `codebase-map` architecture test keeps it honest from then on. +2. `architecture.md` keeps its File Ownership section and drops what described the layer tree. + +### `2)` Replace the skills + +1. One per context: `translate`, `tools`, `distribution`, `framework`. Each answers what goes in, + how, and how it is tested — relying on the invariants rather than repeating them. +2. Keep `test` and `audit-remediate`, which cut across. +3. The launcher subject — locate and execute, never embed — joins the skill of the context that + carries kanban and telemetry. + +### `3)` Promote the three target invariants + +1. The chain `framework → translate → tools → kernel` plus `framework → distribution`. +2. The kernel imports no context and carries no business logic. +3. Rien n'importe l'intérieur d'un contexte : une importation venue d'ailleurs ne vise qu'un module + que ce contexte déclare public. + +### `4)` Rendre compte de la frontière, sans baril + +> Le conflit que cette tâche devait trancher l'a été en phase 7, et dans l'autre sens que sa +> rédaction supposait : il n'y a pas d'`index.ts` de contexte. `1-exports.md` interdisait déjà tout +> baril, `noBarrelFile` est actif, et le cliquet `no-re-export` a une base vide éprouvée par +> injection — c'était l'arbre cible qui était l'intrus, pas la règle. + +1. Écrire la frontière telle qu'elle est réellement tenue : un cliquet d'architecture liste les + modules publics de chaque contexte, et rien ne ré-exporte quoi que ce soit. Vérifier au passage + que la surface publique déclarée a bien rétréci à mesure que les consommateurs entraient dans + leur contexte — 20 modules publics sur 48 fichiers pour `tools` à l'extraction, c'est un point de + départ, pas une cible. + +## Test acceptance criteria + +| Task | Acceptance criteria | +| ---- | ------------------- | +| 1 | The `codebase-map` and `docs-do-not-lie` tests pass without a baseline | +| 2 | Ten skills become six; each context skill answers where a new artifact goes | +| 3 | The three invariants are rules, and each has a test or a lint rule behind it | +| 4 | A context entry is allowed, a convenience barrel is refused, and the rule says which is which | +| all | Nothing in this plan remains described only in a task folder | diff --git a/cli/aidd_docs/tasks/2026_08/2026_08_20_refactor-contextes-cli/phase-2.md b/cli/aidd_docs/tasks/2026_08/2026_08_20_refactor-contextes-cli/phase-2.md new file mode 100644 index 000000000..641b70b38 --- /dev/null +++ b/cli/aidd_docs/tasks/2026_08/2026_08_20_refactor-contextes-cli/phase-2.md @@ -0,0 +1,188 @@ +--- +status: done +--- + +# Instruction: Make the smoke suite run, hermetically + +`scripts/smoke-tools.sh` drives the built binary with real arguments in throwaway projects and +injects faults. It is the only net that exercises the CLI the way a user does. + +It runs nowhere: no CI job, no lefthook entry, last touched by the commit that moved the repository. + +## What running it established + +**It is red.** 73 pass, 4 fail, 7 min 11 s. + +**Its coverage depends on ambient machine state.** Line 106 reads +`TOKEN="${AIDD_TOKEN:-$(gh auth token 2>/dev/null || true)}"`, and everything substantial sits +behind `if [[ -z "$TOKEN" ]]`. Counted statically: + +| | invocations | sections | +|---|---|---| +| hermetic | 11 | help/version, framework build, plugin create, auth, self-update --check, local marketplace | +| behind the token | 30 | the setup matrix, global read-only commands, restore, per-tool AI and IDE commands, plugin commands, the update conflict guard, fault injection | + +So on a machine where `gh` happens to be logged in, the suite covers 41 invocations and reports +100% leaf command coverage. Where it is not, it covers 11 and the coverage report collapses. Same +command, same repository, two different nets — which is why it cannot gate a build as it stands. + +**The four failures are one scenario that stopped testing what it claims.** +`corrupt-cache fault injection` sets up with `--plugins recommended`, corrupts the cached catalog, +then expects `plugin install aidd-dev` to fail with a message naming `marketplace refresh --force`. +It gets `Error: Plugin 'aidd-dev' is already installed.` — `aidd-dev` is in the recommended set, so +setup installed it and the install refuses before ever reading the corrupt catalog. A test defect. + +**And one command hangs.** In a second run, `plugin update (all)` exceeded the script's own 180 s +ceiling and was killed. Seen once, not yet diagnosed. + +## Architecture projection + +> Tree of the final files. ✅ create · ✏️ modify · ❌ delete + +```txt +. +└── cli/ + ├── scripts/smoke-tools.sh ✏️ modify (local fixture, repaired scenario, 11 missing options) + ├── package.json ✏️ modify (smoke:fast and smoke:full) + └── ../.github/workflows/cli-ci.yml ✏️ modify (a blocking smoke job) +``` + +## User Journey + +```mermaid +flowchart TD + A[A change lands] --> B[The binary is built] + B --> C[Every leaf command runs with its real arguments] + C --> D{Every exit code as expected?} + D -->|Yes| E[The change ships] + D -->|No| F[The failing invocation is named, with its output] +``` + +## Test Scope + +```mermaid +--- +title: Test scope +--- +journey + section Setup + build the binary and point setup at the local fixture => no token needed: 5: system + create one throwaway project per group => no shared state between invocations: 5: system + section Happy path + run the suite with no token available => same coverage as with one: 5: cli + run every leaf command with its real arguments => expected exit code for each: 5: cli + pass every declared option at least once => none is silently unimplemented: 5: cli + section Edge case - the repaired fault injection + a corrupt cached catalog and a plugin not yet installed => install it => the error names marketplace refresh --force: 1: cli + the same project => run marketplace refresh --force => the catalog heals: 1: cli + section Edge case - a flag that decides what lands on disk + scope project against scope user => install with each => the two write to different places: 1: cli + a command offering dry-run => run it => nothing is written, exit code zero: 1: cli + section Teardown + remove every throwaway project => nothing left in the home or the repo: 5: system +``` + +## Tasks to do + +### `0)` Reproduce the hang, then bound it + +> A net that can hang is a net that gets bypassed. + +1. Reproduce `plugin update` exceeding 180 s, with and without a token. +2. If it is a product defect, record it as its own issue and fix it outside this phase — a net + phase does not change behavior. +3. Either way, keep a per-command ceiling so one hang cannot stall the run, and make a timeout + report which invocation stalled. + +### `1)` Repair the broken scenario + +> It must fail for the reason it claims, or it guards nothing. + +1. Set up with `--plugins none`, or target a plugin the recommended set does not contain, so the + install genuinely reaches the corrupt catalog. +2. Verify the repair the only way that counts: the assertion passes for the right reason, and still + fails when the actionable message is removed from the product. + +### `2)` Move the 30 gated invocations onto the local fixture + +> This is the phase's real work, and what makes the suite a gate. + +1. Replace `setup --source remote` with `--source local --path "$FRAMEWORK_FIXTURE"` in the seven + places that use it. +2. The per-tool and plugin sections install `aidd-dev`, a really published plugin. The fixture + serves `aidd-test` from a local path — swap the name, and check every assertion that depends on + the plugin's content. +3. Keep a genuinely remote subset for what only remote fetching can prove, still gated, and name it + as such. `smoke:fast` is hermetic and blocking; `smoke:full` adds the remote subset. +4. Record the measured wall-clock of each in the header. The full run is 7 min 11 s today. + +### `3)` Pass the eleven options that never ran + +> 11 of 24 declared options have never been passed once. + +1. `--flat` on every target that accepts it. It is a documented build mode producing a different + tree from marketplace mode, and nothing exercised it before. +2. `--scope project` against `--scope user`: assert the two land in different places. +3. `--dry-run`: assert the exit code **and** that nothing was written. +4. `--from`, `--marketplace`, `--plugin`, `--recommended`, `--no-plugins`, `--overwrite`, + `--release`. +5. `--gh` needs credentials: assert the refusal path and say so in a comment. + +### `4)` Make it run + +1. Add a blocking `cli / Smoke` job running `smoke:fast` after the build job. +2. Keep the summary that already names every failing check — it is what made the four visible. + +## What executing this phase established + +**The hang was not a hang.** `plugin update` exceeding 180 s was the remote path doing real work: +updating recommended plugins across five tools against the published framework. On the local +fixture the same command takes 0.24 s. Task 2 removed it; no product defect. + +**A third token dependency, unnoticed until now.** Beyond gating the sections, the coverage +threshold itself read `if [[ -n "$TOKEN" && "$pct" -lt 95 ]]` — the gate that enforces coverage only +fired when a token happened to be present. It is unconditional now. + +**The corrupt-catalog scenario cannot be made hermetic.** It corrupts the *fetched* catalog cache +(`.aidd/cache/marketplaces`), which only a remote source populates: a local source is read directly, +and its built cache is regenerated rather than trusted — verified by corrupting it and watching the +install succeed anyway. So the scenario moved into the opt-in remote section, where it belongs. + +**And once it finally reached its own code path, its expectation turned out to be obsolete.** With +the fetched catalog corrupted, `plugin install` now **succeeds** instead of failing with a message +naming `marketplace refresh --force`. That is the better behavior: a fetched catalog is a cache, and +the rule for CLI-owned files is to regenerate rather than error. + +The check now pins the recovery instead of demanding the error. Pinning it took two attempts, and +the first one is worth keeping in mind: asserting the cache file was rewritten failed on one shape +of four. Three shapes make the CLI re-fetch, `{ truncated` does not — an internal difference with no +user-visible consequence. The assertion moved to what a user actually sees: the install succeeds and +the CLI keeps working with the corrupt catalog still on disk. All four shapes pass, and the suite +runs with no skipped check in either mode. + +**Two of this phase's own edits were wrong, and the suite said so.** A blanket `aidd-dev` → +`aidd-test` rename reached the remote block too, where the really published marketplace does not +serve the fixture plugin. And a correction to a claim made in phase 1: `plugin install` does +declare `--yes`; only `plugin remove` does not. + +## Measurements + +| | before | after | +|---|---|---| +| hermetic invocations | 11 | all of them | +| gated behind an ambient token | 30 | 0 (one opt-in remote section) | +| checks | 73 pass / 4 fail | 99 pass / 0 fail | +| leaf command coverage without a token | collapsed | 37/37, same as with one | +| declared options never passed | 11 of 24 | 0 | +| wall clock | 7 min 11 s | 92 s | + +## Test acceptance criteria + +| Task | Acceptance criteria | +| ---- | ------------------- | +| 0 | No invocation can stall the run; a timeout names the invocation that stalled | +| 1 | The corrupt-catalog scenario reaches the code path it claims. It no longer asserts an outcome: see below | +| 2 | With no token available, the suite reports the same leaf command coverage as with one, and completes without reaching the network except in the named remote subset | +| 3 | Every declared option is passed at least once; `--dry-run` writes nothing and the two scopes write to different places | +| 4 | A red smoke run fails the build, and one run names every failing invocation with its output | +| all | The suite is green before any later phase moves a file | diff --git a/cli/aidd_docs/tasks/2026_08/2026_08_20_refactor-contextes-cli/phase-20.md b/cli/aidd_docs/tasks/2026_08/2026_08_20_refactor-contextes-cli/phase-20.md new file mode 100644 index 000000000..943f51732 --- /dev/null +++ b/cli/aidd_docs/tasks/2026_08/2026_08_20_refactor-contextes-cli/phase-20.md @@ -0,0 +1,214 @@ +--- +status: done +--- + +# Instruction: Make the tests prove they test something + +Every net in this refactor answers "did the behaviour change?". None answers "would the tests notice +if it did?". Mutation testing is that second question, and it is the only one that measures a test +suite rather than the code. + +It is placed last on purpose: it is worth running against the structure the refactor produces, not +against the one it replaces. + +## Ce que cette phase n'est pas + +La réparation de Stryker appartient à la phase 9, et son premier usage à la phase 14, qui a besoin +d'une mesure avant et après le découpage du Manifest. Ici, la campagne est large : elle mesure la +suite entière contre la structure que le refactor a produite. + +## What is in the way + +Stryker is installed and broken. It was already broken before this refactor, silently, which is part +of how the drift went unnoticed. Two failures were met, in order: + +1. `ts.parseConfigFileTextToJson is not a function` — a TypeScript upgrade broke Stryker's config + reader. Fixed with `tsconfigFile: ""`. +2. Its runner picks up `vitest.workspace.ts`, so it runs the e2e project, and the build golden fails + inside Stryker's sandbox. `vitest.dir`, `vitest.related` and a dedicated config were each tried; + none narrowed the initial run. + +The second may have changed since: the e2e helper now strips drivable tool binaries from `PATH` and +reaches node through `process.execPath`, so the golden no longer depends on what the machine has +installed — which was part of why it could not survive a sandbox. Re-measure before re-diagnosing. + +## Architecture projection + +> Tree of the final files. ✅ create · ✏️ modify · ❌ delete + +```txt +. +└── cli/ + ├── stryker.config.json ✏️ modify (a runner that sees unit tests only) + └── .github/workflows/ ✏️ modify (a scored run, not a gate that blocks a merge) +``` + +## Test Scope + +```mermaid +--- +title: Test scope +--- +journey + section Setup + stryker runs at all, on one file => the harness is alive again: 5: system + section Happy path + mutate the manifest aggregate => surviving mutants name untested behaviour: 5: system + section Edge case - a mutant nobody kills + a surviving mutant is either covered by a new test or recorded as accepted: 1: system + section Teardown + the score is written down => the next run has something to compare against: 5: system +``` + +## Ce que « large » doit vouloir dire, chiffré (2026-09-02) + +Muter tout le code n'a pas de sens : un adaptateur et un câblage ne portent pas de règles, et un +mutant qui y survit ne dit rien sur la conception. Ce qui mérite d'être muté, c'est le domaine de +chaque contexte plus le noyau. + +| cible | fichiers | lignes | +|---|---|---| +| `contexts/tools/domain` | 45 | 4431 | +| `contexts/framework/domain` | 19 | 1100 | +| `contexts/translate/domain` | 9 | 653 | +| `contexts/distribution/domain` | 11 | 423 | +| `kernel` | 17 | 1449 | + +Environ 8000 lignes. À l'échelle du run du manifest — 660 lignes, 386 mutants, trois minutes — cela +donne un ordre de grandeur de plusieurs milliers de mutants et quelques dizaines de minutes, +`ignoreStatic` activé pour écarter les quatre mutants statiques qui consommaient 90 % du temps. + +### La conséquence sur la tâche 3 + +« Chaque mutant survivant est tué ou accepté par écrit » tient pour 109 survivants. Pour plusieurs +centaines, c'est une promesse qu'on ne tiendra pas, et une promesse non tenue est pire qu'une +absence de promesse. La forme honnête : + +1. Un run par contexte, nommé, pour qu'un chiffre désigne un responsable plutôt qu'une moyenne. +2. Le contexte au plus mauvais score est le seul dont les survivants sont traités un par un. +3. Le reste devient une base : un score par contexte, écrit, qui ne peut que monter. + +Un score global unique serait le plus facile à produire et le moins actionnable. + +## Tasks to do + +### `1)` Bring the runner back to life + +1. Point Stryker at the unit project only. The e2e and golden suites spawn a built binary and are + worthless as mutation oracles anyway: they would be slow, and a surviving mutant there would say + nothing about a unit's design. + +### `2)` Mutate what carries the rules + +1. Start with the manifest aggregate and the tool profiles: they hold the invariants everything else + assumes, and they are pure, so a mutant that survives there is a real gap and not a wiring + artefact. + +### `3)` Turn the survivors into a decision + +1. Each surviving mutant is either killed by a test that was missing, or written down as accepted + with the reason. No silent list. +2. Record the score so the next run compares rather than restarts. + +## Les scores, par contexte (2026-09-02) + +Seuil de rupture 50 dans tous les cas. + +| cible | fichiers | score | +|---|---|---| +| `contexts/translate/domain` | 9 | 78,63 % | +| `contexts/framework/domain` | 19 | 77,97 % | +| `contexts/distribution/domain` | 11 | 74,07 % | +| `kernel` | 17 | **61,60 %** | +| `contexts/tools/domain` | 45 | **61,64 %** | + +> **Périmètre, et correction (2026-09-03).** Quatre de ces cibles ne mutaient que la couche +> `domain/` de leur contexte. Aucune commande gardée ne les reproduisait, et lues sans leur +> colonne « cible » elles se laissaient prendre pour le score du contexte entier. Les scopes +> déclarés dans `mutation-scopes.json` couvrent désormais chaque contexte en entier, ce que +> `application/` et `infrastructure/` font au chiffre compris — voir +> `aidd_docs/tasks/2026_09/2026_09_03_mutation-scopes/`. + +Le noyau et `tools` sont à égalité au plus bas. Le noyau est le pire des deux endroits où l'être : +c'est le vocabulaire que les quatre contextes parlent, donc un changement de comportement qui y +passe inaperçu passe inaperçu partout. C'est lui dont les survivants ont été examinés. + +### Ce que 255 survivants du noyau disent réellement + +| fichier | survivants / mutants | +|---|---| +| `errors.ts` | 101 / 247 | +| `markdown.ts` | 60 / 236 | +| `source.ts` | 43 / 266 | +| `jsonc.ts` | 25 / 116 | +| `file.ts` | 14 / 42 | +| `merge.ts` | 10 / 75 | +| `paths.ts` | 2 / 11 | + +**Cent des cent un survivants d'`errors.ts` sont un message remplacé par une chaîne vide.** Aucun +test ne fige la prose d'une erreur, et l'exiger produirait des tests qui cassent au premier +reformulage sans rien protéger. C'est une catégorie **acceptée**, écrite ici pour qu'on cesse de la +recompter comme une dette. L'exception qui confirme la règle vit ailleurs : le message de la garde +de version du manifest **est** un contrat, et un test épingle son invocation littérale — parce que +celui-là dit à l'utilisateur quoi taper. + +La tentation inverse a été écartée aussi : retirer ce mutateur de la configuration aurait fait +monter le chiffre sans rien améliorer. Le score reste ce qu'il est ; c'est sa lecture qui était +fausse. + +**Le manque réel est `markdown.ts`**, où chaque profil d'outil rencontre le contenu qu'il réécrit : +un changement y est un changement partout. Onze tests y ont été ajoutés, écrits sur les branches que +la mutation désignait — le guillemetage des globs, l'apostrophe doublée, le booléen écrit nu, la +chaîne JSON laissée brute, le délimiteur avec espaces en fin de ligne, le bloc non refermé traité +comme du corps, le seul saut de ligne retiré quand il n'y a pas de frontmatter. + +`source.ts` et `jsonc.ts` restent la prochaine cible évidente, dans cet ordre. + +## Ce que la campagne a coûté avant de mesurer quoi que ce soit + +Deux obstacles, tous deux instructifs. + +**Les tests d'architecture ne peuvent pas participer.** Ils lisent l'arbre des fichiers comme du +texte — tailles de dossiers, chemins cités, graphe d'imports. Stryker travaille sur une copie de cet +arbre avec un mutant injecté : ces tests répondent alors à une question sur le bac à sable, pas sur +le code, et ils font échouer le run initial avant le premier mutant. Ils mesurent la structure, et +la mutation ne mesure pas la structure. D'où `vitest.mutation.config.ts`, qui ne garde que les deux +projets qui mesurent du comportement. L'e2e en est exclu pour la raison inverse : il lance le +binaire construit, qu'aucun mutant n'atteint, donc tous survivraient et dilueraient le score. + +À noter pour qui y reviendra : un simple `test.exclude` ne suffit pas. Le fichier de workspace +définit les projets et l'emporte sur une config passée par `--config` ; seule une autre déclaration +de projets le remplace. + +**Deux tests unitaires lisaient la machine du développeur.** `MarketplaceListUseCase` attendait un +marketplace et en voyait trois ; `MarketplaceRegistryAdapter` en attendait zéro et en voyait deux. +Tous deux truquaient `HOME` — mais le CLI ne retombe sur `homedir()` que si +`AIDD_USER_CONFIG_DIR` n'est pas défini, et il suffit qu'une valeur traîne pour que le test aille +lire un vrai registre utilisateur. Ils passaient par chance, et Stryker les a mis à nu en changeant +le répertoire de travail. Corrigés en épinglant le répertoire de configuration, et vérifiés sous un +environnement délibérément empoisonné. + +C'est le premier bénéfice de cette phase, et il est arrivé avant le premier chiffre : la mutation a +trouvé du non-déterminisme que 1969 tests verts ne montraient pas. + +## Test acceptance criteria + +| Task | Acceptance criteria | +| ---- | ------------------- | +| 1 | `stryker run` completes on the unit project without touching the golden or e2e suites | +| 2 | The manifest aggregate and the tool profiles are mutated, with a score recorded | +| 3 | Every surviving mutant is killed or accepted in writing; the score is committed so the next run has a baseline | +| all | Mutation is scored, never a gate: it reports on the suite, it does not block a merge | + +## Un piège d'outillage, pour qui relancera la campagne + +Stryker ne nettoie pas `.stryker-tmp/` quand un run est interrompu ou échoue — c'est écrit dans son +journal : « Not removing the temp dir because an error occurred ». Le répertoire monte vite à une +centaine de mégaoctets, et il contient une copie complète du dépôt, `aidd_docs/` inclus. + +Il est bien dans le `.gitignore`, ce qui ne suffit pas : le hook de pré-commit qui vérifie les liens +markdown lit le disque et non l'index, donc il scanne la copie et signale des liens morts pointant +vers des chemins d'il y a plusieurs phases. Un commit refusé pour des fichiers qui n'existent pas +vraiment. + +`rm -rf .stryker-tmp` après un run interrompu, avant de committer. diff --git a/cli/aidd_docs/tasks/2026_08/2026_08_20_refactor-contextes-cli/phase-3.md b/cli/aidd_docs/tasks/2026_08/2026_08_20_refactor-contextes-cli/phase-3.md new file mode 100644 index 000000000..e40bc7bb0 --- /dev/null +++ b/cli/aidd_docs/tasks/2026_08/2026_08_20_refactor-contextes-cli/phase-3.md @@ -0,0 +1,146 @@ +--- +status: done +--- + +# Instruction: Delete dead code + +Do not move what will be thrown away. Every target re-verified after phases 1 and 2 changed the +code around them. + +| Target | Evidence | Size | +|---|---|---| +| the foreign-catalog branch | `loadForeign` has no production caller; `NormalizedPlugin` appears in 7 source files and 0 tests | 4 parsers (282 l.) + `normalized-plugin.ts` (27 l.) + a port method + 5 adapter methods of 123 | +| `domain/models/marketplace-entry.ts` | the only file unreachable from `src/cli.ts`, and `knip.json` names it to stay silent | 103 l. + its 157-line test | +| 4 exports of `mcp-exclusion.ts` | each appears once in `src` — its own definition — and once in tests | 134 of the file's 186 lines | +| `buildMergeFileEntries` | same shape: defined, tested, called by nothing | ~25 l. | +| `Update{Ai,Ide}Tools{Input,Result}` | once in `src`, zero in tests | 4 type declarations | + +Roughly 700 lines, of which the telling part is the middle three rows: **live tests guarding dead +behavior**. They pass, they prove nothing, and they would have been carried through eleven +relocation phases. + +## Architecture projection + +> Tree of the final files. ✅ create · ✏️ modify · ❌ delete + +```txt +. +└── cli/ + ├── src/domain/ + │ ├── models/ + │ │ ├── marketplace-entry.ts ❌ delete (unreachable; knip.json silenced it) + │ │ ├── normalized-plugin.ts ❌ delete (only the dead foreign path used it) + │ │ ├── mcp-exclusion.ts ✏️ modify (drop 4 uncalled exports) + │ │ └── merge.ts ✏️ modify (drop buildMergeFileEntries) + │ ├── formats/{cursor,codex,copilot,opencode}-marketplace.ts ❌ delete (foreign catalogs) + │ └── ports/plugin-catalog-repository.ts ✏️ modify (drop loadForeign) + ├── src/infrastructure/adapters/ + │ └── plugin-catalog-repository-adapter.ts ✏️ modify (drop loadForeign and its readers) + ├── src/application/use-cases/global/ + │ ├── update-ai-tools-use-case.ts ✏️ modify (drop unused Input/Result types) + │ └── update-ide-tools-use-case.ts ✏️ modify (idem) + ├── tests/domain/models/marketplace-entry.unit.test.ts ❌ delete (157 l., tests a deleted file) + ├── tests/domain/models/mcp.unit.test.ts ✏️ modify (drop the 4 dead-export cases) + ├── tests/domain/models/merge-entry.unit.test.ts ✏️ modify (drop buildMergeFileEntries) + ├── tests/application/use-cases/marketplace/marketplace-list-use-case.unit.test.ts ✏️ modify (drop loadForeign stubs) + ├── tests/infrastructure/adapters/plugin-catalog-repository-adapter.integration.test.ts ✏️ modify (drop foreign reads) + └── knip.json ✏️ modify (empty the ignore list) +``` + +## User Journey + +```mermaid +flowchart TD + A[A reader opens the codebase] --> B{Is this code reachable?} + B -->|Yes| C[It earns its place] + B -->|No| D[It is gone, not silenced in a config] +``` + +## Test Scope + +```mermaid +--- +title: Test scope +--- +journey + section Setup + the golden net covers the surface => phase 1 is done: 5: system + section Happy path + run the whole suite => golden, help, smoke and e2e pass untouched: 5: system + run knip with an empty ignore list => nothing reported: 5: system + read a catalog from a Copilot-native fixture => still parsed correctly: 5: cli + section Edge case - the live catalog path + copilot-marketplace-catalog stays => read .plugin/marketplace.json => plugin list unchanged: 1: cli + section Teardown + the architecture ratchets shrink => tool-addition-cost drops the deleted files: 5: system +``` + +## Tasks to do + +### `1)` Remove the foreign catalog branch + +> Reachable but never invoked. + +1. Delete `loadForeign()` from `PluginCatalogRepositoryAdapter` and from the port. +2. Delete `normalized-plugin.ts` and the four `{cursor,codex,copilot,opencode}-marketplace.ts`. +3. Drop the three `loadForeign` stubs in the marketplace-list unit test. +4. Keep `copilot-marketplace-catalog.ts`: it serves the live `load()` path, reading Copilot's own + `.plugin/marketplace.json` into `PluginCatalog`. + +### `2)` Remove the unreachable model + +1. Delete `domain/models/marketplace-entry.ts` and its unit test. +2. Empty the `ignore` list in `knip.json`. The live namesake is + `domain/capabilities/marketplace-entry.ts`, 25 lines, untouched. + +### `3)` Remove the uncalled exports + +1. From `mcp-exclusion.ts`, drop `extractMcpKeys`, `filterMcpExclusions`, `computeMcpExclusions`, + `detectNewMcpEntries`. Keep `transformFor`, `McpExclusion`, `mcpExclusionEquals`. +2. Drop their cases from `tests/domain/models/mcp.unit.test.ts`. +3. Drop `buildMergeFileEntries` and the four `Update{Ai,Ide}Tools{Input,Result}` types. + +### `4)` Shrink the ratchets + +1. Remove the deleted files from the `tool-addition-cost` baseline. + +## What executing this phase established + +**1871 lines deleted across 23 files, one line inserted.** The nets were not touched: golden, help +surface, smoke and e2e all pass on files unchanged by this phase, which is what makes a deletion +batch reviewable. + +**The compiler found what the plan had missed.** Four whole test files for the deleted parsers — 595 +lines — were not in the projection, and neither was the 180-line `loadForeign` block inside the +catalog adapter's integration test. A projection written by reading is not a projection verified by +compiling. + +**Deleting dead code revealed more dead code.** `ForeignSchemaValidationError` existed only to serve +the foreign-catalog path; once that path went, nothing referenced it. That is the argument for doing +this before the moves rather than after: each removal exposes the next. + +**The ratchets did their job without being asked.** `tool-addition-cost` refused to stay silent on +six entries that had become obsolete, naming each one. Its baseline is now 14 instead of 20 — and +the difference is a measurement of what this phase removed, not a claim about it. + +**Duplication fell with it**: 3.43% to 3.17%, 71 clones to 66. The jscpd threshold moved from 3.5 to +3.2 to match, since leaving it where it was would have allowed a third of a percent of new +duplication to pass unnoticed. + +| | before | after | +|---|---|---| +| unit tests | 1522 | 1399 | +| integration tests | 510 | 482 | +| `knip.json` ignore entries | 1 | 0 | +| duplication | 3.43% | 3.17% | +| `tool-addition-cost` baseline | 20 | 14 | + +## Test acceptance criteria + +| Task | Acceptance criteria | +| ---- | ------------------- | +| 1 | Reading a Copilot-native marketplace still returns the same plugin list; no other behavior changes | +| 2 | `knip.json` carries no ignore entry for `src/`, and knip reports nothing | +| 3 | `mcp-exclusion.ts` exports three symbols, all called from production | +| 4 | The `tool-addition-cost` baseline shrank, and the test fails if an entry is removed from the list without the file being fixed | +| all | The golden snapshot and every e2e file pass **unmodified**: this batch removes only code nothing reaches | diff --git a/cli/aidd_docs/tasks/2026_08/2026_08_20_refactor-contextes-cli/phase-4.md b/cli/aidd_docs/tasks/2026_08/2026_08_20_refactor-contextes-cli/phase-4.md new file mode 100644 index 000000000..3705c10a8 --- /dev/null +++ b/cli/aidd_docs/tasks/2026_08/2026_08_20_refactor-contextes-cli/phase-4.md @@ -0,0 +1,159 @@ +--- +status: done +--- + +# Instruction: Drop plugin scaffolding + +`aidd plugin create` is exposed in `--help` and documented nowhere: zero mentions across `docs/`, +`README.md` and `cli/README.md`. `docs/CREATE_PLUGIN.md`, the contribution guide, describes an +entirely manual flow — create the directory, register it in `marketplace.json`, test, open a PR. + +Nobody writes third-party plugins today, and the command was never on a contributor's path. + +## What this fiche got wrong before, and now does not + +It was written before phases 1 and 2 built three nets, and its projection was read rather than +compiled. + +- It claimed `snapshots/phase0` would lose a help line. It will not: that snapshot holds 22 command + invocations and captures no help output at all. +- The snapshot that changes is the **help surface** golden, which loses its `aidd plugin create` + entry — a net that did not exist when this was planned. +- It ignored the **smoke suite** entirely: a `plugin create (scaffold)` section, an entry in + `ALL_COMMANDS`, and a coverage report that goes from 37 leaf commands to 36. +- It listed one test file. There are four, plus a fifth that falls with the cascade below. + +## The cascade + +`parsePluginComponentKind` has exactly one production caller: the `--type` option of the `create` +subcommand (`commands/plugin.ts:51`). Remove the command and `plugin-component-kind.ts` and +`InvalidPluginComponentKindError` become unreachable in turn — the same pattern phase 3 met with +`ForeignSchemaValidationError`, written down this time instead of discovered. + +Checked and safe: `plugin-manifest-schema.integration.test.ts` is the only test of the scaffold +against the bundled schema, but the adapters it exercises — `AjvSchemaValidatorAdapter` and +`BundledAssetProviderAdapter` — are covered by eight other tests. Deleting it loses no coverage of +anything that survives. + +## Architecture projection + +> Tree of the final files. ✅ create · ✏️ modify · ❌ delete + +```txt +. +└── cli/ + ├── src/ + │ ├── application/ + │ │ ├── commands/plugin.ts ✏️ modify (drop the create subcommand and its --type) + │ │ └── use-cases/plugin/plugin-create-use-case.ts ❌ delete (133 l.) + │ ├── domain/models/ + │ │ ├── plugin-scaffold.ts ❌ delete (86 l.) + │ │ └── plugin-component-kind.ts ❌ delete (12 l., cascade) + │ ├── domain/errors.ts ✏️ modify (drop InvalidPluginComponentKindError) + │ └── infrastructure/deps.ts ✏️ modify (drop the use-case wiring) + ├── tests/ + │ ├── e2e/plugin-create.e2e.test.ts ❌ delete (92 l.) + │ ├── application/use-cases/plugin/plugin-create-use-case.integration.test.ts ❌ delete (293 l.) + │ ├── domain/models/plugin-scaffold.unit.test.ts ❌ delete (77 l.) + │ ├── domain/models/plugin-component-kind.unit.test.ts ❌ delete (24 l., cascade) + │ ├── infrastructure/adapters/plugin-manifest-schema.integration.test.ts ❌ delete (34 l.) + │ └── golden/snapshots/help/surface.json ✏️ modify (loses `aidd plugin create`) + └── scripts/smoke-tools.sh ✏️ modify (drop the section and the ALL_COMMANDS entry) +``` + +Roughly 750 lines, of which 520 are tests. + +## User Journey + +```mermaid +flowchart TD + A[Someone wants to write a plugin] --> B[docs/CREATE_PLUGIN.md] + B --> C[Create the directory, register it, open a PR] + C --> D[The documented path, unchanged] +``` + +## Test Scope + +```mermaid +--- +title: Test scope +--- +journey + section Setup + a project with the framework installed => plugins usable: 5: cli + section Happy path + run plugin --help => create is absent, every other subcommand remains: 5: cli + install, list, update and remove a plugin => unchanged behavior: 5: cli + run the smoke suite => 36 of 36 leaf commands, still 100%: 5: cli + section Edge case - the removed command + a user types plugin create => the CLI reports an unknown command => exit code is non-zero: 1: cli + section Edge case - the removed option + a user passes --type to any surviving plugin subcommand => rejected as unknown: 1: cli + section Teardown + recapture the help surface => one entry gone, every other byte identical: 5: system +``` + +## Tasks to do + +### `1)` Remove the command, its use case and its wiring + +1. Drop the `create` subcommand from `commands/plugin.ts`, including its `--type` option. +2. Delete `plugin-create-use-case.ts` and `plugin-scaffold.ts`, and their wiring in `deps.ts`. +3. Delete the five test files listed in the projection. + +### `2)` Follow the cascade + +> Do not leave behind what only the removed command reached. + +1. `plugin-component-kind.ts` and `InvalidPluginComponentKindError` lose their last caller. +2. Confirm with the compiler, not by reading: `tsc --noEmit` must stay clean once they are gone, and + `knip` must report nothing. + +### `3)` Update the smoke suite + +1. Drop the `plugin create (scaffold)` section and the `plugin create` entry from `ALL_COMMANDS`. +2. Coverage must still report 100%, on 36 leaf commands instead of 37. + +### `4)` Recapture the help surface + +1. `UPDATE_HELP_GOLDEN=1`, then read the diff: exactly one entry disappears and `aidd plugin`'s own + help loses one line. Anything else means the removal reached further than intended. + +## What executing this phase established + +**921 lines deleted, 2 inserted, across 15 files.** Correcting the fiche first paid off: the four +mistakes it carried were fixed before they became surprises, and the cascade it predicted happened +exactly as written — `plugin-component-kind.ts` and `InvalidPluginComponentKindError` lost their last +caller with the `--type` option. + +**But the cascade went one level deeper than predicted.** `knip` found +`domain/formats/marketplace-json.ts` unreachable: it was imported only by the deleted use case. Its +test went with it, 97 lines in all. This is the third phase in a row where deletion uncovered more +deletion, and the second where the tooling found what reading had not — which is why task 2 said to +confirm with the compiler rather than by rereading. + +**A flaw in the duplication ratchet, found by this phase.** Phase 3 tightened `jscpd --threshold` +from 3.5 to 3.2 because duplication had fallen to 3.17%. Deleting 921 more lines of +**non-duplicated** code pushed the ratio back up to 3.22% — 66 clones and 694 duplicated lines, +unchanged in absolute terms, over a smaller codebase. **A percentage ratchet punishes deletion.** +The threshold moved to 3.3, and phase 5 (the last deletion phase) should expect the same effect. +Ratcheting the clone count rather than the ratio would be the real fix. + +| | before | after | +|---|---|---| +| unit tests | 1399 | 1380 | +| integration tests | 482 | 465 | +| e2e files | 16 | 15 | +| smoke leaf commands | 37/37 | 36/36, still 100% | +| help-surface entries | 44 | 43 | + +## Test acceptance criteria + +| Task | Acceptance criteria | +| ---- | ------------------- | +| 1 | `plugin --help` no longer lists `create`; install, list, remove, update, search and doctor behave as before | +| 2 | `tsc --noEmit` is clean and `knip` reports nothing with no new ignore entry | +| 3 | The smoke suite is green at 36/36 leaf commands, 100% | +| 4 | The help-surface diff removes one entry and edits one line; no other entry changes | +| all | `snapshots/phase0`, the e2e suite and the build golden pass **unmodified** — this removes a command, it changes nothing about the ones that stay | +| all | `docs/CREATE_PLUGIN.md` needs no edit: it never mentioned the command | diff --git a/cli/aidd_docs/tasks/2026_08/2026_08_20_refactor-contextes-cli/phase-5.md b/cli/aidd_docs/tasks/2026_08/2026_08_20_refactor-contextes-cli/phase-5.md new file mode 100644 index 000000000..3c7de6d17 --- /dev/null +++ b/cli/aidd_docs/tasks/2026_08/2026_08_20_refactor-contextes-cli/phase-5.md @@ -0,0 +1,364 @@ +--- +status: done +--- + +# Instruction: Let each tool own its own configuration + +## What this slot first held, and why it was cancelled + +It was going to remove flat build mode for the four native tools, believing their flat cells +duplicated their native mode. The premise was wrong: two axes were conflated. `PluginsCapability.mode` +describes how a *plugin* is installed into a tool; `FrameworkBuildMode` describes how the *framework* +is built for a target. The build golden settles it — for claude, marketplace mode produces 198 files +under `.claude-plugin/` and `plugins/`, flat mode 189 under `.claude/agents/`, `.claude/skills/` and +`.claude/hooks/`. Two deliverables, and `cli/README.md` documents the second. The nine build cells +stay. + +## What replaces it + +Today the CLI hand-writes `.claude/settings.json` — another tool's private configuration — and then +records that file's hash in its own manifest. + +A first attempt drove `claude plugin marketplace add` **in addition** to writing the file. Measured: +two writers and one recorder, so `status` reports the file modified forever after. That attempt was +reverted. The fix is not to add a second writer, it is to stop being one. + +So: **write through the tool's command, verify through the tool's command, track nothing the tool +owns.** + +| | today | target | +|---|---|---| +| register | this CLI writes `extraKnownMarketplaces` into `.claude/settings.json` | `claude plugin marketplace add --scope project` | +| verify | compare the file's hash to the manifest | `claude plugin marketplace list --json` | +| track | `.claude/settings.json`, the tool's only tracked file | nothing under `.claude/` | + +`--scope project` is not optional: the command defaults to **user** scope and would otherwise +register the marketplace globally, for every project on the machine. + +## Décision (2026-08-22) + +Tranchée après mesure. La question posée — « que faire hors ligne ? » — n'était pas le vrai blocage, +et les trois issues qu'elle proposait reposaient sur deux faits faux. + +### Ce que la mesure a corrigé + +**Le précédent hors ligne existe déjà, et il ne coûte pas ce que la fiche croyait.** `aidd setup --ai +codex` pose exactement un fichier, `.codex/config.toml`, qui ne contient que `model` et +`approval_policy` : aucun enregistrement. Le marketplace de codex n'existe que par sa commande. Donc +codex sans son binaire, aujourd'hui, c'est déjà un setup qui réussit et n'enregistre rien. Claude +pose lui aussi exactement un fichier. Piloter la commande ne créerait pas un nouveau mode d'échec +silencieux : il est déjà en place pour un outil sur deux. + +**Mais l'enregistrement d'AIDD ne peut pas être partagé, quelle que soit la source.** Mesuré dans les +deux modes : + +| `--source` | ce qui atterrit dans `extraKnownMarketplaces` | +|---|---| +| `remote` (défaut) | `{source:"directory", path:"/.aidd/cache/built/aidd-framework/claude"}` | +| `local --path ` | `{source:"directory", path:"/"}` | + +Ce qu'AIDD enregistre n'est jamais le dépôt amont, c'est **l'arbre construit** — un chemin absolu, et +dans le cas par défaut un chemin vers le dossier que le `.gitignore` exclut. C'est mécanique : l'arbre +construit est la forme claude du marketplace, le dépôt amont est agnostique. Tant que ces arbres ne +sont pas hébergés, l'enregistrement est machine-local par construction. + +**Toutes les entrées de marketplace sont machine-locales, sans exception.** Un marketplace tiers +github semblait produire `{source:"github", repo:"…"}`, partageable — mais c'était un artefact : sa +construction avait échoué. `mergeMarketplacesMap` lit `builtSources.get(name) ?? m.source`, et +`builtSourcesForTool` remplace chaque marketplace construit avec succès par `{kind:"local", path: +builtDir}`, que `resolveSourceForSettings` rend ensuite absolu. Quand la construction réussit — le cas +normal — la source déclarée n'est jamais utilisée. + +Donc la clé `extraKnownMarketplaces` est machine-locale **en entier**, et il n'y a pas à distinguer +entrée par entrée. + +**En revanche, la clé voisine ne l'est pas.** `enabledPlugins` s'écrit `{"plugin@marketplace": true}` : +des noms, aucun chemin. Elle se partage, et la committer est correct. Le fichier mélange donc deux +clés de natures opposées, ce qui est la coupe à faire. + +### Ce qui est tranché + +**La phase se scinde en deux, et une seule moitié est faisable maintenant.** + +**5a — séparer les deux clés selon ce qu'elles peuvent porter. Faisable tout de suite, sans +hébergement, sans piloter aucune commande.** `extraKnownMarketplaces`, faite de chemins absolus, part +dans `.claude/settings.local.json` — un fichier que Claude lit déjà, qu'AIDD ajoute à son `.gitignore` +et dont il n'enregistre pas l'empreinte. `enabledPlugins`, faite de noms, reste dans +`.claude/settings.json` avec la configuration runtime, committée et suivie comme aujourd'hui. + +La capability sait déjà exprimer cette coupe : `MarketplaceSettings` porte +`enabledPluginsSettingsPath` pour envoyer une clé ailleurs. 5a ajoute le miroir pour l'autre clé. +AIDD reste l'unique auteur des deux fichiers, donc aucune collision d'empreinte, et le hors ligne +continue de marcher. + +Ça corrige un défaut réel et vérifié : aujourd'hui un collègue qui clone récupère un enregistrement +qui pointe vers un répertoire ne pouvant pas exister chez lui. + +**5b — piloter la commande de l'outil. Reste bloqué, et pas pour la raison qu'indiquait la fiche.** +Le blocage n'est pas le hors ligne, c'est que piloter ne donne pas un résultat meilleur tant que les +marketplaces ne sont pas hébergés : + +- au scope `project`, `claude plugin marketplace add` réécrit `.claude/settings.json`, le fichier + qu'AIDD écrit et dont il enregistre l'empreinte — la collision qui avait fait échouer la première + tentative revient telle quelle ; +- au scope `local`, il écrit `.claude/settings.local.json`, un fichier séparé donc sans collision + (vérifié : « declared in local settings ») — mais c'est précisément ce que 5a obtient déjà en + écrivant le fichier, sans exiger le binaire ; +- cursor ne peut pas être piloté du tout : sa commande prend une URL git, pas un chemin. + +Piloter devient le bon geste quand `add` prend une URL pour les quatre outils, c'est-à-dire après la +décision d'hébergement. Voir `marketplaces-heberges.md`. 5b y est rattaché. + +### Ce que la décision coûte + +5a laisse AIDD auteur de la configuration d'un autre outil, ce qui est l'objectif affiché de la +phase. C'est assumé : l'objectif est en aval de l'hébergement, pas du hors ligne. + +## The decision this phase needs + +Setup currently works when Claude Code is **not installed**: writing the settings file leaves a +registration that takes effect when the tool arrives. Driving the CLI cannot do that. + +Three ways out, and this phase should not start before one is chosen. + +1. **Require the binary.** Registration fails with a clear message when `claude` is absent. Simplest, + and it drops a case that may not matter. +2. **Write the file only as a fallback.** When the binary is absent, write `.claude/settings.json` + and track it; when it is present, drive the command and track nothing. Preserves both, at the cost + of two code paths and a manifest whose content depends on what was installed at setup time. +3. **Defer to the remote marketplace.** Once the per-tool built marketplaces are hosted rather than + local, `add` takes a URL and there is nothing local to point at. See below. + +## Why the remote direction changes this + +The built marketplaces live in `.aidd/cache/built//` — local paths. That is the only +reason Cursor cannot be driven at all: `cursor-agent plugin marketplace add` takes a **git URL** and +indexes per account, verified against the installed CLI. + +Host the generated per-tool marketplaces and the same three commands work everywhere: claude, codex, +copilot and cursor all accept a URL. The plan's four tool profiles would then differ by paths and +formats only, not by how registration happens — which is the shape phase 10's acceptance test is +asking for. + +That is a product direction, not a refactor step. This phase should be sized once it is settled. + +## Les scopes, outil par outil + +Vérifié contre les quatre CLI installées. + +| outil | scopes exposés par sa propre commande | fichier écrit | +|---|---|---| +| claude | `user` (défaut), `project`, `local` | `~/.claude/`, `.claude/settings.json`, `.claude/settings.local.json` | +| codex | aucun — user-global par conception | `~/.codex/config.toml` | +| copilot | aucun — pas d'option `--scope` | `~/.copilot/` | +| cursor | aucun — niveau compte | indexé côté serveur | + +Seul Claude a des scopes à offrir. Le modèle d'AIDD doit donc passer d'un scope **unique par outil** +(`installScope: "project" | "user"`, une valeur) à la **liste des scopes supportés** plus un défaut, +et n'exposer `--scope` que là où l'outil en accepte un. + +## Ce que le .gitignore change au raisonnement + +AIDD ajoute une seule ligne au `.gitignore` du projet : `.aidd/cache/`. Ce qui reste versionné : +`.aidd/manifest.json`, `.aidd/marketplaces.json` et `.claude/settings.json`. + +Or `.claude/settings.json` est committé **et** contient le chemin du marketplace AIDD — un chemin +**absolu** vers `.aidd/cache/built/aidd-framework/claude`, c'est-à-dire vers le dossier ignoré. +Vérifié sur un projet neuf, dans les deux modes de `--source`. + +Attention à ne pas généraliser : c'est vrai de l'enregistrement d'AIDD, pas de toutes les entrées. +Un marketplace tiers déclaré en github s'écrit `{source:"github", repo:"…"}` et se partage très bien. +C'est ce contraste, et non le chemin seul, qui fonde la décision plus bas. + +Un collègue qui clone récupère donc un pointeur vers un répertoire qui n'existe pas chez lui et +n'existera qu'après son propre `setup`. C'est un défaut latent du modèle actuel, indépendant de tout +le reste, et il décide du scope par défaut : + +| contenu | scope | fichier | pourquoi | +|---|---|---|---| +| config runtime d'AIDD (`respectGitignore`, `permissions`) | `project` | `.claude/settings.json` | réellement partageable, mérite d'être committé | +| enregistrement du marketplace | `local` | `.claude/settings.local.json` | chemin absolu vers un dossier ignoré : il ne peut être que machine-local | + +Le défaut `local` n'est pas un compromis, c'est la seule valeur cohérente avec ce que +l'enregistrement contient. Et `--scope local` écrit un **fichier séparé**, vérifié — ce qui supprime +au passage la collision d'empreinte qui avait fait échouer la première tentative. + +## Architecture projection + +> Tree of the final files. ✅ create · ✏️ modify · ❌ delete + +```txt +. +└── cli/src/ + ├── domain/tools/ai/claude.ts ✏️ modify (nativeActivation, marketplaceSettings dropped) + ├── domain/capabilities/plugins-capability.ts ✏️ modify (claude joins the driven binaries) + ├── domain/ports/native-plugin-activator.ts ✏️ modify (a read: list registered marketplaces) + ├── infrastructure/adapters/native-plugin-cli-adapter.ts ✏️ modify (implement the read) + └── application/use-cases/ ✏️ modify (doctor asks the tool, not the file) +``` + +## User Journey + +```mermaid +flowchart TD + A[aidd setup --ai claude] --> B{Is the claude binary reachable?} + B -->|Yes| C[claude plugin marketplace add --scope project] + C --> D[Claude owns .claude/, the CLI owns .aidd/] + B -->|No| E[Decision above: fail, fall back, or defer] + F[aidd doctor] --> G[claude plugin marketplace list --json] + G --> H[Registered, or reported missing] +``` + +## Test Scope + +```mermaid +--- +title: Test scope +--- +journey + section Setup + a project and a built marketplace => something to register: 5: cli + section Happy path + run setup for claude => the marketplace is registered through the tool's command: 5: cli + run doctor => registration confirmed by asking the tool, not by reading its file: 5: cli + run status => no file under .claude/ is tracked, so none can drift: 5: cli + section Edge case - the tool is absent + the claude binary is not on PATH => run setup => behaves as the decision above states: 1: cli + section Edge case - the user removes the registration + remove the marketplace by hand => run doctor => reported missing, with the command to fix it: 1: cli + section Teardown + the CLI writes nothing under .claude/ => the tool owns its own configuration: 5: system +``` + +## Tasks to do + +### `0)` Settle the offline decision — fait, voir « Décision » plus haut + +> Répondu le 2026-08-22 : la question ne bloquait pas ce qu'elle prétendait bloquer. Ce qui suit est +> la moitié 5a, réalisable sans hébergement. La moitié 5b est rattachée à `marketplaces-heberges.md`. + +### `1)` Écrire chaque entrée dans le fichier que sa nature impose + +1. `MarketplaceSettings` gagne `marketplacesSettingsPath`, miroir de `enabledPluginsSettingsPath` + qui existe déjà. Le profil claude l'ajuste sur `.claude/settings.local.json`. Quand il est + déclaré, la clé y est écrite et son empreinte n'est pas enregistrée. +2. La clé laissée dans le fichier suivi par une installation antérieure en est retirée, sinon un + chemin absolu périmé reste committé. +3. AIDD ajoute le fichier à son `.gitignore` — Claude ne l'y met pas lui-même, vérifié. Le chemin + est lu sur les profils installés, jamais écrit en dur. + +### `2)` Vérifier sans suivre + +> `claude plugin marketplace list` **n'a pas** d'option `--json` — vérifié contre la CLI installée. +> La fiche en supposait une. La distinction utile n'est pas lire par la commande plutôt que par le +> fichier, c'est **lire sans enregistrer d'empreinte** : lire pour confirmer ne crée pas de dérive, +> enregistrer un hachage en crée. + +1. `doctor` confirme l'enregistrement en lisant le fichier machine-local, sans en suivre + l'empreinte. Le fichier partagé n'a pas besoin de ce contrôle : son empreinte est déjà suivie, + donc il signale ses propres dégâts. + +### `3)` Ne suivre que ce qui se partage + +> Le critère d'origine — « plus aucun fichier sous `.claude/` dans le manifest » — est faux et ne peut +> pas être atteint : `configOutputPaths: { "settings.json": ".claude/settings.json" }` fait qu'AIDD +> écrit légitimement ce fichier pour `respectGitignore` et `permissions`. Vérifié sur un projet neuf. +> Ce qui quitte le fichier suivi, c'est l'entrée machine-local, pas le fichier. + +1. `.claude/settings.local.json` n'entre pas dans le manifest : AIDD l'écrit, ne le suit pas, et + `status` ne peut donc pas rapporter de dérive dessus. + +## La réponse, outil par outil + +Chaque outil reçoit ce que son architecture permet, pas une règle uniforme. Ce qui décide, c'est où +l'outil accepte de lire une déclaration machine-locale. + +| outil | ce que son architecture permet | ce qu'AIDD écrit | +|---|---|---| +| claude | `plugin marketplace add … --scope local` écrit `.claude/settings.local.json` ; `plugin install --scope project` écrit `enabledPlugins` | **la commande écrit l'enregistrement**, AIDD n'écrit rien ; la config runtime et `enabledPlugins` restent à AIDD | +| copilot | aucun jumeau machine-local. `.github/copilot/settings.json` est lu par VS Code et documenté comme la recommandation d'équipe ; `chat.plugins.marketplaces` a une portée application et VS Code la refuse en réglages d'espace de travail | `enabledPlugins` seulement. Aucun enregistrement : un chemin absolu ne peut pas être une recommandation d'équipe. Copilot apprend ses marketplaces par sa propre CLI | +| codex | pas de réglages de marketplace du tout, tout passe par sa CLI | rien | +| cursor | idem, et sa commande n'accepte qu'une URL git | rien | +| opencode | mode plat, pas de plugins natifs | rien | + +La capability porte donc trois réponses possibles pour l'emplacement des enregistrements, et non deux : +dans le fichier partagé, dans un fichier machine-local, ou **nulle part**. + +### La règle qui tranche : la commande de l'outil d'abord + +Un outil qui propose une commande pour écrire sa configuration l'écrit mieux que nous — dans son +format, à son scope, et il continuera de le faire quand ce format changera. AIDD n'écrit donc que ce +qu'aucune commande ne couvre. La capability sépare les deux axes : `marketplaceSettings` dit **où** +le fichier se trouve, pour le `.gitignore` et pour `status` ; `nativeActivation` dit **qui** l'écrit. + +Ce qui a résisté à la règle, mesuré plutôt que supposé : `claude plugin install --scope project` +écrit exactement `{"@": true}` dans `.claude/settings.json`, caractère pour +caractère ce qu'AIDD y écrit déjà. Le piloter ne serait pas mieux, ce serait une seconde manière de +faire la même chose, et ça donnerait un second auteur à un fichier dont AIDD enregistre l'empreinte. +`enabledPlugins` reste donc écrit par AIDD. + +**Hors ligne.** L'enregistrement étant piloté, un binaire absent veut dire aucun enregistrement — +comme codex et copilot aujourd'hui. Vérifié : `Warning: claude CLI not found on PATH — skipping +native plugin activation.`, et rien d'écrit. C'est la lecture littérale du principe : le fichier +quand l'outil n'offre **pas de commande**, pas quand le binaire manque. + +### Deux régressions que le golden a attrapées, et une fragilité qu'il révélait + +Piloter la commande rend la sortie observable dépendante de ce que la machine a d'installé. Le golden +est devenu vert ici et rouge sans le binaire — donc inutilisable comme garde-fou. Les runs bac à +sable filtrent maintenant du `PATH` tout répertoire contenant une CLI pilotable, et appellent node par +`process.execPath` : filtrer par répertoire est ce qui tient sur une machine où `node` et `copilot` +partagent `/opt/homebrew/bin`. + +Sous ce filtre, le golden a montré deux vraies régressions : + +- **L'arbre construit disparaissait.** La construction était un effet de bord de l'enregistrement, + donc plus de binaire, plus d'arbre — alors que construire est le travail d'AIDD quel que soit + l'écrivain, et que l'arbre est ce que tout enregistrement désigne. Construire vient maintenant + avant de décider qui écrit. +- **`doctor` sortait en 1** en signalant qu'un outil non installé ne déclare pas son marketplace. + Le contrôle ne se déclenche plus quand le binaire est hors de portée : signaler ça, c'est signaler + qu'un logiciel absent est mal configuré. + +### Ce qui a été écarté, et pourquoi + +Un chemin **relatif** rendrait l'entrée identique sur toutes les machines et sur tous les OS, ce qui +supprimerait le problème à la racine. Deux sondes n'ont pas pu établir que Claude le résout : +`marketplace list` se contente de réafficher la déclaration, et `marketplace update` répond +« Successfully updated » sur un chemin cassé. Aucune ne discrimine, donc rien n'est bâti dessus. +Claude écrit lui-même un chemin absolu au scope local ; suivre sa convention est le choix défendable. + +## Ce que la mise en œuvre a appris + +**Le golden a attrapé une régression que la coupe introduisait.** Sortir la clé du fichier suivi +faisait apparaître `settings.local.json` comme fichier *ajouté* dans `status` : la fausse dérive +avait simplement changé de forme. `detectAddedFiles` excluait déjà les `.backup` pour cette raison +exacte, et les fichiers machine-locaux suivent le même précédent, lus sur le profil par +`machineLocalFilesOf`. + +**Le contrôle de `doctor` n'était pas facultatif.** Un fichier suivi signale lui-même ses dégâts, son +empreinte cesse de correspondre. Un fichier délibérément non suivi ne signale rien : supprimé à la +main, `doctor` disait « installation saine ». `DoctorRegistrationUseCase` comble exactement cet angle +mort, et la commande qu'il propose répare vraiment — vérifié. + +**L'exclusion dans `status` repose sur une égalité de chaînes, donc sur une convention tacite.** +`detectAddedFiles` compare le chemin déclaré par le profil à celui qu'il reconstruit depuis le +répertoire de l'outil : un profil déclarant `settings.local.json` au lieu de +`.claude/settings.local.json` cesserait silencieusement d'être exclu. Un test autour de `status` ne +l'attrape pas — le fichier tomberait hors du répertoire scanné, donc aucune dérive de toute façon, +et le test passerait pour la mauvaise raison. La convention est donc devenue un invariant vérifié +sur tous les profils dans `registry-conformance`, éprouvé par injection. + +**`update` n'appelle pas la synchronisation des marketplaces.** Elle tourne sur `setup`, `install`, +`marketplace add/remove/refresh` et `plugin install`, pas sur `update`. Antérieur à cette phase, non +corrigé ici, consigné dans `findings.md`. + +## Test acceptance criteria + +| Task | Acceptance criteria | +| ---- | ------------------- | +| 0 | La décision est écrite ici avant tout changement de code — fait | +| 1 | Après setup, `.claude/settings.json` ne contient plus aucun chemin absolu, et l'enregistrement du framework se trouve dans `.claude/settings.local.json`, lui-même gitignoré | +| 2 | Retirer l'enregistrement à la main fait que `doctor` le signale, avec la commande qui répare | +| 3 | `settings.local.json` n'apparaît pas dans le manifest, et aucun `status` ne rapporte de dérive dessus | +| all | Un projet cloné par un collègue n'hérite plus d'un chemin qui ne peut pas exister chez lui. Le diff golden montre la scission du fichier et rien d'autre | diff --git a/cli/aidd_docs/tasks/2026_08/2026_08_20_refactor-contextes-cli/phase-6.md b/cli/aidd_docs/tasks/2026_08/2026_08_20_refactor-contextes-cli/phase-6.md new file mode 100644 index 000000000..2b3f42e21 --- /dev/null +++ b/cli/aidd_docs/tasks/2026_08/2026_08_20_refactor-contextes-cli/phase-6.md @@ -0,0 +1,116 @@ +--- +status: done +--- + +# Instruction: Untangle without moving anything + +Four small changes that make every later extraction possible, none of which moves a file. Each was +measured: two design cycles closing through `import type`, six re-export sites, one capability file +mixing two concerns, and one branch re-deriving by name what a profile already declares. + +## Architecture projection + +> Tree of the final files. ✅ create · ✏️ modify · ❌ delete + +```txt +. +└── cli/src/ + ├── domain/ + │ ├── formats/command.ts ✏️ modify (own the two section types) + │ ├── tools/contracts.ts ✏️ modify (import them instead of defining them) + │ ├── tools/registry.ts ✏️ modify (stop re-exporting 8 symbols) + │ ├── capabilities/{rules,commands,skills}-capability.ts ✏️ modify (import AI_TOOL_IDS from its source) + │ ├── capabilities/plugins-capability.ts ✏️ modify (keep PluginsCapability only) + │ └── capabilities/marketplace-settings.ts ✅ create (the MarketplaceSettings half) + └── application/use-cases/ + ├── setup-use-case.ts ✏️ modify (stop re-exporting SetupToolsResult) + ├── global/update-all-use-case.ts ✏️ modify (stop re-exporting GlobalExecutionError) + └── plugin/translator/built-tree-materialization-translator.ts ✏️ modify (read mode from the profile) +``` + +## User Journey + +```mermaid +flowchart TD + A[A file needs a symbol] --> B[It imports it from where it is defined] + B --> C[No hub, no cycle, no second source of truth] +``` + +## Test Scope + +```mermaid +--- +title: Test scope +--- +journey + section Setup + the golden net and the architecture ratchets are in place => regressions are visible: 5: system + section Happy path + run the whole suite => golden and e2e pass untouched: 5: system + build and install for every tool => output unchanged: 5: cli + section Edge case - the opencode branch + opencode as a target => materialize a plugin => flat mode chosen from the profile, not the name: 1: cli + section Teardown + biome reports no re-export => the ratchet for tool names shrank by one: 5: system +``` + +## Tasks to do + +### `1)` Break the two design cycles + +> Neither is a runtime cycle: both close through `import type`, which is why `noImportCycles` stays +> silent. They are still two modules that cannot be separated. + +1. Move `UserFileSection` and `UserFileSectionKey` out of `tools/contracts.ts` into + `formats/command.ts`, and have `contracts.ts` import them. +2. Point the three `AI_TOOL_IDS` imports in `capabilities/` at `models/tool-ids.ts`, their source. + +### `2)` Remove the six re-exports + +1. `registry.ts` re-exports eight symbols it imported from `models/tool-ids.ts`. Delete the + re-export; consumers import the source. +2. Same for `setup-use-case.ts` and `global/update-all-use-case.ts`. + +### `3)` Split the capability that carries two concerns + +1. `plugins-capability.ts` holds `PluginsCapability`, used by the five tool profiles, and + `MarketplaceSettings*`, used only by marketplace settings synchronisation. Move the second half + to its own file. + +### `4)` Read the mode, do not re-derive it + +1. Replace `toolId === "opencode" ? "flat" : "marketplace"` in + `built-tree-materialization-translator.ts` with a read of `mode` on the tool profile. + +## Ce qui a bougé par rapport à la projection + +Deux fichiers de plus que prévu, tous deux pour la même raison : le critère demandait quelque chose +que rien ne surveillait. + +**`domain/tools/registry.ts`** reçoit `frameworkBuildModeFor` au lieu de `plugin-helpers.ts`. La +tâche 4 remplaçait `toolId === "opencode" ? "flat" : "marketplace"` par une lecture du profil, et +cette lecture avait déjà un jumeau exact à cet endroit : `nativeActivationOf`, qui va chercher +`plugins.nativeActivation` dans le profil comme celle-ci va chercher `plugins.mode`. La mettre dans +la couche application l'aurait rendue inaccessible au domaine alors qu'elle ne dépend que de lui. + +**`tests/architecture/no-re-export.arch.test.ts`** est nouveau. Le critère 2 nommait Biome comme +juge, mais Biome ne peut pas rendre ce verdict : `noBarrelFile` ne voit que les fichiers qui ne font +que ré-exporter, `noReExportAll` ne voit que `export *`. Or la forme qui s'était accumulée ici est +plus étroite que les deux — `export type { GlobalExecutionError };`, un module qui importe un symbole +et le ré-exporte. Le critère aurait donc été « vérifié » par un outil aveugle à ce qu'il vérifiait, +c'est-à-dire exactement la panne que ce refactor existe pour corriger. Il est devenu un ratchet à +base vide, et il a été éprouvé par injection : ré-introduire un ré-export le fait échouer. + +Deux artefacts de la réécriture par script ont aussi été nettoyés : cinq fichiers portaient deux +`import type` du même module après le déplacement des identifiants d'outils, et `knip --production` +reste vide. + +## Test acceptance criteria + +| Task | Acceptance criteria | +| ---- | ------------------- | +| 1 | `formats/` no longer imports `tools/`, and `capabilities/` no longer imports `tools/registry` | +| 2 | Biome reports no re-export anywhere under `src/` | +| 3 | The five tool profiles import `PluginsCapability` without pulling marketplace settings | +| 4 | Materializing for OpenCode still produces flat output, chosen from the profile; adding a sixth flat tool needs no edit here | +| all | Golden and e2e pass **unmodified**. This batch moves no file and changes no behavior | diff --git a/cli/aidd_docs/tasks/2026_08/2026_08_20_refactor-contextes-cli/phase-7.md b/cli/aidd_docs/tasks/2026_08/2026_08_20_refactor-contextes-cli/phase-7.md new file mode 100644 index 000000000..621572875 --- /dev/null +++ b/cli/aidd_docs/tasks/2026_08/2026_08_20_refactor-contextes-cli/phase-7.md @@ -0,0 +1,87 @@ +--- +status: done +--- + +# Instruction: Dissolve the shared dumping ground + +`use-cases/shared/` holds fourteen files. Measured against the rule this repo now carries — a module +is shared when it has callers in two areas — seven fail: five have one caller, two have none outside +`shared/` itself. + +The directory is not the cause. `0-layer-responsibilities.md` used to say a use case may be promoted +as soon as another use case calls it; that rule is gone, and this phase clears what it produced. + +Do this before any extraction: otherwise the dumping ground gets moved rather than emptied. + +## Architecture projection + +> Tree of the final files. ✅ create · ✏️ modify · ❌ delete + +```txt +. +└── cli/src/application/ + ├── commands/shared/spawn-cli-command.ts ✏️ modify (move next to its single caller) + └── use-cases/shared/ + ├── resolve-marketplace-use-case.ts ✏️ modify (stays: 9 callers, several areas) + ├── ensure-built-marketplace-use-case.ts ✏️ modify (stays: 5 callers, several areas) + ├── fetch-marketplace-source-use-case.ts ❌ delete (moves under its only caller) + ├── generate-tool-distribution-use-case.ts ❌ delete (moves under restore) + ├── resolve-restore-decision.ts ❌ delete (moves under restore) + ├── restore-drift-entries-use-case.ts ❌ delete (moves under restore) + ├── restore-merge-files-use-case.ts ❌ delete (moves under restore) + └── restore-regular-files-use-case.ts ❌ delete (moves under restore) +``` + +## User Journey + +```mermaid +flowchart TD + A[A developer looks for a step] --> B{Who calls it?} + B -->|One area| C[It lives in that area] + B -->|Several areas| D[It is shared, and it earned it] +``` + +## Test Scope + +```mermaid +--- +title: Test scope +--- +journey + section Setup + the earned-sharing ratchet lists seven violations => the target is measurable: 5: system + section Happy path + run the whole suite => golden and e2e pass untouched: 5: system + run restore on a drifted project => same output, same files rewritten: 5: cli + section Teardown + the earned-sharing baseline is empty => the rule holds without exception: 5: system +``` + +## Tasks to do + +### `1)` Move the seven down + +> Each goes under the area that calls it. Tests follow their subject. + +1. `fetch-marketplace-source` has one caller, `resolve-marketplace`. It becomes its private step. +2. The four `restore-*` files and `resolve-restore-decision` move under `restore/`. +3. `generate-tool-distribution` moves under `restore/`, its only caller. +4. `commands/shared/spawn-cli-command.ts` moves next to its single caller. + +### `2)` Keep the two that earned it + +1. `resolve-marketplace` and `ensure-built-marketplace` stay. Record in one line each why: nine and + five callers, spread across areas. + +### `3)` Empty the ratchet + +1. Remove the seven entries from the `earned-sharing` baseline. The list must be empty. + +## Test acceptance criteria + +| Task | Acceptance criteria | +| ---- | ------------------- | +| 1 | Every moved file sits under the area that calls it; no `shared/` directory holds a single-caller module | +| 2 | The two survivors still serve every caller they served before | +| 3 | The `earned-sharing` baseline is empty, and the test fails if a new single-caller shared module appears | +| all | Golden and e2e pass **unmodified**: this batch moves files and changes no behavior | diff --git a/cli/aidd_docs/tasks/2026_08/2026_08_20_refactor-contextes-cli/phase-8.md b/cli/aidd_docs/tasks/2026_08/2026_08_20_refactor-contextes-cli/phase-8.md new file mode 100644 index 000000000..6ca04e8d5 --- /dev/null +++ b/cli/aidd_docs/tasks/2026_08/2026_08_20_refactor-contextes-cli/phase-8.md @@ -0,0 +1,87 @@ +--- +status: done +--- + +# Instruction: Put three misplaced units where they belong + +Three units carry a name from one area and do the work of another. Each was found by following what +they write, not what they are called. + +Moving them is what makes `distribution` a leaf: afterwards it knows nothing about tools or about +the installation record. + +## Architecture projection + +> Tree of the final files. ✅ create · ✏️ modify · ❌ delete + +```txt +. +└── cli/src/ + ├── application/use-cases/ + │ ├── plugin/translator/ ✏️ modify (moves under the framework side) + │ ├── marketplace/ + │ │ ├── marketplace-check-use-case.ts ✏️ modify (becomes a cross-area flow) + │ │ ├── marketplace-remove-use-case.ts ✏️ modify (idem) + │ │ └── marketplace-sync-settings-use-case.ts ✏️ modify (idem) + │ └── flows/ ✅ create (holds the three, until phase 13 places them) + └── domain/formats/copilot-marketplace-catalog.ts ✏️ modify (moves to the sourcing side) +``` + +## User Journey + +```mermaid +flowchart TD + A[A unit writes something] --> B{Whose state does it write?} + B -->|The installation record| C[It belongs to framework] + B -->|The marketplace registry| D[It belongs to distribution] + B -->|Both| E[It is a flow, and it says so] +``` + +## Test Scope + +```mermaid +--- +title: Test scope +--- +journey + section Setup + a project with a marketplace and an installed plugin => both states populated: 5: cli + section Happy path + run marketplace check => upstream-removed plugins still reported: 5: cli + run marketplace remove with cleanup => registry entry and orphan files both gone: 5: cli + run setup => marketplace entries still written into each tool's settings: 5: cli + section Edge case - a catalog in Copilot's own format + a .plugin/marketplace.json => list its plugins => parsed as before: 1: cli + section Teardown + nothing under the sourcing side imports a tool profile or the manifest => the leaf holds: 5: system +``` + +## Tasks to do + +### `1)` Move the translator to the framework side + +> Four of its six files import `Manifest` and `Plugin`. + +1. It is not translation, it is translation applied at install time and recorded. Move + `use-cases/plugin/translator/` accordingly. + +### `2)` Name the three flows + +1. `marketplace-check` diffs catalogs against `manifest.getPlugins(toolId)`. +2. `marketplace-remove` deletes plugin files and calls `manifest.removePlugin` then `save`. +3. `marketplace-sync-settings` writes into each tool's settings file. +4. All three cross two areas. Move them out of `marketplace/` into a `flows/` directory. + +### `3)` Move the catalog parser to the sourcing side + +1. `copilot-marketplace-catalog.ts` parses a catalog into `PluginCatalog`. Reading a catalog is + sourcing, not formatting. + +## Test acceptance criteria + +| Task | Acceptance criteria | +| ---- | ------------------- | +| 1 | Installing, updating and restoring a plugin behave as before for every tool | +| 2 | `marketplace check`, `marketplace remove --cleanup` and `setup` behave as before | +| 3 | A Copilot-native catalog is still read correctly | +| all | Nothing left under `marketplace/` imports a tool profile or `Manifest`. Golden and e2e pass **unmodified** | diff --git a/cli/aidd_docs/tasks/2026_08/2026_08_20_refactor-contextes-cli/phase-9.md b/cli/aidd_docs/tasks/2026_08/2026_08_20_refactor-contextes-cli/phase-9.md new file mode 100644 index 000000000..a22c5d072 --- /dev/null +++ b/cli/aidd_docs/tasks/2026_08/2026_08_20_refactor-contextes-cli/phase-9.md @@ -0,0 +1,137 @@ +--- +status: done +--- + +# Instruction: Extract the kernel + +Six modules pass the two-area rule and are the shared vocabulary of every context: tool identity, +where content comes from, project paths, files and their hashes, merge strategies, and errors. + +They get a home and a name, and their names move up from mechanism to concept — the project's own +naming rule. + +## Architecture projection + +> Tree of the final files. ✅ create · ✏️ modify · ❌ delete + +```txt +. +└── cli/src/kernel/ ✅ create + ├── tool.ts ✏️ modify (from domain/models/tool-ids.ts) + ├── source.ts ✏️ modify (from domain/models/plugin-source.ts) + ├── paths.ts ✏️ modify (from domain/models/paths.ts) + ├── file.ts ✏️ modify (from domain/models/file.ts) + ├── merge.ts ✏️ modify (from domain/models/merge.ts) + ├── errors.ts ✏️ modify (from domain/errors.ts) + └── ports/ ✅ create (file-reader, file-writer, hasher, logger, asset-provider) +``` + +## User Journey + +```mermaid +flowchart TD + A[Two contexts need the same word] --> B{Does it carry logic?} + B -->|No, it is vocabulary| C[kernel] + B -->|Yes| D[It belongs to one context, and the other asks] +``` + +## Test Scope + +```mermaid +--- +title: Test scope +--- +journey + section Setup + the shared list is measured => six modules, two areas each: 5: system + section Happy path + run the whole suite => golden and e2e pass untouched: 5: system + section Edge case - a kernel that reaches back + the kernel imports a context => biome refuses the import => the build fails: 1: system + section Teardown + every kernel module is imported by at least two contexts => nothing was promoted by convenience: 5: system +``` + +## Tasks to do + +### `1)` Move the six, renamed to the concept + +1. `tool-ids.ts` becomes `tool.ts`, `plugin-source.ts` becomes `source.ts`. The others keep their + names, which already say the concept. +2. No directory per module: six files, six directories would be structure for its own sake. + +### `2)` Move the shared ports + +1. `file-reader`, `file-writer`, `hasher`, `logger` and `asset-provider` serve at least two + contexts. The rest stay with the context that owns them. + +### `3)` Forbid the reverse edge + +1. Add a biome `override`: the kernel may not import from any context. Verify it refuses a + deliberate violation. + +### `4)` Poser les deux filets dont les extractions suivantes dépendent + +> La phase 10 ne peut pas fermer un contexte sans une frontière à opposer, et aucune extraction ne +> peut se dire réussie sans une mesure du découpage. Les deux viennent ici, avant la première. + +1. **Cliquet de frontière.** Une importation venue d'un autre contexte ne vise qu'un module que le + contexte cible déclare public ; tout le reste est intérieur. La liste des modules publics est la + donnée du test, elle ne peut que rétrécir. C'est ce qui remplace l'`index.ts` retiré de l'arbre + cible — voir `arborescence.md`, invariant 4. +2. **Remettre Stryker en marche.** Il ne tourne pas depuis une montée de TypeScript, et aucun job ni + hook ne l'appelle, ce qui est la raison pour laquelle personne ne l'a vu casser. La phase 14 a + besoin d'une mesure **avant** de redécouper le Manifest, et une mesure prise après ne prouve rien + sur le redécoupage : la réparation doit donc précéder, pas suivre. La campagne large reste la + phase 20. + + Deux réglages, deux causes d'échec distinctes, les deux confirmés en les retirant un par un : + + - `disableTypeChecks: false` — c'est le correctif réel. Avec `tsconfigFile: ""` aucun checker de + types ne tourne, donc l'injection par défaut de `// @ts-nocheck` en tête de chaque fichier + copié ne protégeait rien. Or Stryker copie tout le projet dans son bac à sable, y compris + `dist/` (ignoré par git mais pas par sa copie de fichiers) : l'injection y ajoutait 15 octets à + `dist/cli.js`, et le golden `framework-build-golden.e2e.test.ts` — qui compare le binaire + octet à octet — échouait avant même le premier mutant. + - `vitest.configFile: "vitest.config.ts"` — indépendamment nécessaire, vérifié en le retirant : + sans lui, le runner retombe sur `vitest.workspace.ts`, et le dry-run échoue par timeout + (60000 ms) sur les tests golden plutôt que par diff de contenu. La piste d'origine ("le projet + e2e du workspace fait échouer le golden") pointait donc la bonne case sans en avoir la bonne + raison — le workspace ne fait pas échouer le golden par contenu, il le fait échouer par lenteur. + + Score de mutation mesuré sur `src/domain/models/manifest.ts`, seuil de rupture 50 % : **65.32 % + à 73.87 %** sur quatre lancements consécutifs, sans changement de code entre eux. La borne basse + vient d'un quatrième lancement de vérification indépendant, sous la borne annoncée par les trois + premiers — ce qui confirme la variance plutôt que de la contredire. + + Le chiffre qui sert n'est pas le score mais le reste : **110 mutants survivants sur 421**. Un + changement de comportement sur trois passerait inaperçu dans cet agrégat, et c'est précisément ce + que la phase 14 doit savoir avant de le redécouper. L'écart vient de + 4 mutants statiques qui concentrent ~90 % du temps d'exécution ("static mutants" — voir + l'avertissement de Stryker) : sur une machine partagée sous charge variable, certains expirent + (`timed out`) plutôt que d'être tués ou de survivre proprement, et le compte de survivants en + dépend. La borne basse reste largement au-dessus du seuil de rupture ; ce n'est pas un signal + fiable de tendance run-à-run, seulement une preuve que Stryker tourne et mesure. `ignoreStatic` + (suggéré par l'avertissement) réduirait cette variance si la phase 20 en a besoin. + + Avec `coverageAnalysis: "perTest"`, le runner vitest de Stryker active par défaut le mode `related` + (`vitest.related`) : le dry-run n'exécute donc que les 536 tests dont l'import touche + transitivement `manifest.ts`, pas les 2002 de la suite complète — un sous-ensemble déterministe + (fonction du graphe d'imports du fichier muté, pas d'un tirage aléatoire), donc reproductible et + comparable à la mesure que prendra la phase 14. +3. **Cliquet de taille de dossier.** Un dossier ne porte pas plus de dix fichiers source directs, + règle reprise du harnais de `gouvernail`. Les six dossiers qui dépassaient avant la phase 7 — + à remesurer au moment de poser le cliquet, la phase 7 ayant vidé `shared/` entre-temps : + `domain/models` 29, `domain/ports` 25, `infrastructure/adapters` 23, `domain/formats` 21, + `application/commands` 16, `use-cases/shared` 14. La base de départ est cette liste, et chaque + extraction doit la faire rétrécir — c'est la mesure du découpage, pas une opinion sur lui. + +## Test acceptance criteria + +| Task | Acceptance criteria | +| ---- | ------------------- | +| 1 | Every consumer imports the kernel; no duplicate of a moved module remains | +| 2 | A port in the kernel is used by two contexts or more; a port used by one moved with it | +| 4 | Both ratchets fail on a deliberate violation, and their baselines shrink at every later extraction | +| 3 | An import from the kernel to a context fails the lint, verified by introducing one | +| all | Golden and e2e pass **unmodified** | diff --git a/cli/aidd_docs/tasks/2026_08/2026_08_20_refactor-contextes-cli/plan.md b/cli/aidd_docs/tasks/2026_08/2026_08_20_refactor-contextes-cli/plan.md new file mode 100644 index 000000000..8610a2b41 --- /dev/null +++ b/cli/aidd_docs/tasks/2026_08/2026_08_20_refactor-contextes-cli/plan.md @@ -0,0 +1,115 @@ +--- +objective: "cli/src is organised by functional context, each boundary verified by a test rather than a convention, and adding a sixth tool touches one file." +status: implemented +--- + +# Plan: Refactor the CLI by functional context + +## Overview + +| Field | Value | +| ---------- | ------------------------------------------------------------------------------------- | +| **Goal** | Move from a layer-first tree to four functional contexts, without changing behavior except where a scope change is declared and reviewed on its own | +| **Source** | `aidd_docs/tasks/2026_08/2026_08_20_refactor-contextes-cli/` — nine scoping documents, every figure measured on the code | + +## Phases + +| # | Phase | File | +| --- | ------------------------------------------- | -------------------------------- | +| 1 | Extend the golden net | [`phase-1.md`](./phase-1.md) | +| 2 | Revive and complete the smoke suite | [`phase-2.md`](./phase-2.md) | +| 3 | Delete dead code | [`phase-3.md`](./phase-3.md) | +| 4 | Drop plugin scaffolding | [`phase-4.md`](./phase-4.md) | +| 5 | Split the registration by what it can carry | [`phase-5.md`](./phase-5.md) | +| 6 | Untangle without moving anything | [`phase-6.md`](./phase-6.md) | +| 7 | Dissolve the shared dumping ground | [`phase-7.md`](./phase-7.md) | +| 8 | Put three misplaced units where they belong | [`phase-8.md`](./phase-8.md) | +| 9 | Extract the kernel | [`phase-9.md`](./phase-9.md) | +| 10 | Extract the tools context | [`phase-10.md`](./phase-10.md) | +| 11 | Extract the translate context | [`phase-11.md`](./phase-11.md) | +| 12 | Extract the distribution context | [`phase-12.md`](./phase-12.md) | +| 13 | Extract the framework context | [`phase-13.md`](./phase-13.md) | +| 14 | Split the Manifest aggregate | [`phase-14.md`](./phase-14.md) | +| 15 | Drop the manifest version migrations | [`phase-15.md`](./phase-15.md) | +| 16 | Separate presentation from runtime | [`phase-16.md`](./phase-16.md) | +| 17 | Turn kanban into a launcher | [`phase-17.md`](./phase-17.md) | +| 18 | Move the command surface, by alias | [`phase-18.md`](./phase-18.md) | +| 19 | Rewrite the documentation and the skills | [`phase-19.md`](./phase-19.md) | +| 20 | Make the tests prove they test something | [`phase-20.md`](./phase-20.md) | + +## Resources + +| Source | Verified | +| ---------------------------------------------------------- | ------------------------------------------------------------------------------------ | +| https://github.com/obra/superpowers | Ten host manifests point at one shared `skills/` folder; no content translation. The comparison is limited: they ship only skills and hooks, the capabilities that converged | +| https://biomejs.dev/linter/rules/no-restricted-imports/ | Stable since 1.6, gitignore-style patterns with negation, custom message, applied per directory through `overrides` | +| https://biomejs.dev/linter/rules/no-import-cycles/ | Detects runtime cycles only. Verified: it flags a deliberate cycle and stays silent on the two found by hand, which close through `import type` | +| ai-driven-dev/framework#592 | The roadmap materializes project agents into tool trees, and states that symlinking breaks when formats diverge. Materialization is deliberate | +| ai-driven-dev/framework#465, #468, #464 | `doctor` reports healthy on a project never set up; four install use-cases and four capability classes duplicate; `status --json` is documented and absent | + +## Decisions + +| Decision | Why | +| --------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------- | +| A move and a scope change never share a commit | A neutral batch passes golden and e2e untouched; a scope batch recaptures the snapshot and its diff is the review. Without the split, a 22 800-line refactor is unreviewable | +| Translation is the core, framework is one of its clients | A user on Claude Code can register the marketplace themselves; they cannot convert content into Cursor's `.mdc`, Codex's TOML and Copilot's `.github/instructions` | +| The command surface changes last, through aliases | The e2e net invokes the CLI. Renaming breaks it exactly when it is most needed | +| A tool is not a managed resource, it is the scope of every command | `ai install cursor` already equips a tool with everything; `tool add` would be the same command twice. `--tool` replaces both groups | +| Two ownership regimes get two treatments | Generated files are regenerated; files co-owned with the user are merged. Applying hash tracking to the first is over-engineering, blind rewriting of the second destroys their work | +| Telemetry lands in the current structure and migrates with it | It is being built in parallel with this refactor. Following today's conventions keeps one structure at a time; the cost is that its files move with their layer, so every phase projection has to account for whatever it added | + +## Résultat (2026-09-02) + +Les vingt phases sont livrées, une par commit, chacune avec sa fiche. + +### Ce que `src/` est devenu + +| Zone | Fichiers | Lignes | +| ---- | -------: | -----: | +| `kernel/` | 17 | 1 449 | +| `contexts/tools/` | 47 | 4 609 | +| `contexts/translate/` | 16 | 1 601 | +| `contexts/distribution/` | 23 | 1 447 | +| `contexts/framework/` | 88 | 8 489 | +| `presentation/` | 26 | 2 377 | +| `runtime/` | 38 | 2 284 | +| **total** | **256** | **22 365** | + +Pas de barils, pas d'`index.ts` : la surface publique de chaque contexte est une liste dans +`context-boundary.arch.test.ts`, et les arêtes autorisées sont dans `context-graph.arch.test.ts`. + +### L'objectif, honnêtement + +« Ajouter un sixième outil touche un fichier » est vrai pour tout ce qui est propriété de l'outil : +son profil déclare sa mise en page, ses capacités, ses contrats de build et l'emplacement de son +manifeste. Trois fichiers nomment encore des outils, chacun justifié ligne par ligne dans le socle +de `tool-addition-cost` — un recommandeur qui doit nommer ce qu'il recommande, un artefact de config +qui s'écrit comme son outil, et une liste blanche assumée des trois CLI pilotées. + +### Le filet + +- 1 987 tests sur 982 suites, unitaires majoritaires, intégration et e2e déterministes +- 26 tests d'architecture, chaque règle éprouvée par injection d'une violation de synthèse +- smoke : 98 assertions, 22 / 22 commandes feuilles +- mutation par contexte, couche `domain/` seule : translate 78,63 %, framework 77,97 %, + distribution 74,07 %, tools 61,64 %, kernel 61,60 % — mesurée, jamais bloquante. Ces runs + n'étaient reproductibles par aucune commande gardée ; les scopes commités de + `2026_09_03_mutation-scopes` couvrent chaque contexte en entier et donnent d'autres chiffres + +### Ce qui a été trouvé en chemin, et qui n'était pas au plan + +Chaque garde-fou n'a valu que par ce qu'il a attrapé : + +- une règle d'import `translate` qui ne mordait plus depuis six phases, trouvée en l'éprouvant +- deux suites qui ne se chargeaient plus, invisibles dans un run vert : les suites se comptent, + pas seulement les tests +- un socle de ratchet dont la portée était devenue vide, qui déclarait tout réparé +- deux tests unitaires qui lisaient la vraie config de l'utilisateur +- quatre dépendances d'interface texte chargées à chaque invocation, pour une commande masquée + +### Ce qui reste à décider, et qui appartient à l'utilisateur + +- retirer `ink`, `react`, `cli-table3` et `gray-matter` de `cli/package.json` : kanban les déclare + déjà toutes les quatre, mais cela demande de décider ce que fait `aidd kanban` sans elles +- si kanban se publie à part, avec son propre `bin` +- les marketplaces hébergées, qui gardent la phase 5b ouverte (`marketplaces-heberges.md`) diff --git a/cli/aidd_docs/tasks/2026_09/2026_09_02_e2e-build-isolation/phase-1.md b/cli/aidd_docs/tasks/2026_09/2026_09_02_e2e-build-isolation/phase-1.md new file mode 100644 index 000000000..b9ca4a220 --- /dev/null +++ b/cli/aidd_docs/tasks/2026_09/2026_09_02_e2e-build-isolation/phase-1.md @@ -0,0 +1,143 @@ +--- +status: done +--- + +# Instruction: Build the e2e binary into a directory only that run knows + +## Architecture projection + +> Tree of the final files. ✅ create · ✏️ modify · ❌ delete + +```txt +. +└── cli/ + ├── tsup.config.ts ✏️ modify (outDir from the environment, schemas follow it) + ├── vitest.workspace.ts ✏️ modify (globalSetup on the e2e project) + ├── package.json ✏️ modify (test/test:e2e stop building) + ├── tests/ + │ ├── e2e/ + │ │ ├── global-setup.ts ✅ create + │ │ ├── helpers.ts ✏️ modify (CLI_PATH from the run's own build) + │ │ ├── persona.e2e.test.ts ✏️ modify (same) + │ │ └── update-check.e2e.test.ts ✏️ modify (same) + │ └── architecture/ + │ └── no-shared-binary.arch.test.ts ✅ create + └── aidd_docs/memory/testing.md ✏️ modify (the rule becomes a mechanism) +``` + +## Test Scope + +```mermaid +--- +title: Test scope +--- +journey + section Setup + remove dist/ entirely => no shared binary exists on disk: 5: system + section Happy path + run the e2e project => every journey passes against the run's own build: 5: cli + section Edge case - a concurrent writer + delete dist/ while the e2e project is running => the run finishes green: 5: system + section Edge case - the setup did not run + read CLI_PATH with the variable unset => an error naming the cause, not a fallback: 5: system + section Teardown + after the run => the temporary build directory is gone: 5: system +``` + +## Tasks to do + +### `1)` Let the build write somewhere else + +1. `tsup.config.ts`: `outDir` reads `process.env.AIDD_BUILD_OUT_DIR` and falls back to `dist`. +2. `onSuccess` copies the five schema files into that same directory. They are hardcoded to + `dist/` today, so a build elsewhere would produce a binary whose schemas are missing. + +### `2)` Give the e2e project its own build + +1. `tests/e2e/global-setup.ts`: create a directory under the OS temp dir, run `tsup` into it + with `AIDD_BUILD_OUT_DIR` set, publish the binary's path, and remove the directory on teardown. +2. Register it as `globalSetup` on the `e2e` project in `vitest.workspace.ts`. + +### `3)` Point every reader at it + +1. `helpers.ts`, `persona.e2e.test.ts` and `update-check.e2e.test.ts` read the published path. +2. No fallback: an absent value throws an error saying the e2e global setup did not run. + +### `4)` Stop the second pair of writers + +1. `test` and `test:e2e` drop `pnpm build`; nothing in a test run reads `dist/` any more. +2. `smoke` keeps it — `scripts/smoke-tools.sh` runs the published binary and is a separate command. + +### `5)` Keep the path from coming back + +1. `tests/architecture/no-shared-binary.arch.test.ts`: no file under `tests/` may resolve a path + into `dist/`. Prove it by injecting the original line and watching it fail. +2. `aidd_docs/memory/testing.md`: replace "Run one vitest at a time" with what now makes it + unnecessary. Leave the history — the failure was chased twice. + +## Test acceptance criteria + +| Task | Acceptance criteria | +| ---- | ------------------- | +| 1 | `AIDD_BUILD_OUT_DIR=/tmp/x pnpm exec tsup` produces a runnable `/tmp/x/cli.js` with its five schema files beside it | +| 2 | `rm -rf dist && pnpm exec vitest run --project e2e` passes; the temporary directory is gone afterwards | +| 3 | Reading `CLI_PATH` with the variable unset fails with an error naming the global setup, not with ENOENT on `dist/` | +| 4 | `pnpm test` no longer writes `dist/`; `pnpm smoke` still works | +| 5 | Re-injecting `resolve(process.cwd(), "dist/cli.js")` into a test file fails the new architecture test by name | +| all | Goldens unchanged, 1987 tests over 982 suites, tsc 0, biome 0 | + +## Livrée (2026-09-02) + +Deux choses que la fiche n'avait pas vues, trouvées à l'exécution. + +**`skipNodeModulesBundle` rend le critère 1 infaisable tel qu'écrit.** Les dépendances restent des +imports externes que Node résout en remontant depuis le fichier construit. Un build dans le temp de +l'OS n'a aucun `node_modules` au-dessus de lui : le binaire meurt sur `commander` avant d'imprimer +un mot. Premier correctif tenté : un lien symbolique vers le vrai `node_modules` dans le répertoire +temporaire. + +**Ce lien a révélé pire.** Le `clean: true` de tsup traverse une entrée de répertoire symbolique et +vide sa cible au lieu de délier le lien — prouvé avec une cible jetable dont le fichier témoin a +disparu. Un second build dans le même répertoire aurait vidé le `node_modules` réel du dépôt. + +Le lien a donc été supprimé, pas défendu : le répertoire de build est maintenant sous `cli/` +(`.e2e-build/run-XXXX`, gitignoré). Node y trouve `cli/node_modules` en remontant, sans lien, donc +sans rien que `clean` puisse traverser. Vérifié avec un fichier témoin dans `node_modules` : intact +après un run e2e complet. + +Défendre le danger aurait demandé un effet de bord au chargement du module de config, dont la +justesse dépendait de l'ordre interne de tsup. Supprimer sa cause n'en demande aucun. + +## Vérifié + +| Critère | Preuve | +| ------- | ------ | +| 2 | `rm -rf dist && vitest run --project e2e` => 14 fichiers / 104 tests verts, `dist/` toujours absent, `.e2e-build/` vide après | +| 3 | `globalSetup` retiré => `CLI_PATH is unset: tests/e2e/global-setup.ts did not run…`, pas un ENOENT sur `dist/` | +| 5 | ligne d'origine réinjectée dans `persona.e2e.test.ts` => échec nommant le fichier | +| — | **la course elle-même** : deux `vitest run --project e2e` simultanés, `dist/` absent => `A:0 B:0`, 14/14 et 104/104 des deux côtés | +| all | 1 990 tests / 986 suites, ratios égaux · tsc 0 · biome 487 fichiers 0 · goldens `git diff` vide | + +## Revue (2026-09-02) + +Trois défauts trouvés en relisant le candidat commité, deux causés par lui. + +**`AIDD_BUILD_OUT_DIR` détruisait le répertoire qu'on lui donnait.** `clean: true` vide sa cible +avant de construire. Un répertoire témoin contenant `notes.txt` a disparu, sortie 0, sans un mot — +et le binaire produit là ne démarrait pas, faute de `node_modules` au-dessus de lui. La variable +n'accepte plus que `dist` ou un répertoire sous `.e2e-build/` ; tout le reste lève une erreur qui +dit pourquoi. Vérifié sur les deux pièges : un répertoire hors du paquet et `src`. + +**knip signalait `tests/e2e/global-setup.ts` comme fichier mort.** vitest le charge depuis la +configuration, ce que knip ne suit pas. `cli-knip` est une étape de `pre-push` : la porte était +rouge. Déclaré comme point d'entrée, avec `vitest.mutation.config.ts` que Stryker charge de la même +façon et qui était rouge depuis la phase 20. + +**`warnDeprecated` n'a jamais été câblé.** Ajouté à la phase 18, zéro référence dans `src` comme +dans `tests` — la phase avait conclu qu'un renommage pur garde une sortie identique et le +message n'a jamais servi. Supprimé plutôt qu'ignoré : knip avait raison. + +La preuve de concurrence du premier passage montrait deux runs verts, ce qui n'exclut pas qu'ils se +soient sérialisés par hasard. Refaite en hostile : un processus crée `dist/cli.js`, le remplit +d'ordures et le supprime deux cents fois pendant que la suite e2e tourne. 104 / 104 verts. La +dépendance n'est pas devenue improbable, elle n'existe plus. diff --git a/cli/aidd_docs/tasks/2026_09/2026_09_02_e2e-build-isolation/plan.md b/cli/aidd_docs/tasks/2026_09/2026_09_02_e2e-build-isolation/plan.md new file mode 100644 index 000000000..7cbcca549 --- /dev/null +++ b/cli/aidd_docs/tasks/2026_09/2026_09_02_e2e-build-isolation/plan.md @@ -0,0 +1,39 @@ +--- +objective: "Two vitest runs at once can no longer disturb each other, because no test reads a binary another run can rewrite." +status: implemented +--- + +# Plan: Give each e2e run its own binary + +## Overview + +| Field | Value | +| ----- | ----- | +| **Goal** | Remove the sharing that makes concurrent runs report false golden failures, instead of serialising the runs | +| **Source** | `aidd_docs/memory/testing.md` § "Run one vitest at a time" — the failure seen twice during the context refactor, both times chased as a phantom | + +## The measured cause + +`tests/e2e/helpers.ts:27` resolves `CLI_PATH` to `process.cwd()/dist/cli.js`, and two more +e2e files repeat the same line. `pnpm test` is `pnpm build && vitest run`, and `tsup` runs +with `clean: true`. So a second run deletes and rewrites the binary the first run is +reading, mid-suite. The golden suites capture the same command twice and compare bytes, +which is exactly the assertion a rewrite between the two captures breaks. + +The workaround in memory is a rule for humans: run one at a time. A rule nobody can +enforce is not a guarantee. + +## Phases + +| # | Phase | File | +| - | ----- | ---- | +| 1 | Build the e2e binary into a directory only that run knows | [`phase-1.md`](./phase-1.md) | + +## Decisions + +| Decision | Why | +| -------- | --- | +| Build per run rather than lock the shared one | A lock serialises the runs and keeps the coupling; a private directory removes it. The build costs 66 ms, measured, so there is nothing to save by sharing | +| No fallback to `dist/cli.js` when the variable is absent | A fallback restores the shared path silently, which is the bug. Absent means the setup did not run, and that must say so | +| `pnpm test` stops building | Nothing in the test run reads `dist/` any more. Leaving the build in would keep a second pair of concurrent writers on the same directory for no reader | +| A test forbids the path from coming back | Every other boundary in this repo is held by a test rather than a convention; this one should be too | diff --git a/cli/aidd_docs/tasks/2026_09/2026_09_03_code-mort-que-knip-ne-voit-pas/phase-1.md b/cli/aidd_docs/tasks/2026_09/2026_09_03_code-mort-que-knip-ne-voit-pas/phase-1.md new file mode 100644 index 000000000..3b1ecbeea --- /dev/null +++ b/cli/aidd_docs/tasks/2026_09/2026_09_03_code-mort-que-knip-ne-voit-pas/phase-1.md @@ -0,0 +1,65 @@ +--- +status: done +--- + +# Phase 1 — Ce que le câblage construit et que personne ne lit + +## La mesure, et l'instrument qu'il a fallu réparer deux fois + +Vingt champs de `Deps` sur soixante-et-un ne sont jamais relus. Prouvé par suppression : le +champ retiré de l'interface **et** de son entrée dans le littéral, `tsc` comme juge, dans une +copie hors dépôt. + +Ma première exécution annonçait cinq. Elle était fausse deux fois : + +1. Le bac à sable n'avait pas `assets/`, donc `tsc` échouait à chaque itération et **tous** les + champs paraissaient vivants. Le `DEAD (0)` initial ne mesurait rien. +2. Ma substitution retirait la première ligne `,` du fichier — souvent un argument de + constructeur homonyme — au lieu de l'entrée du littéral, laissant une propriété en trop qui + faisait échouer `tsc` pour une raison étrangère. + +Réparé en ciblant le bloc entre `const deps: Deps = {` et sa fermeture, le résultat rejoint +celui de la relecture indépendante : vingt. **La leçon est de vérifier l'instrument avant de +croire sa lecture**, pas de croire un chiffre parce qu'une commande l'a produit. + +## La cascade, suivie par l'outil et non devinée + +Les vingt champs partis, `tsc --noUnusedLocals` nomme dix imports de type et trois +constructions locales devenues mortes. Les locaux partis, `knip` — qui ne voyait rien jusque-là +parce que les classes restaient construites — signale enfin `wireTranslate`. + +`wireTranslate` ne retournait que `frameworkBuildUseCase`, le champ mort. Son commentaire dit +« `framework build`'s own use case » : une commande fusionnée dans `translate` par la refacto. +La fonction entière tombe. Ce qu'elle construisait survit ailleurs — le validateur de schéma et +la stratégie marketplace sont utilisés par le chemin piloté par le registre, et le contrat +copilot par le profil copilot lui-même. + +`requireAuthUseCase` parti, `RequireAuthUseCase` n'est plus référencé que par son propre fichier +et son propre test : du code dont la seule raison d'exister est son test. + +## La vérification avant suppression + +Supprimer une barrière d'authentification demande de prouver qu'il en reste une. Mesuré : +`http-client.ts:85` lève `AuthenticationError` sur 401 et 403, et les deux adaptateurs de fetch +la traduisent en `CatalogFetchAuthError`. `RequireAuthUseCase` était une barrière laissée +derrière, pas la seule. + +## Le garde a fait son travail + +`RequireAuthUseCase` supprimé, `NotAuthenticatedError` n'est plus levée nulle part, et +`errors-that-are-thrown.arch.test.ts` — posé ce matin, socle vide — l'a nommée immédiatement. +Elle part avec, et son cas de test avec elle. + +## Résultat + +124 lignes supprimées, 1 ajoutée, sur 6 fichiers. Le paquet construit passe de 374,7 à +373,6 Ko. + +## Test + +Gates : tsc propre · lint 508 fichiers 0 warning · knip propre · 2070 tests / 206 fichiers · +arch 51/51 · couverture 93,75 % · paquet 373,6 Ko · 9 cellules golden identiques · +smoke 98/0, 22/22. + +Les neuf cellules golden identiques valent particulièrement ici : vingt champs retirés d'une +racine de composition, et la sortie du binaire ne bouge pas d'un octet. diff --git a/cli/aidd_docs/tasks/2026_09/2026_09_03_code-mort-que-knip-ne-voit-pas/phase-2.md b/cli/aidd_docs/tasks/2026_09/2026_09_03_code-mort-que-knip-ne-voit-pas/phase-2.md new file mode 100644 index 000000000..68e0363cd --- /dev/null +++ b/cli/aidd_docs/tasks/2026_09/2026_09_03_code-mort-que-knip-ne-voit-pas/phase-2.md @@ -0,0 +1,58 @@ +--- +status: done +--- + +# Phase 2 — Les ports dont personne n'appelle les méthodes + +## Quatre méthodes, zéro appelant, quatre vérifications + +Un port déclare, un adaptateur implémente : `knip` voit les deux et conclut à l'usage. Personne +ne vérifie qu'un appelant existe. Chacune des quatre a été vérifiée avant suppression, parce +que retirer une capacité que l'outil est seul à offrir serait une perte et non un nettoyage. + +**`FileMerger.hasLocalChanges`** — « ce fichier a-t-il dérivé de son hash enregistré ». La +dérive est bien détectée, ailleurs et autrement : `detect-plugin-drift-use-case.ts` compare +`readFileHash` au hash attendu directement. La méthode dupliquait cette logique sans appelant. + +**`FileMerger.backup`** — écrit une copie `.bak.`. Rien dans `src` ne l'appelle, et +aucun texte destiné à l'utilisateur ne promet de sauvegarde. `status-use-case.ts` portait un +commentaire disant que le CLI écrit des fichiers `.backup` « exprès » : doublement faux, rien ne +les écrit et la méthode produisait un autre suffixe. Le saut de ces fichiers dans le scan reste +— il épargne ce qu'une version plus ancienne aurait laissé — mais le commentaire dit maintenant +ce qui est vrai. + +**`AssetProvider.loadDefaultMarketplace`** — coûtait plus qu'une méthode : il embarquait +`assets/marketplaces/default.json` dans le binaire, fichier qui duplique deux constantes du +code. L'enregistrement du marketplace du framework se fait dans +`MarketplaceRegisterFrameworkUseCase`, qui dérive sa source lui-même et ne lit ni l'asset ni le +port. + +**`MarketplaceCachePort.list`** — traînait un sous-arbre : `buildEntry`, `computeSize`, +`readLastFetchedAt`, l'entité `MarketplaceCacheEntry` avec son `equals()`, son type de +paramètres, et `EmptyMarketplaceCacheNameError`. Le port se réduit à `clear`, sa seule opération +appelée, par `marketplace refresh --force`. + +Il lisait aussi `.fetch-meta.json`, **un fichier que rien dans le dépôt n'écrit** — seuls les +tests le créaient pour éprouver la lecture. `lastFetchedAt` était donc structurellement toujours +nul en usage réel : un champ dont la valeur ne pouvait pas exister. + +## Ce que ça retire + +``` +2 méthodes de port + leurs implémentations et bouchons +1 entité de domaine et son erreur +1 asset embarqué dans le binaire +21 tests qui n'éprouvaient plus que du code supprimé +``` + +Paquet construit : 373,6 → **371,6 Ko**. + +## Test + +Gates : tsc propre · lint 506 fichiers 0 warning · knip propre · 2051 tests / 206 fichiers · +arch 51/51 · couverture 93,77 % · paquet 371,6 Ko · 9 cellules golden identiques · +smoke 98/0, 22/22. + +Le garde des chemins cités a nommé une ligne de `memory/architecture.md` décrivant l'entité +supprimée, dans la même exécution. C'est le second garde de la journée à attraper une +conséquence que je n'avais pas cherchée. diff --git a/cli/aidd_docs/tasks/2026_09/2026_09_03_code-mort-que-knip-ne-voit-pas/phase-3.md b/cli/aidd_docs/tasks/2026_09/2026_09_03_code-mort-que-knip-ne-voit-pas/phase-3.md new file mode 100644 index 000000000..ea4cdffad --- /dev/null +++ b/cli/aidd_docs/tasks/2026_09/2026_09_03_code-mort-que-knip-ne-voit-pas/phase-3.md @@ -0,0 +1,55 @@ +--- +status: done +--- + +# Phase 3 — Ce qui est produit et jamais consommé + +## Deux champs de contrat que chaque profil remplissait + +`ToolBuildContract.manifestDir` et `.marketplaceRelative`. La preuve n'est pas une absence, +c'est un contraste : les stratégies de construction lisent nommément huit autres champs du même +contrat — `synthesizeManifest`, `manifestFileRelative`, `manifestSchemaName`, `pluginRootToken`, +`artifacts`, `buildMarketplaceCatalog`, `buildMarketplaceEntry`, `emitConfigArtifact`. Ces deux +là, zéro lecture. + +Et chaque profil les payait. Pire, quatre profils calculent un `marketplaceRelative` local, +l'utilisent pour un vrai `destRelPath`, **puis** le repassent dans un champ que personne ne +relit : la même valeur écrite deux fois, dont une pour rien. + +## Un champ qui documentait son propre vide + +`SynthesizeClaudeStyleManifestOpts.manifestDir` portait ce commentaire : + +> Output manifest subdirectory name. **Reserved for caller/future divergence.** + +Réservé pour un avenir qui n'est pas venu, passé par trois profils, lu par personne. La +fonction qui le reçoit ne le touche pas. + +## Deux méthodes de classe + +`AgentsCapability.buildUserFilePath` — neuf lignes de construction de chemin avec une branche +sur `userFileExt`, une seule occurrence dans tout le dépôt : sa déclaration. + +`BulkConflictState.isSet()` — redondante avec `get()`, qui renvoie déjà `null` quand rien n'est +posé. + +## Une observation, pas une action + +`userFileExt` survit à la suppression de `buildUserFilePath` : le profil copilot le pose, et +`equals()` le compare. Son seul consommateur est donc une comparaison d'égalité — il n'influence +plus aucun comportement. Et cet `equals()` n'est appelé que par des tests, comme celui de toutes +les autres capacités. C'est le même défaut de forme, à une échelle qui dépasse cette phase : +noté, pas traité ici. + +## Résultat + +Paquet construit : 371,6 → **370,7 Ko**. + +## Test + +Gates : tsc propre · lint 506 fichiers 0 warning · knip propre · 2051 tests / 206 fichiers · +arch 51/51 · paquet 370,7 Ko · 9 cellules golden identiques · smoke 98/0, 22/22. + +Les neuf cellules identiques comptent ici plus qu'ailleurs : ces champs étaient remplis par les +cinq profils de construction, et la sortie des neuf cellules ne bouge pas d'un octet. C'est la +preuve directe qu'ils n'entraient dans aucun artefact. diff --git a/cli/aidd_docs/tasks/2026_09/2026_09_03_code-mort-que-knip-ne-voit-pas/phase-4.md b/cli/aidd_docs/tasks/2026_09/2026_09_03_code-mort-que-knip-ne-voit-pas/phase-4.md new file mode 100644 index 000000000..c9af70bf6 --- /dev/null +++ b/cli/aidd_docs/tasks/2026_09/2026_09_03_code-mort-que-knip-ne-voit-pas/phase-4.md @@ -0,0 +1,40 @@ +--- +status: done +--- + +# Phase 4 — L'exigence que personne ne vérifiait + +## Le défaut, qui n'est pas de l'encombrement + +`AiTool.requiredIdeIds` : déclaré une fois dans le contrat, affecté une fois par le profil +copilot — `requiredIdeIds: ["vscode"]` — et lu **nulle part**. Trois occurrences dans tout le +dépôt, aucun test. + +Ce n'est pas un champ inutile de plus. C'est une exigence écrite que rien n'applique : +`aidd framework install --tool copilot` sur un projet sans vscode passe sans un mot. Une +promesse tacite qui ne tient pas est pire qu'une absence de promesse. + +## L'arbitrage : appliquer ou retirer + +La dépendance est réelle — copilot écrit dans `.vscode/mcp.json` et `.vscode/settings.json`. +La question était donc de savoir laquelle des deux issues est juste. + +Mesuré : **la dépendance est déjà déclarée ailleurs, et cette déclaration-là fonctionne.** +`install-ide-tool-use-case.ts` propage les réglages d'un outil IA vers un IDE en filtrant sur +`c.requiresTool === ideId` — une déclaration portée par la **capacité**, pas par l'outil. Et le +profil copilot la porte bien : `requiresTool: "vscode"` sur sa capacité `settings`. + +Copilot déclarait donc la même chose deux fois : une fois par outil, jamais lue, et une fois +par capacité, honorée. La granularité par capacité est aussi la bonne — c'est un réglage +précis qui a besoin de l'IDE, pas l'outil entier. + +Retirée, donc. Non parce qu'elle était vide, mais parce qu'elle doublait une déclaration qui +marche. + +## Test + +Gates : tsc propre · lint 506 fichiers 0 warning · knip propre · 2051 tests / 206 fichiers · +arch 51/51 · 9 cellules golden identiques · smoke 98/0, 22/22. + +`requiresTool` reste, et c'est lui qui porte la règle. Un futur outil qui a besoin d'un IDE le +déclare sur la capacité concernée, où quelque chose le lira. diff --git a/cli/aidd_docs/tasks/2026_09/2026_09_03_code-mort-que-knip-ne-voit-pas/phase-5.md b/cli/aidd_docs/tasks/2026_09/2026_09_03_code-mort-que-knip-ne-voit-pas/phase-5.md new file mode 100644 index 000000000..d2417188b --- /dev/null +++ b/cli/aidd_docs/tasks/2026_09/2026_09_03_code-mort-que-knip-ne-voit-pas/phase-5.md @@ -0,0 +1,36 @@ +--- +status: done +--- + +# Phase 5 — Le garde qui rend cet angle mort visible + +## Ce qu'il attrape, et pourquoi rien ne l'attrapait + +Un port déclare, un adaptateur implémente. Les deux fichiers nomment la méthode, donc `knip` +les compte utilisés tous les deux. Personne ne vérifie qu'un **appelant** existe. + +Quatre méthodes ont vécu ainsi, retirées en phase 2, et la même cécité a gardé +`GitAdapter.installPreCommitDelegate` en vie depuis le jour de son arrivée. + +Le garde compare les cinquante méthodes déclarées par les vingt fichiers de `ports/` aux +`.methode(` écrits ailleurs dans `src`. Socle vide, et il l'était déjà avant que je l'écrive : +les phases 2 et 4 ont vidé la liste, ce garde empêche qu'elle se remplisse. + +## Ce qu'il ne prouve pas, écrit dans le garde + +La vérification est volontairement grossière. Une méthode dont le nom est partagé par une autre +sera lue comme appelée. Le garde ne peut donc pas prouver qu'une méthode de port est atteinte +sur un chemin réel — seulement que **personne, nulle part, n'écrit son nom comme un appel**. + +C'est le cas qu'il existe pour attraper, et les quatre méthodes de la phase 2 étaient +exactement celui-là. Le dire dans le fichier évite qu'on lui prête plus de portée qu'il n'en a +— la faute que cette session a passé la journée à corriger ailleurs. + +## Test + +Sonde : une méthode ajoutée à `FileMerger` que rien n'appelle échoue en la nommant. Une +troisième assertion refuse que le garde passe en ne sélectionnant aucun port. + +Gates : tsc propre · lint 507 fichiers 0 warning · knip propre · 2054 tests / 207 fichiers · +arch 54/54 · couverture 93,84 % · paquet 370,7 Ko · 9 cellules golden identiques · +smoke 98/0, 22/22. diff --git a/cli/aidd_docs/tasks/2026_09/2026_09_03_code-mort-que-knip-ne-voit-pas/plan.md b/cli/aidd_docs/tasks/2026_09/2026_09_03_code-mort-que-knip-ne-voit-pas/plan.md new file mode 100644 index 000000000..48e8877ae --- /dev/null +++ b/cli/aidd_docs/tasks/2026_09/2026_09_03_code-mort-que-knip-ne-voit-pas/plan.md @@ -0,0 +1,78 @@ +--- +objective: "Rien ne subsiste qui soit construit, implémenté ou produit sans que personne ne le lise." +status: implemented +--- + +# Plan : le code mort que `knip` ne peut pas voir + +## L'angle mort, nommé + +`pnpm knip` ne signale rien, sans exclusion. Il ne peut pourtant pas voir la forme de mort la +plus courante ici : **un objet construit, une méthode implémentée, un champ produit**. Tous +comptent comme utilisés parce que quelqu'un les écrit. Personne ne vérifie que quelqu'un les +lit. + +C'est ainsi que `GitAdapter.installPreCommitDelegate` a survécu à la migration sans jamais +avoir d'appelant, et c'est le même angle mort qui couvre tout ce qui suit. + +## Ce qui est mesuré, et comment + +**Vingt champs de `Deps` sur soixante-et-un ne sont jamais relus.** Prouvé par suppression : +chaque champ retiré de l'interface **et** du littéral, `tsc` comme juge, dans une copie hors +dépôt. + +``` +hasher · cliUpdater · platform · authStorage · http · pluginCatalogRepository +pluginFetcher · pluginDistributionReader · marketplaceRegistry · marketplaceTrustStore +pluginAddUseCase · frameworkBuildUseCase · pluginInstallFromMarketplaceUseCase +resolveMarketplaceUseCase · ensureBuiltMarketplaceUseCase · installRuntimeConfigUseCase +installIdeConfigUseCase · pluginPickUseCase · syncConflictResolverUseCase +requireAuthUseCase +``` + +Ma première exécution disait cinq, et elle était fausse deux fois : le bac à sable n'avait pas +`assets/`, donc `tsc` échouait toujours ; puis ma substitution retirait un argument de +constructeur homonyme au lieu de l'entrée du littéral. Instrument réparé, résultat identique à +celui de la relecture indépendante. **La leçon est de vérifier l'instrument avant la lecture**, +pas de croire un chiffre parce qu'il est sorti d'une commande. + +**Quatre méthodes de port implémentées et jamais appelées** : `FileMerger.hasLocalChanges`, +`FileMerger.backup`, `AssetProvider.loadDefaultMarketplace`, `MarketplaceCachePort.list`. Zéro +site d'appel dans `src`. + +**Deux méthodes de classe** avec une seule occurrence dans tout le dépôt, leur propre +déclaration : `AgentsCapability.buildUserFilePath`, `BulkConflictState.isSet`. + +**Deux champs de contrat que chaque profil remplit et que rien ne lit** : +`ToolBuildContract.manifestDir` et `.marketplaceRelative`, zéro lecture dans `translate`, alors +que les stratégies lisent nommément huit autres champs du même contrat. + +**Un invariant déclaré que rien n'applique** : `AiTool.requiredIdeIds`, déclaré une fois, +affecté une fois par le profil copilot, jamais lu. `aidd framework install --tool copilot` sur +un projet sans vscode passe sans rien dire. Ce n'est pas de l'encombrement : c'est une exigence +écrite que personne ne vérifie, et les deux issues — l'appliquer ou la retirer — valent mieux +que le silence. + +## Une affirmation de la relecture que je rejette + +`AIDD_BUILD_OUT_DIR` serait « écrit et jamais lu ». Il est lu, dans `tsup.config.ts`. Le grep +couvrait `src/`, `tests/` et `scripts/`, pas la racine du paquet. Même erreur de périmètre que +celle que ce dépôt passe la journée à corriger, cette fois du côté de la relecture. + +## Phases + +| # | Phase | Ce qu'elle ferme | +| - | ----- | ---------------- | +| 1 | Ce que le câblage construit et que personne ne lit | 20 champs, `wireTranslate` | +| 2 | Les ports dont personne n'appelle les méthodes | 4 méthodes, et le sous-arbre que `list` traîne | +| 3 | Ce qui est produit et jamais consommé | 2 champs de contrat, 2 méthodes de classe | +| 4 | L'exigence que personne ne vérifie | `requiredIdeIds` : appliquer ou retirer | +| 5 | Le garde qui rend cet angle mort visible | un port dont une méthode n'a pas d'appelant échoue | + +## Ce qui n'est pas dans ce plan + +Les treize champs de résultat produits et jamais consommés — `inSync`, `rebuilt`, +`orphanCount`, `totalPluginFilesRestored` et les autres. Les retirer peut être une suppression +de fonctionnalité plutôt qu'un nettoyage : quelqu'un a voulu compter les fichiers restaurés, et +la question de savoir si l'utilisateur devrait les voir n'est pas une question de code mort. +Ils sont listés dans le rapport de cartographie et attendent un arbitrage. diff --git a/cli/aidd_docs/tasks/2026_09/2026_09_03_dependances-kanban/plan.md b/cli/aidd_docs/tasks/2026_09/2026_09_03_dependances-kanban/plan.md new file mode 100644 index 000000000..d63d701b7 --- /dev/null +++ b/cli/aidd_docs/tasks/2026_09/2026_09_03_dependances-kanban/plan.md @@ -0,0 +1,86 @@ +--- +objective: "Personne ne télécharge 24 Mo pour une commande qu'il ne voit pas." +status: implemented +--- + +# Plan : les quatre dépendances que le CLI portait pour le kanban + +## La mesure + +``` +50 paquets, 24,0 Mo installés chez chaque utilisateur d'aidd + es-toolkit 16,86 Mo (dépendance d'ink) + react-reconciler 1,64 Mo + ink 1,07 Mo + type-fest 1,06 Mo + js-yaml 1,00 Mo +``` + +Pour `aidd kanban` : une commande **cachée**, dont le code dit d'elle-même qu'elle « n'est pas +prête à être proposée aux utilisateurs » et qu'il faut « la démasquer quand sa direction produit +sera tranchée ». + +Deux mesures fausses en route, corrigées : mon premier relevé de 1,9 Mo venait d'une résolution +`require.resolve` bloquée par les champs `exports`, et il fallait indexer le magasin `.pnpm` pour +voir l'arbre réel. Le chiffre de 24 Mo, lui, tenait depuis le début. + +## Pourquoi les options se réduisaient à une + +`optionalDependencies` ne fait rien ici : npm et pnpm les installent par défaut, et ne les sautent +que si l'installation *échoue*. Écarté après vérification, pas avant. + +Un paquet publié à part : écarté par le propriétaire du produit. + +Un lanceur qui trouve et exécute un binaire — la tâche 1 de la phase 17 du refactor — demande que +kanban ait un point d'entrée. Or `kanban/src/presentation/kanban-deps.ts` dit l'inverse en toutes +lettres : « The kanban source is a folder inside the framework, not a standalone package: it never +reaches for the host's modules itself. » Il reçoit son canal de sortie et son répertoire de docs de +l'hôte qui le monte. Un lanceur demanderait d'inverser ce design : un paquet de plus sans le nom. + +Restait : payer, ou débrancher. + +## Ce qui est fait + +La commande est débranchée. `kanban/` garde son source, ses 68 tests et son `pnpm test:kanban`. +Rebrancher coûte un fichier et quatre lignes de manifeste — mais en respectant l'invariant cette +fois, ce que le source de kanban ne permet pas encore. + +Retirées de `cli/package.json` : `ink`, `react`, `cli-table3`, `gray-matter`, et en développement +`@types/react` et `ink-testing-library`. `knip.json` n'ignore plus aucune dépendance — cette +liste d'ignorés existait exactement pour ces quatre. + +Le hook `cli-typecheck` n'installe plus les dépendances de `kanban/` : il ne les type-vérifie plus. + +`splitting` passe à `false` dans `tsup.config.ts`. Il était à `true` pour que les imports différés +des deux vues du kanban le restent ; ce différé n'existe plus, et la sortie est un seul fichier +dans les deux cas. Le commentaire disait une raison disparue, ce qui est le défaut que cette +session passe son temps à corriger. + +## Gains mesurés + +| | Avant | Après | +| - | ----: | ----: | +| Paquets installés pour le kanban | 50 | 0 | +| Poids | 24,0 Mo | 0 | +| Paquet construit | 389,8 Ko | 374,8 Ko | + +## Ce que ça ouvre, et qui reste à faire + +`knip.json` n'ignore plus rien, mais le script CI garde `--exclude exports,types`. Sans cette +exclusion, l'outil signale neuf exports morts qui n'ont rien à voir avec le kanban : + +``` +marketplaceProbes contexts/translate/domain/plugin-format.ts +parseEntryKeys kernel/merge.ts +InvalidToolIdError kernel/errors.ts +PluginTargetExistsError kernel/errors.ts +MarketplaceEntryAlreadyExistsError kernel/errors.ts +AdoptRequiresVersionError kernel/errors.ts +InvalidCategoryError kernel/errors.ts +FileDiff (type) kernel/file.ts +ConflictDecision (type) kernel/merge.ts +``` + +L'exclusion reste donc en place aujourd'hui. La retirer est le prochain geste, une fois ces neuf +tranchés un par un — cinq erreurs typées qui ne sont jamais levées demandent de vérifier qu'aucun +chemin utilisateur ne les attendait. diff --git a/cli/aidd_docs/tasks/2026_09/2026_09_03_dette-du-raisonnement/phase-1.md b/cli/aidd_docs/tasks/2026_09/2026_09_03_dette-du-raisonnement/phase-1.md new file mode 100644 index 000000000..6b556fc8f --- /dev/null +++ b/cli/aidd_docs/tasks/2026_09/2026_09_03_dette-du-raisonnement/phase-1.md @@ -0,0 +1,48 @@ +# Phase 1 — La paire que la raison du noyau omettait + +status: done + +## Le défaut + +`src/kernel` restait au socle avec cette raison : « le vocabulaire que parlent les quatre +contextes : errors, file, paths, markdown, jsonc, merge, scope, source, tool. Un dossier ici +serait une catégorie inventée pour le compte. » + +Neuf noms pour onze fichiers. Les deux absents sont exactement ceux qui réfutent la phrase : +`flat-paths.ts` et `relative-link-rewrite.ts`. + +## La mesure + +```sh +grep -rln "kernel/flat-paths.js" src --include='*.ts' | grep -v '^src/kernel/' +grep -rln "kernel/relative-link-rewrite.js" src --include='*.ts' | grep -v '^src/kernel/' +``` + +| Fichier | Appelants | +| ------- | --------- | +| `flat-paths.ts` | 5 `profiles/*/build.ts` + `translate/…/flat-build-strategy.ts` | +| `relative-link-rewrite.ts` | les mêmes, plus `tools/domain/marketplace-catalog.ts` et `translate/…/marketplace-strategy-helpers.ts` | + +Huit fichiers distincts, deux contextes, cinq d'entre eux lisant les deux modules. C'est le +recouvrement qui compte, pas un total : « lus par les mêmes huit fichiers » serait faux, et +cette formulation fausse est passée d'ici dans le commentaire du test et dans le plan. + +## Le nom, qui était le vrai arbitre + +`flat/` aurait menti : `relative-link-rewrite` sert aussi le chemin marketplace. Le critère +n'était pas « ces deux fichiers vont-ils ensemble » mais « existe-t-il un nom honnête qui +couvre les appelants des deux ». `materialization/` le fait : les deux sont des primitives de +matérialisation de contenu — où le fichier atterrit, comment ses liens suivent — et les deux +formes de matérialisation, flat et marketplace, les appellent. + +Sans ce nom, le bon geste aurait été de corriger la raison, pas de déplacer. + +## Résultat + +`src/kernel` passe de 11 à 9 et quitte le socle. Il ne reste qu'une entrée. + +## Test + +`pnpm test:arch` — le socle de taille signale `src/kernel` comme « fixed », la carte du code +réclame `materialization/`. Les deux sont les gardes qui font leur travail, pas des +régressions. diff --git a/cli/aidd_docs/tasks/2026_09/2026_09_03_dette-du-raisonnement/phase-2.md b/cli/aidd_docs/tasks/2026_09/2026_09_03_dette-du-raisonnement/phase-2.md new file mode 100644 index 000000000..47b0f8f89 --- /dev/null +++ b/cli/aidd_docs/tasks/2026_09/2026_09_03_dette-du-raisonnement/phase-2.md @@ -0,0 +1,65 @@ +# Phase 2 — L'arbre de test suit l'arbre de source + +status: done + +## Le défaut + +Le commit `224deafa` disait fermer ceci : « trois classes de capacité posées à côté du dossier +qui tient les cinq autres — même suffixe, même rôle, deux emplacements, aucune raison écrite ». +Après le commit, la phrase restait vraie mot pour mot de l'arbre de test. + +`folder-size` ne l'a pas vu parce qu'il ne mesure que `src/` : `sourceFiles()` marche sur +`join(CLI_ROOT, "src")`. Le défaut n'a pas été supprimé, il a été déplacé hors de l'arbre +mesuré. + +## L'option écartée, et pourquoi + +Étendre `sourceFiles()` à `tests/` était l'autre réponse. Mesure faite avant de choisir : + +``` +17 tests/helpers/ports +17 tests/contexts/framework/application +15 tests/architecture +14 tests/contexts/framework/application/framework/translator +13 tests/e2e +11 tests/kernel +11 tests/contexts/framework/application/plugin +``` + +Sept dossiers au-dessus de la limite. Le socle passerait de deux entrées à neuf, dans une +session dont la règle est qu'un socle ne fait que rétrécir. Écarté. + +## Ce qui bouge + +Neuf fichiers de test, en miroir des déplacements de source : + +- `tests/contexts/tools/domain/{mcp,plugins,settings}-capability.unit.test.ts` → `capabilities/` +- `tests/…/install/install-{agents,commands,rules,skills}-use-case.unit.test.ts` → `content/` +- `tests/kernel/{flat-paths,relative-link-rewrite}.unit.test.ts` → `materialization/` + +Trois dossiers parents, pas deux, et il faut le dire précisément parce que la version +précédente de cette phrase disait le contraire de sa propre liste : + +| Parent | avant | après | +| ------ | ----: | ----: | +| `tests/contexts/tools/domain` | 7 | 7 | +| `tests/…/application/install` | 10 | 6 | +| `tests/kernel` | **11** | 9 | + +Deux des trois étaient sous la limite : pour ceux-là le gain est de lisibilité, le test se +trouvant là où se trouve ce qu'il teste. Le troisième, `tests/kernel`, était à onze — il figure +deux paragraphes plus haut, dans la liste même des sept dossiers au-dessus de la limite. Sous +l'hypothèse écartée, le déplacer l'aurait sorti du socle : pour ce dossier-là, c'est bien un +gain de compte. + +Rien n'a été truqué, puisque aucun ratchet ne mesure `tests/`. Mais la phrase qui défendait ce +déplacement contre l'accusation de fausse mesure était elle-même une fausse mesure, et elle +contredisait un tableau imprimé au-dessus d'elle. + +## Test + +```sh +git diff -M -- tests/contexts tests/kernel | grep -E '^[+-]' | grep -vE '^(\+\+\+|---)' | grep -cvE '(import|from ")' +``` + +`0` — aucune ligne modifiée hors import. Déplacement pur. diff --git a/cli/aidd_docs/tasks/2026_09/2026_09_03_dette-du-raisonnement/phase-3.md b/cli/aidd_docs/tasks/2026_09/2026_09_03_dette-du-raisonnement/phase-3.md new file mode 100644 index 000000000..827149ac7 --- /dev/null +++ b/cli/aidd_docs/tasks/2026_09/2026_09_03_dette-du-raisonnement/phase-3.md @@ -0,0 +1,39 @@ +# Phase 3 — Un compteur qu'on ne peut plus écrire faux + +status: done + +## Le défaut + +Le socle de `folder-size` portait ses comptes en commentaire : + +```ts +// 14 — thirteen files, one per command … The two helpers could move and would leave twelve +"src/presentation/commands", +``` + +Treize plus deux ne font pas quatorze, et le vrai compte n'est ni l'un ni l'autre : onze +fichiers enregistrent une commande, `menu.ts` porte la boucle interactive, deux sont des +utilitaires. Douze plus deux. + +`expectRatchet` compare des noms de dossier. Rien ne lisait ces nombres, donc rien ne pouvait +les contredire — et l'erreur a survécu dans quatre documents. + +## Ce qui change + +Le socle devient `{ path, count }` et un test compare le compte enregistré à celui mesuré. +Un nombre écrit sans être mesuré échoue immédiatement, et une dérive silencieuse aussi. + +La sonde du dossier synthétique traverse maintenant `expectRatchet` au lieu de s'arrêter au +détecteur — le critère de la phase précédente promettait « échoue **le socle** en le +nommant », et seule la moitié était couverte. + +## Test + +Sonde manuelle, en mettant délibérément `count: 13` : + +``` +× holds each baseline entry to the count its reason was written around + → expected [ 'src/presentation/commands: 14' ] to deeply equal [ 'src/presentation/commands: 13' ] +``` + +L'affirmation exacte qui a survécu quatre fois échoue maintenant à l'écriture. diff --git a/cli/aidd_docs/tasks/2026_09/2026_09_03_dette-du-raisonnement/phase-4.md b/cli/aidd_docs/tasks/2026_09/2026_09_03_dette-du-raisonnement/phase-4.md new file mode 100644 index 000000000..c80343200 --- /dev/null +++ b/cli/aidd_docs/tasks/2026_09/2026_09_03_dette-du-raisonnement/phase-4.md @@ -0,0 +1,54 @@ +# Phase 4 — Les chiffres faux, là où ils sont écrits + +status: done + +## Ce qui était faux + +| Écrit | Mesuré | +| ----- | ------ | +| « biome 485 files » | `pnpm lint` dit `Checked 511 files`. 485 est le compte de `biome check src tests` : un chiffre réel, pris d'une commande plus étroite que la gate qu'il nommait | +| « 2 032 tests over 1 001 suites » | `Test Files 205 passed`, `Tests 2032 passed` | +| « dix-neuf chemins d'import périmés dans la liste des modules publics » | neuf entrées sur dix-huit lignes, dont quatre dans cette liste | +| « 36 files of 62 » touchent le manifeste | 41 sur 62, prédicat énoncé ci-dessous | +| tableau des sous-dossiers de `framework` | huit lignes sur dix ; `framework/` et `shared/` manquaient | +| `install 6/12`, `uninstall 3/4` | comptes d'avant déplacement présentés comme le relevé | +| « ses seuls importateurs » (phase-2) puis « ses quatre importateurs » | quatre, dont le câblage et le test | + +## Le prédicat, qui manquait + +Un chiffre sans son prédicat n'est pas une mesure, c'est une assertion. Celui-ci : + +```sh +grep -rlE 'from "[^"]*[Mm]anifest' src/contexts/framework/application/ --include='*.ts' +``` + +41/62. Reproduit indépendamment par une relecture qui n'avait pas vu le mien. + +## Ce que le chiffre ne prouvait pas + +Le non-découpage de `contexts/framework` tient — deux relectures indépendantes y arrivent — +mais pas par ce chiffre. Un couplage dense au manifeste plaide pour un manifeste *partagé*, +pas contre un découpage : ce dépôt porte déjà une quatrième chose dont les contextes +dépendent, elle s'appelle `src/kernel`. Ce qui tranche est qualitatif et vérifiable en lisant +le dossier : un contexte possède un concept, celui-ci possède le relevé d'installation, et le +manifeste est le cycle de vie de ce relevé — créé par install, lu par doctor, réécrit par sync, +rejoué par restore. Découpé en trois, personne ne le possède. + +Le chiffre reste, avec son prédicat. Il n'est plus l'argument. + +## Ce qui reste hors de portée + +`224deafa` et `884501da` portent les chiffres faux dans leur message. Rien n'est poussé, la +réécriture reste possible ; elle n'est pas prise ici parce que réécrire l'historique n'est pas +une décision d'agent. Le message de clôture les nomme. + +## Test + +```sh +grep -rnE '\b485\b|\b1001\b|dix-neuf' cli/aidd_docs/tasks/2026_09/ +``` + +Six lignes, dans trois fichiers — toutes des lignes qui enregistrent la correction, aucun +chiffre faux ne subsiste. La première version de cette fiche annonçait « une seule +occurrence » : un résultat de test écrit sans avoir été lancé, dans le document dont le sujet +est les chiffres écrits sans avoir été mesurés. Corrigé en le lançant. diff --git a/cli/aidd_docs/tasks/2026_09/2026_09_03_dette-du-raisonnement/plan.md b/cli/aidd_docs/tasks/2026_09/2026_09_03_dette-du-raisonnement/plan.md new file mode 100644 index 000000000..875ff3104 --- /dev/null +++ b/cli/aidd_docs/tasks/2026_09/2026_09_03_dette-du-raisonnement/plan.md @@ -0,0 +1,42 @@ +# Payer la dette du raisonnement, pas seulement celle des dossiers + +status: implemented + +## D'où ça vient + +Une relecture indépendante du commit `224deafa` a confirmé le déplacement — neuf renommages +identiques octet pour octet une fois les préfixes d'import normalisés, trois socles repointés +et jamais grossis, les neuf cellules golden inchangées. Elle a trouvé dix défauts, et aucun +n'est dans le code déplacé : ils sont tous dans le raisonnement écrit autour. + +C'est la même signature que le reste de la session. Je mesure un échantillon, je conclus sur +l'ensemble, et j'écris la conclusion comme une mesure. Ici : « aucun regroupement non +arbitraire dans le noyau » alors que la paire qui le réfute est dans le dossier ; « treize +fichiers, un par commande » alors qu'il y en a douze et que treize plus deux ne font pas +quatorze ; « 485 fichiers lintés, 1 001 suites » alors que l'outil dit 511 et 205. + +## Ce qu'on obtient + +Les compteurs cessent d'être des affirmations. L'arbre de test cesse de cacher le défaut que +l'arbre de source vient de payer. Le socle de taille tombe à une entrée. + +## Phases + +| # | Phase | Ce qu'elle ferme | +| - | ----- | ---------------- | +| 1 | La paire que la raison du noyau omettait | F3 | +| 2 | L'arbre de test suit l'arbre de source | F1 | +| 3 | Un compteur qu'on ne peut plus écrire faux | F2, F9, F10 | +| 4 | Les chiffres faux, là où ils sont écrits | F4, F5, F6, F8 | + +## Ce qui reste hors de portée + +Deux commits déjà écrits (`224deafa`, `884501da`) portent les chiffres de gate faux. Rien +n'est poussé, donc la réécriture reste possible ; elle n'est pas prise ici parce que +réécrire l'historique n'est pas une décision d'agent. Le commit de clôture les nomme et +donne les vrais chiffres, pour que le lecteur du journal trouve la correction sans la +chercher. + +Le non-découpage de `contexts/framework` tient : deux relectures indépendantes y arrivent. +Ce qui saute est le chiffre qui le justifiait, irreproductible sous tout prédicat essayé. +La raison qualitative reste, elle est vérifiable en lisant le dossier. diff --git a/cli/aidd_docs/tasks/2026_09/2026_09_03_gardes-qui-mesurent-ce-quils-nomment/phase-1.md b/cli/aidd_docs/tasks/2026_09/2026_09_03_gardes-qui-mesurent-ce-quils-nomment/phase-1.md new file mode 100644 index 000000000..10abc12b3 --- /dev/null +++ b/cli/aidd_docs/tasks/2026_09/2026_09_03_gardes-qui-mesurent-ce-quils-nomment/phase-1.md @@ -0,0 +1,66 @@ +--- +status: done +--- + +# Phase 1 — Le contexte que la frontière ne regardait pas + +## Le défaut + +`context-boundary.arch.test.ts` porte cette ligne : + +```ts +if (owner === null || !(owner in publicModules)) continue; +``` + +`PUBLIC_MODULES` avait trois clés — `tools`, `translate`, `distribution` — et quatre contextes +existent sur disque. Tout fichier de `framework` était donc sauté **avant** toute vérification. +Non pas autorisé : jamais regardé. + +Le fichier annonçait sa propre lacune dans son commentaire d'en-tête : la liste devait grandir +« as `framework` and `distribution` are extracted ». Seul `distribution` l'a été. + +## Mesure + +Quinze imports atteignaient l'intérieur de `framework`, treize modules distincts, racine de +composition exclue. Sonde de la relecture : un import vers un module profond de `framework` +passe le test ; le même geste vers l'un des trois autres contextes échoue. + +## Ce qui est fait + +**La surface publique de `framework` est déclarée**, douze modules groupés par rôle comme les +trois autres : le relevé d'installation qu'il possède, les flux qu'une commande pilote, ce +qu'un affichage ou une invite lit pour rendre une décision qu'il ne prend pas, et l'unique +opération qu'un autre contexte demande vraiment. + +**Un méta-contrôle empêche la lacune de se reformer** : chaque répertoire sous `src/contexts/` +doit avoir son entrée. Sans lui, créer un cinquième contexte le laisserait libre par défaut, +silencieusement — c'est le geste que `import-rules-bite` fait déjà pour biome, appliqué ici. + +Il vient avec sa propre sonde, qui documente le mode de défaillance plutôt que de l'affirmer : +un contexte non déclaré produit zéro violation, le même déclaré avec une surface vide en +produit une. + +## Ce que la clôture a révélé, et qui n'était pas un problème de garde + +Deux imports restaient en violation après la déclaration : +`runtime/self-update/{check-update,self-update}-use-case.ts → framework/domain/semver.ts`. + +Les déclarer publics aurait écrit un mensonge dans la clôture : comparer des versions n'a rien +à voir avec le relevé d'installation. Mesuré : 18 lignes, aucun import, quatre lecteurs — deux +dans `framework`, deux dans `runtime`. C'est du vocabulaire parlé par deux aires, exactement la +règle d'appartenance au noyau que `scope.ts` et `merge.ts` satisfont déjà. + +`semver.ts` rejoint donc `kernel/`. Le noyau passe de 9 à 10 fichiers directs, à la limite et +sous elle. + +Au passage, ma première liste de lecteurs en comptait deux et il y en avait quatre : je grepais +`domain/semver.js`, ce qui rate `../semver.js`. La même erreur de périmètre que ce plan +corrige, commise en le corrigeant. + +## Test + +`pnpm test:arch` — 41 tests, dont le méta-contrôle et sa sonde. La clôture de `framework` mord : +avant le déplacement de `semver.ts`, elle nommait les deux imports fautifs. + +Gates : tsc propre · lint 510 fichiers 0 warning · knip propre · 2063 tests / 207 fichiers · +arch 41/41 · 9 cellules golden identiques · smoke 98/0, 22/22. diff --git a/cli/aidd_docs/tasks/2026_09/2026_09_03_gardes-qui-mesurent-ce-quils-nomment/phase-2.md b/cli/aidd_docs/tasks/2026_09/2026_09_03_gardes-qui-mesurent-ce-quils-nomment/phase-2.md new file mode 100644 index 000000000..50d887d0d --- /dev/null +++ b/cli/aidd_docs/tasks/2026_09/2026_09_03_gardes-qui-mesurent-ce-quils-nomment/phase-2.md @@ -0,0 +1,98 @@ +--- +status: done +--- + +# Phase 2 — Les gardes dont le périmètre est plus étroit que le nom + +## Quatre candidats, trois défauts + +| Garde | Périmètre | Verdict | +| ----- | --------- | ------- | +| `codebase-map` | l'arbre vers la carte, par **noms** de répertoire | réel — cinq blocs fantômes invisibles | +| `referenced-paths` | `.claude/skills` seulement | réel — huit citations mortes ailleurs | +| `docs-do-not-lie` | deux documents | réel — le brief enseignait l'ancienne surface | +| `no-shared-binary` | `tests/` seulement | **écarté**, mesuré | + +## `codebase-map` — des noms aux chemins + +Le garde comparait des noms de répertoire. Un `application/` dessiné sous `contexts/tools/` +passait donc, puisque `application` existe ailleurs. Et il ne regardait qu'un sens. + +Passé aux chemins complets, reconstruits depuis l'indentation de l'arbre : 70 dessinés, 65 +réels, zéro réel non dessiné — l'ancienne direction était satisfaite, elle ne pouvait rien voir +de l'autre côté. Les cinq surplus : + +``` +src/application src/domain +src/application/use-cases src/domain/models +src/contexts/tools/application +``` + +Les deux premiers étaient décrits comme « currently empty (.gitkeep) » alors qu'ils n'existaient +pas du tout, et la table de placement envoyait un développeur dans l'un d'eux. Le troisième +attribuait à `tools` six cas d'usage qui vivent dans `framework` — contredisant le commentaire +de `context-boundary`, qui dit que `tools` n'a pas de couche application parce qu'installer est +le travail de `framework`. + +Une carte qui invente un répertoire est pire qu'une carte qui en omet un : le lecteur crée un +fichier là où rien n'appartient. + +## `referenced-paths` — au-delà des skills + +Huit citations mortes hors du périmètre, dont quatre dans `memory/testing.md`, chargé dans +chaque conversation, et trois dans `vitest.config.ts` : des exclusions de couverture pointant +des répertoires disparus. Elles n'excluaient donc rien, les fichiers que leur commentaire +défend d'inclure étaient comptés, et le seuil était à un point d'échouer pour une raison que +personne n'avait voulue. + +`aidd_docs/tasks/` reste volontairement hors périmètre : ce sont des archives. Un plan terminé +qui décrit l'arbre tel qu'il était est un relevé, pas une instruction. + +Deux ajustements du matcher, tous deux des faux positifs et non des défauts : un `.ts` cité en +`.js` est la forme ESM correcte, et une barre oblique finale se retire sans casser le chemin. + +**Et mon propre commentaire de correction citait les chemins morts qu'il décrivait.** Le garde +l'a refusé, à raison. Expliquer une correction demande de nommer l'ancien chemin ; il faut donc +le décrire au lieu de l'écrire. + +## `docs-do-not-lie` — le document qui mentait n'était pas regardé + +Périmètre étendu au brief, à la carte et aux guidelines. Le brief présentait la surface +d'avant la refacto comme actuelle — `ai install`, `ide install`, `plugin create`, +`framework build`, `self-update` — **et** listait `aidd sync` comme supprimé alors que la +commande existe. Faux dans les deux sens. + +La surface est réécrite depuis `--help`. Le relevé des retraits pointe vers `commandes.md`, où +il vit avec ses raisons, au lieu d'être recopié ici où il a déjà vieilli deux fois. Un +diagramme de parcours enseignait aussi trois commandes disparues ; il est raconté avec celles +qui existent. + +## `no-shared-binary` — élargissement écarté + +Une relecture proposait d'étendre à `scripts/`. Mesuré : rien sous `tests/` ne lit plus le +`dist/` partagé, et les deux lecteurs restants sont `smoke-tools.sh` et +`check-bundle-size.mjs`, dont le rôle est d'éprouver le binaire livré. Les deux construisent +avant de lire. Interdire reviendrait à interdire la seule chose qui teste ce qu'un utilisateur +installe. + +Le refus est écrit dans le docstring du garde, pour qu'un prochain lecteur ne rouvre pas la +question. + +## Une gate inerte rendue réelle + +Les seuils de couverture existaient dans `vitest.config.ts` et **rien ne les exécutait** : +aucun `--coverage` dans un script, un hook ou un workflow. Des seuils configurés et jamais +lancés se lisent comme une couverture que le projet n'a pas. + +Mesuré après repointage des exclusions : 93,76 / 89,30 / 94,49 / 93,76, contre 86,23 avant. +Seuils portés juste en dessous — 92 / 87 / 93 / 92 — et sondés : `statements: 99` sort en +`exit 1` avec `Coverage for statements (93.76%) does not meet global threshold (99%)`, `92` +sort en 0. `pnpm test:coverage` existe, un job CI l'exécute. + +## Test + +`pnpm test:arch` — 45 tests. Chaque élargissement a nommé ses violations avant correction. + +Gates : tsc propre · lint 510 fichiers 0 warning · knip propre · 2067 tests / 207 fichiers · +arch 45/45 · couverture 93,76 % au-dessus de seuils qui mordent · 9 cellules golden identiques · +smoke 98/0, 22/22. diff --git a/cli/aidd_docs/tasks/2026_09/2026_09_03_gardes-qui-mesurent-ce-quils-nomment/phase-3.md b/cli/aidd_docs/tasks/2026_09/2026_09_03_gardes-qui-mesurent-ce-quils-nomment/phase-3.md new file mode 100644 index 000000000..bf6cbf942 --- /dev/null +++ b/cli/aidd_docs/tasks/2026_09/2026_09_03_gardes-qui-mesurent-ce-quils-nomment/phase-3.md @@ -0,0 +1,66 @@ +--- +status: done +--- + +# Phase 3 — Les socles admettent des arêtes, pas des comptes + +## Le défaut + +Un socle qui enregistre `"framework->runtime"` dit qu'une arête existe. Il ne dit rien de son +poids. Un import de plus sur une arête déjà admise passe donc au vert, indéfiniment. Sonde de +la relecture : un tout nouvel import `framework → runtime/platform` dans un fichier qui n'en +avait aucun — test vert. + +Le dépôt connaissait déjà le remède et l'avait appliqué une fois : `folder-size` porte +`{ path, count }` et vérifie le compte. Il n'avait pas été porté ailleurs. + +## Une exception, mesurée avant d'agir + +`context-boundary` n'en a pas besoin. Ses entrées sont des paires `importateur -> fichier`, +soit la granularité la plus fine possible : un import de plus **est** une entrée de plus. Y +ajouter un compte serait du bruit. Le dire évite qu'on l'ajoute par symétrie. + +## `context-graph` + +Chaque arête admise porte désormais son poids, en imports et en fichiers. + +| Arête | Imports | Fichiers | +| ----- | ------: | -------: | +| `distribution->framework` | 1 | 1 | +| `distribution->runtime` | 5 | 3 | +| `framework->presentation` | 4 | 3 | +| `framework->runtime` | 13 | 11 | + +**Et la mesure a corrigé le commentaire.** Un seul commentaire couvrait deux arêtes et se +trompait sur les deux : il disait « quatorze fichiers de contexte importent runtime » là où +`framework` en a onze, et attribuait à `framework` deux implémentations — le client HTTP et +l'injection de jeton git — qui appartiennent à `distribution`, laquelle en a trois. +`distribution->runtime` vivait sous ce commentaire sans raison propre. + +Mesuré par cible : `framework->runtime` importe quatre choses, **toutes des interfaces**, zéro +implémentation. `distribution->runtime` importe trois implémentations et un port. Les deux +arêtes ont maintenant chacune la sienne, et elle est vraie. + +## `orchestrator-deps` + +Deux entrées, **aucune raison écrite** — juste « exceed the limit today », dans un fichier dont +les voisins portent des paragraphes. Mesuré : six cas d'usage injectés chacun. + +`doctor` : six vérifications, une par chose qui peut dériver. Le fan-out est la fonctionnalité ; +il se résout en donnant un type de résultat à chaque vérification, pas en en retirant une. + +`setup` : six étapes d'un seul flux, de rien à un projet correct. Il se résout en scindant le +flux en deux — le socle marketplace, puis les outils et les plugins. + +Au passage, `isUseCase` nommait encore `src/application/use-cases/`, répertoire disparu. +Branche morte retirée ; le garde anti-périmètre-vide du fichier couvrait déjà le risque. + +## Test + +Deux sondes, chacune remise puis retirée, fichiers vérifiés identiques à l'octet : + +- un import supplémentaire sur `framework->runtime` fait échouer le poids +- un septième collaborateur sur `doctor-use-case` fait échouer le compte + +Gates : tsc propre · lint 510 fichiers 0 warning · knip propre · 2069 tests / 207 fichiers · +arch 47/47. diff --git a/cli/aidd_docs/tasks/2026_09/2026_09_03_gardes-qui-mesurent-ce-quils-nomment/phase-4.md b/cli/aidd_docs/tasks/2026_09/2026_09_03_gardes-qui-mesurent-ce-quils-nomment/phase-4.md new file mode 100644 index 000000000..f9e65b2a0 --- /dev/null +++ b/cli/aidd_docs/tasks/2026_09/2026_09_03_gardes-qui-mesurent-ce-quils-nomment/phase-4.md @@ -0,0 +1,86 @@ +--- +status: done +--- + +# Phase 4 — Le coût d'ajout d'un outil, mesuré pour le prochain + +## Le défaut, en deux moitiés + +Le test s'appelait « adding a tool costs one file ». Le coût réel est dix fichiers. + +**Le matcher ne voyait qu'une forme sur quatre.** + +```ts +return TOOL_IDS.some((id) => source.includes(`"${id}"`)); +``` + +Un littéral entre guillemets doubles, et rien d'autre. Invisibles : la clé nue +(`codex: { … }`), le chemin d'import d'un profil, et le nom noyé dans une chaîne plus longue. +La troisième est décisive : `presentation/commands/translate.ts` liste les cinq outils dans +son texte d'aide, et `grep -c '"claude"'` y renvoie **0**. + +Pire, sa propre sonde le validait contre `'if (id === "cursor")'` — la seule forme qu'il +attrape. + +**La liste des outils était écrite à la main**, donc un outil *nouveau* n'était pas apparié du +tout. Une relecture a ajouté un vrai sixième profil, écrit son nom dans un fichier que la règle +interdit, et toute la suite d'architecture est restée verte. La règle qui borne le coût du +prochain outil ne pouvait pas voir le prochain outil. + +## Ce qui change + +**Les outils viennent des répertoires de profils.** Un profil est soumis à la règle dès qu'il +existe, sans que personne édite une liste. + +**Quatre formes, commentaires retirés d'abord.** Littéral, clé d'objet, chemin d'import, et +chaîne qui **énumère au moins deux** outils — une ligne d'aide listant cinq cibles est une +liste qu'une sixième doit rejoindre. Une chaîne nommant un seul outil est laissée : un message +sur l'outil qui existe n'est pas une liste en attente. + +La prose est de la documentation, pas du couplage. Un commentaire expliquant que la disposition +de claude diffère ne coûte rien à un sixième outil. + +## Le calibrage, mesuré et non deviné + +Trois règles essayées avant de choisir : + +| Règle | Fichiers signalés | +| ----- | ----------------: | +| mot entier partout, commentaires inclus | 52 | +| quatre formes, toute chaîne nommant un outil | 25 | +| quatre formes, chaîne énumérant ≥ 2 outils | **10** | + +Les dix recoupent l'expérience de la relecture, qui avait touché dix fichiers hors profil. + +## Le socle, avec ses comptes + +Trois entrées portent une raison qui n'est pas de la dette — les recommandations d'outils, le +nom d'un artefact de configuration, l'allowlist des CLI pour lesquelles un activateur existe. + +Les sept autres sont la facture réelle, et elles se divisent en deux : + +- **L'enregistrement**, quatre fichiers : les trois câblages répètent les mêmes imports à effet + de bord, et le chargeur d'assets indexe un enregistrement par outil. Un profil qui + s'enregistrerait lui-même les supprimerait tous les quatre. +- **Les mots montrés à l'utilisateur**, trois fichiers : `translate` liste ses cibles dans son + aide, `setup` donne des exemples, le menu étiquette ses entrées. Les dériver du registre est + possible, et c'est un changement de présentation. + +## Ce que le périmètre n'inclut pas, et le chiffre pour le dire + +Le périmètre est `src/`. Le coût mesuré comprend aussi trois fichiers sous `tests/` — la liste +d'enregistrement de la conformance, les identifiants codés de `tool-config`, l'aide des deps +unitaires — plus les listes de cibles de la matrice golden. Un test qui nomme l'outil qu'il +teste n'est pas du couplage, donc ils ne sont pas gardés ici ; le chiffre est écrit pour que +les dix ne passent pas pour la facture entière. + +## Test + +Sonde décisive : un vrai sixième profil créé, son nom écrit dans un fichier interdit. Avant, +37/37 verts. Après, la règle le nomme. Profil et sonde retirés, arbre vérifié propre. + +Trois autres sondes dans le fichier : chacune des trois formes que le matcher précédent ratait, +plus un commentaire et un message à un seul outil, qui doivent rester silencieux. + +Gates : tsc propre · lint 510 fichiers 0 warning · knip propre · 2071 tests / 207 fichiers · +arch 49/49. diff --git a/cli/aidd_docs/tasks/2026_09/2026_09_03_gardes-qui-mesurent-ce-quils-nomment/phase-5.md b/cli/aidd_docs/tasks/2026_09/2026_09_03_gardes-qui-mesurent-ce-quils-nomment/phase-5.md new file mode 100644 index 000000000..251940a5e --- /dev/null +++ b/cli/aidd_docs/tasks/2026_09/2026_09_03_gardes-qui-mesurent-ce-quils-nomment/phase-5.md @@ -0,0 +1,66 @@ +--- +status: done +--- + +# Phase 5 — Le code mort dans les gardes eux-mêmes + +## Quatre morts, chacune d'une nature différente + +**Quatre entrées inertes dans `context-boundary`.** Sous la clé `translate` vivaient quatre +chemins `contexts/tools/...`, copies conformes des entrées de `tools`. Jamais consultées : le +lookup est indexé par le contexte du fichier **importé**, donc un fichier de `tools` n'est +comparé qu'à `PUBLIC_MODULES.tools`. Une permission par consommateur n'est pas quelque chose +que ce mécanisme sait exprimer, et ces modules sont publics pour tout le monde de toute façon. + +**Cinq branches mortes dans `earned-sharing`.** `areaOf` nommait encore les arbres plats +`application/`, `domain/` et `infrastructure/` que les contextes ont remplacés. Elles ne +correspondaient à rien — **et sa propre sonde était écrite contre deux d'entre elles**, donc +l'exemple de la règle décrivait une disposition disparue. Les retirer cassait la sonde, ce qui +est la preuve qu'elle ne testait plus la règle. + +Remplacées par des branches qui couvrent les endroits où un appelant vit réellement, y compris +le domaine et l'infrastructure d'un contexte — sans quoi deux appelants distincts tombaient +tous les deux dans `other` et comptaient pour une seule aire. + +**Aucune protection de périmètre vide dans `earned-sharing`.** Trois règles voisines vérifient +que leur sélection n'est pas vide, depuis que l'une d'elles a cessé de s'appliquer en silence +quand son répertoire a bougé. Celle-ci ne le faisait pas, et son périmètre est **un seul +répertoire**. Ajoutée, et sondée en vidant la sélection. + +**Un chemin impossible dans la sonde de `context-graph`**, qui affirmait sur +`src/application/commands/ai.ts`. Remplacé par un chemin qui ne prétend pas exister. + +## Deux extracteurs d'imports qui se contredisaient + +Dans le même répertoire : + +``` +helpers.ts /(?:from\s+|import\s+)["'](\.[^"']+)["']/ +context-graph.ts /(?:from|import)\s*\(?\s*["'](\.[^"']+\.js)["']/ +``` + +Le premier exige un espace après `from` ou `import`, donc il rate `import("...")`. Le second +le gère. Et il y en a un vrai dans le code : `presentation/commands/marketplace.ts:181` nomme +`contexts/distribution/domain/catalog.ts` dans une expression de type. Dépendance réelle, +invisible à `context-boundary`, à `earned-sharing` et à tout ce qui repose sur cet extracteur. + +Unifié, et l'alias `@/` de `tsconfig.json` est géré aussi — rien ne l'utilise dans `src` +aujourd'hui, mais s'en servir aurait retiré un fichier de la vue de toutes les règles. + +**Preuve empirique** plutôt que lecture : `catalog.ts` retiré temporairement de la surface +publique de `distribution`, et `context-boundary` a nommé l'arête +`marketplace.ts -> catalog.ts`. Elle était invisible avant. + +## Une erreur de ma part, à consigner + +J'ai sondé la protection de périmètre vide puis annulé la sonde avec `git checkout --`, ce qui +a effacé **tout** mon travail non commité sur ce fichier, pas seulement la sonde. Refait, et +les sondes suivantes passent par une copie hors du dépôt. + +## Test + +`pnpm test:arch` — 50 tests. Chaque suppression a été vérifiée inerte avant retrait, et les +deux ajouts sont sondés. + +Gates : tsc propre · lint 510 fichiers 0 warning · knip propre · 2072 tests / 207 fichiers · +arch 50/50. diff --git a/cli/aidd_docs/tasks/2026_09/2026_09_03_gardes-qui-mesurent-ce-quils-nomment/phase-6.md b/cli/aidd_docs/tasks/2026_09/2026_09_03_gardes-qui-mesurent-ce-quils-nomment/phase-6.md new file mode 100644 index 000000000..4aba12949 --- /dev/null +++ b/cli/aidd_docs/tasks/2026_09/2026_09_03_gardes-qui-mesurent-ce-quils-nomment/phase-6.md @@ -0,0 +1,60 @@ +--- +status: done +--- + +# Phase 6 — La règle de couche que personne ne gardait + +## Ce qui était gardé, ce qui ne l'était pas + +`domain` ne peut pas importer `application`, `infrastructure`, `presentation` ni `runtime` : +enforcé par `biome.json`, en CI, et attaqué sept fois par une relecture — import relatif, +`import type`, import dynamique, profondeur arbitraire de `../`. Refusé à chaque fois. + +`application` ne peut pas importer `infrastructure` : **rien ne l'interdisait**. Zéro violation +aujourd'hui, mesuré des deux côtés indépendamment. Une règle gratuite à poser tant qu'elle est +vide, coûteuse le jour où elle ne l'est plus. + +## Une moitié posée, une moitié refusée + +La première version interdisait aussi `presentation`. Elle a trouvé des violations — et ce sont +exactement les quatre imports de l'arête `framework->presentation` que `context-graph` admet +déjà nommément, avec sa raison : trois orchestrateurs nomment les classes d'invite qu'on leur +passe, en `import type`, et les inverser en port est un changement de conception. + +La doubler en erreur biome aurait cassé la construction pour une dette délibérément consignée. +Réduite à `infrastructure`, la moitié qui est propre. + +## Ce que je refuse, avec la mesure + +Le gouvernail interdit au domaine d'importer un paquet tiers — `react`, `@tanstack/*`, +`@prisma/client`. Mesuré ici, le domaine importe deux choses : + +``` +node:path 6 fichiers isAbsolute, relative, join +smol-toml 2 fichiers parse, stringify +``` + +Les deux sont purs : manipulation de chaînes et sérialisation, sans I/O ni cycle de vie. Ce +n'est pas la classe que le gouvernail vise. Interdire reviendrait à injecter un sérialiseur +TOML par un port pour ne rien gagner. La règle existe pour tenir l'I/O et l'interaction humaine +hors du domaine, pas les imports. + +## Le document qui se contredisait + +`ARCHITECTURE.md` disait « Three invariants hold this together, and each is enforced by a test +rather than by this document » puis, quatre lignes plus bas, « A biome override refuses… ». Le +paragraphe se contredisait lui-même, et **il ne mentionnait pas du tout la règle de couche** — +sa section s'intitule « Contexts, not layers », et la règle n'existait que comme une chaîne +dans `biome.json`. + +Quatre invariants sont maintenant écrits, chacun avec ce qui l'enforce, et le refus +ci-dessus avec lui pour qu'il ne soit pas rouvert. + +## Test + +Sonde : un `import type` d'un adaptateur d'infrastructure depuis un fichier `application/` +refusé par biome avec le message de la règle. `import-rules-bite` voit la nouvelle règle et +confirme que son glob nomme un chemin qui existe. + +Gates : tsc propre · lint 510 fichiers 0 warning · knip propre · 2072 tests / 207 fichiers · +arch 50/50 · couverture 93,76 % · 9 cellules golden identiques · smoke 98/0, 22/22. diff --git a/cli/aidd_docs/tasks/2026_09/2026_09_03_gardes-qui-mesurent-ce-quils-nomment/plan.md b/cli/aidd_docs/tasks/2026_09/2026_09_03_gardes-qui-mesurent-ce-quils-nomment/plan.md new file mode 100644 index 000000000..23405ec45 --- /dev/null +++ b/cli/aidd_docs/tasks/2026_09/2026_09_03_gardes-qui-mesurent-ce-quils-nomment/plan.md @@ -0,0 +1,70 @@ +--- +objective: "Aucun garde ne passe au vert sur un périmètre plus étroit que son propre nom." +status: implemented +--- + +# Plan : que chaque garde mesure ce qu'il annonce + +## Le défaut, une seule forme répétée + +Cinq relectures indépendantes ont attaqué le CLI. La direction des dépendances tient — sept +sondes, sept refus, zéro violation sur 252 fichiers. Ce qui ne tient pas est ailleurs, et +c'est chaque fois la même chose : + +| Garde | Son nom promet | Son périmètre couvre | +| ----- | -------------- | -------------------- | +| `context-boundary` | la frontière des contextes | trois contextes sur quatre | +| `tool-addition-cost` | le coût d'ajout d'un outil | les outils déjà présents | +| `referenced-paths` | « a path named in the skills » | `.claude/skills` seulement | +| `docs-do-not-lie` | les documents ne mentent pas | deux documents, pas celui qui mentait | +| `codebase-map` | la carte correspond à l'arbre | l'arbre vers la carte, pas l'inverse | +| `no-shared-binary` | personne ne partage le binaire | les fichiers sous `tests/` | +| `errors-that-are-thrown` | chaque erreur est levée | chaque erreur a un `throw new` **textuel** | + +Et la même forme dans mes propres mesures de la journée : une regex exigeant un préfixe +`src/` que les skills n'écrivent jamais, un `du` renvoyant 0 Mo sur des liens symboliques, un +grep de références périmées cantonné à `cli/` alors que le workflow cassé est au-dessus. + +Le dépôt connaît déjà le remède et l'a écrit une fois : `import-rules-bite.arch.test.ts` +existe parce qu'un motif ne correspondant à rien a laissé `translate` importer `framework` +pendant six phases. Ce plan étend ce geste à tous les gardes. + +## L'ordre, et pourquoi celui-là + +Les gardes d'abord, avant tout autre correctif, parce qu'un garde qui ment rend inutilisable +chaque mesure qui le suit. Le code mort mesuré aujourd'hui — vingt champs de `Deps`, quatre +méthodes de port, `wireTranslate()` entier — attend la phase suivante : il se supprime mieux +avec des gardes en qui on peut avoir confiance. + +## Phases + +| # | Phase | Ce qu'elle ferme | +| - | ----- | ---------------- | +| 1 | Le contexte que la frontière ne regardait pas | `framework` hors clôture, 14 imports non vérifiés | +| 2 | Les gardes dont le périmètre est plus étroit que le nom | `referenced-paths`, `docs-do-not-lie`, `codebase-map`, `no-shared-binary` | +| 3 | Les socles admettent des arêtes, pas des comptes | dette admise qui grossit sans bruit | +| 4 | Le coût d'ajout d'un outil, mesuré pour le prochain | matcher aveugle à trois formes sur quatre | +| 5 | Le code mort dans les gardes eux-mêmes | entrées inertes, branches mortes, deux extracteurs qui se contredisent | +| 6 | La règle de couche que personne ne garde | `application` importe `infrastructure` sans obstacle | + +## Ce qui reste hors de portée, volontairement + +**Ce n'est pas une usine à gaz.** Trois règles que le gouvernail applique et que je n'importe +pas ici : les plafonds de taille et de complexité (`max-lines 220` refuserait +`wiring/framework.ts` à 519 lignes et `kernel/errors.ts` à 517 — c'est une décision de +découpage, pas un garde à poser), la vérification de duplication documentaire (le vrai défaut +est trois copies du même document d'instructions, qu'un fichier-pointeur supprime mieux qu'un +vérificateur), et l'enforcement au niveau du type par compilation de sondes (excellent, et +prématuré ici). + +Un garde ne se pose que s'il attrape un défaut que ce dépôt peut vraiment avoir. Chaque phase +en fournit la preuve : la sonde qui échoue avant le correctif. + +## Le socle grandit une fois, et c'est assumé + +La phase 1 ajoute 14 entrées au socle de `context-boundary` — le contraire de la règle « un +socle ne fait que rétrécir ». La règle vaut pour une dette nouvelle, pas pour une dette qui +existait et n'était pas comptée. Ces 14 imports sont là depuis toujours ; les écrire est ce +qui permet de les faire décroître. Un socle qui passe de 5 à 19 en révélant 14 imports +jusqu'ici invisibles est un gain, pas une régression, et le dire ainsi est la seule façon +d'éviter que quelqu'un « corrige » le chiffre plus tard en rétrécissant le périmètre. diff --git a/cli/aidd_docs/tasks/2026_09/2026_09_03_golden-neuf-cellules/plan.md b/cli/aidd_docs/tasks/2026_09/2026_09_03_golden-neuf-cellules/plan.md new file mode 100644 index 000000000..1f57c2954 --- /dev/null +++ b/cli/aidd_docs/tasks/2026_09/2026_09_03_golden-neuf-cellules/plan.md @@ -0,0 +1,76 @@ +--- +objective: "A regression particular to one build target fails a test, instead of waiting for someone to notice." +status: implemented +--- + +# Plan: Freeze the nine golden cells + +## Why + +The golden captures nine target/mode builds and compared exactly one of them — `claude` — +byte-for-byte against its stored baseline. The other eight were stored and never checked. + +That is how a copilot-only regression shipped the same day: `claude`'s content rewrite is +the identity, so the one guarded cell was structurally incapable of catching a change in any +other profile's. A guard that cannot fail for eight of nine cases is a guard for one case. + +## What freezing found immediately + +Three of the eight were already stale, and none of the drift came from the work that +prompted this — each was verified against a binary built at this branch's base, which +produces the same output. + +| Cell | Files | Cause | +| ---- | ----: | ----- | +| `codex` | 30 | Codex is the only target that re-serialises skill frontmatter (`stripCodexSkillFrontmatter`), and `serializeFrontmatter` quotes scalars. Its output stopped matching the source bytes the baseline had recorded | +| `copilot:flat` | 2 | The hooks format grew a `version` field and a flattened shape after the baseline was written | +| `codex:flat` | 1 | `.codex/config.toml` | + +The stored file has had **one write in its life**, at the migration commit of 2026-07-22. +Every change to codex frontmatter, to the hooks format and to the codex config since then +went unrecorded, because nothing compared them. + +## Decisions + +| Decision | Why | +| -------- | --- | +| Freeze all nine, not a chosen subset | Any subset repeats the question of which target is allowed to regress unnoticed. The answer that needs no judgement is none | +| Re-baseline the three stale cells rather than treat them as failures | Each was verified to be what already ships; the baseline was wrong, the output was not. Freezing a wrong baseline would fail every run until someone re-baselined it in a hurry, which is worse than recording reality once with the reason | +| Update the values in place, key order preserved | A regenerated file rewrites 186 lines for 33 real changes and buries them | +| Every re-baseline carries its reason in the file's header | The next person to see a red run needs to know whether re-baselining is the answer or the reflex | + +## Revue (2026-09-03) + +Le candidat tient — première fois de la séquence qu'une relecture indépendante ne trouve pas de +défaut. Elle a en revanche trouvé un trou de preuve qui valait la peine d'être comblé, et quatre +points de rigueur. + +**Trente des trente-trois empreintes re-baselinées n'étaient gardées que par le baseline que ce +commit venait d'écrire.** `copilot:flat` et `codex:flat` ont chacune une spécification unitaire +antérieure à la branche qui confirme la nouvelle valeur. `codex` n'en avait aucune : +`stripCodexSkillFrontmatter` n'était testé nulle part. Poser la valeur et la geler dans le même +geste ne prouve rien. Le commit présentait les trois comme également corroborées ; c'était faux. + +Corrigé par une spécification du transformateur lui-même — et l'argument le plus fort est apparu +en l'écrivant : **deux skills de la release épinglée ont un frontmatter que `js-yaml` refuse**. + +``` +FAIL aidd-context/skills/03-context-generate/SKILL.md | bad indentation of a mapping entry (2:75) +FAIL aidd-async-dev/skills/02-run/SKILL.md | bad indentation of a mapping entry (2:55) +``` + +Une `description` contenant `: ` non citée. Les quotes que codex ajoute ne sont pas cosmétiques, +elles réparent un fichier illisible. Le baseline enregistrait la source cassée ; la sortie +actuelle est meilleure que ce qu'il gardait. C'est maintenant un cas de test. + +**Quatre autres points, tous corrigés :** + +| Point | Ce qui a changé | +| ----- | --------------- | +| `codex:flat` était re-baselinée sans raison écrite | Sa cause et ses invariants sont dans l'en-tête, avec le test qui les tient | +| « un seul écrit dans sa vie » | Vrai depuis la migration seulement ; les passes antérieures existent sur des branches repliées dans l'instantané. Reformulé | +| L'assertion s'arrêtait à la première cellule fautive | Elle les collecte et les nomme toutes. Éprouvé : deux cellules corrompues, les deux nommées | +| « les neuf » ne voulait dire « toutes » que tant que les listes écrites à la main couvraient le registre | Un test compare les listes à `AI_TOOL_IDS` et l'ensemble des clés stockées à l'ensemble attendu. Éprouvé dans les deux sens : cible retirée d'une liste, outil absent des deux | + +Le doublon de test signalé est parti avec ce dernier changement. + diff --git a/cli/aidd_docs/tasks/2026_09/2026_09_03_integration-telemetry/config-a-reprendre.md b/cli/aidd_docs/tasks/2026_09/2026_09_03_integration-telemetry/config-a-reprendre.md new file mode 100644 index 000000000..93b841399 --- /dev/null +++ b/cli/aidd_docs/tasks/2026_09/2026_09_03_integration-telemetry/config-a-reprendre.md @@ -0,0 +1,105 @@ +--- +status: pending +--- + +# La configuration de `next` mise de côté pendant la fusion, et qu'il faut reprendre + +## Ce que c'est + +`next` porte dans son `biome.json` une règle que nous n'avons pas : + +```json +"complexity": { + "noExcessiveLinesPerFunction": { + "level": "error", + "options": { "maxLines": 20, "skipBlankLines": true, "skipIifes": true } + } +} +``` + +C'est **exactement** un des trois manques que la comparaison au harnais du gouvernail avait +nommés : le CLI n'a aucun plafond de taille, là où `wiring/framework.ts` fait 519 lignes et +`kernel/errors.ts` 517. + +## Pourquoi elle n'est pas prise dans la fusion + +Elle arrive avec dix-sept exemptions, toutes vers des chemins que la refacto a supprimés : + +``` +src/application/use-cases/framework/strategies/tool-contracts.ts +src/application/use-cases/install/install-content-section-use-case.ts +src/application/use-cases/plugin/plugin-add-use-case.ts +src/domain/capabilities/plugins-capability.ts +src/domain/formats/jsonc.ts +src/domain/models/plugin-source.ts +src/domain/tools/ai/copilot.ts +src/infrastructure/deps.ts +… et neuf autres +``` + +Les importer telles quelles ne protégerait rien — `import-rules-bite` refuserait d'ailleurs +des globs nommant des chemins morts. Les re-dériver contre notre arbre demande de mesurer +quelles fonctions dépassent vingt lignes chez nous, ce qui est une **gate neuve**, pas une +nécessité de fusion. L'ajouter pendant qu'on résout 232 conflits met les deux en danger. + +## Ce qu'il faudra faire, après la fusion + +1. Mesurer : combien de fonctions dépassent 20 lignes dans `src/`, et où. +2. Décider du seuil sur cette mesure plutôt que sur celui de `next` — 20 est leur chiffre, + pas forcément le nôtre, et un seuil qui exempte cinquante fichiers ne gate rien. +3. Écrire les exemptions avec une raison chacune, comme les socles des tests d'architecture : + une exemption sans raison est une dette qu'on ne saura plus lire. +4. Vérifier que la règle mord, en allongeant une fonction volontairement. + +## Ce qui la garde en vie + +Ce document. La règle a été écartée par un choix de séquencement, pas rejetée — et un choix +de séquencement qui n'est écrit nulle part est un oubli avec un délai. + +--- + +# Deuxième : le contrôle de couches de `next`, `scripts/check-cli-layering.mjs` + +## Ce que c'est + +Un hook `cli-layering` dans `lefthook.yml`, adossé à un script qui vérifie deux invariants +que biome ne sait pas exprimer : les dépendances pointent vers l'intérieur, et aucun type +n'est élargi par `as unknown as` ni `as never`. + +Sa raison d'exister est **exactement** notre trouvaille sur `import-rules-bite`, arrivée +indépendamment : + +> Measured, not assumed — a `noRestrictedImports` rule written against +> `"../../infrastructure"` never fired on a violation planted in `src/domain`. + +## Ce qu'il trouve chez nous, aujourd'hui + +Lancé tel quel contre notre arborescence, il fonctionne et signale six élargissements de type +réels : + +``` +src/contexts/translate/application/translate-source.ts +tests/contexts/framework/application/flows/marketplace-sync-settings.unit.test.ts +tests/contexts/framework/application/shared/ensure-built-marketplace-use-case.integration.test.ts +tests/contexts/tools/domain/mcp-exclusion.unit.test.ts +tests/contexts/tools/domain/registry-conformance.unit.test.ts +tests/runtime/auth/auth-provider-adapter.unit.test.ts +``` + +Le dernier est un test écrit aujourd'hui même. Le contrôle attrape donc du code neuf, pas +seulement de l'ancien — c'est un argument pour l'adopter, pas contre. + +## Pourquoi il n'est pas pris dans la fusion + +Sa liste `CASTS_ALLOWED` nomme des chemins d'avant la refacto, et il le signale lui-même : +`framework-build-use-case.ts no longer casts - drop its CASTS_ALLOWED entry`. L'adopter +demande de trancher six casts ou de re-baser la liste — une gate neuve, la même décision que +pour la règle de taille, et le même risque à la prendre au milieu de 232 conflits. + +## Ce qu'il faudra faire + +1. Trancher les six : chacun est soit un vrai défaut de typage, soit une exemption qui mérite + sa raison écrite. +2. Vider `CASTS_ALLOWED` de ses entrées mortes. +3. Rebrancher le hook `cli-layering` dans `lefthook.yml`. +4. Vérifier qu'il mord, en plantant un `as unknown as` volontaire. diff --git a/cli/aidd_docs/tasks/2026_09/2026_09_03_integration-telemetry/oracle-head.md b/cli/aidd_docs/tasks/2026_09/2026_09_03_integration-telemetry/oracle-head.md new file mode 100644 index 000000000..201356530 --- /dev/null +++ b/cli/aidd_docs/tasks/2026_09/2026_09_03_integration-telemetry/oracle-head.md @@ -0,0 +1,46 @@ +# Oracle : la branche avant la fusion + +Releve pris sur `HEAD` (`37348891`), dans un worktree detache, avant la fusion de `origin/next`. + +``` +suite complete de HEAD 2054 tests, tous verts + unit 1443 (697 fichiers) + integration 453 (248 fichiers) + e2e 104 ( 33 fichiers) + architecture 54 ( 35 fichiers) + +suite complete apres 3240 noms distincts, tous verts +``` + +Les quatre etages ont tourne des deux cotes. Un etage qui ne collecte rien compte zero, et +laisserait ce diff muet sur tout ce qu'il contient - d'ou les comptes par etage. + +Chaque nom de test de HEAD doit se retrouver apres la fusion. Un `describe` renomme est +un deplacement ; un test absent sans raison est une regression. + +## Les 19 noms absents, et pourquoi + +| Test | Sort | +| ---- | ---- | +| `Cursor plugin.files tracking enables uninstall of hooks.json and mcp.json (Phase 2) Plugin.files keys join to the exact written absolute paths (uninstall can find the files)` | **Supersede.** Meme test sous « ... enables uninstall of mcp.json; hooks.json is out-of-band (Phase 6) ». | +| `FlatOutputStrategy integration AC #11: unsupported hooks warn-and-skip (opencode contract) warns and skips hooks for a hooks-bearing plugin when hooks is unsupported` | **Supersede.** Le contrat plat d'OpenCode declare `hooks: supported` ; `build-hooks-support-declaration.unit.test.ts` tient les deux declarations ensemble. | +| `PluginAddUseCase OpenCode hooks skip (Phase 3) emits exactly one logger.warn for hooks skip` | **Supersede.** Meme changement : il n'y a plus de skip a signaler. | +| `PluginAddUseCase OpenCode hooks skip (Phase 3) writes no hooks/ files to the project when plugin has hooks` | **Supersede.** OpenCode accepte les hooks depuis la phase 7 de `next` — mesure, pas preference. `plugin-add-opencode-hooks-install.integration.test.ts` affirme l'inverse. | +| `PluginAddUseCase skip warnings when adapter returns skip entries emits one logger.warn per skip entry with the expected format` | **Renomme.** Survit sous « warn message format formats skip warnings as Plugin : skipped for ». | +| `PluginAddUseCase skip warnings when adapter returns skip entries emits one warning for hooks skip when plugin ships hooks against opencode` | **Supersede.** Devient « emits no logger.warn — OpenCode delivers sample-plugin's hooks instead of skipping them », dans le meme fichier. | +| `PluginContentTranslator skip list flat mode (opencode) emits no skip entry per file — exactly one entry per plugin regardless of hooks file count` | **Supersede.** Comptait les entrees d'une liste desormais vide. | +| `PluginContentTranslator skip list flat mode (opencode) returns one skip entry when plugin has hooks (hooks not accepted by flat mode)` | **Supersede.** Remplace dans le meme fichier par « returns no skip entry when plugin has hooks — OpenCode now accepts them ». | +| `PluginInstallUseCase source arg routing delegates to PluginInstallFromMarketplaceUseCase when arg is a plugin name` | **Renomme.** Le collaborateur est desormais typé par l'interface etroite que son module exporte : « ... delegates to PluginInstallFromMarketplace ... ». | +| `PluginsCapability flat mode exposes flatNamespacePrefix` | **Renomme.** Idem. | +| `PluginsCapability flat mode exposes mode as flat` | **Renomme.** Le `describe` « flat mode » se scinde en « flat mode, hooks unsupported » et « flat mode, hooks accepted ». Les cinq assertions sont intactes. | +| `PluginsCapability flat mode pluginManifestRelativePath is null` | **Renomme.** Idem. | +| `PluginsCapability flat mode pluginOutputDir returns null` | **Renomme.** Idem. | +| `PluginsCapability flat mode pluginsDir is null` | **Renomme.** Idem. | +| `install cursor plugin with hooks and mcp (Phase 2) emits no skip warnings for hooks or mcp` | **Supersede.** Repris tel quel par la phase 6. | +| `install cursor plugin with hooks and mcp (Phase 2) rewrites ${CLAUDE_PLUGIN_ROOT}/ to ./ in hook commands` | **Supersede.** Meme changement de destination ; la reecriture vise maintenant `.cursor/hooks//`. | +| `install cursor plugin with hooks and mcp (Phase 2) tracks hooks.json and mcp.json in Plugin.files for uninstall` | **Supersede.** `hooks.json` n'est plus sous le baseDir du plugin, donc plus dans `Plugin.files` — affirme a l'envers par la phase 6. | +| `install cursor plugin with hooks and mcp (Phase 2) writes converted hooks.json at plugin root with camelCase events` | **Supersede.** Trois sondes ont mesure qu'un `hooks.json` plugin-scope ne declenche rien chez Cursor : `hooksDestination: \"project\"` l'ecrit desormais dans `.cursor/hooks.json`, ce que la phase 6 affirme. | +| `install cursor plugin with hooks and mcp (Phase 2) writes mcp.json at plugin root with the source content unchanged` | **Supersede.** Repris tel quel par la phase 6, qui affirme la meme chose sur le meme fichier. | + +Aucun test de HEAD n'a disparu sans raison : douze sont remplaces par l'affirmation +inverse, mesuree sur le vrai outil, et sept portent un nom neuf pour le meme corps. diff --git a/cli/aidd_docs/tasks/2026_09/2026_09_03_integration-telemetry/oracle-next.md b/cli/aidd_docs/tasks/2026_09/2026_09_03_integration-telemetry/oracle-next.md new file mode 100644 index 000000000..a01b2b9cb --- /dev/null +++ b/cli/aidd_docs/tasks/2026_09/2026_09_03_integration-telemetry/oracle-next.md @@ -0,0 +1,1047 @@ +# Oracle : la telemetrie telle qu'elle passe sur origin/next + +Releve pris sur `origin/next` (`0144ec84`) avant toute fusion, dans un worktree detache. + +``` +suite complete de next 304 fichiers, 3353 tests, tous verts +dont telemetrie 70 fichiers, 882 tests +dont le reste 2471 tests +``` + +Apres fusion, ces memes tests doivent passer **avec leurs corps inchanges**. Un import +repointe est un deplacement ; une assertion modifiee est une regression deguisee, et le +signal d'arret. + +## Par fichier + +| Fichier (chemin sur next) | Tests | +| ------------------------- | ----: | +| `tests/application/display/cost-report-artefact.unit.test.ts` | 19 | +| `tests/application/display/cost-report-display.unit.test.ts` | 26 | +| `tests/application/display/telemetry-check-display.unit.test.ts` | 25 | +| `tests/application/display/telemetry-display.unit.test.ts` | 23 | +| `tests/application/display/telemetry-forget-display.unit.test.ts` | 8 | +| `tests/application/use-cases/telemetry/diagnose-telemetry-use-case.unit.test.ts` | 22 | +| `tests/application/use-cases/telemetry/forget-telemetry-use-case.unit.test.ts` | 21 | +| `tests/application/use-cases/telemetry/person-identity-use-case.unit.test.ts` | 34 | +| `tests/application/use-cases/telemetry/read-local-cost-use-case.unit.test.ts` | 53 | +| `tests/application/use-cases/telemetry/report-cost-use-case.unit.test.ts` | 26 | +| `tests/application/use-cases/telemetry/telemetry-off-use-case.unit.test.ts` | 11 | +| `tests/application/use-cases/telemetry/telemetry-on-use-case.unit.test.ts` | 11 | +| `tests/application/use-cases/telemetry/tool-attribution.unit.test.ts` | 4 | +| `tests/domain/formats/commit-session-trailer.unit.test.ts` | 12 | +| `tests/domain/formats/local-cost-fixtures.redaction.unit.test.ts` | 7 | +| `tests/domain/models/cost-report-backlog.unit.test.ts` | 8 | +| `tests/domain/models/cost-report-envelope.unit.test.ts` | 19 | +| `tests/domain/models/cost-report-person.unit.test.ts` | 13 | +| `tests/domain/models/cost-report-task.unit.test.ts` | 11 | +| `tests/domain/models/cost-report.unit.test.ts` | 69 | +| `tests/domain/models/flow-attribution.unit.test.ts` | 20 | +| `tests/domain/models/session-project.unit.test.ts` | 5 | +| `tests/domain/models/step-attribution.unit.test.ts` | 11 | +| `tests/domain/models/task-attribution.unit.test.ts` | 21 | +| `tests/domain/models/telemetry-claim.unit.test.ts` | 44 | +| `tests/domain/models/telemetry-export-leftover.unit.test.ts` | 6 | +| `tests/domain/models/telemetry-host-registration.unit.test.ts` | 10 | +| `tests/domain/models/telemetry-removal.unit.test.ts` | 7 | +| `tests/domain/models/telemetry-setup.unit.test.ts` | 6 | +| `tests/domain/models/telemetry-sink-record.unit.test.ts` | 11 | +| `tests/domain/models/telemetry-sink-retention.unit.test.ts` | 5 | +| `tests/domain/models/telemetry-switch.unit.test.ts` | 25 | +| `tests/domain/tools/telemetry-route-supply.unit.test.ts` | 5 | +| `tests/e2e/telemetry-backlog-axis.e2e.test.ts` | 4 | +| `tests/e2e/telemetry-check-skill-commands.e2e.test.ts` | 2 | +| `tests/e2e/telemetry-check.e2e.test.ts` | 24 | +| `tests/e2e/telemetry-commit-trailer.e2e.test.ts` | 8 | +| `tests/e2e/telemetry-cost-skill-commands.e2e.test.ts` | 4 | +| `tests/e2e/telemetry-flow-axis.e2e.test.ts` | 4 | +| `tests/e2e/telemetry-forget.e2e.test.ts` | 11 | +| `tests/e2e/telemetry-hook-install.e2e.test.ts` | 1 | +| `tests/e2e/telemetry-host-registration.e2e.test.ts` | 2 | +| `tests/e2e/telemetry-identity-resolution.e2e.test.ts` | 8 | +| `tests/e2e/telemetry-identity.e2e.test.ts` | 16 | +| `tests/e2e/telemetry-init-skill-commands.e2e.test.ts` | 5 | +| `tests/e2e/telemetry-journal-gitignore.e2e.test.ts` | 2 | +| `tests/e2e/telemetry-lifecycle.e2e.test.ts` | 3 | +| `tests/e2e/telemetry-multi-tool.e2e.test.ts` | 12 | +| `tests/e2e/telemetry-on-runs-privacy.e2e.test.ts` | 7 | +| `tests/e2e/telemetry-plugin-standalone.e2e.test.ts` | 2 | +| `tests/e2e/telemetry-reference-week.e2e.test.ts` | 15 | +| `tests/e2e/telemetry-refusal.e2e.test.ts` | 7 | +| `tests/e2e/telemetry-report.e2e.test.ts` | 10 | +| `tests/e2e/telemetry-six-questions.e2e.test.ts` | 3 | +| `tests/e2e/telemetry-stored-export-record.e2e.test.ts` | 2 | +| `tests/e2e/telemetry-task-midsession.e2e.test.ts` | 3 | +| `tests/e2e/telemetry.e2e.test.ts` | 4 | +| `tests/infrastructure/adapters/copilot-cost-reader-adapter.integration.test.ts` | 4 | +| `tests/infrastructure/adapters/git-adapter-telemetry-project-id.integration.test.ts` | 1 | +| `tests/infrastructure/adapters/opencode-cost-reader-adapter.integration.test.ts` | 6 | +| `tests/infrastructure/adapters/person-identity-adapter.integration.test.ts` | 12 | +| `tests/infrastructure/adapters/person-identity-location.unit.test.ts` | 4 | +| `tests/infrastructure/adapters/run-journal-file-written.integration.test.ts` | 4 | +| `tests/infrastructure/adapters/run-journal-reader-adapter.integration.test.ts` | 26 | +| `tests/infrastructure/adapters/run-journal-task-declared.integration.test.ts` | 4 | +| `tests/infrastructure/adapters/telemetry-evidence-adapter.integration.test.ts` | 17 | +| `tests/infrastructure/adapters/telemetry-sink-adapter.integration.test.ts` | 19 | +| `tests/infrastructure/adapters/telemetry-sink-location.unit.test.ts` | 12 | +| `tests/infrastructure/adapters/transcript-cost-reader-adapter.integration.test.ts` | 7 | +| `tests/integration/telemetry-trailer-line-agrees.integration.test.ts` | 9 | + +## Noms complets + +Conserves pour qu'un test disparu se voie, et pas seulement un compte qui baisse. + +``` +# tests/application/display/cost-report-artefact.unit.test.ts + buildCostReportArtefact labels the no-identifier row distinctly from an unresolved one + buildCostReportArtefact lists person among the known axes + buildCostReportArtefact names the project's switch being off in its own header, on every axis + buildCostReportArtefact names two different causes with two different caveats + buildCostReportArtefact prints every figure and a caveat when the identity could not be read + buildCostReportArtefact prints every figure and a different caveat when no identity was declared at all + buildCostReportArtefact prints no person caveat on the total axis when nobody opted in - that is the default state, not a degraded read + buildCostReportArtefact prints one row per person with the identities behind it, mapped rows first + buildCostReportArtefact prints two unplaced identifiers as two labelled rows, never one bucket + buildCostReportArtefact refuses an unknown axis by name, listing the ones that exist + buildCostReportArtefact says nothing about the switch in the header when it is on + buildCostReportArtefact still prints the unreadable caveat on the total axis - that one is real damage + buildCostReportArtefact — by step, two rows sharing one name carries the attribution on every row, so two rows for one step are distinguishable on their own + buildCostReportArtefact — by step, two rows sharing one name reconciles to what the terminal prints for that step, row for row + buildCostReportArtefact — the flow axis states its own limits with the figures names every unqualified orchestrating skill the declared set holds, whatever it holds + buildCostReportArtefact — the flow axis states its own limits with the figures says a hand-run skill counts inside the flow it ran during + buildCostReportArtefact — the flow axis states its own limits with the figures says a same-named skill of the reader's own project opens a flow of its own + buildCostReportArtefact — the flow axis states its own limits with the figures says neither when the period names no flow at all - a limit that bit nothing is noise + buildCostReportArtefact — the flow axis states its own limits with the figures states them on the flow axis alone, never on every axis +# tests/application/display/cost-report-display.unit.test.ts + printCostReport answers the question before any breakdown is read + printCostReport breaks a period down by tokens when no amount exists anywhere in it + printCostReport calls a task selection's own zero rows 'nothing in this selection' too + printCostReport calls a zero row 'nothing in this selection', never 'this period', once a filter is active + printCostReport carries no prompt, code or diff, over records and journals that hold them + printCostReport gives a record with no model its own row, named as unknown, rather than vanishing + printCostReport gives a record with no project its own row, named as unknown + printCostReport labels active time as per-session and keeps it out of every breakdown + printCostReport names a task by its identity, never by a path it was derived from + printCostReport names how many days a long period carries, rather than printing every row + printCostReport names the filter that emptied a selection, and suppresses the noise under it + printCostReport never says work ran outside every step, and never calls it a residual + printCostReport prints a day with nothing as a row of zeros, never an omitted row + printCostReport prints a session total on its own tool row, not 'nothing in this period' (#697) + printCostReport prints a tool that cannot be read as not covered, with its own reason + printCostReport prints an empty period as nothing measured, not as zeros + printCostReport prints an unknown amount for a tool whose records carry none, never a zero + printCostReport prints the three attribution shares together + printCostReport says a task or a tool was never seen without claiming a record check it never ran + printCostReport says how much of the read it could not place or could not parse + printCostReport says which selection it answered, in the header + printCostReport separates a tool that measured nothing from one that could not be read + printCostReport still calls a zero row 'nothing in this period' when the whole period, not a filter, is why + printCostReport — measurement is off names the sink's real scope, never denying the figure it sits beside + printCostReport — measurement is off says nothing about the switch when it is on, even on an empty period + printCostReport — measurement is off says the project's switch is off, on an empty period +# tests/application/display/telemetry-check-display.unit.test.ts + the claims, and what is deliberately not one names a tool nothing can read with its own reason, never as a failing claim + the claims, and what is deliberately not one prints a verdict and its detail for every claim judged + the claims, and what is deliberately not one warns about a leftover export on stderr, on both sides of the gate + the row saying whether commits carry their session does not excuse a shortfall when a part is broken + the row saying whether commits carry their session keeps the count when git could not name the hooks directory + the row saying whether commits carry their session leads with how many recent commits carry it + the row saying whether commits carry their session names each missing piece after the count + the row saying whether commits carry their session never excuses zero, whatever else is in place + the row saying whether commits carry their session says a delegate that is not executable will not be run + the row saying whether commits carry their session says a hook git will not run is not executable + the row saying whether commits carry their session says a shortfall is expected when every part is in place + the row saying whether commits carry their session says nothing about pieces when every piece is in place + the row saying whether commits carry their session says the hook is somebody else's without naming a tool + the row saying whether commits carry their session says there is no history to read rather than reporting zero + the row saying whether commits carry their session says there is no repository rather than listing missing pieces + the row saying whether the host will load what aidd installed names the answer and the detail on each line, never a bare pass + the row saying whether the host will load what aidd installed orders a disabled registration and an unanswerable one between the two + the row saying whether the host will load what aidd installed puts what will not load above what is fine + the row saying whether the host will load what aidd installed says a project has no plugin recorded rather than printing nothing + the row saying whether the host will load what aidd installed says the manifest could not be read, distinctly from having nothing installed + the setup a person reads before any claim lists every location it looked in when nothing declares the recorder + the setup a person reads before any claim names a person's own refusal rather than reporting the project as off + the setup a person reads before any claim names a plugin version nothing journalled apart from one that was never stamped + the setup a person reads before any claim prints the setup even when the run was gated before judging anything + the setup a person reads before any claim reads a damaged declaration location as unreadable, not as undeclared +# tests/application/display/telemetry-display.unit.test.ts + linking an identifier this person could not simply take as their own names the identifier it withdrew + linking an identifier this person could not simply take as their own reports one already listed as already listed, never as a fresh write + linking an identifier this person could not simply take as their own reports unlinking one nobody listed as nothing to remove, never a failure + linking an identifier this person could not simply take as their own says a fresh link is a declaration nothing here can check + the warning about where the figures land says nothing when the directory was named outright, or defaulted + the warning about where the figures land warns when the figures were placed by the variable that also moves the token + what `telemetry read` says about each tool gives every status a label of its own, so no two can be read as the same fact + what `telemetry read` says about each tool leads with how many sessions it covered, not one line per tool per session + what `telemetry read` says about each tool names a session that could not be read beside a tool that otherwise read fine + what `telemetry read` says about each tool never says a tool found nothing when nothing was ever asked of it + what `telemetry read` says about each tool tells a refusal apart from an empty journal + what minting says it does, and does not do does not claim nothing changed when a display name was set alongside + what minting says it does, and does not do names what the identifier attaches to, and what it never attaches to + what minting says it does, and does not do reports nothing to withdraw when nobody had chosen + what minting says it does, and does not do says a damaged file was discarded rather than left behind + what minting says it does, and does not do says withdrawing never gives the same identifier back + what the identity commands say names the identifier that was replaced, when one was + what the identity commands say says records carry no person when nobody has chosen + what the identity commands say says withdrawing takes the added identifiers with it + what the identity commands say tells an identifier minted here from one taken from another machine + what the switch says when it is flipped says off stops new recording only, and names what removes the rest + what the switch says when it is flipped says the file is tracked, because turning it on decides for everyone who clones + what the switch says when it is flipped tells an already-on project from one it just turned on +# tests/application/display/telemetry-forget-display.unit.test.ts + what a person is shown before anything is removed counts the run files it would remove, so the count can be checked afterwards + what a person is shown before anything is removed names history that git already holds + what a person is shown before anything is removed says the stored records span every project measured on this machine + what a person is shown before anything is removed says there is nothing to remove when nothing was ever measured + what a refusal says reports nothing removed and names the flag, never a failure + what is reported once it is done counts each location separately, so the three can be checked against the preview + what is reported once it is done names every file it could not remove, and why + what is reported once it is done says the switch was left alone, and how to turn measurement on again +# tests/application/use-cases/telemetry/diagnose-telemetry-use-case.unit.test.ts + DiagnoseTelemetryUseCase — a leftover export config is reported alongside the four claims when the switch is on + DiagnoseTelemetryUseCase — a leftover export config is reported even when the switch is off and the run is gated + DiagnoseTelemetryUseCase — a leftover export config reports an empty list on a clean machine, never omitting the field + DiagnoseTelemetryUseCase — every claim is judged never lets absent evidence produce an ok: no claim is ever left unjudged + DiagnoseTelemetryUseCase — gathering local evidence names a reader that threw as failing to read, never crashing the whole diagnostic + DiagnoseTelemetryUseCase — gathering local evidence names every uncovered tool with its own reason + DiagnoseTelemetryUseCase — gathering local evidence only consults Codex's own hook trust for a Codex-anchored session + DiagnoseTelemetryUseCase — gathering local evidence reads every covered tool's own files for every journalled session + DiagnoseTelemetryUseCase — gating names a non-repository, never blaming the hook, once the switch is on + DiagnoseTelemetryUseCase — gating stops at the switch before judging anything else + DiagnoseTelemetryUseCase — the first claim reads the same declaration setup prints fails, naming the recorder, when the setup's own recorder declaration is false + DiagnoseTelemetryUseCase — the first claim reads the same declaration setup prints reads unknown, never a failure, when the setup's own recorder declaration could not be read + DiagnoseTelemetryUseCase — the first claim reads the same declaration setup prints reports nothing to evaluate when the setup's own recorder declaration is true + DiagnoseTelemetryUseCase — what the host will actually load cannot ask any registry about a plugin recorded without a marketplace + DiagnoseTelemetryUseCase — what the host will actually load says a plugin the host's registry carries is registered + DiagnoseTelemetryUseCase — what the host will actually load says a plugin the registry lacks is not registered, and names the file + DiagnoseTelemetryUseCase — what the host will actually load survives a manifest it cannot parse, and says so instead of dying + the versions check reports names this CLI's own version, which a person always has since nothing reads without it + the versions check reports reports the newest, so an upgrade mid-period is not hidden by the sessions before it + the versions check reports reports the plugin version the hook itself stamped, never one re-derived here + the versions check reports skips a session carrying no version rather than letting it hide a later one that does + the versions check reports tells a project that measured nothing yet apart from one whose hook could not name itself +# tests/application/use-cases/telemetry/forget-telemetry-use-case.unit.test.ts + ForgetTelemetryUseCase.preview() — every location, resolved once, and nothing touched a machine where nothing was ever measured has nothing to remove, and offers nothing + ForgetTelemetryUseCase.preview() — every location, resolved once, and nothing touched asks listTrackedFiles and hasHistoryFor about the journal's own pathspec + ForgetTelemetryUseCase.preview() — every location, resolved once, and nothing touched names the journal as this project's own, at its own resolved path + ForgetTelemetryUseCase.preview() — every location, resolved once, and nothing touched names the sink as this machine's own, spanning whatever it holds + ForgetTelemetryUseCase.preview() — every location, resolved once, and nothing touched reads a non-repository as no history at all, never as a possibility + ForgetTelemetryUseCase.preview() — every location, resolved once, and nothing touched reads a staged-but-never-committed journal honestly — tracked, not certainly held + ForgetTelemetryUseCase.preview() — every location, resolved once, and nothing touched reads an untracked journal as possible, never as an all-clear + ForgetTelemetryUseCase.preview() — every location, resolved once, and nothing touched reads history at its true strength: committed reads as certain + ForgetTelemetryUseCase.preview() — every location, resolved once, and nothing touched reports a damaged identity file as present, not absent + ForgetTelemetryUseCase.preview() — every location, resolved once, and nothing touched reports an opted-in identity as present + ForgetTelemetryUseCase.preview() — every location, resolved once, and nothing touched reports no identity at all as absent + ForgetTelemetryUseCase.preview() — every location, resolved once, and nothing touched touches nothing: previewing leaves the sink, the journal and the identity exactly as they were + ForgetTelemetryUseCase.remove() — acts on the value preview() produced, never its own resolution a journal run file that refuses removal is reported, and the sink still empties + ForgetTelemetryUseCase.remove() — acts on the value preview() produced, never its own resolution a location that refuses removal is reported, and every other location is still emptied + ForgetTelemetryUseCase.remove() — acts on the value preview() produced, never its own resolution an identity that refuses removal is reported, and the other locations still empty + ForgetTelemetryUseCase.remove() — acts on the value preview() produced, never its own resolution never touches an identity that was not shown in the preview — the gate on preview.identity.present + ForgetTelemetryUseCase.remove() — acts on the value preview() produced, never its own resolution proves the guarantee by mutation for the identity: removal acts on the preview's own path, never the store's own resolution + ForgetTelemetryUseCase.remove() — acts on the value preview() produced, never its own resolution proves the guarantee by mutation for the journal: removal acts on the preview's own directory, never the reader's own resolution + ForgetTelemetryUseCase.remove() — acts on the value preview() produced, never its own resolution proves the guarantee by mutation: removal acts on the preview's own names, never a fresh directory listing + ForgetTelemetryUseCase.remove() — acts on the value preview() produced, never its own resolution removes exactly the run files, day files and identity the preview named, and reports matching counts + ForgetTelemetryUseCase.remove() — acts on the value preview() produced, never its own resolution repeats history unchanged after removing — history is not made reachable by removing the rest +# tests/application/use-cases/telemetry/person-identity-use-case.unit.test.ts + PersonIdentityUseCase.link adds the identifier onto this person + PersonIdentityUseCase.link refuses an empty or whitespace-only identifier, writing nothing + PersonIdentityUseCase.link refuses when nobody opted in, naming the missing step + PersonIdentityUseCase.link reports an identifier already listed as already listed, not as a second write + PersonIdentityUseCase.link reports the person's own identifier as already listed, and appends nothing onto alsoMe + PersonIdentityUseCase.off discards a damaged identity file rather than leaving a person unable to withdraw + PersonIdentityUseCase.off does nothing when already off + PersonIdentityUseCase.off opting in again after withdrawing mints a fresh identifier, never the old one back + PersonIdentityUseCase.off removes a file that exists but names nobody, rather than reading it as already off + PersonIdentityUseCase.off removes the whole declaration, stating how many added identifiers went with it + PersonIdentityUseCase.off states that new records will carry no person, and removes the file + PersonIdentityUseCase.off still throws off's own way for anything that is not the store's own unreadable error + PersonIdentityUseCase.status answers an identity with a name + PersonIdentityUseCase.status answers an identity with no name and no added identifiers + PersonIdentityUseCase.status answers no identity when nobody opted in + PersonIdentityUseCase.status lists every identifier added onto this person, including how it was obtained + PersonIdentityUseCase.status throws, never answers 'no identity', when the store cannot be read + PersonIdentityUseCase.unlink reports nothing to remove for an empty identifier, never as a failure - link already refuses to write one + PersonIdentityUseCase.unlink reports nothing to remove for an identifier nobody listed, and exits successfully + PersonIdentityUseCase.unlink reports nothing to remove when nobody opted in at all - off already took the alsoMe list with it + PersonIdentityUseCase.unlink withdraws an identifier from this person + PersonIdentityUseCase.use keeps alsoMe already declared when adopting a different identifier + PersonIdentityUseCase.use refuses an empty or whitespace-only identifier, writing nothing + PersonIdentityUseCase.use replaces a different identifier, naming what it replaced + PersonIdentityUseCase.use reports the identifier already in effect, and writes nothing + PersonIdentityUseCase.use takes an identifier minted elsewhere, recording it as adopted + PersonIdentityUseCase.use, attaching a display name attaches the display name beside the identifier already opted into + PersonIdentityUseCase.use, attaching a display name mints an identifier for a name given when none stands, rather than refusing + PersonIdentityUseCase.use, attaching a display name refuses an empty or whitespace-only value + PersonIdentityUseCase.use, minted apart from adopted calls a fresh identifier minted, and one carried here adopted + PersonIdentityUseCase.use, minted apart from adopted calls replacing one identifier with another adopted, never minted + PersonIdentityUseCase.use, settling which identifier stands a second on reports the same identifier, never a new one + PersonIdentityUseCase.use, settling which identifier stands mints an identifier when none exists + what the errors tell a person to run names no identity verb the command surface does not have +# tests/application/use-cases/telemetry/read-local-cost-use-case.unit.test.ts + ReadLocalCostUseCase a Codex turn read while it runs is not the last word lands the completed figures even once the run journal's own turn_end has been seen + ReadLocalCostUseCase a Codex turn read while it runs is not the last word lands the completed figures once the rest of the turn arrives + ReadLocalCostUseCase a Codex turn read while it runs is not the last word never lets a later, smaller reading of the same turn replace the larger one + ReadLocalCostUseCase a Codex turn read while it runs is not the last word stops re-appending once a re-read brings nothing new + ReadLocalCostUseCase carries a covered tool's stated limitation through to the report, since a source comment reaches nobody + ReadLocalCostUseCase carries a display name alongside the identifier, never in its place + ReadLocalCostUseCase distinguishes not-covered from covered-and-empty + ReadLocalCostUseCase forbids a reader from naming its own tool, at compile time + ReadLocalCostUseCase invents no limitation for a covered tool that declares none + ReadLocalCostUseCase leaves a session stored before opting in unnamed, even on a later read + ReadLocalCostUseCase leaves the store byte-identical on a second read of the same session + ReadLocalCostUseCase never re-appends a kind: 'session' record sharing a turn_id, even once corrections exist for kind: 'request' + ReadLocalCostUseCase never synthesises a key for a candidate with no request identifier, and cannot dedup it + ReadLocalCostUseCase project attribution falls back to the directory-name field with no remote, and says so + ReadLocalCostUseCase project attribution prefers the remote, and says so + ReadLocalCostUseCase project attribution stores no project for a session with no journal at all + ReadLocalCostUseCase reports a tool with no declared local read as not-covered, with its declared reason + ReadLocalCostUseCase reports an unmeasured tool as not-covered with no reason invented for it + ReadLocalCostUseCase stamps no cli_version at all when no version reader was given - never a guessed default + ReadLocalCostUseCase stamps no person field when nobody opted in - the default + ReadLocalCostUseCase stamps the CLI's own version on the record it stores, read through the version port + ReadLocalCostUseCase stamps the identifier a person chose, and a display name only once they set one + ReadLocalCostUseCase stamps the tool it asked + ReadLocalCostUseCase step attribution carries a tool-stated plugin alongside its step + ReadLocalCostUseCase step attribution derives a step from a journal interval when the tool states none + ReadLocalCostUseCase step attribution prefers the tool's own stated step over a journal interval that also covers it + ReadLocalCostUseCase step attribution reads a record as unattributed when neither the tool nor a journal can say + ReadLocalCostUseCase step attribution stores a tool-stated step, marked as stated by the tool + ReadLocalCostUseCase step attribution yields identical counters whether a journal is present or not + ReadLocalCostUseCase stores a found session's counters in the stored shape, marked as read locally + ReadLocalCostUseCase stores what a partial read returns without erroring, when a session is still in progress + a failure in a sweep does not disappear behind a success counts no failure when every session read cleanly + a failure in a sweep does not disappear behind a success prunes day files outside the retention window, once per sweep + a failure in a sweep does not disappear behind a success reports its figures even when a day file cannot be deleted + a failure in a sweep does not disappear behind a success reports the tool as read, and still says how many sessions it could not read + a reader that fails claims no zero when every reader fails + a reader that fails costs its own tool's figures and no other tool's + a reader that fails is a fifth answer, never one of the four that already exist + a reader that fails says the tool could not be read, and why, in the reader's own words + a reader that fails stores what a failed read missed, once the reader recovers + a refusal holds on the one writer left reads nothing and stores nothing when the project switch is off + a refusal holds on the one writer left refuses even a direct --session read, which never touches the journal + reading every session the journal knows keeps reading the other sessions when one session's reader throws + reading every session the journal knows reads every journalled session when none is named + reading every session the journal knows reads nothing, without failing, when the journal names no session + reading every session the journal knows reads only the session named, when one is + reading every session the journal knows stores nothing new on a second sweep + reading every session the journal knows sums a tool's counts across the sweep and keeps its strongest answer + which readers a session reaches asks every reader when no journal names a tool, since then none is ruled out + which readers a session reaches asks every reader when the journal names a host no tool claims + which readers a session reaches asks only the reader the journal named, and says so about the ones it skipped + which readers a session reaches never reports a skipped reader as having found no session + which readers a session reaches still names a tool nothing can read, with its own declared reason, whoever the session belongs to +# tests/application/use-cases/telemetry/report-cost-use-case.unit.test.ts + ReportCostUseCase answers an empty period with an empty report and no error + ReportCostUseCase gives every declared tool a row, with the reason an unreadable one cannot be read + ReportCostUseCase leaves out work that happened before the period, however recently it was stored + ReportCostUseCase names no tool, by string literal + ReportCostUseCase re-throws an error it does not recognise rather than mislabelling it as a named cause + ReportCostUseCase reports a period from what the sink holds, whatever session it belongs to + ReportCostUseCase reports a period whose sessions have no journal at all + ReportCostUseCase reports no identity declared as its own cause, distinct from unreadable + ReportCostUseCase reports the switch as on when the evidence reader says so, even with nothing measured + ReportCostUseCase reports what the read could not place or could not parse + ReportCostUseCase reports whether the project switch is on, from the evidence reader alone + ReportCostUseCase resolves byPeople against the identity this store holds + ReportCostUseCase resolves the declaration through TaskBacklogReader, keyed on the folder the task identity resolves to + ReportCostUseCase restricts to the sessions that wrote into the task asked for + ReportCostUseCase survives an identity that cannot be read, reporting every figure with the caveat set + a report that catches the sink up first deletes no stored day file, since a question is not housekeeping + a report that catches the sink up first leaves a session already stored alone rather than reading it again + a report that catches the sink up first never reaches for a session journalled the instant the period ends + a report that catches the sink up first never reaches for a session whose own moment falls outside the period asked about + a report that catches the sink up first opens no tool's files at all when the project switch is off + a report that catches the sink up first reaches a session journalled at the very first instant of the period + a report that catches the sink up first reaches a session journalled on the last day of the period, which runs to midnight + a report that catches the sink up first reports a journalled session nobody ran a read for + a report that catches the sink up first reports only what the sink holds when no read was wired, rather than guessing + a report that catches the sink up first says what a reader could not answer, rather than reporting the silence as no spend + a report that catches the sink up first skips a journal whose own moment cannot be read at all, rather than treating it as now +# tests/application/use-cases/telemetry/telemetry-off-use-case.unit.test.ts + TelemetryOffUseCase — an endpoint configuration is untouched leaves a tool's settings file exactly as `endpoint ` wrote it + TelemetryOffUseCase — names a leftover export it cannot clear warns nothing when no leftover export is found + TelemetryOffUseCase — names a leftover export it cannot clear warns with the file and the keys still set, when one is found + TelemetryOffUseCase — never on prints the resolved switch path even when there is nothing to do + TelemetryOffUseCase — never on succeeds and changes nothing when the project was never on + TelemetryOffUseCase — taking back what on installed asks git to remove the delegate, whatever the switch's previous state was + TelemetryOffUseCase — taking back what on installed says new commits carry nothing, and that the old ones keep theirs + TelemetryOffUseCase — taking back what on installed says nothing when there was nothing installed to take back + TelemetryOffUseCase — the switch does not delete the switch file — deleting it would lose the endpoint + TelemetryOffUseCase — the switch reports unchanged when the switch was already off + TelemetryOffUseCase — the switch sets enabled: false, preserving the endpoint the project chose +# tests/application/use-cases/telemetry/telemetry-on-use-case.unit.test.ts + TelemetryOnUseCase — making commits joinable to the session that made them installs on every successful on, so a project turned on before this is caught up + TelemetryOnUseCase — making commits joinable to the session that made them installs the delegate the domain declares, never a script written out a second time + TelemetryOnUseCase — making commits joinable to the session that made them says nothing when it was already installed - a no-op is not news + TelemetryOnUseCase — making commits joinable to the session that made them says what it will write into commit messages, and how to undo it + TelemetryOnUseCase — the same consent `endpoint --scope project` already demands fires even when the switch is already on — the same unconditional guard `endpoint` uses + TelemetryOnUseCase — the same consent `endpoint --scope project` already demands with --yes, writes the switch + TelemetryOnUseCase — the same consent `endpoint --scope project` already demands without --yes, refuses and writes nothing, naming the consequence + TelemetryOnUseCase — the switch alone enabling twice reports the switch unchanged the second time + TelemetryOnUseCase — the switch alone preserves an endpoint already recorded in the switch file — `on` has no opinion on it + TelemetryOnUseCase — the switch alone prints the resolved switch path before writing anything + TelemetryOnUseCase — the switch alone succeeds with no endpoint anywhere, and writes no tool's settings file +# tests/application/use-cases/telemetry/tool-attribution.unit.test.ts + every stored record names its tool contains no tool name, by string literal, in the local-read use-case + every stored record names its tool names a tool on every record produced from a captured transcript + every stored record names its tool names only a declared tool identifier, never a free string + every stored record names its tool names the tool consistently, and the vendor field it read the identity from +# tests/domain/formats/commit-session-trailer.unit.test.ts + the delegate a commit's message actually passes through names the commands that install and remove it, where a person will look + the delegate a commit's message actually passes through needs nothing but a shell and git - it runs neither node nor this CLI + the delegate a commit's message actually passes through never fails a commit: every path out of it exits zero + the delegate a commit's message actually passes through reads Codex's own variable before Claude Code's, the precedence session-anchor.ts measured + the delegate a commit's message actually passes through skips a merge and a squash, so one commit never claims the work it brings in + the delegate a commit's message actually passes through writes nothing when no session made the commit - an unknown is never a guess + the delegate a commit's message actually passes through writes the trailer once however often it runs, amend included + the line added to a repository's own prepare-commit-msg forwards git's own arguments, so the delegate can tell a merge from an authored commit + the line added to a repository's own prepare-commit-msg leaves a POSIX path exactly as it was + the line added to a repository's own prepare-commit-msg quotes the path, so a checkout living under a directory with a space still runs + the line added to a repository's own prepare-commit-msg writes a Windows path with forward slashes, which is the only form sh resolves + what the delegate is called on disk is named for what it does, and is a shell script +# tests/domain/formats/local-cost-fixtures.redaction.unit.test.ts + tests/fixtures/local-cost — no fixture carries prompt, response, or file content .claude/projects/fake-project/22222222-2222-4222-8222-222222222222.jsonl carries no forbidden key, absolute path, email, or oversized string + tests/fixtures/local-cost — no fixture carries prompt, response, or file content .claude/projects/fake-project/22222222-2222-4222-8222-222222222222/subagents/agent-aa81cdef3bb58820c.jsonl carries no forbidden key, absolute path, email, or oversized string + tests/fixtures/local-cost — no fixture carries prompt, response, or file content .codex/sessions/2026/07/16/rollout-2026-07-16T09-25-07-019f69d0-9e1f-7951-86c9-ddb23cfd51f4.jsonl carries no forbidden key, absolute path, email, or oversized string + tests/fixtures/local-cost — no fixture carries prompt, response, or file content .codex/sessions/2026/07/29/rollout-2026-07-29T17-12-26-019fae6f-2009-7cd3-86b2-b8f83481b160.jsonl carries no forbidden key, absolute path, email, or oversized string + tests/fixtures/local-cost — no fixture carries prompt, response, or file content .copilot/session-state/33333333-3333-4333-8333-333333333333/events.jsonl carries no forbidden key, absolute path, email, or oversized string + tests/fixtures/local-cost — no fixture carries prompt, response, or file content .copilot/session-state/44444444-4444-4444-8444-444444444444/events.jsonl carries no forbidden key, absolute path, email, or oversized string + tests/fixtures/local-cost — no fixture carries prompt, response, or file content finds at least the four known fixtures — the scan itself is not vacuous +# tests/domain/models/cost-report-backlog.unit.test.ts + buildCostReport — by_backlog regroups tasks by what their folder declares a task with no entry in the resolved declarations still counts, defaulting to none rather than dropping the record + buildCostReport — by_backlog regroups tasks by what their folder declares carries a record in no task at all through as its own reason row, unchanged + buildCostReport — by_backlog regroups tasks by what their folder declares gives a damaged declaration its own row, costing only its own resolution + buildCostReport — by_backlog regroups tasks by what their folder declares gives a task declaring none its own row, distinct from a record in no task at all + buildCostReport — by_backlog regroups tasks by what their folder declares merges two tasks declaring the same item into one row + buildCostReport — by_backlog regroups tasks by what their folder declares mutation proof: a task declaring no item is never silently merged into one that declared + buildCostReport — by_backlog regroups tasks by what their folder declares orders named items largest first, then none, then unreadable, then every reason + buildCostReport — by_backlog regroups tasks by what their folder declares reconciles to the same total as the task, step, model, person and project axes +# tests/domain/models/cost-report-envelope.unit.test.ts + the two renderings are one computation prints the figures the object carries, from the same report value + the two renderings are one computation takes the same value on both sides, so neither can see a figure the other cannot + toCostReportEnvelope carries a version a consumer can refuse + toCostReportEnvelope carries all three attribution strengths, strongest first, zeros included + toCostReportEnvelope carries every day the period spans, a gap included, never sorted by size + toCostReportEnvelope carries measurement_enabled, the one field the terminal rendering could see and this could not + toCostReportEnvelope carries money as whole micro-dollars, so summing reports stays exact + toCostReportEnvelope carries no filters object at all for an unfiltered period + toCostReportEnvelope carries only the generic filters given, snake_case field names untouched + toCostReportEnvelope carries session_totals snake_case, beside the ordinary totals, only where measured (#697) + toCostReportEnvelope carries the period absolutely, as it resolved + toCostReportEnvelope carries what the read could not place and could not parse + toCostReportEnvelope carries why an uncovered tool cannot be read + toCostReportEnvelope gives a record with no project its own row, project absent rather than a placeholder + toCostReportEnvelope keeps an absent counter absent, never turning it into a zero + toCostReportEnvelope names the filter that emptied a selection, distinguishing known from never seen + toCostReportEnvelope reads no clock and no filesystem + toCostReportEnvelope says what each tool can supply on each route, from its declaration + toCostReportEnvelope serializes an empty period to a valid object rather than to nothing +# tests/domain/models/cost-report-person.unit.test.ts + byPeople — a billed call seen by both routes keeps its person backfills the local-read sibling's person_id onto the export-route survivor + byPeople — no mapping declared at all carries identityUnusableCause through when the caller states it + byPeople — no mapping declared at all identityUnusableCause is absent when nothing said otherwise + byPeople — no mapping declared at all resolves every identifier as unresolved, and leaves the figures unchanged + byPeople — one raw identity resolved per group, never merged or dropped a mapped row names every raw identity behind it + byPeople — one raw identity resolved per group, never merged or dropped a record with no identifier lands in its own row, distinct from every unresolved one + byPeople — one raw identity resolved per group, never merged or dropped an identity nobody declared gets its own row, labelled unresolved + byPeople — one raw identity resolved per group, never merged or dropped orders mapped rows first, then unresolved, then the no-identifier row last + byPeople — one raw identity resolved per group, never merged or dropped summing every person row's own money and tokens equals the report's own total + byPeople — one raw identity resolved per group, never merged or dropped summing every person row's requests equals the report's own total + byPeople — one raw identity resolved per group, never merged or dropped two identifiers nobody declared produce two rows, never one merged bucket + byPeople — one raw identity resolved per group, never merged or dropped two identifiers one person declared produce one row, not two + the envelope carries by_person for a program to parse carries the person rows, their identities, and a raised report version +# tests/domain/models/cost-report-task.unit.test.ts + buildCostReport — by_task groups by the declared interval a record falls in answers all six questions over one period, each reconciling to the same total + buildCostReport — by_task groups by the declared interval a record falls in carries the attribution a closed interval always rests on + buildCostReport — by_task groups by the declared interval a record falls in counts each record once, in exactly one row, when a session declares twice + buildCostReport — by_task groups by the declared interval a record falls in gives one row per task declared in the period + buildCostReport — by_task groups by the declared interval a record falls in holds everything in the no-task row when nothing was ever declared, and still reconciles + buildCostReport — by_task groups by the declared interval a record falls in never contradicts a --task header: the inferred route's own record still names no declared interval + buildCostReport — by_task groups by the declared interval a record falls in never lets the whole-session written-path inference the --task filter uses leak into this breakdown + buildCostReport — by_task groups by the declared interval a record falls in places a record before any declaration in its own row, never dropped + buildCostReport — by_task groups by the declared interval a record falls in resolves a declared interval whose path merely contains '..' as text, never misreading live coverage as journal-silent + buildCostReport — by_task groups by the declared interval a record falls in sorts named tasks largest first, with the no-task row placed last regardless of size + buildCostReport — by_task groups by the declared interval a record falls in sums the task rows back to the same period total as every other breakdown +# tests/domain/models/cost-report.unit.test.ts + buildCostReport — a line on disk holds whatever it holds, not what a type declares ignores an active time that is not a number, rather than adding it + buildCostReport — a line on disk holds whatever it holds, not what a type declares leaves a null active time unobserved, rather than counting it as a zero + buildCostReport — a line on disk holds whatever it holds, not what a type declares still sums the active times that are numbers + buildCostReport — a local-read session total, the first kind: 'session' report figure (#697) carries a session total on the tool's own row, never on the period total + buildCostReport — a local-read session total, the first kind: 'session' report figure (#697) never enters by_step or by_day — it reconciles with neither + buildCostReport — a local-read session total, the first kind: 'session' report figure (#697) never folds an export-route session delta into the by-tool session total + buildCostReport — a local-read session total, the first kind: 'session' report figure (#697) stays off every row for a tool with no session-kind local-read record + buildCostReport — a still-open local-read turn is superseded, never doubled answers the same whichever order the two readings arrive in + buildCostReport — a still-open local-read turn is superseded, never doubled keeps the larger reading, and does not sum the two into a figure neither reported + buildCostReport — a still-open local-read turn is superseded, never doubled never collapses a kind: 'session' record sharing a turn_id (Copilot's shutdown total) + buildCostReport — a still-open local-read turn is superseded, never doubled never collapses the export route's own turn_id, which several billed calls share + buildCostReport — a still-open local-read turn is superseded, never doubled never lets a smaller reading of the same turn win, in either arrival order + buildCostReport — a still-open local-read turn is superseded, never doubled prefers an observed zero over an unmentioned counter when two readings tie on weight + buildCostReport — a task can be declared, not just derived a declaration left open by one session does not reach a later, unrelated one + buildCostReport — a task can be declared, not just derived a declared interval closes at its own bound - work after it falls back to inferred + buildCostReport — a task can be declared, not just derived a session that never declared and never wrote into the folder belongs to none - never the last one seen + buildCostReport — a task can be declared, not just derived an unclosed declaration is capped at the journal's own last recorded moment, never left boundless + buildCostReport — a task can be declared, not just derived attributes a tool whose payloads name no path at all - a declared interval, never a written file + buildCostReport — a task is a filter over a period attaches a session that wrote into no task folder to no task at all + buildCostReport — a task is a filter over a period counts a session with no journal in the period, unattributed to any task + buildCostReport — a task is a filter over a period counts every session when no task is asked for, journalled or not + buildCostReport — a task is a filter over a period counts only the sessions that wrote into the task asked for + buildCostReport — an absent quantity stays absent gives a covered tool that did nothing a row of its own, not silence + buildCostReport — an absent quantity stays absent keeps a counter observed as zero distinct from one never observed + buildCostReport — an absent quantity stays absent reports no amount for a tool whose records carry none, never a zero + buildCostReport — an unknown keeps its row, never a zero gives a damaged moment no day row, while the total still holds it + buildCostReport — an unknown keeps its row, never a zero gives a record with no model its own row in byModels, and it still reconciles + buildCostReport — an unknown keeps its row, never a zero reads a non-numeric cost as unknown, never as a zero + buildCostReport — any dimension filters as well as it groups drops a session-only figure a model filter cannot speak to, never as a false zero + buildCostReport — any dimension filters as well as it groups filtering and grouping on the same single-keyed dimension answers with one row + buildCostReport — any dimension filters as well as it groups keeps a session-only figure under a step filter when a journal interval stamped one + buildCostReport — any dimension filters as well as it groups names the combination, not either filter alone, when both are real but their overlap is empty + buildCostReport — any dimension filters as well as it groups names the filter that emptied a selection a project nobody ever worked in + buildCostReport — any dimension filters as well as it groups narrows two filters to their intersection, never their union + buildCostReport — any dimension filters as well as it groups never reports a filter as the culprit when the period itself has nothing + buildCostReport — any dimension filters as well as it groups reconciles every breakdown to this selection's own total, exactly + buildCostReport — any dimension filters as well as it groups says a tool was never seen without claiming a record check it never ran + buildCostReport — any dimension filters as well as it groups says which selection it answered + buildCostReport — any dimension filters as well as it groups tells that empty apart from a known value with no work in this period + buildCostReport — by day and by project gives a record with no project its own row, named as unknown + buildCostReport — by day and by project gives every day in the period a row, a gap included, and reconciles to the total + buildCostReport — by day and by project never folds a record with no project into a neighbour's row + buildCostReport — by day and by project treats an empty-string project_id the same as no project at all + buildCostReport — by_flow reads the journal's own sequence, nothing declared gives a session that never ran an orchestrating skill exactly one row, outside every flow, total intact + buildCostReport — by_flow reads the journal's own sequence, nothing declared gives two orchestrated runs of the same skill in one session two rows, never one merged by name + buildCostReport — by_flow reads the journal's own sequence, nothing declared gives work before the first orchestrating step its own row, outside any flow + buildCostReport — by_flow reads the journal's own sequence, nothing declared holds nothing, and says so rather than swallowing later work, for a flow opened at the journal's very last moment + buildCostReport — by_flow reads the journal's own sequence, nothing declared opens no flow for a skill outside the declared set, however plausible its name + buildCostReport — by_flow reads the journal's own sequence, nothing declared puts a hand-run skill's cost inside the flow it ran during - the journal cannot tell it apart from one the orchestrator invoked + buildCostReport — by_flow reads the journal's own sequence, nothing declared puts the outside-every-flow row last even when it is the largest - the tail convention by_task and by_backlog already keep + buildCostReport — by_flow reads the journal's own sequence, nothing declared reconciles by_flow to the same total as every other breakdown + buildCostReport — every breakdown reconciles keeps one skill reached both ways as two rows, never merged into one claim + buildCostReport — every breakdown reconciles names what nothing could attribute as unattributed, with no step of its own + buildCostReport — every breakdown reconciles orders by tokens where no amount exists, so an amount-less tool is not sorted as free + buildCostReport — every breakdown reconciles orders each breakdown largest first, so the biggest thing is read first + buildCostReport — every breakdown reconciles splits the total three ways by how strongly each part was attributed + buildCostReport — every breakdown reconciles sums each breakdown exactly back to the total it belongs to + buildCostReport — every breakdown reconciles weighs a costless row by all four counters, cache included - not input and output alone + buildCostReport — one billed call, seen by both routes, counts once collapses the two routes' records for the same call into one, in the built report + buildCostReport — one billed call, seen by both routes, counts once sums a naive union of both routes' records to double — the reproduced defect + buildCostReport — the same records, however they arrive keeps the same order when two rows carry equal weight + buildCostReport — the same records, however they arrive produces a byte-identical report from the records reversed + buildCostReport — the same records, however they arrive produces a byte-identical report twice from the same records + buildCostReport — the two kinds are never summed reports no active time at all, rather than zero, when no record carried it + buildCostReport — the two kinds are never summed takes active time from session records alone, and never breaks it down by step + buildCostReport — the two kinds are never summed takes money and tokens from request records alone + buildCostReport — what it says about itself answers an empty period with an empty report, never an error + buildCostReport — what it says about itself carries the undated and unreadable counts through to the caller + buildCostReport — what it says about itself names no tool and no skill, by string literal +# tests/domain/models/flow-attribution.unit.test.ts + ORCHESTRATING_SKILLS — declared once, both capture spellings hands out a project's fourth orchestrator too, without anything else being told about it + ORCHESTRATING_SKILLS — declared once, both capture spellings hands out the unqualified spellings alone, sorted - the ones a project can collide with + ORCHESTRATING_SKILLS — declared once, both capture spellings matches no plugin name in passing - nothing here reads a prefix or a substring + ORCHESTRATING_SKILLS — declared once, both capture spellings names every orchestrator skill, in the argument spelling and the bare directory spelling + buildFlowIntervals — a journal with no readable moment in it builds nothing from a journal with no boundary at all + buildFlowIntervals — a journal with no readable moment in it builds nothing when every moment it holds is unparseable - never an interval bounded by NaN + buildFlowIntervals — a journal with no readable moment in it ends a flow nothing ever closed at the journal's own last witnessed moment + buildFlowIntervals — pure: journal lines -> bounded flow intervals caps an unclosed flow at its own moment, never at Infinity + buildFlowIntervals — pure: journal lines -> bounded flow intervals clamps an unclosed flow's end to the report's own period end, never past a clock-skewed future moment + buildFlowIntervals — pure: journal lines -> bounded flow intervals closes a flow at turn_end when nothing else orchestrates first + buildFlowIntervals — pure: journal lines -> bounded flow intervals closes at the turn_end itself, not at whatever the journal witnessed after it + buildFlowIntervals — pure: journal lines -> bounded flow intervals declares no flow interval at all for a session that never ran an orchestrating skill + buildFlowIntervals — pure: journal lines -> bounded flow intervals leaves work done after the turn ended outside the flow that turn opened + buildFlowIntervals — pure: journal lines -> bounded flow intervals matches the bare directory spelling a Cursor or Codex payload actually writes + buildFlowIntervals — pure: journal lines -> bounded flow intervals never lets a hand-run, non-orchestrating step_start close an open flow + buildFlowIntervals — pure: journal lines -> bounded flow intervals opens a flow at an orchestrating step_start and closes it at the next one + buildFlowIntervals — pure: journal lines -> bounded flow intervals opens two distinct intervals for the same skill run twice in one session, never merged into one + buildFlowIntervals — pure: journal lines -> bounded flow intervals touches no filesystem — the module imports none of Node's fs APIs + buildFlowIntervals — pure: journal lines -> bounded flow intervals widens an unclosed flow's end to the journal's own last witnessed moment - a file written after it, no turn_end yet + buildFlowIntervals — the limit the flow axis prints beside its figures opens a flow on a bare 01-sdlc, whichever project's own skills/ directory named it +# tests/domain/models/session-project.unit.test.ts + resolveSessionProject falls back to the directory-name field when no remote exists + resolveSessionProject names no project for a journal with no session at all + resolveSessionProject names no project for a session with no journal at all + resolveSessionProject names no project when the session carries neither field + resolveSessionProject prefers the remote, and says so +# tests/domain/models/step-attribution.unit.test.ts + buildStepIntervals — a step the session never closed attributes a much later moment to it, which is what leaving it open means + buildStepIntervals — a step the session never closed leaves the last step open when no turn_end ever closed it + step-attribution — pure: journal lines + records -> intervals closes an interval at the next step_start, not at the turn's end past it + step-attribution — pure: journal lines + records -> intervals closes the last step at its own turn_end, leaving nothing beyond it covered + step-attribution — pure: journal lines + records -> intervals does not let an unparseable boundary extend the step before it into the step after + step-attribution — pure: journal lines + records -> intervals maps a moment inside a step interval to that step, marked as derived + step-attribution — pure: journal lines + records -> intervals reads a moment before the first boundary as unattributed, never folded into it + step-attribution — pure: journal lines + records -> intervals reads a record with no moment at all as unattributed, never the first interval + step-attribution — pure: journal lines + records -> intervals reads every moment as unattributed when the journal opened no step + step-attribution — pure: journal lines + records -> intervals touches no filesystem — the module imports none of Node's fs APIs + step-attribution — pure: journal lines + records -> intervals yields three intervals and two names from A, then B, then A +# tests/domain/models/task-attribution.unit.test.ts + task-attribution — pure: journal lines -> bounded intervals caps an unclosed declaration at its own moment, never at Infinity + task-attribution — pure: journal lines -> bounded intervals caps an unclosed declaration at the last boundary the journal actually recorded + task-attribution — pure: journal lines -> bounded intervals clamps an unclosed interval's end to the report's own period end, never past it + task-attribution — pure: journal lines -> bounded intervals closes a declaration at a later declaration, never at the turn's own end past it + task-attribution — pure: journal lines -> bounded intervals closes a declared interval at the turn_end that follows it + task-attribution — pure: journal lines -> bounded intervals declares no interval at all for a journal that never named a task + task-attribution — pure: journal lines -> bounded intervals drops a task_declared line whose own `at` this reader cannot parse, the same as one that was never written + task-attribution — pure: journal lines -> bounded intervals emits no interval for a declared path this reader cannot turn into an identity, but still lets it close the interval before it + task-attribution — pure: journal lines -> bounded intervals leaves an unclosed interval's end exactly where a real closer put it, when that is well inside the period + task-attribution — pure: journal lines -> bounded intervals never lets a step_start close a declared interval early - only task_declared and turn_end do + task-attribution — pure: journal lines -> bounded intervals never lets a written file reach further back than the interval's own last closer + task-attribution — pure: journal lines -> bounded intervals reads a moment inside the interval as covered, and one outside as not + task-attribution — pure: journal lines -> bounded intervals reads a record with no moment, or an unparseable one, as not covered + task-attribution — pure: journal lines -> bounded intervals still never runs away: a written file does not turn the interval open-ended + task-attribution — pure: journal lines -> bounded intervals touches no filesystem — the module imports none of Node's fs APIs + task-attribution — pure: journal lines -> bounded intervals widens an unclosed declaration's end to a written file the journal witnessed after it + taskUnattributedReason — which of three distinct facts applies names journal-silent for a record after the last declared interval's own end + taskUnattributedReason — which of three distinct facts applies names journal-silent for a record with no moment, once a task was declared + taskUnattributedReason — which of three distinct facts applies names no-declaration for a session whose journal never declared a task + taskUnattributedReason — which of three distinct facts applies names precedes-declaration for a record before the session's only declaration + taskUnattributedReason — which of three distinct facts applies names precedes-declaration for a record in the gap a turn_end leaves before the next declaration - never journal-silent, since the journal keeps going right through it +# tests/domain/models/telemetry-claim.unit.test.ts + diagnoseTelemetryClaims — hook fired cannot tell whether this session's hook fired without an anchor + diagnoseTelemetryClaims — hook fired does not read a torn run file (session-less) as an unrecognised payload without the marker + diagnoseTelemetryClaims — hook fired lets an untrusted Codex hook explain the absence ahead of either new reason, even when the recorder is declared + diagnoseTelemetryClaims — hook fired names an anchorless run file as its own reason, unconditional on the recorder's own declaration + diagnoseTelemetryClaims — hook fired names an unrecognised payload as its own fault, distinct from never firing + diagnoseTelemetryClaims — hook fired names an untrusted Codex hook, not never having fired, when the trust state says so + diagnoseTelemetryClaims — hook fired names an untrusted hook for this session too, when an older session left a run file but this one did not + diagnoseTelemetryClaims — hook fired names the hook never having fired when no run file appears + diagnoseTelemetryClaims — hook fired names the recorder as what is missing when it is declared nowhere and no run file has appeared + diagnoseTelemetryClaims — hook fired names this session as having left no run file when an older one exists but not its own + diagnoseTelemetryClaims — hook fired reads ok once the current session's own run file is found among them + diagnoseTelemetryClaims — hook fired reports nothing to evaluate, never a failure, when the recorder is declared but no run file has appeared + diagnoseTelemetryClaims — hook fired says it could not tell, never a failure, when the declaration itself could not be read + diagnoseTelemetryClaims — hook fired says nothing about trust for a tool with no trust gate + diagnoseTelemetryClaims — hook fired says the trust state could not itself be read, rather than guessing + diagnoseTelemetryClaims — hook fired still names the generic never-fired fault once the hook is actually trusted + diagnoseTelemetryClaims — records join has nothing to join when neither a step interval nor a tool-stated step exists + diagnoseTelemetryClaims — records join has nothing to join when no record was read + diagnoseTelemetryClaims — records join names every record unattributed, not a missing record + diagnoseTelemetryClaims — records join reads ok once a record joined a step + diagnoseTelemetryClaims — session journalled has nothing to read when no run file exists + diagnoseTelemetryClaims — session journalled names a run file that carries only session_start + diagnoseTelemetryClaims — session journalled reads ok when a run file closed its turn + diagnoseTelemetryClaims — the whole set always returns exactly four claims, in the fixed order, none of them ever unjudged + diagnoseTelemetryClaims — the whole set no claim mentions exporting, a destination, or an identity attribute + diagnoseTelemetryClaims — the whole set no claim recommends a command the system no longer offers + diagnoseTelemetryClaims — tool files readable carries the count read against the count attempted, not just the tool that worked + diagnoseTelemetryClaims — tool files readable has no session to look for when the journal names none + diagnoseTelemetryClaims — tool files readable names a reader that threw as failing to read, not as a plain miss + diagnoseTelemetryClaims — tool files readable names no session found for any tool, while the journal names one + diagnoseTelemetryClaims — tool files readable reads ok once one covered tool found the session + the diagnostic skill states the claims the command prints, in the number it prints them agrees with the actual number of claims diagnoseTelemetryClaims prints, everywhere it is stated + the diagnostic skill's account of every no-run-file reason matches the command's own reasons anchorless-run-file is never paired with the wrong verdict token in step 6 + the diagnostic skill's account of every no-run-file reason matches the command's own reasons anchorless-run-file is what step 6 of the skill teaches, in the same sentence as its own verdict token + the diagnostic skill's account of every no-run-file reason matches the command's own reasons anchorless-run-file is what the live code actually produces + the diagnostic skill's account of every no-run-file reason matches the command's own reasons recorder-declaration-unreadable is never paired with the wrong verdict token in step 6 + the diagnostic skill's account of every no-run-file reason matches the command's own reasons recorder-declaration-unreadable is what step 6 of the skill teaches, in the same sentence as its own verdict token + the diagnostic skill's account of every no-run-file reason matches the command's own reasons recorder-declaration-unreadable is what the live code actually produces + the diagnostic skill's account of every no-run-file reason matches the command's own reasons recorder-declared-not-yet-fired is never paired with the wrong verdict token in step 6 + the diagnostic skill's account of every no-run-file reason matches the command's own reasons recorder-declared-not-yet-fired is what step 6 of the skill teaches, in the same sentence as its own verdict token + the diagnostic skill's account of every no-run-file reason matches the command's own reasons recorder-declared-not-yet-fired is what the live code actually produces + the diagnostic skill's account of every no-run-file reason matches the command's own reasons recorder-declared-nowhere is never paired with the wrong verdict token in step 6 + the diagnostic skill's account of every no-run-file reason matches the command's own reasons recorder-declared-nowhere is what step 6 of the skill teaches, in the same sentence as its own verdict token + the diagnostic skill's account of every no-run-file reason matches the command's own reasons recorder-declared-nowhere is what the live code actually produces +# tests/domain/models/telemetry-export-leftover.unit.test.ts + findLeftoverExportKeys finds nothing in a file with no env block + findLeftoverExportKeys finds nothing in an env block that carries none of the known keys + findLeftoverExportKeys names every known export key present in the file's env block + findLeftoverExportKeys reads a file that does not exist (null) as nothing found + findLeftoverExportKeys reads an env block that is not an object as nothing found + findLeftoverExportKeys reads unparseable content as nothing found, rather than throwing +# tests/domain/models/telemetry-host-registration.unit.test.ts + what a host's own registry says about a plugin AIDD installed gives every plugin its own entry, across tools + what a host's own registry says about a plugin AIDD installed is not registered when the registry was read and lacks the ref + what a host's own registry says about a plugin AIDD installed is registered when the registry carries its ref + what a host's own registry says about a plugin AIDD installed is unanswerable for a host nothing here knows how to ask + what a host's own registry says about a plugin AIDD installed is unanswerable when no ref can be built at all, and names no ref + what a host's own registry says about a plugin AIDD installed is unanswerable when the registry could not be read, never `not-registered` + what a host's own registry says about a plugin AIDD installed reports no entry for a project with nothing installed + what a host's own registry says about a plugin AIDD installed says a declared registry is unmeasured, not that none exists + what a host's own registry says about a plugin AIDD installed says a host declaring no registry has none to read + what a host's own registry says about a plugin AIDD installed tells a disabled registration from an absent one +# tests/domain/models/telemetry-removal.unit.test.ts + TelemetryRemovalPreview — a project's journal and a machine's records are never the same kind of thing carries what cannot be reached beside what can, on the same value + TelemetryRemovalPreview — a project's journal and a machine's records are never the same kind of thing names the journal as this project's own + TelemetryRemovalPreview — a project's journal and a machine's records are never the same kind of thing names the sink and the identity file as this machine's own + telemetryRemovalIsEmpty() is empty when every location has nothing + telemetryRemovalIsEmpty() is not empty when an identity is present, even if damaged + telemetryRemovalIsEmpty() is not empty when the journal holds a run file + telemetryRemovalIsEmpty() is not empty when the sink holds a day file +# tests/domain/models/telemetry-setup.unit.test.ts + buildTelemetryAllowedSetup — whose choice this was never lets a damaged switch file masquerade as a refusal + buildTelemetryAllowedSetup — whose choice this was never treats an unset AIDD_TELEMETRY as a refusal + buildTelemetryAllowedSetup — whose choice this was reads AIDD_TELEMETRY=0 as this person's own refusal, whatever the project file says + buildTelemetryAllowedSetup — whose choice this was reads a person's refusal as always readable — an env var never fails to read + buildTelemetryAllowedSetup — whose choice this was reads a project never switched on as the project's own decision, not a refusal + buildTelemetryAllowedSetup — whose choice this was reads a project's own switch, turned on, as the project's decision +# tests/domain/models/telemetry-sink-record.unit.test.ts + parseTelemetrySinkLine() carries provenance for both routes, on the same fixture + parseTelemetrySinkLine() parses a hand-written fixture the mapper never produced + parseTelemetrySinkLine() parses a line written before cli_version existed, losing no figure to the gap + parseTelemetrySinkLine() parses a stored line that still carries the now-removed user_id, inertly + parseTelemetrySinkLine() rejects an unknown sink_schema_version rather than guessing its shape + parseTelemetrySinkLine() rejects the v1 shape specifically, not just an unrecognised number + telemetrySinkRecordDayKey() answers the UTC day for a real moment, the fast path and the parsed one alike + telemetrySinkRecordDayKey() answers undefined for a string merely shaped like a moment, never a sliced fragment + telemetrySinkRecordDayKey() answers undefined for no moment at all + telemetrySinkRecordDayKey() — a line holds whatever it holds answers nothing for a moment stored as a number, never 1970 + telemetrySinkRecordDayKey() — a line holds whatever it holds answers nothing for a moment stored as null +# tests/domain/models/telemetry-sink-retention.unit.test.ts + decideTelemetrySinkRetention() accepts files out of order and still sorts chronologically + decideTelemetrySinkRetention() defaults to a ninety-day window + decideTelemetrySinkRetention() keeps the window's files and prunes the oldest, on real file names + decideTelemetrySinkRetention() never prunes the newest file, even at a window of 0 + decideTelemetrySinkRetention() touches nothing when the sink is younger than the window +# tests/domain/models/telemetry-switch.unit.test.ts + buildTelemetrySwitchFile falls back to an empty root when the existing content is unparseable + buildTelemetrySwitchFile omits the endpoint key when none is given + buildTelemetrySwitchFile preserves unrelated top-level keys already in the file + buildTelemetrySwitchFile writes enabled and endpoint from nothing + parseTelemetrySwitchFile reads enabled and endpoint from a well-formed switch + parseTelemetrySwitchFile reads enabled: false without an endpoint + parseTelemetrySwitchFile returns null for unparseable JSON — the same failure direction as the hook + parseTelemetrySwitchFile returns null when the telemetry key has the wrong shape + parseTelemetrySwitchFile returns null when the telemetry key is absent + parseTelemetrySwitchFile treats a non-boolean-true enabled value as off, not a throw + personRefusesTelemetry only the literal string '0' is a refusal + personRefusesTelemetry unset, empty, or any other value is not a choice — never a refusal + resolveTelemetryEnabled an unset refusal never turns measurement on by itself + resolveTelemetryEnabled no refusal, project off or absent — not enabled + resolveTelemetryEnabled no refusal, project on — enabled + resolveTelemetryEnabled the refusal wins over a project that turned measurement on + telemetryConfigPath resolves .aidd/config.json under the project root + the hook (repo.cjs) and the CLI agree, for every combination file off, no refusal => false + the hook (repo.cjs) and the CLI agree, for every combination file off, refused => false + the hook (repo.cjs) and the CLI agree, for every combination file on, any other value is not a choice => true + the hook (repo.cjs) and the CLI agree, for every combination file on, empty refusal is not a choice => true + the hook (repo.cjs) and the CLI agree, for every combination file on, no refusal => true + the hook (repo.cjs) and the CLI agree, for every combination file on, refused => false + the hook (repo.cjs) and the CLI agree, for every combination no file, no refusal => false + the hook (repo.cjs) and the CLI agree, for every combination no file, refused => false +# tests/domain/tools/telemetry-route-supply.unit.test.ts + what a route declares it supplies, against what its reader actually produces claude's local route supplies exactly what it declares + what a route declares it supplies, against what its reader actually produces codex's local route supplies exactly what it declares + what a route declares it supplies, against what its reader actually produces copilot's local route supplies exactly what it declares + what a route declares it supplies, against what its reader actually produces has a capture for every route that claims to supply anything + what a route declares it supplies, against what its reader actually produces opencode's local route supplies exactly what it declares +# tests/e2e/telemetry-backlog-axis.e2e.test.ts + aidd telemetry report — by_backlog through the real adapter, on real disk leaves every task folder byte-identical after the report runs + aidd telemetry report — by_backlog through the real adapter, on real disk merges two tasks declaring the same backlog item into one row, and gives the third its own + aidd telemetry report — by_backlog through the real adapter, on real disk prints the backlog axis through --axis, naming the item and the none row + aidd telemetry report — by_backlog through the real adapter, on real disk reconciles by_backlog to the same total as the period and as by_task +# tests/e2e/telemetry-check-skill-commands.e2e.test.ts + E2E: 02-check answers through the CLI every command the skill names is one the CLI accepts + E2E: 02-check answers through the CLI names no script beside itself any more +# tests/e2e/telemetry-check.e2e.test.ts + aidd telemetry check — not yet stops being a failure keeps the verdict a run file already earned, whatever the recorder's own declaration says + aidd telemetry check — not yet stops being a failure keeps the verdict an anchorless run file already earned once the recorder is declared, never nothing to evaluate + aidd telemetry check — not yet stops being a failure reports nothing to evaluate, never a failure, once the recorder is declared — and a failure naming it before that + aidd telemetry check — the journey and its edge cases falls back to never-fired, never a guess at trust, when Codex's config.toml is absent entirely + aidd telemetry check — the journey and its edge cases names a non-repository, and never blames the hook + aidd telemetry check — the journey and its edge cases names an anchorless run file as its own failure, not an unrecognised payload, for a run file torn before session_start ever parsed + aidd telemetry check — the journey and its edge cases names an unrecognised payload the real hook wrote, not one this test typed + aidd telemetry check — the journey and its edge cases names an unrecognised payload, not a hook that never ran + aidd telemetry check — the journey and its edge cases names an untrusted Codex hook, not a hook that never fired + aidd telemetry check — the journey and its edge cases names the hook never firing when measurement is on and no run file appears + aidd telemetry check — the journey and its edge cases reports a hook approved under an old event name as untrusted — approval is per entry + aidd telemetry check — the journey and its edge cases settles every claim — none is ever printed as not yet judged + aidd telemetry check — the journey and its edge cases stops at the switch before judging anything else + aidd telemetry check — what is in place, before any verdict distinguishes this person's own refusal from a project nobody switched on + aidd telemetry check — what is in place, before any verdict keeps every other stated fact when the identity file cannot be read + aidd telemetry check — what is in place, before any verdict names Copilot's own settings file as a declaration route, not one this build never reads + aidd telemetry check — what is in place, before any verdict names Cursor's project-scope hooks file as a declaration — the only route a Cursor install's hook ever fires from + aidd telemetry check — what is in place, before any verdict names where the recorder is declared, when a hooks block invokes it directly rather than through enabledPlugins + aidd telemetry check — what is in place, before any verdict names where the recorder is declared, when a tool's own settings say so + aidd telemetry check — what is in place, before any verdict never reads another plugin's own journal.cjs as this recorder being declared + aidd telemetry check — what is in place, before any verdict says the declaration could not be read, never that the recorder is missing, for a damaged declaring file + aidd telemetry check — what is in place, before any verdict states what is in place on a machine that has never measured anything, naming the file behind each fact + aidd telemetry check — what is in place, before any verdict still recognises the recorder's own hooks-block entry point when the command is quoted + aidd telemetry check — what is in place, before any verdict stops recognising a hooks block once it stops naming the recorder's own script, proving the match is not a loose substring +# tests/e2e/telemetry-commit-trailer.e2e.test.ts + a commit names the session that made it carries nothing at all until measurement is turned on + a commit names the session that made it carries nothing when no session made the commit + a commit names the session that made it carries the session's own identifier once measurement is on + a commit names the session that made it keeps a hook the repository already ran, and commits still succeed + a commit names the session that made it names the session actually running, not the one whose variable it inherited + a commit names the session that made it says what it did, so nobody finds a trailer in their history unannounced + a commit names the session that made it stops trailering after off, and leaves the commits already made alone + a commit names the session that made it writes it once, not twice, when the commit is amended +# tests/e2e/telemetry-cost-skill-commands.e2e.test.ts + E2E: 01-cost answers through the CLI every command the skill names is one the CLI accepts + E2E: 01-cost answers through the CLI names no script beside itself any more + E2E: 01-cost answers through the CLI the envelope is what the deleted script produced, field for field + E2E: 01-cost answers through the CLI the fixture is not vacuous, so this pin cannot pass on emptiness +# tests/e2e/telemetry-flow-axis.e2e.test.ts + aidd telemetry report — by_flow through the real adapter, on real disk gives the same orchestrating skill run twice in one session two rows, never merged into one + aidd telemetry report — by_flow through the real adapter, on real disk gives work before the first orchestrating step its own row, outside any flow + aidd telemetry report — by_flow through the real adapter, on real disk prints the flow axis through --axis, naming both runs and the outside-flow row + aidd telemetry report — by_flow through the real adapter, on real disk reconciles by_flow to the same total as the period +# tests/e2e/telemetry-forget.e2e.test.ts + aidd telemetry forget — shows, confirms, removes, and names what history keeps a damaged record file is removed and reported as removed, exactly like any other + aidd telemetry forget — shows, confirms, removes, and names what history keeps a machine where nothing was ever measured has nothing to remove, and offers nothing + aidd telemetry forget — shows, confirms, removes, and names what history keeps a relocated AIDD_RUNS_DIR touches only the relocated location, never the project's own runs dir + aidd telemetry forget — shows, confirms, removes, and names what history keeps a relocated AIDD_USER_CONFIG_DIR touches only the relocated location, never the real profile + aidd telemetry forget — shows, confirms, removes, and names what history keeps a run file that refuses removal (a directory named *.jsonl) is reported, and the rest is still removed + aidd telemetry forget — shows, confirms, removes, and names what history keeps history is repeated after removing, not only before + aidd telemetry forget — shows, confirms, removes, and names what history keeps previews a staged-but-never-committed journal honestly, never as certainly held + aidd telemetry forget — shows, confirms, removes, and names what history keeps previews a tracked journal as history certainly holding it, naming the file + aidd telemetry forget — shows, confirms, removes, and names what history keeps previews an untracked journal as history possibly holding it, never as an all-clear + aidd telemetry forget — shows, confirms, removes, and names what history keeps with --yes, removes exactly what was shown, in counts that match, and leaves the switch alone + aidd telemetry forget — shows, confirms, removes, and names what history keeps without --yes, refuses: nothing removed, and it says so plainly, exiting successfully +# tests/e2e/telemetry-hook-install.e2e.test.ts + E2E: the journal hook runs from where installation puts it records a session through the installed plugin, not the source tree +# tests/e2e/telemetry-host-registration.e2e.test.ts + check says whether the host will load what aidd installed does not count a registration made for a different project + check says whether the host will load what aidd installed names each plugin's answer, with no AI tool on PATH and nothing to spend +# tests/e2e/telemetry-identity-resolution.e2e.test.ts + aidd telemetry report --axis person, and the identity commands that feed it a second machine's identifier prints unresolved before linking, and merges after + aidd telemetry report --axis person, and the identity commands that feed it an identity placed under a project-scoped config directory has no effect + aidd telemetry report --axis person, and the identity commands that feed it an identity that does not parse costs the resolution, never one figure + aidd telemetry report --axis person, and the identity commands that feed it identity lists every added identifier with no report ever run + aidd telemetry report --axis person, and the identity commands that feed it no identity declared at all still reports every figure, naming that cause + aidd telemetry report --axis person, and the identity commands that feed it sums every person row to the period total, and never merges two unmapped identifiers + aidd telemetry report --axis person, and the identity commands that feed it the two causes read as two different caveats end to end + aidd telemetry report --axis person, and the identity commands that feed it two tools under one identifier print one person row +# tests/e2e/telemetry-identity.e2e.test.ts + a choice made today does not reach backwards records stored before opting in stay unnamed; only later records carry it + aidd telemetry identity — the journey and its edge cases a minted identity discloses what it attaches to, and what it never attaches to + aidd telemetry identity — the journey and its edge cases a repository pointing AIDD_USER_CONFIG_DIR elsewhere never moves an existing identity + aidd telemetry identity — the journey and its edge cases a second use reports the same identifier, never a new one + aidd telemetry identity — the journey and its edge cases an empty OS profile beside a populated AIDD_USER_CONFIG_DIR still reads off + aidd telemetry identity — the journey and its edge cases an unreadable identity file surfaces as an error, never as 'no identity is set' + aidd telemetry identity — the journey and its edge cases mints for a name given before anything stands, rather than refusing + aidd telemetry identity — the journey and its edge cases off on a profile that never had an identity says there was nothing to withdraw + aidd telemetry identity — the journey and its edge cases off removes an identity file that exists but names nobody + aidd telemetry identity — the journey and its edge cases off removes the whole declaration, stating how many added identifiers went with it + aidd telemetry identity — the journey and its edge cases off says past records keep the identifier they were written with + aidd telemetry identity — the journey and its edge cases off still withdraws a damaged identity file, and says it was discarded + aidd telemetry identity — the journey and its edge cases walks identity -> use -> use --name -> identity -> off, each state legible from stdout + the on-disk format the deleted script produced name: matches the exact bytes the script wrote from the same starting identity + the on-disk format the deleted script produced use with no identifier: from empty, mints a v4 identifier recording how it was obtained + what a default install actually stores: reading every line it wrote carries no person field anywhere, proven from the stored bytes +# tests/e2e/telemetry-init-skill-commands.e2e.test.ts + E2E: 00-init calls the CLI 01-check's absent-CLI wording has not drifted from 01-cost's own copy + E2E: 00-init calls the CLI every command the skill names is one the CLI accepts, run in a safe order + E2E: 00-init calls the CLI names no script beside itself any more + E2E: 00-init calls the CLI the skill's account names both the preview and the confirmed removal + E2E: 00-init calls the CLI the sweep itself would fail if the skill's account named a command the CLI refuses +# tests/e2e/telemetry-journal-gitignore.e2e.test.ts + aidd setup never offers the run journal to a commit adds the run journal to .gitignore, and covers nothing wider + aidd setup never offers the run journal to a commit stops offering a journalled session to git status once measurement is on +# tests/e2e/telemetry-lifecycle.e2e.test.ts + measurement, from nothing to off and back answers a program the same way through the same cycle + measurement, from nothing to off and back leaves the project's own config alone through the whole cycle + measurement, from nothing to off and back lives the whole sequence, each step meaning what the one before it set up +# tests/e2e/telemetry-multi-tool.e2e.test.ts + aidd telemetry, across every tool that can be read attributes a task from what the journal hook itself recorded + aidd telemetry, across every tool that can be read breaks a real session down by model and lists only models + aidd telemetry, across every tool that can be read names the one tool nothing here can read, with its measured reason + aidd telemetry, across every tool that can be read reads three tools' own files and reports all three in one period + aidd telemetry, across every tool that can be read shows all three attribution strengths at once, each from its own source + aidd telemetry, across every tool that can be read stamps every stored record with this CLI's own version, read through the real binary - never the framework's + aidd telemetry, across every tool that can be read stores nothing twice when the same sessions are read again + the flow a person can actually follow answers a program with the same object twice, for the same absolute period + the flow a person can actually follow reads every journalled session without anyone naming one + the flow a person can actually follow refuses a period that is not one, naming the flag + the flow a person can actually follow says so, and exits 0, when nothing has been journalled yet + the flow a person can actually follow tells a program what each tool can supply, so it never infers it from a missing number +# tests/e2e/telemetry-on-runs-privacy.e2e.test.ts + aidd telemetry on carries over what the switch script did beyond flipping a flag a journal already tracked by git is named once, and nothing is removed or rewritten + aidd telemetry on carries over what the switch script did beyond flipping a flag a project outside any git repository still turns on, quietly + aidd telemetry on carries over what the switch script did beyond flipping a flag adds the run journal to .gitignore, and nothing wider + aidd telemetry on carries over what the switch script did beyond flipping a flag an existing entry is left as it is, never duplicated + aidd telemetry on carries over what the switch script did beyond flipping a flag git add -A stages the .gitignore change and leaves the journal out of the index + aidd telemetry on carries over what the switch script did beyond flipping a flag nothing extra is said when no journal file is tracked + aidd telemetry on carries over what the switch script did beyond flipping a flag turning measurement off touches neither .gitignore nor the tracked-file notice +# tests/e2e/telemetry-plugin-standalone.e2e.test.ts + the plugin measures on its own journals a whole Claude Code session with no aidd on the path + the plugin measures on its own reads a session's figures complete, though the CLI did not exist when it ran +# tests/e2e/telemetry-reference-week.e2e.test.ts + a report that needs no read first answers with figures on a sink nobody has filled + the reference week breaks the week down by task, and by the backlog item a task declared + the reference week breaks the week down by the flow that ran, keeping what ran outside one apart + the reference week counts what the week actually produced + the reference week gives every day of the period a row, and only the worked days a figure + the reference week keeps a session-total tool out of the request totals and still shows its figure + the reference week leads with the run it can name, though more of the week fell outside every flow + the reference week names a teammate's records as an identity it cannot resolve, never as nobody + the reference week names each step's attribution, all three strengths in one week + the reference week names every tool it cannot read, with the reason, rather than omitting it + the reference week prints, beside those figures, the two things this axis cannot tell apart + the reference week reconciles every breakdown to that same total + the reference week splits the week by person and by project, without either standing in for the other + the reference week states no amount anywhere, because no tool supplies one + the week builds inside a git hook's own environment ignores a leaked GIT_DIR rather than resolving the real repository +# tests/e2e/telemetry-refusal.e2e.test.ts + a person's own refusal, without touching a tracked file an unset refusal turns nothing on by itself, and a project with measurement on still records + a person's own refusal, without touching a tracked file refusing in this person's own environment records nothing, in a project whose tracked configuration allows it + a person's own refusal, without touching a tracked file removing the refusal records again, in the same project whose switch never changed + a person's own refusal, without touching a tracked file the refusal wins over a project that turns measurement on, never the file + turning measurement on for everyone who clones is confirmed confirmed with --yes, writes the switch and says what was done + turning measurement on for everyone who clones is confirmed turning it off needs no confirmation + turning measurement on for everyone who clones is confirmed without --yes, refuses and writes nothing, naming the consequence +# tests/e2e/telemetry-report.e2e.test.ts + aidd telemetry report keeps only the project asked for, saying so in the object it answers with + aidd telemetry report leaves work outside the period out of it, however recently it was stored + aidd telemetry report names a project nobody ever worked in, apart from a total of zero + aidd telemetry report names every tool that cannot be read, with its own reason + aidd telemetry report narrows two filters to their intersection, project as filter and step as axis + aidd telemetry report prints nothing measured and exits 0 for a period holding nothing + aidd telemetry report prints the composed selection in the header a person reads + aidd telemetry report refuses a period that is not a whole number of days, naming the flag + aidd telemetry report reports what a real session consumed + aidd telemetry report tells a known value idle in this period apart from one never seen at all +# tests/e2e/telemetry-six-questions.e2e.test.ts + aidd telemetry report — the six questions, over one period answers total, by model, by task, by step, by person and by project, all reconciling + aidd telemetry report — the six questions, over one period names the no-task row for what is known, never for what is guessed + aidd telemetry report — the six questions, over one period prints every axis through --axis, each stating the same total it belongs to +# tests/e2e/telemetry-stored-export-record.e2e.test.ts + a record the removed export route already wrote stays readable counts once, not twice, when the same billed call also has a local-read sibling + a record the removed export route already wrote stays readable is counted by `aidd telemetry report`, with its own figures +# tests/e2e/telemetry-task-midsession.e2e.test.ts + aidd telemetry report — a task declared while the work is still going attributes what follows a declaration while the session is still running, and the same closing the turn afterwards does not change + aidd telemetry report — a task declared while the work is still going names each of the three unattributed reasons distinctly, never collapsing two into one + aidd telemetry report — a task declared while the work is still going prints each reason in the text rendering too, never one label for all three +# tests/e2e/telemetry.e2e.test.ts + E2E: aidd telemetry on/off — the switch alone off on a project never turned on leaves the switch absent and every tool untouched + E2E: aidd telemetry on/off — the switch alone on succeeds with no endpoint anywhere, and writes no tool's settings file + E2E: the deleted export route's commands are gone, not disabled `telemetry endpoint` is refused as unknown, the way any unknown command is + E2E: the deleted export route's commands are gone, not disabled `telemetry receive` is refused as unknown, the way any unknown command is +# tests/infrastructure/adapters/copilot-cost-reader-adapter.integration.test.ts + CopilotCostReaderAdapter answers with nothing, not an error, when the declared home does not exist + CopilotCostReaderAdapter finds the session and reports it empty, not missing, when shutdown carried no tokenDetails + CopilotCostReaderAdapter reads one session record from its own events.jsonl, stamped with the id it was asked for + CopilotCostReaderAdapter says it found no session, not that the session cost nothing, when no directory names it +# tests/infrastructure/adapters/git-adapter-telemetry-project-id.integration.test.ts + neither side follows a leaked GIT_DIR reads the remote of the repository at cwd, not the one GIT_DIR names +# tests/infrastructure/adapters/opencode-cost-reader-adapter.integration.test.ts + OpencodeCostReaderAdapter reads a well-behaved export into one record per billed message + OpencodeCostReaderAdapter returns nothing when the opencode binary is not on PATH + OpencodeCostReaderAdapter says it found no session, not an error, for an unknown session + OpencodeCostReaderAdapter throws OpencodeExportError when the command answers with something that is not JSON + OpencodeCostReaderAdapter throws OpencodeExportError, and stores nothing, on a non-zero exit unrelated to an unknown session + OpencodeCostReaderAdapter throws OpencodeExportError, and stores nothing, when the command exceeds its timeout +# tests/infrastructure/adapters/person-identity-adapter.integration.test.ts + PersonIdentityAdapter — what it writes, and what it reads back adds and withdraws an added identifier, leaving the person's own untouched + PersonIdentityAdapter — what it writes, and what it reads back keeps a display name across a later write + PersonIdentityAdapter — what it writes, and what it reads back mints an identifier that survives a read back through the file + PersonIdentityAdapter — what it writes, and what it reads back reads a damaged file as nothing, and refuses it strictly + PersonIdentityAdapter — what it writes, and what it reads back reads back nothing at all before anyone has chosen + PersonIdentityAdapter — what it writes, and what it reads back records an adopted identifier as adopted, not as minted + PersonIdentityAdapter — what it writes, and what it reads back refuses to add an identifier when nobody has chosen one to add it onto + PersonIdentityAdapter — what it writes, and what it reads back refuses to list the person's own identifier among the ones added onto them + PersonIdentityAdapter — what it writes, and what it reads back writes a file a person can open and correct by hand + PersonIdentityAdapter.forget — resolved once, acts on the path it is handed acts on the path it is handed, immune to HOME being relocated afterwards + PersonIdentityAdapter.forget — resolved once, acts on the path it is handed is a no-op, not a failure, when the path is already gone + PersonIdentityAdapter.forget — resolved once, acts on the path it is handed removes the identity file it was constructed against +# tests/infrastructure/adapters/person-identity-location.unit.test.ts + where the identity file lands AIDD_USER_CONFIG_DIR never moves it, on either platform + where the identity file lands Windows without APPDATA falls back rather than inventing a path + where the identity file lands a POSIX machine keeps it under the OS user's own .config + where the identity file lands a Windows machine keeps it under %APPDATA%, never under .config +# tests/infrastructure/adapters/run-journal-file-written.integration.test.ts + file_written, from the hook that writes it to the reader that reads it appends a repository-relative path the reader surfaces as written + file_written, from the hook that writes it to the reader that reads it covers Claude Code alone, which a report has to print as a limit rather than assume away + file_written, from the hook that writes it to the reader that reads it records nothing for a write outside any task folder + file_written, from the hook that writes it to the reader that reads it uses the session id it is handed, not the payload's own spelling +# tests/infrastructure/adapters/run-journal-reader-adapter.integration.test.ts + RunJournalReaderAdapter answers null for a session no run file names, rather than the wrong file + RunJournalReaderAdapter answers null, not an error, when aidd_docs/runs does not exist at all + RunJournalReaderAdapter honors AIDD_RUNS_DIR over /aidd_docs/runs, matching the writing hook + RunJournalReaderAdapter reads a session's step_start and turn_end lines, in file order, skipping every other type + RunJournalReaderAdapter skips a truncated final line rather than failing the whole read + RunJournalReaderAdapter, beyond the boundaries honours AIDD_RUNS_DIR when listing, exactly as when reading one session + RunJournalReaderAdapter, beyond the boundaries keeps a session's boundaries when its header line is torn + RunJournalReaderAdapter, beyond the boundaries lists every session it holds, for a caller with no identifier to ask about + RunJournalReaderAdapter, beyond the boundaries lists nothing, rather than throwing, when no runs directory exists + RunJournalReaderAdapter, beyond the boundaries reads no plugin_version at all for a line written before this field existed - unknown, never a guessed default + RunJournalReaderAdapter, beyond the boundaries reads plugin_version off the header when the hook stamped one + RunJournalReaderAdapter, beyond the boundaries reads the header line, so a report knows which tool and project a session was + RunJournalReaderAdapter, beyond the boundaries reads the written paths as paths, deriving no task from them + RunJournalReaderAdapter, beyond the boundaries refuses a header missing a field a join needs, rather than surfacing half of one + RunJournalReaderAdapter.deleteRunFile — confined to the directory it is handed acts on the dir it is handed, immune to AIDD_RUNS_DIR being relocated afterwards + RunJournalReaderAdapter.deleteRunFile — confined to the directory it is handed is a no-op, not a failure, when the name is already gone + RunJournalReaderAdapter.deleteRunFile — confined to the directory it is handed refuses a bare '..' or '.' rather than acting on the directory itself + RunJournalReaderAdapter.deleteRunFile — confined to the directory it is handed refuses a relative walk out of the directory it is handed, rather than deleting outside it + RunJournalReaderAdapter.deleteRunFile — confined to the directory it is handed removes a run file by name, from the directory it is handed + sanitizePathSegment — agrees with the journal hook's own function matches for + sanitizePathSegment — agrees with the journal hook's own function matches for . + sanitizePathSegment — agrees with the journal hook's own function matches for .. + sanitizePathSegment — agrees with the journal hook's own function matches for 22222222-2222-4222-8222-222222222222 + sanitizePathSegment — agrees with the journal hook's own function matches for already__contains-a-double-underscore + sanitizePathSegment — agrees with the journal hook's own function matches for has spaces + sanitizePathSegment — agrees with the journal hook's own function matches for weird/../chars? +# tests/infrastructure/adapters/run-journal-task-declared.integration.test.ts + task_declared, from the hook that writes it to the reader that reads it appends a repository-relative path the reader surfaces as declared + task_declared, from the hook that writes it to the reader that reads it declares nothing for a call that names no task path at all + task_declared, from the hook that writes it to the reader that reads it derives a bounded interval a report can attribute a record against + task_declared, from the hook that writes it to the reader that reads it uses the session id it is handed, not the payload's own spelling +# tests/infrastructure/adapters/telemetry-evidence-adapter.integration.test.ts + a payload that matched no known host answers nothing for a line that is not one of these records + a payload that matched no known host answers nothing when the file is absent + a payload that matched no known host reads the moment one arrived + an export a deleted command left behind in a tool's own settings finds none in a project whose settings carry no export at all + an export a deleted command left behind in a tool's own settings names the file and the keys still in it + what the switch setup reports, beside the answer itself names the file it read, and reads a damaged one as unreadable rather than off + what the switch setup reports, beside the answer itself reads an absent file as readable and undecided + whether anything is declared to do the recording finds it in a Claude hooks block that names the entry point by its plugin token + whether anything is declared to do the recording finds it in enabledPlugins whatever marketplace the key names + whether anything is declared to do the recording finds the recorder in the manifest a plugin install writes + whether anything is declared to do the recording names a damaged location as unreadable rather than counting it as undeclared + whether anything is declared to do the recording refuses a hooks block naming a bare journal.cjs belonging to some other plugin + whether anything is declared to do the recording reports nothing declared, and still names every location it looked in + whether measurement is allowed here lets a person refuse in their own environment, over a project that turned it on + whether measurement is allowed here reads a project that never decided as off, without a file to read + whether measurement is allowed here reads a project that turned it on + whether measurement is allowed here treats a switch file that is not JSON as off, never as on +# tests/infrastructure/adapters/telemetry-sink-adapter.integration.test.ts + TelemetrySinkAdapter appends real lines and reports whether the day file was just created + TelemetrySinkAdapter fails ensureWritable at startup with a message naming the path, when the directory cannot be written + TelemetrySinkAdapter finds a vendor's records across every day file, ignoring other vendors + TelemetrySinkAdapter never rewrites the file it appends to — appendRecord is the only write primitive + TelemetrySinkAdapter prunes real day files beyond the window, keeping the newest, on real disk state + TelemetrySinkAdapter skips a torn final line rather than failing the whole scan + TelemetrySinkAdapter writes under /telemetry, honoring the constructor override + TelemetrySinkAdapter.readRecordsInPeriod answers an empty period with no records and nothing skipped, never an error + TelemetrySinkAdapter.readRecordsInPeriod hands back a record with no moment rather than placing it in a period + TelemetrySinkAdapter.readRecordsInPeriod keeps a moment-less record out of every period, however wide + TelemetrySinkAdapter.readRecordsInPeriod places a moment written with a non-UTC offset on the day it actually happened + TelemetrySinkAdapter.readRecordsInPeriod reads across sessions, unlike the per-vendor read it sits beside + TelemetrySinkAdapter.readRecordsInPeriod reads the same period whichever way round the two days are given + TelemetrySinkAdapter.readRecordsInPeriod returns every record inside the range and none outside it + TelemetrySinkAdapter.readRecordsInPeriod selects on when the work ran, not on the day file the line landed in + TelemetrySinkAdapter.readRecordsInPeriod skips a line whose schema version this build does not know, and says how many + TelemetrySinkAdapter.readRecordsInPeriod skips a torn final line, keeps the file's other lines, and counts what it skipped + the real sink and its in-memory double place a record on the same day agrees on which records fall in a period and which carry no moment at all + the real sink and its in-memory double place a record on the same day refuses a relative walk out of the directory it is handed, rather than deleting outside it +# tests/infrastructure/adapters/telemetry-sink-location.unit.test.ts + a sandboxed run's sink, agreed between the helper and the adapter agrees on linux, whichever platform this suite runs on + a sandboxed run's sink, agreed between the helper and the adapter agrees on win32, whichever platform this suite runs on + where the figures land by default Windows without APPDATA falls back rather than inventing a path + where the figures land by default a POSIX machine keeps them under the OS user's own .config + where the figures land by default a fresh Windows machine keeps them under %APPDATA%, never under .config + where the figures land by default the plugin README states the exact default the code writes + where the figures land, and what does not follow them there leaves the token where it was when the figures are shared + where the figures land, and what does not follow them there prefers the name given to the figures when both are set + where the figures land, and what does not follow them there puts the figures exactly where AIDD_TELEMETRY_DIR names, not in a subdirectory of it + where the figures land, and what does not follow them there still honours the older variable, so a setup that predates the split keeps working + who may list the days a person worked leaves a location a person named themselves exactly as they made it + who may list the days a person worked tightens a default location to this person alone +# tests/infrastructure/adapters/transcript-cost-reader-adapter.integration.test.ts + TranscriptCostReaderAdapter — Claude Code answers with nothing, not an error, when the declared root does not exist + TranscriptCostReaderAdapter — Claude Code reads both the main transcript and a subagent's own file for one session + TranscriptCostReaderAdapter — Claude Code says it found no session, not that the session cost nothing, when no file names it + TranscriptCostReaderAdapter — Codex finds the resumed session, so a report never reads 38% of Codex sessions as absent + TranscriptCostReaderAdapter — Codex resolves a resumed session by its own id, never its parent's, even with both on disk + TranscriptCostReaderAdapter — Codex resolves the parent's own session independently, not the resumed session's records + TranscriptCostReaderAdapter — Codex says it found no session for an id no rollout file names +# tests/integration/telemetry-trailer-line-agrees.integration.test.ts + the hook and the CLI spell the call site identically agrees on a POSIX path + the hook and the CLI spell the call site identically agrees on a Windows path, where the separator is the whole difficulty + the hook and the CLI spell the call site identically agrees on a path with spaces + the hook and the CLI spell the call site identically agrees on the delegate's filename, which decides where each side looks + the hook and the CLI spell the call site identically agrees on the header a hook written from scratch starts with + the hook and the CLI spell the call site identically agrees on the hook's own filename + what the repair reports about a directory it will not write to declines a hooks directory outside the git directory, delegate and all + what the repair reports about a directory it will not write to has nothing to do without a hooks directory at all + what the repair reports about a directory it will not write to repairs one inside it +``` diff --git a/cli/aidd_docs/tasks/2026_09/2026_09_03_integration-telemetry/plan.md b/cli/aidd_docs/tasks/2026_09/2026_09_03_integration-telemetry/plan.md new file mode 100644 index 000000000..652c0da4e --- /dev/null +++ b/cli/aidd_docs/tasks/2026_09/2026_09_03_integration-telemetry/plan.md @@ -0,0 +1,140 @@ +--- +objective: "La télémétrie de next vit dans la nouvelle architecture, et rien de ce qui marchait des deux côtés ne s'arrête." +status: in-progress +--- + +# Plan : intégrer la télémétrie sans régression + +## Ce que la mesure dit, contre ce que je disais + +J'ai répété qu'« 147 des 148 fichiers atterrissent sur des chemins supprimés ». C'est exact et +c'était trompeur : ça décrit les chemins, pas le travail. Une fusion d'essai — `merge-tree`, +arbre de travail intact — donne la vraie forme. + +``` +232 fichiers en conflit + 129 file location les deux côtés ont bougé : choix mécanique, notre emplacement gagne + 75 content vrai conflit textuel + 22 modify/delete next modifie ce qu'on a supprimé : une décision chacun + 6 rename/rename, rename/delete, directory split +``` + +Et par catégorie d'apport : + +``` + ajoutés reportés par git DURS +cli/src 72 60 14 +cli/tests 124 70 5 +plugins 35 12 0 +scripts 63 4 0 +``` + +Git suit 60 de nos renommages tout seul. **Dix-neuf fichiers demandent un jugement**, et je +peux tous les nommer. + +Fait structurel vérifié en premier, parce qu'il aurait tout changé : le commit de migration +`10bdd605` est ancêtre des deux branches. L'arbre `cli/` n'est pas ajouté deux fois ; c'est une +fusion à trois branches normale. + +## La contrainte qui commande le reste + +La télémétrie ne doit pas atterrir dans l'ancienne disposition puis être rangée après. Elle +doit **naître dans la nouvelle architecture**. Bonne nouvelle : elle est déjà en couches — +domaine (7 modèles, 4 ports, 1 capacité, 1 format), application (7 cas d'usage), affichage, +infrastructure (4 adaptateurs). Elle demande à être placée, pas restructurée. + +Cible : + +``` +contexts/telemetry/domain/ modèles, ports, capacité +contexts/telemetry/application/ les sept cas d'usage +contexts/telemetry/infrastructure/ sink, evidence, lecteurs de coût +presentation/display/ les cinq afficheurs +presentation/commands/telemetry.ts la commande +runtime/git/ le VersionControl à six méthodes et son adaptateur +``` + +## Le cycle, et pourquoi il ne prend pas de socle + +`diagnose-telemetry-use-case` lit `ManifestRepository`, qui vit dans `contexts/framework`. Et +`clean-use-case` + `gitignore-use-case`, côté framework, atteignent la télémétrie. Cycle, que +`context-graph` interdit. + +Un socle le rendrait légal et permanent. Un `grep` tranche autrement : + +``` +manifestRepo.load() +manifestRepo.path (pour un message d'erreur) +manifest.getPlugins(tool) +``` + +Rien sur les versions, les fichiers suivis, l'état de fusion. La télémétrie ne veut pas le +manifeste : elle veut **la liste des plugins installés par outil**. Elle déclare donc son +propre port étroit, l'adaptateur de `framework` le satisfait, et le graphe redevient acyclique : +`framework -> telemetry -> {kernel, tools}`. + +## Une décision de la journée que la fusion révise, et pourquoi ce n'est pas un revirement + +J'ai supprimé `VersionControl` et son adaptateur : un port à une méthode, +`installPreCommitDelegate`, sans aucun appelant. + +`next` porte un `VersionControl` **différent** sous le même nom : six méthodes, toutes des +questions git posées par la télémétrie — URL du remote, pose et retrait du délégué de message +de commit, fichiers suivis, présence d'un dépôt, historique, état du trailer. Il est appelé. + +La fusion prend celui de next. Ma suppression reste juste pour ce qu'elle a retiré ; les deux +ports ne partagent que leur nom. + +## Les critères d'acceptation existent déjà + +Je n'ai pas de liste à écrire : les 54 tests d'architecture **sont** la définition mécanique de +« conforme à la nouvelle architecture ». + +| Garde | Ce qu'il imposera à la télémétrie | +| ----- | --------------------------------- | +| `context-boundary` | déclarer une surface publique, sinon elle n'est pas clôturée | +| `context-graph` | résoudre le cycle plutôt que l'admettre | +| biome `domain`/`application` | ses afficheurs sortent du contexte | +| `ports-are-called` | aucune de ses six méthodes git n'arrive sans appelant | +| `errors-that-are-thrown` | aucune de ses classes d'erreur n'arrive orpheline | +| `folder-size` | sept cas d'usage tiennent, dix ne tiendraient pas | +| `codebase-map` | la carte dessine le contexte, dans les deux sens | +| `referenced-paths` | ses documents ne citent pas les anciens chemins | +| `tool-addition-cost` | ses lecteurs de coût par outil ne nomment pas les outils hors profil | + +## L'oracle qui manque, et qu'il faut capturer avant de commencer + +Les 9 cellules golden et le smoke prouvent que **notre** comportement survit. Rien ne prouve +que celui de la télémétrie survit au déplacement. + +À capturer sur `origin/next` avant tout : le nombre et les noms des tests qui passent sur ses +**87 fichiers de test** côté CLI, ses 12 tests de scripts, et son plugin de 35 fichiers. + +Après fusion, les mêmes tests — repointés vers les nouveaux chemins, **corps inchangés** — +doivent passer. Un test de télémétrie dont il faut changer le *corps* est le signal d'arrêt : +c'est la même règle que les vérifications de déplacement pur tenues toute la journée. + +## Phases + +| # | Phase | Gate | +| - | ----- | ---- | +| 0 | Capturer l'oracle sur `origin/next` | le relevé existe et est commité | +| 1 | Brancher sur HEAD, fusionner, ne rien résoudre | 232 conflits, le compte est celui prévu | +| 2 | Les 163 fichiers ajoutés, non câblés | tsc, suite inchangée | +| 3 | Les 129 conflits d'emplacement | arch 54/54 | +| 4 | Les 22 modify/delete, décision écrite avant résolution | tsc, knip | +| 5 | Les 75 conflits de contenu, une aire à la fois | suite + arch après chaque aire | +| 6 | Reloger la télémétrie dans son contexte | arch 54/54, cycle résolu par un port | +| 7 | Les deux oracles | 9 cellules golden, smoke 22/22, tests télémétrie inchangés | + +## Ce qui est interdit dans ce plan + +**Aucune entrée de socle ajoutée pour faire passer la fusion.** Un socle est de la dette +mesurée, pas un passage en force. Si un garde refuse la télémétrie, c'est le placement qui +change, pas le garde. + +**Aucun corps de test modifié pour le faire passer.** Repointer un import est un déplacement ; +changer une assertion est une régression déguisée. + +**On ne commence pas sur cette branche.** Une branche depuis HEAD, pour que `git merge --abort` +reste disponible et que les 102 commits restent joignables. diff --git a/cli/aidd_docs/tasks/2026_09/2026_09_03_mutants-sans-couverture/phase-1.md b/cli/aidd_docs/tasks/2026_09/2026_09_03_mutants-sans-couverture/phase-1.md new file mode 100644 index 000000000..066b04316 --- /dev/null +++ b/cli/aidd_docs/tasks/2026_09/2026_09_03_mutants-sans-couverture/phase-1.md @@ -0,0 +1,168 @@ +--- +status: done +--- + +# Instruction: The source spellings a user types + +`aidd plugin add` accepts a source in several spellings. Four of them are parsed by code no +test executes: 71 mutants in `src/kernel/source.ts`, 27 % of the file. The kernel is the +vocabulary all four contexts speak, so a behaviour change that passes unnoticed here passes +unnoticed everywhere. + +## Architecture projection + +> Tree of the final files. ✅ create · ✏️ modify · ❌ delete + +```txt +. +└── cli/ + └── tests/kernel/ + └── source.unit.test.ts ✏️ modify (extend; the nested shape already exists) +``` + +No production file changes. If a test cannot be made to pass without touching `src/`, that is +a bug found, and it gets its own commit before the test lands. + +## What is untested, and what breaks for a user + +| Function | Mutants | What a user types | What breaks if it regresses | +| -------- | ------: | ----------------- | --------------------------- | +| `parsePluginSourceShorthand` | 30 | `owner/repo`, `https://…`, `git@…`, `./local`, or raw JSON | The wrong source kind is chosen, so the plugin is fetched by the wrong adapter — or a valid spelling is rejected outright | +| `parseGitHubVersionedShorthand` | 18 | `owner/repo@v1.2.0` | The ref is dropped and the default branch is installed instead of the pinned version, silently | +| `parseGitLabShorthand` | 11 | `gitlab:owner/repo`, `gitlab:owner/repo@ref` | The built URL is wrong, so the clone fails — or worse, points somewhere else | +| `describePluginSource` | 6 | nothing; it is what `status` and `doctor` print back | The user is shown a source that is not the one recorded | +| `optionalString` / `optionalSha` | 4 | a manifest field of the wrong type | A malformed manifest is accepted instead of refused | +| `parseObjectPluginSource` | 2 | an unknown `kind` | The error does not say which kinds exist | + +## Test Scope + +```mermaid +--- +title: Test scope +--- +journey + section Setup + no fixture, no port, no filesystem => pure functions called directly: 5: system + section Happy path + each spelling a user types => the source kind and its fields, asserted exactly: 5: system + section Edge case - a pinned version + owner/repo@v1.2.0 => kind github, repo without the ref, ref kept: 5: system + section Edge case - an at-sign that is not a ref + a spelling whose @ is not a version separator => not mistaken for a versioned repo: 5: system + section Edge case - a spelling that is nothing + an unrecognized string => an error naming what was given: 5: system + section Teardown + nothing to clean: 5: system +``` + +## Tasks to do + +### `1)` Name by intention, with the functional case inside + +1. One `describe` per spelling the user types — `describe("gitlab: shorthand")`, not + `describe("parseGitLabShorthand")`. The nested `it` states the outcome: + `it("resolves gitlab:owner/repo to a gitlab.com git URL")`. +2. Never a method name in an `it`. The repo's `aidd-dev:test` skill, action + `02-name-behaviorally`, is the authority; this phase only refuses to drift from it. + +### `2)` Pin the spellings + +1. `parsePluginSourceShorthand`: one case per branch — https, http, `git@`, `./`, `/`, + `gitlab:`, bare `owner/repo`, versioned, raw JSON, and the unrecognized string. +2. Assert the whole returned object, not one field. A mutant that swaps `kind` or drops + `ref` survives an assertion that only checks the URL. + +### `3)` Pin the two that decide a version + +1. `parseGitHubVersionedShorthand` through its caller: `owner/repo@ref` keeps the ref and + strips it from the repo; `@` at index 0 is not a separator; a repo that fails the pattern + falls through rather than returning a broken source. +2. `parseGitLabShorthand`: with and without a ref, and the malformed case that must throw. + +### `4)` Pin what the user is shown + +1. `describePluginSource` for all five kinds, including the `ref` and `version` suffixes — + these are what `status` and `doctor` print, and a wrong one misleads silently. + +### `5)` Measure, and say what moved + +1. `pnpm test:mutation:kernel`, compare against 62,74 %, and record the delta to the point, + not the hundredth — the scope's run-to-run noise is around 0,4. +2. Record the mutants that survive on purpose, with the reason, rather than adding a test + that only kills them. + +## Test acceptance criteria + +| Task | Acceptance criteria | +| ---- | ------------------- | +| 1 | Every `it` reads as an outcome a user could observe; no `it` names a function | +| 2 | Each spelling has a case, and each asserts the complete parsed source | +| 3 | A ref survives the round trip; a lone `@` and a malformed gitlab spelling behave as stated | +| 4 | All five kinds print back what was recorded | +| 5 | The kernel scope is re-measured and the delta reported, with the surviving mutants explained | +| all | 1 995 tests still pass with the new ones added, suites ratio equal, tsc 0, biome 0 | + +## Livrée (2026-09-03) + +`src/kernel/source.ts` : 71 mutants sans couverture, il n'en reste aucun. Le scope `kernel` +passe de 62,74 % à **72,74 %**, soit dix points. Plus que les sept que le compte laissait +attendre, parce que couvrir les orthographes traverse aussi le reste du fichier : 639 mutants +tués avant, 740 après. Deux points de ces dix viennent de la revue, pas du premier jet. + +31 tests ajoutés, 60 dans le fichier après avoir supprimé un bloc que les nouveaux couvraient mieux. + +### Ce qu'une supposition a coûté, et ce qu'elle a appris + +J'avais écrit que `owner/repo@release@2` gardait `release@2` comme ref, parce que la fonction +coupe sur le dernier `@`. Le test a échoué. La moitié dépôt devient `owner/repo@release`, qui +ne satisfait pas le motif `owner/repo`, donc l'orthographe entière est refusée. Le comportement +réel est le bon — mieux vaut refuser que d'installer un dépôt dont le nom porte un `@` en +silence — et il est maintenant épinglé. C'était une supposition écrite comme une observation. + +### Deux trous trouvés en relisant le rapport, pas en lisant le code + +`parsePluginSource("owner/repo")` et `parsePluginSource("./chemin")` : une source enregistrée +comme **chaîne** dans le manifeste plutôt que comme objet. Rien ne passait par là. C'est le +genre de branche qu'une lecture ne signale pas et qu'un mutant sans couverture désigne. + +### Ce qui survit, et pourquoi ce n'est pas poursuivi + +**Corrigé après revue.** La première version de cette section déclarait inoffensive une famille +qui contenait une vraie perte de donnée, silencieuse et sur le chemin principal. Elle affirmait +que les gardes `if (src.ref !== undefined)` de la sérialisation ne survivaient qu'à cause de +`toEqual`, qui ignore les clés valant `undefined`, et que `JSON.stringify` les supprimant, rien +d'observable ne différait. Faux sur les trois points, pour deux des douze mutants : + +- `resolvePluginSourceFromMarketplace` construit une source `git-subdir` portant le `ref` de la + marketplace, c'est-à-dire la version épinglée. +- `InstalledPlugin.create` et `fromDistribution` appellent `serializePluginSource` puis + `fromJSON` → `parsePluginSource` **en mémoire**, sans jamais passer par `JSON.stringify`. +- Une garde cassée en `false` ne pose pas la clé du tout : ni `toEqual` ni `toStrictEqual` ne la + rattrapent. Seul un aller-retour `git-subdir` **portant** un `ref` et un `sha` la tue. + +Le `ref` disparaissait donc du plugin enregistré, et la branche par défaut s'installait là où une +version était demandée — mot pour mot le préjudice que le tableau d'ouverture de cette fiche +donne comme raison d'écrire la phase. Le test manquant existe maintenant, en `toStrictEqual`. + +Ce qui reste hors de portée, et pourquoi — 25 survivants, comptés un par un : + +| Famille | Nombre | Pourquoi ils restent | +| ------- | -----: | -------------------- | +| `StringLiteral` dans des messages d'erreur | 8 | Les tests affirment le fragment qui porte l'information — le nom du champ, la forme attendue. Figer la phrase entière rendrait chaque reformulation rouge sans qu'un utilisateur y gagne | +| Gardes `!== undefined` mutées en `true` | 7 | La clé est posée avec la valeur `undefined` ; `JSON.stringify` la supprime et l'aller-retour en mémoire la relit comme absente. Aucun manifeste écrit ni aucun plugin enregistré ne diffère. C'est la seule moitié de la famille dont l'argument d'origine tenait | +| Gardes de position `atIndex` | 4 | Deux formes du même test ; la valeur limite qui les distingue est déjà écartée par le motif du dépôt, testé juste à côté | +| `Regex` sur les motifs de dépôt et de paquet | 3 | Les cas limites qui les tuent sont des chaînes qu'aucun format n'autorise | +| Un `case` vidé qui retombe sur le suivant | 1 | `url` et `git-subdir` produisent la même sortie pour les champs communs ; la sortie observable est identique | +| Divers | 2 | Deux conditions dont les deux branches mènent au même résultat | + +Les tuer monterait le chiffre sans protéger quoi que ce soit, ce que la règle du plan interdit. +La différence avec la version précédente de ce tableau est qu'il compte les survivants un par +un, au lieu d'en ranger 27 dans trois familles et de laisser les dix autres hors du récit. Trois +de ces dix étaient des trous d'une ligne, tous couverts depuis : un chemin absolu enregistré +comme chaîne, un champ présent mais vide, et une erreur dont seule la classe était affirmée +alors que les deux branches lèvent la même classe. + +### Le noyau, ce qu'il en reste + +46 mutants sans couverture ailleurs dans le scope : `errors.ts` 20, `merge.ts` 18, `file.ts` 5, +`markdown.ts` 3. À traiter avec la même règle, pas parce qu'ils sont là. diff --git a/cli/aidd_docs/tasks/2026_09/2026_09_03_mutants-sans-couverture/phase-2.md b/cli/aidd_docs/tasks/2026_09/2026_09_03_mutants-sans-couverture/phase-2.md new file mode 100644 index 000000000..4a90ca10f --- /dev/null +++ b/cli/aidd_docs/tasks/2026_09/2026_09_03_mutants-sans-couverture/phase-2.md @@ -0,0 +1,131 @@ +--- +status: done +--- + +# Instruction: The references Copilot rewrites, in both directions + +`src/contexts/tools/domain/profiles/copilot/profile.ts` carries 173 mutants no unit or +integration test executes — 46 % of the file, the largest single gap outside the command +wiring. Nearly all of it is one thing: the rewriting of framework references into Copilot's +own layout, and the reverse. + +Copilot is the only tool that rewrites content between the canonical form and its workspace +paths. Every other profile passes content through. So this code has no sibling to compare +against, and a regression in it is a regression nobody else's tests would notice. + +> **Found while measuring, before writing a line: 61 of those 173 mutants are in code nothing +> calls.** See "The reverse surface has no consumer" below. This phase covers the live 112 and +> writes no test for the dead 61, because a test there would freeze code that should probably +> be deleted and would make deleting it harder. + +## Architecture projection + +> Tree of the final files. ✅ create · ✏️ modify · ❌ delete + +```txt +. +└── cli/ + └── tests/contexts/tools/domain/profiles/ + └── copilot.unit.test.ts ✏️ modify (extend) +``` + +No production file changes. If a test cannot pass without touching `src/`, that is a bug +found, and it gets its own commit before the test lands. + +## What is untested, and what breaks for a user + +| Behaviour | What a user sees if it regresses | +| --------- | -------------------------------- | +| `@{{TOOLS}}/agents/x.md` becomes a markdown link to `.github/agents/x.agent.md` | An installed Copilot file points at a path that does not exist; the reference is dead in the editor | +| `@{{TOOLS}}/commands/…` resolves through the same flattening the install uses | The link points at the unflattened name, so it resolves nowhere | +| `@{{TOOLS}}/rules/…` and `…/skills/…` reach `instructions/` and `skills/` | Same, for two more sections | +| `@{{DOCS}}/…` becomes a link into the project's docs directory | Documentation references break for whoever configured a non-default docs dir | +| `{{TOOLS}}/…` without the `@` replaces the prefix and stays plain text | A frontmatter path turns into a markdown link, which frontmatter cannot hold | +| An unknown section falls back to a prefixed path | A new framework section silently drops its references instead of degrading predictably | +| ~~The reverse turns each installed form back into its placeholder~~ | ~~Nothing.~~ No caller — see below | +| ~~`detectUserFileSectionKey` maps an installed path back to its canonical key~~ | ~~Nothing.~~ No caller — see below | + +## The reverse surface has no consumer + +Four symbols are declared, implemented in every profile, and called from no production file: + +| Symbol | Declared | Implemented | Production callers | +| ------ | -------- | ----------- | -----------------: | +| `AiTool.reverseRewriteContent` | `tools/domain/contracts.ts` | 5 profiles | **0** | +| `AiTool.detectUserFileSectionKey` | `tools/domain/contracts.ts` | 5 profiles | **0** | +| `Capability.reverseConvertFrontmatter` | 4 capability classes | 5 profiles | **0** | +| `detectSectionKeyFromPrefixes` | `tools/domain/formats/command.ts` | — | **0** | + +Established by searching `src/` for each name and excluding the declaration and implementation +sites; the remaining count is zero in all four cases. No dynamic dispatch reaches them either: +there is no bracket access on a tool or config object anywhere in `src/`. Git history shows no +caller has existed under `framework/` or `application/` since the CLI was migrated into this +repository — the symmetry was built, the consumer never was. + +In copilot's profile that is 61 uncovered mutants: 38 in `reverseCopilotContent`, 23 in +`detectUserFileSectionKey`. Three other profiles carry unit tests for `detectUserFileSectionKey` +already, which is how dead code keeps looking alive. + +The decision — delete the four, or wire them to the feature they were built for — is not this +phase's to take. What this phase refuses to do is write tests that make either choice harder. + +## Test Scope + +```mermaid +--- +title: Test scope +--- +journey + section Setup + no port, no filesystem => the profile's own functions, called directly: 5: system + section Happy path + each reference form => the exact rewritten text, asserted whole: 5: system + section Edge case - a section nobody declared + a reference into an unknown section => a predictable prefixed path, not a dropped link: 5: system + section Edge case - a directory reference + a reference ending in a slash => the section directory, not a file path: 5: system + section Teardown + nothing to clean: 5: system +``` + +## Tasks to do + +### `1)` Name by intention, with the functional case inside + +1. `describe` names what the content does — `describe("a reference to another framework file")`, + not `describe("rewriteCopilotContent()")`. The nested `it` names what the reader of the + installed file gets. +2. The existing blocks in this file are named after methods. They are left as they are: this + phase adds, it does not rename, and mixing the two changes in one commit hides both. + +### `2)` Pin each reference form + +1. One case per form: agents, commands, rules, skills, docs, the bare `{{TOOLS}}/` prefix, + and the unknown section. +2. Assert the whole rewritten string, not that it contains a substring. A mutant that + changes the link target while keeping the label survives a `toContain`. + +### `3)` Leave the reverse alone, and say why + +1. No test for `reverseRewriteContent` or `detectUserFileSectionKey`. Nothing calls them. +2. Record the finding with the search that establishes it, so the decision to delete or to + wire them up is made on evidence rather than on the shape of the API. + +### `5)` Measure, then account for every survivor + +1. `pnpm test:mutation:tools`, compare against 61,04 %, report the delta in points. +2. For every surviving mutant, either cover it or state why it is harmless — and state it by + naming the call chain that reaches the code, not by reasoning about what the code looks + like. Phase 1 declared a family harmless on an argument about `JSON.stringify` that did not + apply, and the family contained a silent loss of a pinned version. Every claim of harmless + in this phase cites the caller it followed. + +## Test acceptance criteria + +| Task | Acceptance criteria | +| ---- | ------------------- | +| 1 | Every added `it` reads as an outcome someone reading an installed file could observe | +| 2 | Each reference form asserts the complete rewritten string | +| 3 | No test is added for the four dead symbols, and the finding is recorded with the search that establishes it | +| 5 | The `tools` scope is re-measured, and each survivor is covered or explained with its caller | +| all | The full suite passes with the new tests added, suites ratio equal, tsc 0, biome 0, knip 0 | diff --git a/cli/aidd_docs/tasks/2026_09/2026_09_03_mutants-sans-couverture/phase-3.md b/cli/aidd_docs/tasks/2026_09/2026_09_03_mutants-sans-couverture/phase-3.md new file mode 100644 index 000000000..efec1865e --- /dev/null +++ b/cli/aidd_docs/tasks/2026_09/2026_09_03_mutants-sans-couverture/phase-3.md @@ -0,0 +1,223 @@ +--- +status: done +--- + +# Instruction: Delete the reverse surface nobody calls + +Phase 2 stopped before writing tests because 61 of the 173 mutants it targeted were in code +with no caller. The same search found the pattern is not local to Copilot: an entire reverse +API is declared, implemented in every profile, and used by nothing. + +Deleting it is what the project's own rule asks for — a test over it would freeze code that +should not exist and make removing it dearer. + +## What is being removed, and the evidence it is dead + +| Symbol | Declared in | Implemented in | Production callers | +| ------ | ----------- | -------------- | -----------------: | +| `AiTool.reverseRewriteContent` | `tools/domain/contracts.ts` | 5 profiles | **0** | +| `AiTool.detectUserFileSectionKey` | `tools/domain/contracts.ts` | 5 profiles | **0** | +| `AgentsCapability.reverseConvertFrontmatter` | `capabilities/agents-capability.ts` | 5 profiles wire it | **0** | +| `CommandsCapability.reverseConvertFrontmatter` | `capabilities/commands-capability.ts` | idem | **0** | +| `RulesCapability.reverseConvertFrontmatter` | `capabilities/rules-capability.ts` | idem | **0** | +| `SkillsCapability.reverseConvertFrontmatter` | `capabilities/skills-capability.ts` | idem | **0** | +| `detectSectionKeyFromPrefixes` | `formats/command.ts` | — | **0** | +| `UserFileSectionKey` | `formats/command.ts` | — | only by the two above | + +Established three ways: each name searched across `src/` with the declaration and +implementation sites excluded, leaving zero; no bracket access on a tool or config object +exists anywhere in `src/`, so no dynamic dispatch reaches them; and `git log -S` shows no +caller has ever existed under `framework/` or `application/` since the CLI entered this +repository. The symmetry was built, the consumer never was. + +`UserFileSection` stays — `install-content-section-use-case.ts` uses it. + +## Correction (2026-09-03) — la moitié « placeholders » de cette phase était fausse + +Ce qui suit décrit la suppression telle qu'elle a été faite. Une relecture indépendante a montré +qu'une moitié était un défaut, et elle est revenue. La section est gardée telle quelle parce que +le raisonnement qui a conduit à l'erreur vaut plus que sa correction, mais **la réécriture des +placeholders de copilot est restaurée** : voir « Ce que la preuve ne pouvait pas voir ». + +## La moitié qui tenait, et celle qui ne tenait pas + +The first draft of this phase kept the `{{TOOLS}}` / `{{DOCS}}` rewriting on the grounds that +it is called even if nothing feeds it. That was too cautious, and `placeholders.ts` said so in +its own comment: + +> Placeholder substitution removed in marketplace-only architecture. Plugin content is +> tool-agnostic with relative paths and hardcoded aidd_docs. Kept as identity for backward +> compat with existing callers; will be removed when capability classes drop docsDir threading. + +`baseRewriteContent` was already an identity function for claude, cursor, codex and opencode. +Copilot was the last profile carrying real placeholder logic. The module announced its own +removal and the condition it was waiting for; this phase met the condition. + +`docsDir` existed only to feed that substitution. Unwinding it reached the `AiTool` contract, +the five profiles, the content translator, the `PluginTranslator` port and both its +implementations, the install, plugin and restore use-cases, and the commands at the top. +`DOCS_DIR` itself stays — `kanban` reads the task documents from it. + +## Architecture projection + +> Tree of the final files. ✅ create · ✏️ modify · ❌ delete + +```txt +. +└── cli/ + ├── src/contexts/tools/domain/ + │ ├── contracts.ts ✏️ two methods off the AiTool contract + │ ├── formats/command.ts ✏️ the helper and the type it returns + │ ├── capabilities/agents-capability.ts ✏️ the reverse method and its param + │ ├── capabilities/commands-capability.ts ✏️ idem + │ ├── capabilities/rules-capability.ts ✏️ idem + │ ├── capabilities/skills-capability.ts ✏️ idem + │ └── profiles/{claude,codex,copilot,cursor,opencode}/profile.ts ✏️ their implementations + └── tests/contexts/tools/domain/ + ├── profiles/{codex,cursor,opencode}.unit.test.ts ✏️ the tests over the deleted methods + ├── registry-conformance.unit.test.ts ✏️ the contract conformance rows + └── tool-config.unit.test.ts ✏️ the stub's members +``` + +## Test Scope + +```mermaid +--- +title: Test scope +--- +journey + section Setup + build the real framework for all nine target and mode pairs, before the deletion: 5: cli + section Happy path + build again after the deletion => byte-identical trees, all nine: 5: cli + section Edge case - the compiler + every profile still satisfies the AiTool contract => tsc clean, no member left dangling: 5: system + section Edge case - the measurement + the tools scope re-measured => 61 uncovered mutants gone from the denominator: 5: system + section Teardown + the comparison trees removed: 5: system +``` + +## Tasks to do + +### `1)` Take the before-picture first + +1. Build the real framework for the five targets in marketplace mode and the five in flat, + with the current binary. This is the only reference the deletion can be checked against. + +### `2)` Remove the surface + +1. The two methods from the `AiTool` contract and from all five profiles. +2. `reverseConvertFrontmatter` from the four capability classes and from every profile that + passes one in. +3. `detectSectionKeyFromPrefixes` and `UserFileSectionKey`. Keep `UserFileSection`. +4. The tests that exist only to exercise the deleted methods. + +### `3)` Prove nothing moved + +1. Rebuild, build the framework again for all nine pairs, and diff against task 1's trees. + Deleting code nobody calls cannot change output; a difference means it was called. +2. Full suite, smoke, tsc, biome, knip. + +### `4)` Re-measure + +1. `pnpm test:mutation:tools` against 61,77 %, and report the delta with its cause: part of it + is dead mutants leaving the denominator, not tests gaining ground. Say which part. + +## Test acceptance criteria + +| Task | Acceptance criteria | +| ---- | ------------------- | +| 1 | Ten reference trees exist before a line is deleted | +| 2 | No occurrence of the four names remains in `src/`, and `UserFileSection` still resolves | +| 3 | All nine target/mode builds are byte-identical to their reference; suite, smoke, tsc, biome, knip clean | +| 4 | The `tools` score is re-measured and the delta is attributed, not just quoted | + + +## Livrée (2026-09-03) + +60 fichiers, **803 lignes supprimées pour 147 ajoutées**. Bundle 389,8 => 382,4 Ko. + +### Vérifié + +| Quoi | Preuve | +| ---- | ------ | +| Aucune sortie n'a bougé | Les neuf couples cible/mode construits avant la première suppression, rejoués après la dernière : identiques octet pour octet, 434 fichiers en marketplace, 425 à 427 en flat | +| La suite | 2 005 tests / 992 suites, 0 échec | +| Les portes | tsc 0, biome 0 avertissement, knip 0, smoke 98 / 0 sur 22 commandes feuilles | + +Supprimer du code que personne n'appelle ne peut pas changer la sortie. Une différence aurait +voulu dire qu'il était appelé — c'est le seul contrôle qui vaut ici, et c'est pour cela que la +photo a été prise avant la première ligne supprimée, pas après. + +### Ce que la méthode vaut, et ne vaut pas + +Le déroulement de `docsDir` a été mécanique, guidé par le compilateur passe après passe sur +une soixantaine de fichiers. C'est une manœuvre où ma relecture ne prouve rien : ce qui prouve, +c'est la sortie identique et la suite verte. Les deux tiennent. + +Les dix-huit tests écrits en phase 2 pour épingler la réécriture des placeholders sont partis +avec elle — ils décrivaient exactement ce qui n'existe plus. Il en reste un, qui dit ce qui est +vrai maintenant : le contenu passe inchangé. + + +## Ce que la preuve ne pouvait pas voir + +`aidd plugin install --tool copilot` écrivait `{{TOOLS}}/...` littéralement dans les fichiers +installés. Reproduit sur `tests/fixtures/framework-real`, l'instantané figé d'une release que ce +dépôt embarque : + +``` +avant : - validator: `.github/plugins/aidd-pm/skills/05-spec/assets/spec-validator.yml` +après : - validator: `{{TOOLS}}/plugins/aidd-pm/skills/05-spec/assets/spec-validator.yml` +``` + +Les neuf builds identiques ne pouvaient pas l'attraper, pour trois raisons dont aucune n'était +écrite ici : + +1. `aidd translate` n'appelle jamais `rewriteContent`. La réécriture n'existe que sur le chemin + d'installation, que la comparaison de builds ne touche pas. +2. Le golden ne gèle qu'une cellule — `FROZEN_CELLS = new Set(["claude"])` — et `claude` avait + déjà l'identité pour `rewriteContent`. La seule cellule comparée octet à octet était + structurellement incapable d'attraper un changement propre à copilot. +3. Les plugins livrés aujourd'hui ne portent aucun placeholder, donc l'échantillon ne pouvait pas + déclencher le défaut. L'exposition est ailleurs : les releases épinglées plus anciennes et les + plugins tiers. + +L'erreur de raisonnement tient en une phrase : « pas déclenché par mon échantillon » a été écrit +comme « pas appelé ». La phase 2 avait pourtant fait la distinction, explicitement, et la phase 3 +l'a effacée sans la traiter. + +`rewriteCopilotContent`, `resolveInstalledPath` et les quatre constantes sont revenus, avec +`DOCS_DIR` importé du noyau plutôt que le paramètre `docsDir` déroulé — il valait cette constante +à chaque site d'appel, ce que le déroulement a confirmé. + +### Vérifié sur le bon chemin, cette fois + +| Quoi | Preuve | +| ---- | ------ | +| L'installation ne bouge pas | `setup` puis `plugin install aidd-dev` pour les cinq outils, binaire d'avant contre binaire d'après : identiques — copilot 246 fichiers, claude 248, codex 48, opencode 46, cursor 5. Seuls les horodatages de `marketplaces.json` diffèrent | +| La régression est épinglée | Re-supprimée, 13 tests échouent, dont un au niveau du traducteur — la chaîne exacte que suit `plugin install` | +| La ligne qui a régressé est un cas de test | `validator: \`{{TOOLS}}/plugins/…\`` est écrit tel quel dans `copilot.unit.test.ts` | +| Les portes | 2 018 tests / 996 suites, tsc 0, biome 0, knip 0 | + +## Le score, et son attribution + +`tools` passe de **61,04 % à 63,95 %**, mesuré par `pnpm test:mutation:tools`. Deux causes, et +elles ne se séparent pas proprement parce qu'elles ont atterri ensemble : + +- **Le dénominateur a rétréci** : 2 859 mutants avant, 2 613 après. Les 246 disparus étaient dans + du code supprimé, donc aucun n'était tué. Retirer des mutants non tués monte le score sans + qu'un test gagne un pouce de terrain. +- **La couverture a gagné** : `copilot/profile.ts` passe de 173 mutants sans couverture à 20, + grâce aux tests de réécriture restaurés. + +Prétendre à un partage chiffré entre les deux serait une précision inventée. Ce qui est vrai : +une partie de ces trois points est du code en moins, pas du test en plus. + +## Ce que cette phase laisse au dépôt + +Le golden ne gèle qu'une cible sur neuf. Les huit autres sont recapturées à chaque re-baseline, +donc une régression propre à copilot, cursor, codex ou opencode ne fait échouer personne. Ce +n'est pas corrigé ici — c'est un choix qui appartient à qui décide du coût des re-baselines — mais +c'est le trou par lequel ce défaut est passé, et il reste ouvert. diff --git a/cli/aidd_docs/tasks/2026_09/2026_09_03_mutants-sans-couverture/phase-4.md b/cli/aidd_docs/tasks/2026_09/2026_09_03_mutants-sans-couverture/phase-4.md new file mode 100644 index 000000000..54f55d75c --- /dev/null +++ b/cli/aidd_docs/tasks/2026_09/2026_09_03_mutants-sans-couverture/phase-4.md @@ -0,0 +1,165 @@ +--- +status: done +--- + +# Instruction: The marketplace sync, where a user's own file is at stake + +`marketplace-sync-settings-use-case.ts` carries 100 mutants no unit or integration test +executes, of 331. It is the flow that writes into files the user also edits — the tool's +`settings.json`, its machine-local registration, its enabled-plugins map — so a regression +here does not fail loudly, it quietly rewrites somebody's file. + +## What the measurement says, function by function + +Ranked by what is reachable, because that is the distinction the last two phases turned on. + +| Function | Mutants | Killed | Reachable today | +| -------- | ------: | -----: | --------------- | +| `mergeMarketplaces` + `…Array` + `…Map` | 57 | **0** | **no** — see below | +| `existingArray` | 10 | **0** | **no** — only called from the merge | +| `resolveSourceForSettings` | 8 | **0** | **no** — idem | +| `loadSettings` | 19 | 3 | yes, from two of its three call sites | +| `builtSourcesForTool` | 7 | **0** | yes, before the branch that stops | +| `existingRecord` | 13 | 7 | yes, from `mergeEnabledPlugins` | +| `nativeActivationBinary` | 10 | 5 | yes | +| `mergeEnabledPlugins` | 34 | 23 | yes | + +## The 75 mutants nothing can reach, and why that is not the last phase's finding again + +`syncMarketplacesFile` stops before the merge whenever the tool declares a native plugin +CLI or no marketplace file of its own: + +```ts +if (settings.marketplacesSettingsPath === null || nativeActivationOf(toolId) !== undefined) { + return this.evictMarketplacesFromSharedFile(toolId, projectRoot, manifest, settings); +} +``` + +Checked by execution, not by reading — the five registered profiles were run through that +condition: + +| Tool | `marketplacesSettingsPath` | native CLI | reaches the merge | +| ---- | ------------------------- | ---------- | ----------------- | +| claude | `.claude/settings.local.json` | yes | no | +| copilot | `null` | yes | no | +| codex | none | yes | no | +| cursor | none | no | no | +| opencode | none | no | no | + +**This is not the reverse API.** That had no caller at all and never had one. This has a +live call site, and a branch that no shipped profile takes. Phase 5 of the context refactor +made "drive the tool's own command where it offers one" the rule, and all three plugin-capable +tools gained a `nativeActivation` then; this merge is the path that rule superseded. A profile +that dropped its `nativeActivation` tomorrow would make it live again the same day. + +**What this phase cannot see:** whether a tool without a plugin CLI is coming. The +`MarketplaceSettings` contract still carries both an array and a map shape, which is design +for tools that do not exist yet. So the question — retire the merge, or keep it as the +fallback for a tool that offers no CLI — is not answered by a mutation report, and this phase +does not answer it. It writes no test there: a test would freeze a path pending a decision, +which is what phase 2 refused to do and phase 3 got wrong. + +## Architecture projection + +> Tree of the final files. ✅ create · ✏️ modify · ❌ delete + +```txt +. +└── cli/ + └── tests/contexts/framework/application/flows/ + └── marketplace-sync-settings.unit.test.ts ✅ create +``` + +No production file changes. + +## What is covered, and what breaks for a user + +| Behaviour | What breaks if it regresses | +| --------- | --------------------------- | +| A settings file holding malformed JSON is warned about and treated as empty | The whole sync throws on a file the user hand-edited — `setup`, `sync` and `update` all fail, and the message names JSON rather than the file | +| A settings file that parses to an array or `null` is treated as empty | Garbage spreads into the merge and lands in the user's settings | +| A file that is absent is treated as empty, not as an error | First sync on a fresh project fails | +| `existingRecord` keeps what is already under the key | A user's own enabled-plugins entries are dropped on the next sync | +| A plugin the user disabled stays disabled | Sync silently re-enables a plugin somebody turned off | +| A marketplace whose build fails is left out of the built-source map, and the others still sync | One unbuildable marketplace takes the whole sync down, or worse, its registration points at a directory that was never built | +| The activator is picked by the binary the profile declares | The wrong tool's CLI is driven, or none is | + +## Test Scope + +```mermaid +--- +title: Test scope +--- +journey + section Setup + an in-memory project with a manifest, a marketplace and a settings file: 5: system + section Happy path + sync a tool => its own keys written, the user's untouched: 5: system + section Edge case - a hand-edited file + a trailing comma in settings.json => a warning, and the sync continues: 5: system + section Edge case - a file that is not an object + settings.json holding an array => treated as empty rather than merged into: 5: system + section Edge case - a plugin turned off + an enabled-plugins entry set to false => still false after the sync: 5: system + section Edge case - a marketplace that will not build + one of two marketplaces fails to build => the other still syncs: 5: system + section Teardown + nothing on disk, the filesystem is in memory: 5: system +``` + +## Tasks to do + +### `1)` Cover what a user's own file is exposed to + +1. `loadSettings` through `execute`: absent, malformed, array, null, and an object that + parses. The malformed case must show the warning and leave the sync standing. +2. `existingRecord` through `mergeEnabledPlugins`: an entry already present, an entry set to + `false`, and a value under the key that is not an object. + +### `2)` Cover the build that happens whatever the tool + +1. `builtSourcesForTool` has seven mutants and no test kills one. Two marketplaces, one that + builds and one that does not, and the sync still reports the tool. + +### `3)` Say what is not covered, and why + +1. Record the 75 unreachable mutants with the table above, and leave the retire-or-keep + question to whoever owns the tool profiles. +2. Re-measure `framework` against 66,10 % and attribute the delta. + +## Test acceptance criteria + +| Task | Acceptance criteria | +| ---- | ------------------- | +| 1 | A malformed settings file warns and does not throw; a non-object is treated as empty; a disabled plugin stays disabled | +| 2 | A failing build leaves its marketplace out and the sync still completes for the rest | +| 3 | The unreachable block is recorded with the execution that establishes it, and no test is written against it | +| all | Suite green with the ratios equal, tsc 0, biome 0, knip 0 | + +## Livrée (2026-09-03) + +Neuf tests sur le fichier que l'utilisateur édite aussi. `framework` passe de 66,10 % à +**66,67 %** ; dans ce fichier, 143 mutants tués deviennent 166 et 100 sans couverture +deviennent 92. Le gain global est petit parce que le scope compte 4 110 mutants et que cette +phase touche un fichier ; le gain local est celui qui compte. + +### Trois régressions réinjectées, les trois attrapées + +| Injection | Test qui tombe | +| --------- | -------------- | +| `loadSettings` relance au lieu d'avertir | un fichier mal formé fait échouer `setup`, `sync` et `update` | +| `mergeEnabledPlugins` écrase au lieu d'ignorer | **un plugin désactivé par l'utilisateur est réactivé en silence** | +| `buildForTool` relance au lieu de sauter | une marketplace qui ne compile pas arrête la synchronisation des autres | + +La deuxième est la plus coûteuse et tient à une ligne : +`if (!(key in existing)) toAdd[key] = true`. Entre respecter un choix et le défaire à chaque +synchronisation, il y a ce test d'appartenance. + +### Ce qui reste non couvert, et pourquoi aucun test n'est écrit dessus + +Les 75 mutants de `mergeMarketplaces`, `mergeMarketplacesArray`, `mergeMarketplacesMap`, +`existingArray` et `resolveSourceForSettings` sont dans une branche qu'aucun profil livré ne +prend, établi en exécutant la condition sur les cinq. Écrire un test là figerait un chemin en +attente d'une décision — retirer ou garder — qui appartient à qui possède les profils. + +Cette décision est prise dans `2026_09_03_registration-native/`. diff --git a/cli/aidd_docs/tasks/2026_09/2026_09_03_mutants-sans-couverture/phase-5.md b/cli/aidd_docs/tasks/2026_09/2026_09_03_mutants-sans-couverture/phase-5.md new file mode 100644 index 000000000..c753fd939 --- /dev/null +++ b/cli/aidd_docs/tasks/2026_09/2026_09_03_mutants-sans-couverture/phase-5.md @@ -0,0 +1,136 @@ +--- +status: done +--- + +# Phase 5 — Trois adaptateurs, et l'un d'eux ne devrait pas exister + +## Ce que la mesure dit, contre ce que le plan annonçait + +Le plan estimait 105 mutants pour cette phase, écrits avant la mesure. Re-mesuré sur les +périmètres `distribution` et `runtime` : + +| Cible | Sans couverture | Survivants | Vivants | +| ----- | --------------: | ---------: | ------: | +| `plugin-fetcher-adapter.ts` | 39 | 35 | **74** | +| `auth-provider-adapter.ts` | 32 | 0 | **32** | +| `self-update/git-adapter.ts` | 34 | 3 | **37** | + +143, pas 105. C'est précisément pourquoi le plan interdisait d'écrire les phases avant de +mesurer. + +## Le fait qui change la phase + +`GitAdapter.installPreCommitDelegate` n'est appelé par personne. + +```sh +grep -rn "deps\.git|\bgit\b\s*[,}]" src # rien hors du câblage +grep -rnE "^\s*(const|let)\s*\{[^}]*\bgit\b" src # aucune déstructuration +git log -S "installPreCommitDelegate" -- cli/src # un seul commit : la migration +``` + +Le câblage construit `new GitAdapter(fs)` et pose l'objet dans un champ `git` que rien ne +relit. `noGit`, le bouchon du helper de test, est exporté et jamais reçu. Aucun cas d'usage ne +prend un `VersionControl`. La capacité — installer un hook pré-commit qui délègue à `aidd` — +n'a jamais tourné depuis son arrivée dans ce dépôt. + +Vérifié avant de conclure, parce que supprimer ce que l'outil est seul à faire serait une perte : +rien d'autre dans `src` n'installe de hook git (les occurrences `hooks.json` sont les hooks de +plugin, un autre concept), et aucun plugin ne le fait non plus — `aidd-vcs` se contente de +réagir à un hook déjà présent. + +Écrire 37 mutants de tests sur du code que personne n'appelle reviendrait à figer un +comportement que personne n'observe. Il est supprimé. + +`knip` ne l'a jamais signalé : l'objet est bien construit, donc l'outil le voit utilisé. C'est +le même angle mort qui a caché la citation sans préfixe au test `referenced-paths` — un garde +qui mesure la forme, pas l'usage. + +## Ordre, et pourquoi + +1. **`auth-provider-adapter`** — 32 mutants, aucun risque de fusion : `next` n'y touche pas. +2. **`plugin-fetcher-adapter`** — 74 mutants ; `next` a modifié `github-raw-fetcher-adapter.ts`, + donc ces tests pourront demander une révision après l'intégration. +3. **La suppression** — commit séparé. Sa justification n'est pas de même nature que celle des + tests, et l'enterrer dans un message sur la couverture mutationnelle la rendrait invisible. + +`next` a aussi modifié `git-adapter.ts`. Le conflit de fusion se résoudra par la suppression, +et c'est écrit ici pour que personne ne le ressuscite en croyant bien faire. + +## Ce qui se teste, par intention + +### `auth-provider-adapter` + +Le `logout` est déjà épinglé par `auth-logout-use-case.integration.test.ts`. Restent : + +- `login` par jeton vérifie le jeton ; `login` externe appelle le fournisseur nommé +- `login` enregistre au niveau demandé, avec la racine du projet +- `status` sans configuration répond « pas authentifié », et ne vérifie rien +- `status` avec configuration renvoie le niveau enregistré +- une configuration externe sans fournisseur nommé retombe sur `gh` +- une configuration par jeton sans jeton lève « invalid config » +- un fournisseur externe inconnu lève une erreur qui **nomme** le fournisseur demandé + +### `plugin-fetcher-adapter` + +Deux tests portent une conséquence de sécurité et passent en premier : + +- un jeton présent dans l'URL ne doit pas atteindre le message d'erreur (`scrubCredentials`) +- un échec SSH doit recevoir le conseil SSH, pas le conseil « pose un jeton » + +Puis les clés de cache, qui décident silencieusement d'un re-clonage ou d'un cache partagé : + +- `github` : `github---` +- `url` : URL encodée + `-` ou `-HEAD` +- `git-subdir` : URL encodée + `-subdir-` + ref +- `encodeKey` tronque à 64 caractères — épinglé tel que documenté, **sans** affirmer l'absence + de collision, que le code déclare explicitement ne pas garantir + +Puis le reste du comportement observable : + +- `git@` n'accepte pas d'injection de jeton ; une URL https en accepte une +- `forceRefresh` supprime le répertoire avant de recloner, et ne fait rien s'il est absent +- clonage superficiel : `--depth 1`, et `--branch ` seulement si une ref est donnée +- clonage épars : `--filter=blob:none --no-checkout`, puis `sparse-checkout set`, puis la ref +- `npm` sans version résout `@latest`, et un échec cite la spécification demandée +- un chemin local absent lève une erreur qui donne le chemin **résolu** + +## Test + +`pnpm test:mutation:distribution` et `pnpm test:mutation:runtime` re-mesurés à la fin : les +mutants vivants des deux adaptateurs conservés doivent baisser, et `git-adapter.ts` doit avoir +disparu du rapport. + +## Ce que la phase a donné + +| Cible | Avant | Après | Comment | +| ----- | ----: | ----: | ------- | +| `plugin-fetcher-adapter.ts` | 74 | **2** | 33 tests, 123 mutants tués | +| `auth-provider-adapter.ts` | 32 | **1** | 9 tests, 52 mutants tués | +| `self-update/git-adapter.ts` | 37 | **0** | supprimé | + +143 vivants au départ, 3 à l'arrivée, et les trois sont laissés vivants en connaissance de +cause : + +- deux dans le fetcher, le même mutant équivalent (`startsWith("git@")` mué en `endsWith`), + neutralisé par le garde `https://` de `injectTokenIntoUrl` ; le tuer reviendrait à affirmer + un détail d'implémentation +- un dans l'auth, le message `"invalid config"` remplacé par la chaîne vide — un message qui + *décrit*, et le dépôt a déjà tranché que la prose ne s'épingle pas + +## Ce que la phase a trouvé et qui n'était pas dans le plan + +Une fuite de secret, sur deux chemins. Un utilisateur peut écrire son identifiant dans l'URL +source ; il atterrissait dans le message d'erreur, `displayUrl` étant interpolé brut, et dans +le **nom du répertoire de cache**, `encodeKey` remplaçant les caractères non alphanumériques +sans retirer les identifiants. Le secret était donc écrit sur disque et y restait. + +Corrigé au point d'étranglement par `withoutCredentials`. Les deux tests correspondants ont +été lancés contre le code non corrigé avant d'être gardés : ils échouaient. + +## Deux observations qui ne sont pas de cette phase + +- `"invalid config"` ne dit rien d'actionnable à quelqu'un dont la session est cassée. Le + rendre instructif le ferait entrer dans le champ de `errors-that-instruct`. +- `knip` n'a jamais signalé `GitAdapter` ni `chmodExecutable` : la classe est construite, la + méthode est implémentée, donc l'outil les voit utilisées. C'est la même forme d'angle mort + que la citation sans préfixe invisible au test `referenced-paths`. diff --git a/cli/aidd_docs/tasks/2026_09/2026_09_03_mutants-sans-couverture/plan.md b/cli/aidd_docs/tasks/2026_09/2026_09_03_mutants-sans-couverture/plan.md new file mode 100644 index 000000000..0be52f3f5 --- /dev/null +++ b/cli/aidd_docs/tasks/2026_09/2026_09_03_mutants-sans-couverture/plan.md @@ -0,0 +1,69 @@ +--- +objective: "The behaviour a user types is pinned by a test that names it, not left to a mutant nobody generated." +status: implemented +--- + +# Plan: Cover what no test executes + +## Overview + +| Field | Value | +| ----- | ----- | +| **Goal** | Turn the measured no-coverage set into tests that pin user-visible behaviour, and refuse the ones that would only move a number | +| **Source** | `reports/mutation//mutation.json`, produced by `pnpm test:mutation:` — the seven committed scopes of `2026_09_03_mutation-scopes` | + +## What the measurement says + +2 582 mutants across 134 files sit in code no unit or integration test executes. That is not +one problem. Ranked, it is three: + +| Where | Mutants | Share of the file | What it is | +| ----- | ------: | ----------------: | ---------- | +| `presentation/commands/*` | 1 069 sur 1 094 | 98 % | mostly commander wiring: `.command()`, `.option()`, `.action()` | +| `presentation/display/*` | 130 | 100 % | pure formatting functions | +| everything else | ~1 470 | 27–92 % | parsing, transforms, orchestration, adapters | + +## The decision that shapes this plan + +**The command files are not covered here, and the score stays low on purpose** — but the reason +is priority, not impossibility, and the first version of this paragraph overstated it. Most of +that branching is commander's, not ours, and a unit test over it asserts that `.option()` was +called; the repo's test skill forbids the shape it would take ("snapshot tests on menu trees / +output strings"); and what proves those files is the e2e suite and the smoke script, which the +mutation run cannot see — checked on the case most likely to break the argument, the +`doctor --plugin` exit-code gate, which `tests/e2e/command-matrix-plugin.e2e.test.ts` covers +exactly. + +The overstatement: not all of it is commander's. `presentation/commands/global-options.ts` is +eighteen lines of pure option reading with four uncovered mutants, one of which flips every +invocation to verbose; `doctor.ts`'s `categoryOf` and `printInventory` are the same shape. Those +are ours and a unit test reaches them. They belong to a later phase, not to the exclusion. + +`presentation` scoring 14,08 % stays a known artifact of the measurement's blind spot rather +than a debt — for the command wiring, which is most of it, not for all of it. + +Everything else is covered where a regression would be visible to someone using the CLI. + +## Phases + +| # | Phase | Mutants | File | +| - | ----- | ------: | ---- | +| 1 | The source spellings a user types | 71 | [`phase-1.md`](./phase-1.md) | +| 2 | Copilot's content transforms | 173 | to write after phase 1 is measured | +| 3 | The marketplace sync flow | 100 | idem | +| 4 | What the displays print | 130 | idem | +| 5 | Three adapters — two tested, one deleted | 143 measured (105 estimated) | [`phase-5.md`](./phase-5.md) | + +Each phase was written only after the one before it was re-measured. That rule paid twice: +phase 5's estimate of 105 mutants measured 143, and one of its three targets turned out to be +code no caller reaches, where the right answer was deletion rather than tests. + +## Decisions + +| Decision | Why | +| -------- | --- | +| A test is written only when the regression it prevents can be named | A test written to kill a mutant raises the score and protects nothing. Each phase states what breaks for a user if the behaviour regresses; if that cannot be stated, the test is not written | +| Named by intention, with the functional case inside | `describe` names the thing the user does — the spelling, the flow — and the nested `it` names the observable outcome. Never the function called. Note a conflict this plan does not resolve: `cli/.claude/skills/test` requires the *parent* `describe` to wrap a class (`describe('')`), and only constrains `it` names. The instruction given here overrides that for the `describe` layer; the two documents should be reconciled, and until they are, this is a deliberate divergence rather than a drift | +| Extend the existing test file, do not open a new one | `kernel/source.unit.test.ts` already has the nested shape. A second file for the same unit splits the story of one behaviour across two places | +| Re-measure after each phase, and quote the delta as approximate | Run-to-run noise on a scope is around 0,4 point. A delta quoted to the hundredth claims a precision the instrument does not have | +| The score is never the acceptance criterion | Stated in the project goal: scored, never gating. A phase is done when the named behaviours are pinned, and the score is reported as what it is — a consequence | diff --git a/cli/aidd_docs/tasks/2026_09/2026_09_03_mutation-scopes/phase-1.md b/cli/aidd_docs/tasks/2026_09/2026_09_03_mutation-scopes/phase-1.md new file mode 100644 index 000000000..795e340e4 --- /dev/null +++ b/cli/aidd_docs/tasks/2026_09/2026_09_03_mutation-scopes/phase-1.md @@ -0,0 +1,147 @@ +--- +status: done +--- + +# Instruction: Declare the scopes, run them, and check nothing escapes + +## Architecture projection + +> Tree of the final files. ✅ create · ✏️ modify · ❌ delete + +```txt +. +└── cli/ + ├── mutation-scopes.json ✅ create (the one declaration) + ├── stryker.conf.json ✏️ modify (no file list, per-scope reports) + ├── package.json ✏️ modify (one script per scope) + ├── scripts/ + │ └── run-mutation.mjs ✅ create (reads the declaration, files the report) + ├── tests/architecture/ + │ └── mutation-covers-source.arch.test.ts ✅ create + └── aidd_docs/memory/testing.md ✏️ modify (how to run one, what the numbers are) +``` + +## Test Scope + +```mermaid +--- +title: Test scope +--- +journey + section Setup + declare the scopes in one file => runner and guard read the same list: 5: system + section Happy path + run one scope => a score, and a report filed under that scope's own name: 5: cli + section Edge case - a new source file + add a file to a scoped context => it is mutated without editing any config: 5: system + section Edge case - an unscoped context + add a file outside every scope and every declared exclusion => the guard fails: 5: system + section Teardown + after a run => .stryker-tmp removed, reports kept: 5: system +``` + +## Tasks to do + +### `1)` Declare the scopes once + +1. `mutation-scopes.json`: each scope maps a name to its glob, plus an `excluded` map giving + a reason per directory left out. Both halves are read by the guard. +2. `stryker.conf.json` drops its seventeen-file `mutate` list; the scope arrives per run. + +### `2)` Run a scope by name + +1. `scripts/run-mutation.mjs `: looks the scope up, runs stryker with `--mutate `, + files the html and json reports under `reports/mutation//`, and removes `.stryker-tmp`. + Without an argument it lists the scopes. +2. `package.json`: `test:mutation` keeps working and names what to pass; one script per scope. + +### `3)` Check that nothing escapes + +1. `tests/architecture/mutation-covers-source.arch.test.ts`: every `.ts` under `src/` matches a + scope glob or sits under a declared exclusion. A file in neither fails, naming it. +2. Prove it by adding a synthetic file outside every scope and watching it fail. + +### `4)` Confirm or correct the numbers on record + +1. Run every scope. Record the score each one actually produces. +2. The context refactor's `plan.md` and `phase-10.md` quote five scores from runs nobody kept. + Where a reproducible run disagrees, correct the document and say the earlier figure was + unreproducible — do not leave a number standing that no command produces. + +## Test acceptance criteria + +| Task | Acceptance criteria | +| ---- | ------------------- | +| 1 | The scope globs and the exclusions are in one file, and nothing else in the repo lists them | +| 2 | `node scripts/run-mutation.mjs kernel` prints a score and leaves `reports/mutation/kernel/`; a second scope leaves its own directory without overwriting the first | +| 3 | A synthetic file outside every scope fails the new test by name; removing it makes it pass | +| 4 | Every quoted score in `aidd_docs/` is one a committed command reproduces, or is marked as corrected | +| all | 1990 tests over 986 suites, knip clean, tsc 0, biome 0 | + +## Livrée (2026-09-03) + +Deux choses que la fiche n'avait pas prévues. + +**Le traducteur de glob du test avait le bug qu'il devait empêcher.** `src/kernel/**/*.ts` ne +matchait pas `src/kernel/errors.ts` : `**` était traduit sans le cas « zéro répertoire ». Un +scope n'aurait couvert que ses sous-dossiers, et les fichiers à la racine du contexte auraient +échappé à la mutation sans que rien ne le dise — exactement le défaut que cette phase corrige. +Attrapé par les cas de la règle elle-même, avant tout run. + +**Les anciens chiffres mesuraient la couche `domain/` seule.** `phase-20.md` le dit dans sa +colonne « cible », mais aucune commande gardée ne les reproduisait, et lus sans cette colonne +ils passent pour le score d'un contexte entier. Les deux documents qui les citent portent +maintenant la correction. Le scoping `domain/` seul aurait d'ailleurs échoué au nouveau test : +tous les fichiers `application/` et `infrastructure/` seraient tombés hors de tout scope et +hors de toute exclusion. + +## Vérifié + +| Critère | Preuve | +| ------- | ------ | +| 2 | Les cinq scopes tournent, chacun laisse `reports/mutation//` ; aucun n'écrase le précédent | +| 3 | `src/orphan/thing.ts` hors de tout scope => échec nommant le fichier ; retiré, le test repasse | +| 3 | `mutate` remis dans `stryker.conf.json` => `stryker.conf.json declares its own mutate again` | +| 4 | Les cinq chiffres du dossier sont corrigés avec leur périmètre, et chacun est reproductible par `pnpm test:mutation:` | +| all | 1 994 tests / 988 suites · tsc 0 · biome 0 · knip exit 0 | + + +## Revue (2026-09-03) + +Sept défauts trouvés par une relecture indépendante, dont un qui vidait la phase de son sens. + +**L'exclusion de `presentation` et `runtime` reposait sur une raison fausse.** Elle affirmait que +leur preuve était e2e et la smoke, qu'aucun mutant n'atteint. Mesuré : 31 tests unitaires et +d'intégration visent ces deux répertoires, zéro test e2e n'y est écrit, et +`runtime/self-update/check-update-use-case.ts` est 66 lignes de branchement avec son propre test +unitaire. La garde n'exigeait d'une exclusion qu'une raison de plus de quarante caractères, pas +qu'elle soit vraie. Les deux sont devenus des scopes ; seule `src/cli.ts` reste exclue. + +**Les rapports étaient copiés dans le bac à sable.** Les déplacer sous `reports/mutation//` +les a sortis du chemin que `stryker.conf.json` déclarait, donc chaque run recopiait les rapports +des précédents. Mesuré : 1 091 fichiers projet avec les rapports présents, 1 081 sans. +`ignorePatterns: ["reports"]` ramène à 1 081 rapports présents. + +**Les noms de scopes étaient une deuxième liste.** Déclarés dans `mutation-scopes.json`, recopiés +à la main dans sept scripts `package.json`. Le test compare désormais les deux ensembles : +supprimer un script fait échouer en le nommant. + +**`"constructor" in SCOPES` était vrai.** `node scripts/run-mutation.mjs constructor` prenait le +chemin heureux et lançait Stryker avec `--mutate 'function Object() { [native code] }'`. +`Object.hasOwn`. + +**Trois formulations trop fortes, corrigées :** « code qu'aucun test n'exécute » devient « aucun +test unitaire ou d'intégration » — la mesure écarte e2e et architecture ; le message de commit dit +cinq cibles `domain/` là où les documents disent quatre, et quatre est juste, `kernel` ayant +toujours été mesuré en entier ; et le seuil `break: 50` contredisait « jamais bloquante » en +faisant sortir `presentation` en erreur, il est retiré. + +## Vérifié après revue + +| Quoi | Preuve | +| ---- | ------ | +| Rapports hors du bac à sable | `Found 23 of 1081` avec `reports/` peuplé, contre 1 091 avant | +| Garde des scripts | script `test:mutation:runtime` retiré => échec nommant `runtime` | +| Lookup durci | `run-mutation.mjs constructor` => `Unknown scope "constructor"` | +| Jamais bloquante | `presentation` à 14,08 % sort en 0 | +| Traducteur de glob | comparé au `minimatch` embarqué de Stryker sur 255 fichiers × 8 globs : zéro désaccord | diff --git a/cli/aidd_docs/tasks/2026_09/2026_09_03_mutation-scopes/plan.md b/cli/aidd_docs/tasks/2026_09/2026_09_03_mutation-scopes/plan.md new file mode 100644 index 000000000..57203097b --- /dev/null +++ b/cli/aidd_docs/tasks/2026_09/2026_09_03_mutation-scopes/plan.md @@ -0,0 +1,107 @@ +--- +objective: "Every mutation score in this repo can be reproduced by a committed command, and no source file escapes mutation by being new." +status: implemented +--- + +# Plan: Make the mutation scores reproducible + +## Overview + +| Field | Value | +| ----- | ----- | +| **Goal** | Replace a hand-kept file list and four undocumented command lines with scopes the repo declares, runs and checks | +| **Source** | `plan.md` of the context refactor records five per-context scores; `stryker.conf.json` reproduces exactly one of them | + +## The measured cause + +`stryker.conf.json` names seventeen files under `src/kernel/` explicitly. A file added to +the kernel escapes mutation in silence: the score does not drop, because the mutants that +would have died were never generated. That is the same failure that the stale `translate` +import rule and the emptied `orchestrator-deps` scope already produced in this repo — +a check that stops checking and still reads green. + +The four other numbers on record — tools 61,64 %, translate 78,63 %, distribution 74,07 %, +framework 77,97 % — came from command lines typed once and not kept. Nothing in the repo +runs them, so nothing can confirm or refute them. Measured, they turn out to have covered +each context's `domain/` layer alone: `phase-20.md` says so in its "cible" column, and read +without that column they pass for the score of a whole context. + +## Phases + +| # | Phase | File | +| - | ----- | ---- | +| 1 | Declare the scopes, run them, and check nothing escapes | [`phase-1.md`](./phase-1.md) | + +## Decisions + +| Decision | Why | +| -------- | --- | +| Globs, not file lists | The list is what goes stale. A glob covers a file the day it is written | +| One declared scope map, read by both the runner and its guard | Two lists disagree eventually; one cannot | +| A scope per context, not one run over `src/` | The scores are per context because the contexts have different test pressure. One number would hide which one is weak, and a full run is too slow to be used | +| Every directory under `src/` is scoped; only `src/cli.ts` is excluded | The first draft excluded `presentation` and `runtime` on a reason that measurement refuted — see the result section. An exclusion the guard cannot check is a hiding place, so the set is kept to the one file where the argument survives inspection | +| Never gating, threshold included | Stated in the project goal. A `break` threshold that fails a command is a gate whatever it is called; it is removed. What is enforced is that the score exists and covers everything | + +## Résultat (2026-09-03) + +Cinq scopes déclarés, cinq commandes qui les rejouent, 23 minutes pour les cinq. + +| scope | fichiers | mutants | score | sans couverture | +| ----- | -------: | ------: | ----: | --------------: | +| `contexts/translate` | 16 | 891 | 72,05 % | 48 (5 %) | +| `contexts/distribution` | 23 | 865 | 70,75 % | 63 (7 %) | +| `contexts/framework` | 88 | 4 112 | 66,10 % | 463 (11 %) | +| `runtime` | 38 | 1 000 | 63,50 % | 218 (22 %) | +| `kernel` | 17 | 1 060 | 62,74 % | 117 (11 %) | +| `contexts/tools` | 47 | 2 859 | 61,04 % | 423 (15 %) | +| `presentation` | 25 | 1 712 | **14,08 %** | 1 250 (73 %) | + +Durées : distribution 47 s, presentation 47 s, runtime 58 s, translate 3 min, kernel 3 min 13, +framework 4 min 48, tools 11 min 30. Vingt-cinq minutes pour les sept. + +### `presentation` à 14 %, et pourquoi il reste un scope + +Le premier jet excluait `presentation` et `runtime` en affirmant que leur preuve était e2e et +la smoke, qu'aucun mutant n'atteint. C'était faux, et la revue l'a montré : 31 tests unitaires +et d'intégration visent ces deux répertoires, zéro test e2e n'y est écrit, et +`runtime/self-update/check-update-use-case.ts` est 66 lignes de branchement avec son propre +test unitaire. La garde vérifiait qu'une exclusion porte une raison de plus de quarante +caractères, pas qu'elle soit vraie — un fichier déposé dans `src/runtime/` restait non muté en +silence, le défaut même que cette phase supprime, déplacé de « non listé » vers « exclu pour +une mauvaise raison ». + +Les deux sont donc des scopes. `runtime` donne 63,50 %, comparable aux contextes. +`presentation` donne 14,08 %, avec 73 % de ses mutants dans du code qu'aucun test unitaire ni +d'intégration n'exécute — ce qui est le chiffre honnête : sa vraie couverture est le binaire +qui tourne, en e2e et en smoke, et la mutation ne la voit pas. Le score est bas parce que la +mesure ne peut pas voir ce qui le protège, pas parce que rien ne le protège. Il reste mesuré +plutôt qu'écarté, pour que le jour où l'on décide de tester `presentation` en unitaire, le +chiffre le dise. + +Seule `src/cli.ts` reste exclue. + +### Le seuil de rupture est retiré + +`presentation` à 14,08 % passait sous le `break: 50` et faisait sortir la commande en erreur. +L'objectif du projet dit « scorée, jamais bloquante » ; un seuil qui fait échouer une commande +est une porte. `break` est désormais nul, et le runner ne sort en erreur que sur une vraie +panne. + +### Ce que la mesure apprend, au-delà du score + +**Le périmètre expliquait presque tout l'écart.** `kernel` est la seule cible identique aux +deux mesures : 61,60 % puis 62,74 %. Les quatre autres couvrent maintenant leur contexte +entier au lieu de sa seule couche `domain/`, et le chiffre baisse partout — c'est ce que +`application/` et `infrastructure/` pèsent quand on cesse de ne mesurer que la couche la +plus pure. `framework` passe de 19 fichiers à 88 et de 77,97 % à 66,10 %. + +**Le score a du bruit.** Deux runs du noyau sur le même code : 63,11 % puis 62,74 %. Le +nombre de mutants en `Timeout` varie (23 à 26). Deux décimales suggèrent une précision qui +n'existe pas ; l'unité est le point, pas le centième. + +**Le signal actionnable n'est pas le score, c'est `NoCoverage`.** 1 114 mutants sur 9 787 se +trouvent dans du code qu'aucun test **unitaire ou d'intégration** n'exécute — pas des mutants +qui survivent à un test faible, des mutants que rien ne regarde. La précision compte : la +mesure tourne sous `vitest.mutation.config.ts`, qui écarte e2e et architecture, donc une +partie de ce code est atteinte par le binaire en e2e. `tools` en a 15 %. C'est la matière +première du travail sur les survivants, et c'est moins ambigu qu'un pourcentage global. diff --git a/cli/aidd_docs/tasks/2026_09/2026_09_03_registration-native/plan.md b/cli/aidd_docs/tasks/2026_09/2026_09_03_registration-native/plan.md new file mode 100644 index 000000000..9dd055b80 --- /dev/null +++ b/cli/aidd_docs/tasks/2026_09/2026_09_03_registration-native/plan.md @@ -0,0 +1,110 @@ +--- +objective: "The CLI stops carrying a second way to register a marketplace, and its contract stops promising one." +status: implemented +--- + +# Plan: Leave the registration to the tools that do it + +## The decision this closes + +Phase 4 of the uncovered-mutants work found 75 mutants in a branch no shipped profile takes, +and refused to write tests there: retire it or keep it is a decision about tool profiles, not +a question a mutation report answers. + +The decision is to retire it, on the rule phase 5 of the context refactor already set — +drive the tool's own command where it offers one, and do not rebuild badly what it does well. + +`syncMarketplacesFile` had two paths. One drove nothing and wrote the tool's settings file +itself, merging a marketplace entry into whatever was already there. The other returned +early, leaving the registration to the tool's CLI. Established by running the condition over +the five registered profiles: **all five take the early return**. claude, copilot and codex +declare a native plugin CLI; cursor and opencode declare no marketplace settings at all. + +## What was checked before removing anything, and why that list is the point + +Deleting a path the tool covers is right. Deleting a path the tool does *not* cover is a +regression that no compiler catches. Two things survived that check: + +| Kept | Because | +| ---- | ------- | +| `toEntry` | It is called from `mergeEnabledPlugins` too — the live path. Claude registers its own marketplaces but does **not** write `enabledPlugins`; this CLI does, and the existing test says so (`enablesPlugins: false`). Removing it with the merge would have broken plugin activation | +| The marketplace build | `builtSourcesForTool` returned a map that only the merge read, but the build itself must happen whoever registers — including on a machine where the tool's CLI is absent and activation stops short. It became `buildAllForTool`, which builds and returns nothing | + +## The contract narrowed with the code + +`marketplacesSettingsPath` documented three answers. The first — `undefined`, "into +`settingsPath` alongside the rest" — described the era when this CLI wrote the registration +itself. It is now `string | null`. + +`toEntry`'s array shape had no producer at all: the single entry builder returns a map. Gone, +with the `valueShape` discriminant that existed to tell the two apart. + +A contract promising more than the code delivers is legacy wearing the costume of generality. + +## Verified + +| Path | Result | +| ---- | ------ | +| `setup` + `plugin install` + `sync`, five tools, tool CLIs **present** | identical — cursor 47 files, copilot 246, codex 48, opencode 46; claude identical but for the absolute path, which the tool writes itself | +| `setup` + `plugin install`, claude, tool CLI **absent from PATH** | identical, 247 files, built tree present on both sides | +| delete `settings.local.json`, then `marketplace refresh` + `doctor` | restored on both sides, identical but for the path | + +The third is the one that mattered. `doctor` tells the user to run `aidd marketplace refresh` +to write the file back; had that recovery run through the deleted merge, `doctor` would have +started giving advice that no longer worked. + +168 lines removed against 40 added, across three files. + +## What this proof does not cover + +`update`, `clean` and `framework remove` were not exercised. And a profile that dropped its +`nativeActivation` tomorrow would no longer have a registration written for it — that is the +decision, not an oversight, and it is why the contract now says so out loud. + +The Windows path normalisation (`replace(/\\/g, "/")`) went with the merge. Nothing is lost, +its only consumer leaving with it, but it is written here rather than left to be discovered. + +## Revue (2026-09-03) + +Aucune régression trouvée : ni sur les cinq profils, ni sur `update`, `clean` et +`framework remove`, les trois chemins que ce dossier signalait comme non éprouvés. Le +relecteur les a suivis un par un — `aidd update` ne fait plus que la mise à jour du CLI, +`framework update` ne touche jamais cette classe, `clean` ne mentionne aucune clé de +réglages, et `MarketplaceRemoveUseCase` n'a jamais écrit d'entrée. + +En revanche il a trouvé que le rétrécissement s'était arrêté trop tôt, et l'argument était +le mien. + +**`entry.value` n'avait plus aucun lecteur.** Un seul `grep` le montre : la seule survivante +de `toEntry` lit `entry.key` et rien d'autre. Derrière, toute une chaîne devenait écriture +pure — `version` → `versionByName` → `loadAllVersions` → `loadCatalogVersion`, une lecture +asynchrone du catalogue par marketplace et par synchronisation, dont le résultat était jeté. +J'avais retiré `valueShape` en écrivant qu'un contrat promettant plus que ce que le code +tient est du legacy déguisé en généricité, et laissé debout une instance plus grosse. + +`toEntry` devient `toEntryKey` : une clé, ou `null`. Le `null` était la partie porteuse — il +empêche d'écrire une entrée pour une source que l'outil ne sait pas exprimer, et garde les +plugins qui en viennent hors de la carte des plugins activés. Partent avec : le type +d'entrée, `version`, les deux chargeurs de catalogue, et le port `PluginCatalogRepository` +que cette classe n'a plus de raison de recevoir. + +Trois autres, tous réels et tous laissés par mon propre rétrécissement : une garde +`marketplacesSettingsPath === undefined` devenue inatteignable, un commentaire de `doctor` +décrivant trois cas dont un n'existe plus, et `enabledPluginsSettingsPath`, champ sans +producteur — la justification exacte qui avait fait retirer `valueShape`. + +### Le test qui a failli ne rien prouver + +La revue notait que la raison de garder le build n'était épinglée par aucun test. Le premier +que j'ai écrit passait **aussi avec le build supprimé** : l'activateur factice avait +`enablesPlugins: false`, donc `toRegister` valait toutes les marketplaces et l'autre chemin +construisait tout de toute façon. + +Le cas non redondant est celui qu'un outil dont la CLI active les plugins présente : il ne +déclare que les marketplaces qu'un plugin utilise, donc une marketplace sans plugin est +construite là ou nulle part. Option câblée dans le harnais, et la suppression du build fait +maintenant tomber le test. + +Sans cette correction, j'aurais commité un test qui prouve zéro — la forme même du défaut que +cette séquence entière a passé son temps à corriger. + diff --git a/cli/aidd_docs/tasks/2026_09/2026_09_03_taille-des-dossiers/phase-1.md b/cli/aidd_docs/tasks/2026_09/2026_09_03_taille-des-dossiers/phase-1.md new file mode 100644 index 000000000..67cf25bfc --- /dev/null +++ b/cli/aidd_docs/tasks/2026_09/2026_09_03_taille-des-dossiers/phase-1.md @@ -0,0 +1,69 @@ +--- +status: done +--- + +# Instruction: Put the capability classes where the capability classes live + +`src/contexts/tools/domain` holds twelve direct files against a limit of ten. Three of them +are capability classes sitting beside the folder that holds the other five. + +| In `capabilities/` | Beside it | +| ------------------ | --------- | +| `AgentsCapability`, `CommandsCapability`, `HooksCapability`, `RulesCapability`, `SkillsCapability` | `McpCapability`, `PluginsCapability`, `SettingsCapability` | + +Same suffix, same role in a profile's `capabilities` object, two locations, no reason +written anywhere. Moving the three takes the folder to nine — under the limit because the +inconsistency is gone, not because three files were shuffled to satisfy a count. + +## Architecture projection + +```txt +. +└── cli/src/contexts/tools/domain/ + ├── mcp-capability.ts ❌ moved + ├── plugins-capability.ts ❌ moved + ├── settings-capability.ts ❌ moved + └── capabilities/ + ├── mcp-capability.ts ✅ here + ├── plugins-capability.ts ✅ here + └── settings-capability.ts ✅ here +``` + +## Test Scope + +```mermaid +--- +title: Test scope +--- +journey + section Setup + capture the nine build outputs before a file moves: 5: cli + section Happy path + move the three classes and repoint every import => the build is byte-identical: 5: cli + section Edge case - the folder-size ratchet + tools/domain now under the limit => the ratchet fails on a stale entry until it is removed: 5: system + section Edge case - the boundary + the moved files stay inside the tools context => the import rules keep biting: 5: system + section Teardown + the comparison trees removed: 5: system +``` + +## Tasks to do + +### `1)` Move, and repoint + +1. The three files into `capabilities/`, with every importer updated. +2. Nothing else changes: no rename, no signature, no behaviour. + +### `2)` Take the entry out of the ratchet + +1. `src/contexts/tools/domain` leaves `folder-size`'s baseline. The ratchet fails on a stale + entry, so this is not optional — it is how the test tells you the debt is paid. + +## Test acceptance criteria + +| Task | Acceptance criteria | +| ---- | ------------------- | +| 1 | Nine target/mode builds byte-identical to the pre-move capture | +| 2 | `folder-size` passes with the entry gone, and fails if it is left in | +| all | Types, lint, knip, suite with equal ratios, architecture, smoke — all green | diff --git a/cli/aidd_docs/tasks/2026_09/2026_09_03_taille-des-dossiers/phase-2.md b/cli/aidd_docs/tasks/2026_09/2026_09_03_taille-des-dossiers/phase-2.md new file mode 100644 index 000000000..4fe01b1e7 --- /dev/null +++ b/cli/aidd_docs/tasks/2026_09/2026_09_03_taille-des-dossiers/phase-2.md @@ -0,0 +1,80 @@ +--- +status: done +--- + +# Instruction: Give the install folder its two real groupings + +`src/contexts/framework/application/install` holds twelve direct files. Two groupings are +already there, unstated. + +**A use case in the wrong folder.** `uninstall-tools-use-case.ts` lives in `install/`, and +its only importers inside the context are `uninstall/uninstall-use-case.ts` and +`uninstall/uninstall-ide-use-case.ts` — the folder it should have been in. Four files import +it in all: those two, `runtime/wiring/framework.ts`, which imports everything, and its test. + +**Four descriptors around one engine.** `install-{agents,commands,rules,skills}-use-case.ts` +are 33 to 35 lines each, every one of them a `ContentSectionDescriptor` handed to the same +`InstallContentSectionUseCase`. They are one idea in five files. + +## Architecture projection + +```txt +. +└── cli/src/contexts/framework/application/ + ├── install/ + │ ├── uninstall-tools-use-case.ts ❌ moved to uninstall/ + │ └── content/ ✅ create + │ ├── install-content-section-use-case.ts ✅ moved (the engine) + │ ├── install-agents-use-case.ts ✅ moved + │ ├── install-commands-use-case.ts ✅ moved + │ ├── install-rules-use-case.ts ✅ moved + │ └── install-skills-use-case.ts ✅ moved + └── uninstall/ + └── uninstall-tools-use-case.ts ✅ here, beside its importers +``` + +`install/` drops from twelve to six. + +## Test Scope + +```mermaid +--- +title: Test scope +--- +journey + section Setup + capture the nine build outputs before a file moves: 5: cli + section Happy path + move six files and repoint every import => the build is byte-identical: 5: cli + section Edge case - the folder-size ratchet + install now under the limit => the stale entry fails the ratchet until removed: 5: system + section Edge case - the layer rules + the moved files stay in application/ => the domain import rules keep biting: 5: system + section Teardown + the comparison trees removed: 5: system +``` + +## Tasks to do + +### `1)` Put the uninstall use case with the uninstalls + +1. Move it, repoint its four importers, change nothing else. + +### `2)` Gather the content sections + +1. `install/content/` holds the engine and its four descriptors. +2. No merge, no rename: five files, one folder. Merging them into one is a different change + with a different risk, and it does not belong in a move. + +### `3)` Take the entry out of the ratchet + +1. `src/contexts/framework/application/install` leaves `folder-size`'s baseline. + +## Test acceptance criteria + +| Task | Acceptance criteria | +| ---- | ------------------- | +| 1 | Nine target/mode builds byte-identical to the pre-move capture | +| 2 | `install/` holds six direct files; nothing was renamed or merged | +| 3 | `folder-size` passes with the entry gone, and fails if it is left in | +| all | Types, lint, knip, suite with equal ratios, architecture, smoke — all green | diff --git a/cli/aidd_docs/tasks/2026_09/2026_09_03_taille-des-dossiers/phase-3.md b/cli/aidd_docs/tasks/2026_09/2026_09_03_taille-des-dossiers/phase-3.md new file mode 100644 index 000000000..9fa1bd46b --- /dev/null +++ b/cli/aidd_docs/tasks/2026_09/2026_09_03_taille-des-dossiers/phase-3.md @@ -0,0 +1,65 @@ +--- +status: done +--- + +# Instruction: Replace the two remaining promises with their reason + +`folder-size`'s baseline says `src/presentation/commands` is over the limit and "split +remains for a later phase", and calls `src/kernel` and the two others "born of this refactor +and to be split by a later phase". Two of the four are now paid. The other two will not be, +and saying so is worth more than carrying the promise. + +**`src/kernel` — eleven files.** It is the vocabulary all four contexts speak: errors, file, +paths, markdown, jsonc, merge, scope, source, tool. Any folder here would be a category +invented for the count — `text/` and `paths/` are not concepts this repo has, and every +import in every context grows a segment to express them. + +**`src/presentation/commands` — fourteen files.** Thirteen are one command each, which is +the flattest possible mapping from the CLI's surface to its source. The two helpers +(`global-options.ts`, `spawn-cli-command.ts`) could move, taking the folder to twelve, which +is still over the limit and buys nothing. + +## Architecture projection + +```txt +. +└── cli/tests/architecture/folder-size.arch.test.ts ✏️ the baseline carries reasons, not promises +``` + +## Test Scope + +```mermaid +--- +title: Test scope +--- +journey + section Setup + the two paid entries already gone from the baseline: 5: system + section Happy path + the remaining entry carries why it stays, and the count its reason names => the ratchet still passes: 5: system + section Edge case - a new offender + a folder crossing the limit => the ratchet fails, naming it: 5: system + section Teardown + nothing to clean: 5: system +``` + +## Tasks to do + +### `1)` Say why each stays + +1. Replace the "later phase" wording with the reason, one entry at a time, in the shape + `tool-addition-cost` already uses for what it does not intend to fix. +2. Nothing else changes: the limit stays ten, the rule stays the same. + +### `2)` Prove the ratchet still catches a newcomer + +1. A synthetic folder past the limit must fail the test by name, and the two justified + entries must not. + +## Test acceptance criteria + +| Task | Acceptance criteria | +| ---- | ------------------- | +| 1 | No entry in the baseline promises a future phase; each says why it is there | +| 2 | A folder pushed past the limit fails the ratchet by name | +| all | Types, lint, knip, suite with equal ratios, architecture, smoke — all green | diff --git a/cli/aidd_docs/tasks/2026_09/2026_09_03_taille-des-dossiers/plan.md b/cli/aidd_docs/tasks/2026_09/2026_09_03_taille-des-dossiers/plan.md new file mode 100644 index 000000000..2fff4931a --- /dev/null +++ b/cli/aidd_docs/tasks/2026_09/2026_09_03_taille-des-dossiers/plan.md @@ -0,0 +1,133 @@ +--- +objective: "Every folder over the size limit is either split for a reason, or carries the reason it stays — and no promise of a later phase." +status: implemented +--- + +# Plan: Pay down the folder-size baseline, and settle whether framework splits + +## The question that came first, and its answer + +`contexts/framework` is 88 files and 8 248 lines — 38 % of `src/`, against 16 files for +`translate`. The question was whether it is one context or three (`install`, `sync`, +`restore`). + +**It is one.** Not one of its ten application subdirectories is free of the manifest. + +Predicate, so the next reader can re-run it rather than trust it — a file counts when it +imports a module whose path names the manifest: + +```sh +grep -rlE 'from "[^"]*[Mm]anifest' src/contexts/framework/application/ --include='*.ts' +``` + +| doctor | flows | uninstall | plugin | install | global | restore | setup | framework | shared | +| ------ | ----- | --------- | ------ | ------- | ------ | ------- | ----- | --------- | ------ | +| 7/7 | 3/3 | 5/5 | 6/8 | 5/11 | 5/8 | 3/8 | 1/3 | 4/6 | 2/3 | + +41 files of 62, measured after the moves below. The number is what an independent review +reproduced; the 36/62 first recorded here was not reproducible under any predicate either of +us tried, and the earlier table omitted two of the ten subdirectories. + +But coupling this dense is an argument for a *shared* manifest, not against a split — this +repo already carries a fourth thing three contexts depend on, and it is called `src/kernel`. +So the count is not what settles it. What settles it: a context owns a concept, this one owns +the installation record, and the manifest is that record's lifecycle — created by install, +read by doctor, rewritten by sync, replayed by restore. Split it three ways and no context +owns it; the lifecycle fragments and the aggregate has to be duplicated or promoted. 88 files +is a size observation, not a boundary violation. + +Recorded here so the next person to ask finds the measurement and its predicate instead of +re-deriving both. + +## What is actually owed + +`tests/architecture/folder-size.arch.test.ts` holds four directories over its limit of ten, +two of them promising a split "by a later phase". That promise is the debt. + +| Directory | Files | What is really there | +| --------- | ----: | -------------------- | +| `src/contexts/tools/domain` | 12 | Five capability classes live in `capabilities/`, three live beside it. Same suffix, same role, two locations, no stated reason | +| `src/contexts/framework/application/install` | 12 | `uninstall-tools-use-case.ts` sits here while `uninstall/` exists and holds its only importers. And four 33-line descriptors around one shared engine | +| `src/kernel` | 11 | A vocabulary the contexts speak — with one cohesive pair inside it, seen only later | +| `src/presentation/commands` | 14 | Twelve files carrying the command surface, plus two helpers | + +The first two hide a real inconsistency. `src/kernel` was written off here as arbitrary, and +that was wrong: `flat-paths.ts` is read by six files across `tools` and `translate` and +`relative-link-rewrite.ts` by seven — eight distinct files, five of them reading both — and +both answer where content lands and how its links follow. A follow-up named that +`materialization/` and the entry left. Only `src/presentation/commands` stays. + +## Phases + +| # | Phase | File | +| - | ----- | ---- | +| 1 | Put the capability classes where the capability classes live | [`phase-1.md`](./phase-1.md) | +| 2 | Give the install folder its two real groupings | [`phase-2.md`](./phase-2.md) | +| 3 | Replace the two remaining promises with their reason | [`phase-3.md`](./phase-3.md) | + +## Decisions + +| Decision | Why | +| -------- | --- | +| A baseline entry leaves only when the defect behind it is fixed | Shuffling files to get under a count is churn that reads as progress. `tools/domain` drops to 9 because three classes rejoin their siblings, not because three files moved | +| Two entries stay, with a reason instead of a promise | "A later phase" is a debt nobody owes. A reason is a decision someone can disagree with. This is the shape `tool-addition-cost` already uses for what it cannot fix | +| These are moves, so the built output must not change | The gate is not the suite passing; it is nine build outputs byte-identical to the ones taken before the first file moved. A move that changes output is not a move | + +## Gates + +Every phase runs all of them, and none is optional: + +| Gate | Command | +| ---- | ------- | +| Types | `pnpm typecheck` | +| Lint | `pnpm lint`, zero warnings included | +| Dead code | `pnpm knip:production` | +| Suite | `pnpm test`, with passed/total equal for **suites** and tests | +| Architecture | `pnpm test:arch` | +| Journeys | `pnpm smoke`, 98/0 across 22 of 22 leaf commands | +| Output | nine target/mode builds byte-identical to the pre-move capture | + +## Résultat (2026-09-03) + +| Dossier | Avant | Après | Ce qui a bougé | +| ------- | ----: | ----: | -------------- | +| `contexts/tools/domain` | 12 | **9** | Trois classes de capacité rejoignent les cinq autres dans `capabilities/` | +| `framework/application/install` | 12 | **6** | `uninstall-tools-use-case` rejoint `uninstall/`, où vivent ses seuls importateurs ; les quatre descripteurs et leur moteur passent dans `install/content/` | +| `presentation/commands` | 14 | 14 | Reste, avec sa raison | +| `kernel` | 11 | 11 | Reste, avec sa raison | + +### Ce que les gardes ont attrapé, et que le compilateur n'aurait pas vu + +Trois tests d'architecture ont échoué pendant le déplacement : + +- `codebase-map` — le dossier `content/` absent de la carte +- `context-boundary` — neuf entrées périmées sur dix-huit lignes : quatre dans la liste des + modules publics, cinq dans son socle +- `tool-addition-cost` — une entrée de socle pointant l'ancien emplacement + +Les socles suivent les fichiers. Aucun n'a grossi. + +### Les gates + +| Gate | Résultat | +| ---- | -------- | +| Types | propre | +| Lint | 511 fichiers, zéro avertissement | +| Code mort | `knip` exit 0 | +| Suite | 205 fichiers de test, 2 032 / 2 032 tests | +| Architecture | 33 / 33 | +| Sortie | les neuf builds identiques octet pour octet à la capture d'avant le premier déplacement | +| Parcours | smoke 98 / 0, 22 commandes feuilles sur 22 | + +La sixième est la seule qui vaut pour un déplacement, et elle a été prise avant que le premier +fichier bouge. Un déplacement qui change la sortie n'est pas un déplacement. + +Éprouvé après coup : un dossier synthétique de onze fichiers est bien détecté. Le test +s'arrêtait là — il prouvait le détecteur, pas le socle, alors que le critère écrit ici +promettait le second. Corrigé depuis : le dossier synthétique traverse `expectRatchet`, qui +le nomme. + +Les chiffres de gate ci-dessus étaient faux dans la première version de ce document et dans +les messages de commit `224deafa` et `884501da` : « 485 fichiers », « 1 001 suites ». `pnpm +lint` en compte 511 — 485 est le compte de `biome check src tests`, une commande plus étroite +que la gate qu'il nommait. `pnpm test` rapporte 205 fichiers de test. Le 2 032 tenait. diff --git a/cli/aidd_docs/tasks/2026_09/2026_09_04_memory-check/report.md b/cli/aidd_docs/tasks/2026_09/2026_09_04_memory-check/report.md new file mode 100644 index 000000000..0d7ce4521 --- /dev/null +++ b/cli/aidd_docs/tasks/2026_09/2026_09_04_memory-check/report.md @@ -0,0 +1,205 @@ +# Memory check — cli/ + +The bank of the `cli/` package, read against the tree at commit `04348966`. Nine files on +disk, one gap, 112 findings. Nothing under `aidd_docs/memory/` was changed. + +## Structure + +| File | Gap | Why | +| --- | --- | --- | +| `ecosystem.md` | missing | the capability always holds, and the page would carry what the repo bank does not: the CLI binaries this one drives at runtime — `claude`, `codex`, `copilot`, `opencode`, `gh` | + +No orphan: every file on disk is produced by a destination row. Scaffold complete — +`README.md`, `GUIDELINES.md`, `CONTRIBUTING.md`, `memory/README.md`, `internal/`, `external/`. + +## Findings + +### architecture.md + +| Finding | Evidence | +| --- | --- | +| bundle budget is 500 KB | `package.json:47` says 590; the build prints `536.5 KB / budget: 590 KB` | +| all assets inlined, no fs reads at runtime | `tsup.config.ts:88-110` copies `assets/schemas/*.json` beside the binary, `asset-loader.ts:65-77` `readFileSync`s them | +| `.md` files go through the text loader | no `.md` asset exists and no source imports one; the loader feeds nothing | +| Claude reads its settings natively, no CLI step | `profiles/claude/profile.ts` declares `enableVerb`/`disableVerb`/`upgradeVerb`; the CLI drives `claude plugin install` | +| only Codex and Copilot need native activation | Claude is the third; `activateNativeTools` drives whichever binary a profile declares | +| `gh auth token` fires when `method: "gh"` | `auth.ts` defines `AuthMethod = "external" \| "stored"`; there is no `"gh"` method | +| the file never names `telemetry` | a fifth context, 12 ports, 7 use cases, its own command, its own mutation scope | + +### auth.md + +| Finding | Evidence | +| --- | --- | +| names `application/commands/auth.ts` | the file is `src/presentation/commands/auth.ts` | +| names `infrastructure/adapters/auth-reader-adapter.ts` | the file is `src/runtime/auth/auth-reader-adapter.ts` | +| names `infrastructure/auth/auth-storage.ts` | the file is `src/runtime/auth/auth-storage.ts` | +| `auth.json` shape is `{ method, token?, level }` | `isAuthConfig` also requires `version: 1` and a string `createdAt`; a file of the documented shape reads as unauthenticated | +| the config dir is "overridable via env" | the variable has a name the page omits: `AIDD_USER_CONFIG_DIR` | +| token presence is the only gate | nothing gates on presence; a missing token surfaces at fetch time as `CatalogFetchAuthError` | +| `RequireAuthUseCase` throws when a command needs a token | the class is gone from `src/` | +| an external token is resolved fresh on each read | `AuthReaderAdapter.resolve()` memoizes; `gh` is spawned at most once per process | +| omits the asymmetry of the `gh` spawn | absent `gh` returns null, non-zero `gh` throws `GhCliError` on a 3s timeout | +| omits that `auth.json` is written 0600 / `icacls`, and that failing to restrict throws | `auth-storage.ts:39-56` | + +### cli.md + +| Finding | Evidence | +| --- | --- | +| the authoritative command list is `project-brief.md` | that page says 22; the binary and the smoke harness both carry 29 | +| top-level `status`, `restore`, `self-update` | all three exit 1 with `unknown command` | +| top-level list omits `framework`, `translate`, `sync` | all three in `aidd --help` | +| top-level list omits the whole `telemetry` group | 7 leaves, one with 4 of its own | +| `ai ` and `ide ` groups | `unknown command`; the tool is chosen by `--tool ` | +| `plugin create`, `plugin doctor` | both exit 1 | +| `framework build` is maintainer-only | it exits 1; the author-side build is `aidd translate` | +| output lives in `application/output.ts`, `error-handler.ts` | both are under `src/presentation/` | +| `status --json` emits the full report | `status` does not exist; the only `--json` is on telemetry | +| omits the default entry point | empty argv on a TTY runs `runMenuLoop()` and never reaches `program.parse` | +| all assets inlined, no fs reads at runtime | same schema reads as `architecture.md` | +| Node `>=22.12` and the dual publish | owned by `deployment.md` | +| "no silent failures" | true of commands; the update-check hook deliberately swallows, and one of its three catches drops the reason entirely | + +### codebase-map.md + +| Finding | Evidence | +| --- | --- | +| the telemetry CLI "is unbuilt", to be spawned as a subprocess | it is built and in-process: 52 files, a registered command, 24 e2e files | +| `global/` holds `update-all` | no such file; it is `update-tools-use-case.ts`, which the tree above names correctly | +| `restore/` has a `plugin` sub-use-case | only `restore-all-plugins-use-case.ts` exists | +| draws `settings-capability.ts`, `mcp-capability.ts`, `plugins-capability.ts` under `tools/domain/` | all three are in `tools/domain/capabilities/` | +| `capabilities/` holds content-translation classes | it also holds the mcp, plugins and settings capabilities | +| `tools/domain/formats/` holds `placeholders` | no such file | +| `translate/domain/formats/` holds `claude-root-path-rewrite` | it moved to `kernel/materialization/`, where the same page places it correctly | +| translate strategies hold `marketplace-strategy-helpers` | deleted; `plugin-source-tree-reader.ts` and `write-skill-tree.ts` are undrawn | +| `runtime/wiring/` is four modules | it holds `telemetry.ts` and `installed-plugins-from-manifest.ts` too | +| `framework.ts` composes three wiring modules | it composes four | +| `runtime/self-update/` holds the version-reader and version-control ports | one is in `kernel/ports/`, the other in telemetry's own `domain/ports/` | +| `runtime/git/` is "token injection" | it is `GitAdapter implements VersionControl`, plus the `GIT_*` stripping | +| `runtime/auth/ports/` is three files | four; `credential-file-store.ts` is undrawn | +| `presentation/display/` renders doctor, restore, setup, status | 9 files, 5 of them telemetry | +| claude and codex profiles carry only their paths files | both also carry a `*-transcript-location.ts` | +| the translator subtree is five files | `project-hooks-materializer.ts` is undrawn | +| the `kernel/` block enumerates every file | `describe-error.ts` is absent from an otherwise exhaustive list | +| `tests/contexts/tools/` covers install/uninstall use-cases | those are under `tests/contexts/framework/application/` | +| `tests/presentation/` covers display | there is no `tests/presentation/display/`; they are at `tests/application/display/` | +| `tests/runtime/` mirrors `src/runtime/` | `platform/` and `project-root/` do not exist there, and `home-dir.unit.test.ts` covers a kernel file | +| the `tests/` tree draws 10 directories | `tests/contexts/telemetry/`, `tests/application/`, `tests/domain/`, `tests/integration/`, `tests/helpers/` are undrawn | +| `tests/fixtures/` holds two dirs | ten | +| the architecture ratchets are ten named tests | seventeen | +| restates the manifest v6 guard, and holds the Launchers decision | both belong to `architecture.md` | + +### coding-assertions.md + +| Finding | Evidence | +| --- | --- | +| "3-layer architecture: Domain → Application → Infrastructure" | `src/` is `cli.ts contexts kernel presentation runtime`; the tree is organised by bounded context | +| output formatting lives in `application/output.ts` | `src/presentation/output.ts` | +| before commit: typecheck, lint, knip, jscpd, test | the pre-commit hooks are lint, `test:arch`, typecheck, layering; knip and test are pre-push, jscpd is CI only | +| omits `pnpm test:arch` | a pre-commit hook and a required CI job | +| omits the layering check | `pre-commit.cli-layering` runs `node scripts/check-cli-layering.mjs` from the repo root | +| before push: build then test | the hooks are knip then test; build runs on no hook | +| omits two blocking CI gates | `cli-coverage` and `cli-smoke` | +| restates the six runtime deps | `architecture.md` owns that list | + +### deployment.md + +| Finding | Evidence | +| --- | --- | +| lists only `AIDD_TOKEN` | omits `AIDD_BUILD_OUT_DIR`, `AIDD_SELF_UPDATE_API_BASE`, `AIDD_SELF_UPDATE_NPM_BASE` | +| the build produces `dist/cli.js` | it also copies five schema JSONs the runtime reads; drop them and the CLI breaks | +| bundle budget 500 KB | 590; at 500 the current build would fail | +| pnpm `>= 9` | no file states it; `packageManager` pins 10.14.0 and CI activates latest | +| merging tags `vX.Y.Z` | this package's tag is `cli-v`; bare `vX.Y.Z` is the root component | +| "the Publish job" fires on a tag | the job is `publish-cli`, gated on `cli` being in `paths_released` | +| publishes to both registries | the GitHub Packages step is `continue-on-error`; only npm is load-bearing | +| publishes with `pnpm publish` and `NPM_TOKEN` | it runs `npm publish`; the workflow says no token is needed and that pnpm is avoided on purpose | +| the command is `aidd self-update` | no such command; the verb is `aidd update` | +| the changelog is best-effort from GitHub | it is dead: wrong repo constant and wrong tag shape, and the error is swallowed | +| biome config at the repo root | it is `cli/biome.json` | +| a `lefthook.yml` with no parent delegation | the only one is the repo root's, and every CLI hook is exactly that delegation | +| pre-commit is lint + typecheck | four commands, not two | +| the git-hooks tables | `coding-assertions.md` owns them, and the two disagree | +| `ci.yml` runs typecheck, lint, test, build, knip, jscpd | those live in `cli-ci.yml`, a workflow this page never names | +| `pnpm test` is build + vitest | it is `vitest run`; `testing.md` records why | +| mutation is a CI gate | it runs on no workflow, and takes a required scope argument | + +### project-brief.md + +| Finding | Evidence | +| --- | --- | +| twenty-two leaf commands | 29; the smoke guard diffs the list, never the prose count | +| the command sections omit `telemetry` | 7 leaves | +| tracked state is the manifest and hash drift | a project now also carries `.aidd/config.json`, `aidd_docs/runs/`, a machine identity | +| the domain language has no measurement vocabulary | `kernel/measurement.ts` and telemetry's domain define terms the binary speaks | +| "gated by a GitHub token" | `guardRemoteAuth` gates private sources only; the default source is public | +| the framework resolves from GitHub Releases or a tarball | neither exists; the modes are remote git and local path | +| the bank is scaffolded by an `aidd-context` project-init skill | no such skill; it is `02-project-memory` | + +### testing.md + +| Finding | Evidence | +| --- | --- | +| names `scripts/refresh-framework-fixture.sh` | no such file anywhere in the repo | +| three tiers | four vitest projects; `architecture` is the fourth, with its own script | +| unit is domain and kernel, no I/O | `.unit.test.ts` files sit under `application/` and `infrastructure/`, one reading a fixture off disk | +| integration uses a real temp fs and never mocks the fs, manifest or hasher | 24 of 66 integration files use the in-memory doubles; 29 touch `mkdtemp` | +| `describe.concurrent()` required in e2e | 25 of 39 files use none | +| `try/finally` required in e2e | 12 files have none | +| list e2e files with `ls tests/e2e/*.e2e.test.ts` | the project glob is `tests/**`, so that misses the three golden suites | +| the e2e journey list | 39 files, 24 of them telemetry, none mentioned | +| `global-setup.ts` is a knip entry point | it is not in `knip.json`, and knip reports nothing unused | +| the sandbox rule covers smoke and dogfood | the e2e suite enforces it too, with its own measured PATH helpers and a guard test | +| machine independence is about absolute paths | a second axis is documented in the helpers: the sink lands under `AppData\Roaming` on Windows | +| `tests/fixtures/` holds two dirs | ten | +| 69/23/6/1 measured 2026-09-02 | 70.1/20.0/8.3/1.7 on 2026-09-04; the shape claim survives | +| five mutation scopes | eight | +| one mutation scope per context | three of the eight are not contexts | +| a single `pnpm smoke` | two: `smoke` is hermetic, `smoke:full` adds the remote section | +| restates the per-tool activation mechanism | `architecture.md` owns it; only the testing lesson belongs here | + +### vcs.md + +| Finding | Evidence | +| --- | --- | +| scopes `cli`, `domain`, `infra`, `install` | only `cli` is in `scope-enum`; the other three warn | +| example `feat(install): …` | that exact string warns | +| the type list omits `build` | config-conventional accepts it | +| branch format `type/ticket-short-description` | the repo bank says `type/short-description`, and no live branch carries a ticket | +| main branch is `main`, `next` unmentioned | every prefix but `hotfix/*` targets `next` | +| merging tags `vX.Y.Z` | this package tags `cli-v` | +| subject max 72 | the enforced gate is 100 | +| omits the `AIDD-Session-Id` trailer | installed by `telemetry on`, live on 14 of the last 30 commits here | +| omits what would justify a child page at all | that `cli` is this package's only scope, and that a `cli/` change releases `@ai-driven-dev/cli` alone | + +## Duplicated facts + +| Fact | Home | Copy | +| --- | --- | --- | +| the six runtime dependencies | `architecture.md` | `coding-assertions.md` | +| the git-hook tables | `coding-assertions.md` | `deployment.md` | +| Node `>=22.12`, dual publish | `deployment.md` | `cli.md` | +| token resolution order | `auth.md` | `architecture.md` | +| the manifest v6 guard | `architecture.md` | `codebase-map.md` | +| release tags | `deployment.md` | `vcs.md` | +| conventional commits, 72-char subject | the repo bank's `vcs.md` | `vcs.md` | +| platform, main branch, `gh` | the repo bank's `vcs.md` | `vcs.md` | +| per-tool native activation | `architecture.md` | `testing.md` | +| the Launchers decision | `architecture.md` | `codebase-map.md` | + +## Notes + +- Three defects found while verifying, none of them in the bank: + - `src/runtime/self-update/self-updater-adapter.ts:14` names `ai-driven-dev/aidd-cli` and + fetches `/releases/tags/v${version}`. The repository is `ai-driven-dev/framework` and the + tag is `cli-v`, so the changelog request always 404s, and the catch swallows it. + `aidd update` has never shown a changelog. + - `tests/architecture/referenced-paths.arch.test.ts:31` matches only paths prefixed + `src`, `tests`, `kernel`, `contexts`, `presentation` or `runtime`. A citation written + `application/…` or `infrastructure/…` is invisible to it, which is why three dead paths + survived in `auth.md` and a fourth in `testing.md`. + - `.claude/rules/` is in no guard's scope, and four rule files name the pre-refactor tree. + `07-quality/7-auth.md:24` names `RequireAuthUseCase`, a class no longer in `src/`. A rule + is injected into every session, so a stale one instructs rather than merely misinforms. +- `ecosystem.md` — the repo bank's own page carries the repository's tools. Whether the + package's page should exist beside it, carrying the four AI-tool binaries this CLI drives + and `gh`, is the one thing this run did not settle. diff --git a/cli/assets/marketplaces/default.json b/cli/assets/marketplaces/default.json deleted file mode 100644 index 05291c736..000000000 --- a/cli/assets/marketplaces/default.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "name": "aidd-framework", - "source": "https://github.com/ai-driven-dev/framework.git", - "type": "git" -} diff --git a/cli/biome-plugins/no-process-exit.grit b/cli/biome-plugins/no-process-exit.grit new file mode 100644 index 000000000..6218f7fa2 --- /dev/null +++ b/cli/biome-plugins/no-process-exit.grit @@ -0,0 +1,6 @@ +language js + +// Only the command edge turns a failure into an exit code; everything below it throws. +`process.exit($args)` where { + register_diagnostic(span=$args, message="process.exit belongs to the command edge: throw a typed error from kernel/errors.ts and let errorHandler decide the exit code", severity="error") +} diff --git a/cli/biome.json b/cli/biome.json index 0a6e7c1b1..c8a330c89 100644 --- a/cli/biome.json +++ b/cli/biome.json @@ -1,5 +1,5 @@ { - "$schema": "https://biomejs.dev/schemas/2.4.7/schema.json", + "$schema": "https://biomejs.dev/schemas/2.5.11/schema.json", "assist": { "actions": { "source": { @@ -10,16 +10,20 @@ "linter": { "enabled": true, "rules": { - "recommended": true, - "complexity": { - "noExcessiveLinesPerFunction": { - "level": "error", - "options": { - "maxLines": 20, - "skipBlankLines": true, - "skipIifes": true - } - } + "preset": "recommended", + "performance": { + "noBarrelFile": "error", + "noReExportAll": "error" + }, + "suspicious": { + "noImportCycles": "error", + "noTsIgnore": "error" + }, + "correctness": { + "noUnresolvedImports": "error" + }, + "style": { + "noExportedImports": "error" } } }, @@ -39,6 +43,7 @@ "includes": [ "**", "!**/dist", + "!**/.e2e-build", "!**/node_modules", "!**/example", "!**/temp", @@ -60,11 +65,37 @@ }, "overrides": [ { - "includes": ["tests/**", "scripts/**"], + "includes": ["src/kernel/**/*.ts"], "linter": { "rules": { - "complexity": { - "noExcessiveLinesPerFunction": "off" + "style": { + "noRestrictedImports": { + "level": "error", + "options": { + "patterns": [ + { + "group": [ + "**/domain/**", + "**/application/**", + "**/infrastructure/**", + "**/presentation/**", + "**/runtime/**" + ], + "message": "kernel must not import any context \u2014 it is the shared vocabulary contexts speak, not a consumer of one" + } + ] + } + } + } + } + } + }, + { + "includes": ["tests/helpers/**/*.ts"], + "linter": { + "rules": { + "performance": { + "noBarrelFile": "off" } } } @@ -76,39 +107,462 @@ } }, { - "includes": ["src/application/commands/**"], + "includes": ["src/contexts/framework/domain/**/*.ts"], + "linter": { + "rules": { + "style": { + "noRestrictedImports": { + "level": "error", + "options": { + "patterns": [ + { + "group": [ + "**/application/**", + "**/infrastructure/**", + "**/presentation/**", + "**/runtime/**" + ], + "message": "domain must not import application, infrastructure, presentation or runtime" + }, + { + "group": ["**/telemetry/**"], + "message": "framework may import translate, tools and distribution, never telemetry \u2014 see context-graph.arch.test.ts" + } + ] + } + } + } + } + } + }, + { + "includes": ["src/contexts/framework/application/**/*.ts"], + "linter": { + "rules": { + "style": { + "noRestrictedImports": { + "level": "error", + "options": { + "patterns": [ + { + "group": ["**/infrastructure/**"], + "message": "application must not import infrastructure \u2014 take a port and let the composition root supply the adapter" + }, + { + "group": ["**/telemetry/**"], + "message": "framework may import translate, tools and distribution, never telemetry \u2014 see context-graph.arch.test.ts" + } + ] + } + } + } + } + } + }, + { + "includes": ["src/contexts/framework/infrastructure/**/*.ts"], + "linter": { + "rules": { + "style": { + "noRestrictedImports": { + "level": "error", + "options": { + "patterns": [ + { + "group": ["**/telemetry/**", "**/presentation/**", "**/runtime/**"], + "message": "framework/infrastructure may import translate, tools and distribution, never telemetry, presentation or runtime \u2014 see context-graph.arch.test.ts" + } + ] + } + } + } + } + } + }, + { + "includes": ["src/contexts/translate/domain/**/*.ts"], + "linter": { + "rules": { + "style": { + "noRestrictedImports": { + "level": "error", + "options": { + "patterns": [ + { + "group": [ + "**/application/**", + "**/infrastructure/**", + "**/presentation/**", + "**/runtime/**" + ], + "message": "domain must not import application, infrastructure, presentation or runtime" + }, + { + "group": ["**/framework/**", "**/distribution/**", "**/telemetry/**"], + "message": "translate may import only the kernel and contexts/tools \u2014 see context-graph.arch.test.ts" + } + ] + } + } + } + } + } + }, + { + "includes": ["src/contexts/translate/application/**/*.ts"], + "linter": { + "rules": { + "style": { + "noRestrictedImports": { + "level": "error", + "options": { + "patterns": [ + { + "group": ["**/infrastructure/**"], + "message": "application must not import infrastructure \u2014 take a port and let the composition root supply the adapter" + }, + { + "group": [ + "**/framework/**", + "**/distribution/**", + "**/telemetry/**", + "**/presentation/**", + "**/runtime/**" + ], + "message": "translate may import only the kernel and contexts/tools \u2014 see context-graph.arch.test.ts" + } + ] + } + } + } + } + } + }, + { + "includes": ["src/contexts/translate/infrastructure/**/*.ts"], + "linter": { + "rules": { + "style": { + "noRestrictedImports": { + "level": "error", + "options": { + "patterns": [ + { + "group": [ + "**/framework/**", + "**/distribution/**", + "**/telemetry/**", + "**/presentation/**", + "**/runtime/**" + ], + "message": "translate may import only the kernel and contexts/tools \u2014 see context-graph.arch.test.ts" + } + ] + } + } + } + } + } + }, + { + "includes": ["src/contexts/tools/domain/**/*.ts"], + "linter": { + "rules": { + "style": { + "noRestrictedImports": { + "level": "error", + "options": { + "patterns": [ + { + "group": [ + "**/application/**", + "**/infrastructure/**", + "**/presentation/**", + "**/runtime/**" + ], + "message": "domain must not import application, infrastructure, presentation or runtime" + }, + { + "group": [ + "**/framework/**", + "**/translate/**", + "**/distribution/**", + "**/telemetry/**" + ], + "message": "tools imports no other context \u2014 every context that needs it imports tools, not the reverse (context-graph.arch.test.ts)" + } + ] + } + } + } + } + } + }, + { + "includes": ["src/contexts/tools/application/**/*.ts"], "linter": { "rules": { - "complexity": { - "noExcessiveLinesPerFunction": "off" + "style": { + "noRestrictedImports": { + "level": "error", + "options": { + "patterns": [ + { + "group": ["**/infrastructure/**"], + "message": "application must not import infrastructure \u2014 take a port and let the composition root supply the adapter" + }, + { + "group": [ + "**/framework/**", + "**/translate/**", + "**/distribution/**", + "**/telemetry/**", + "**/presentation/**", + "**/runtime/**" + ], + "message": "tools imports no other context \u2014 every context that needs it imports tools, not the reverse (context-graph.arch.test.ts)" + } + ] + } + } } } } }, { - "includes": [ - "src/application/use-cases/framework/strategies/tool-contracts.ts", - "src/application/use-cases/install/install-content-section-use-case.ts", - "src/application/use-cases/install/install-ide-config-use-case.ts", - "src/application/use-cases/marketplace/marketplace-sync-settings-use-case.ts", - "src/application/use-cases/plugin/plugin-add-use-case.ts", - "src/application/use-cases/plugin/plugin-install-from-marketplace-use-case.ts", - "src/application/use-cases/plugin/plugin-update-use-case.ts", - "src/application/use-cases/plugin/translator/built-tree-materialization-translator.ts", - "src/application/use-cases/restore/restore-all-plugins-use-case.ts", - "src/application/use-cases/shared/restore-regular-files-use-case.ts", - "src/domain/capabilities/plugins-capability.ts", - "src/domain/formats/copilot-marketplace-catalog.ts", - "src/domain/formats/jsonc.ts", - "src/domain/models/plugin-source.ts", - "src/domain/tools/ai/copilot.ts", - "src/infrastructure/adapters/plugin-distribution-reader-adapter.ts", - "src/infrastructure/deps.ts" - ], + "includes": ["src/contexts/tools/infrastructure/**/*.ts"], + "linter": { + "rules": { + "style": { + "noRestrictedImports": { + "level": "error", + "options": { + "patterns": [ + { + "group": [ + "**/framework/**", + "**/translate/**", + "**/distribution/**", + "**/telemetry/**", + "**/presentation/**", + "**/runtime/**" + ], + "message": "tools imports no other context \u2014 every context that needs it imports tools, not the reverse (context-graph.arch.test.ts)" + } + ] + } + } + } + } + } + }, + { + "includes": ["src/contexts/distribution/domain/**/*.ts"], + "linter": { + "rules": { + "style": { + "noRestrictedImports": { + "level": "error", + "options": { + "patterns": [ + { + "group": [ + "**/application/**", + "**/infrastructure/**", + "**/presentation/**", + "**/runtime/**" + ], + "message": "domain must not import application, infrastructure, presentation or runtime" + }, + { + "group": [ + "**/framework/**", + "**/translate/**", + "**/tools/**", + "**/telemetry/**", + "**/manifest.js" + ], + "message": "distribution knows no tool, no translation and no installation record \u2014 it says where content comes from, not what is done with it (context-graph.arch.test.ts)" + } + ] + } + } + } + } + } + }, + { + "includes": ["src/contexts/distribution/application/**/*.ts"], + "linter": { + "rules": { + "style": { + "noRestrictedImports": { + "level": "error", + "options": { + "patterns": [ + { + "group": ["**/infrastructure/**"], + "message": "application must not import infrastructure \u2014 take a port and let the composition root supply the adapter" + }, + { + "group": [ + "**/translate/**", + "**/tools/**", + "**/telemetry/**", + "**/presentation/**", + "**/runtime/**" + ], + "message": "distribution knows no tool, no translation and no installation record (context-graph.arch.test.ts)" + }, + { + "group": ["**/manifest.js"], + "message": "distribution reads a marketplace, never framework's installation record \u2014 marketplace-add-use-case.ts's own MarketplaceRemoveUseCase dependency is the one grandfathered exception (context-graph.arch.test.ts BASELINE), not a licence to read manifest.js too" + } + ] + } + } + } + } + } + }, + { + "includes": ["src/contexts/distribution/infrastructure/**/*.ts"], + "linter": { + "rules": { + "style": { + "noRestrictedImports": { + "level": "error", + "options": { + "patterns": [ + { + "group": [ + "**/framework/**", + "**/translate/**", + "**/tools/**", + "**/telemetry/**", + "**/presentation/**" + ], + "message": "distribution knows no tool, no translation and no installation record, and reaches no presentation (context-graph.arch.test.ts). Its runtime imports are the grandfathered exception recorded there as BASELINE, not extended here" + } + ] + } + } + } + } + } + }, + { + "includes": ["src/contexts/telemetry/domain/**/*.ts"], + "linter": { + "rules": { + "style": { + "noRestrictedImports": { + "level": "error", + "options": { + "patterns": [ + { + "group": [ + "**/application/**", + "**/infrastructure/**", + "**/presentation/**", + "**/runtime/**" + ], + "message": "domain must not import application, infrastructure, presentation or runtime" + }, + { + "group": ["**/framework/**", "**/translate/**", "**/distribution/**"], + "message": "telemetry may import only the kernel and contexts/tools \u2014 see context-graph.arch.test.ts" + } + ] + } + } + } + } + } + }, + { + "includes": ["src/contexts/telemetry/application/**/*.ts"], + "linter": { + "rules": { + "style": { + "noRestrictedImports": { + "level": "error", + "options": { + "patterns": [ + { + "group": ["**/infrastructure/**"], + "message": "application must not import infrastructure \u2014 take a port and let the composition root supply the adapter" + }, + { + "group": [ + "**/framework/**", + "**/translate/**", + "**/distribution/**", + "**/presentation/**", + "**/runtime/**" + ], + "message": "telemetry may import only the kernel and contexts/tools \u2014 see context-graph.arch.test.ts" + } + ] + } + } + } + } + } + }, + { + "includes": ["src/contexts/telemetry/infrastructure/**/*.ts"], + "linter": { + "rules": { + "style": { + "noRestrictedImports": { + "level": "error", + "options": { + "patterns": [ + { + "group": [ + "**/framework/**", + "**/translate/**", + "**/distribution/**", + "**/presentation/**", + "**/runtime/**" + ], + "message": "telemetry may import only the kernel and contexts/tools \u2014 see context-graph.arch.test.ts" + } + ] + } + } + } + } + } + }, + { + "includes": ["src/contexts/*/domain/**/*.ts", "src/contexts/*/application/**/*.ts"], + "linter": { + "rules": { + "style": { + "noRestrictedGlobals": { + "level": "error", + "options": { + "deniedGlobals": { + "process": "domain and application read no environment fact directly - take a port injected by the composition root, such as framework/domain/ports/environment.ts" + } + } + } + } + } + } + }, + { + "includes": ["src/kernel/**", "src/contexts/**", "src/runtime/**"], + "plugins": ["./biome-plugins/no-process-exit.grit"] + }, + { + "includes": ["src/**", "tests/**", "!**/*.d.ts"], "linter": { "rules": { - "complexity": { - "noExcessiveLinesPerFunction": "off" + "style": { + "noDefaultExport": "error" } } } diff --git a/cli/knip.json b/cli/knip.json index e8299f409..e34c01215 100644 --- a/cli/knip.json +++ b/cli/knip.json @@ -1,13 +1,9 @@ { - "entry": ["src/cli.ts", "scripts/check-bundle-size.mjs"], - "ignore": [ - "tests/**/helpers.ts", - "tests/helpers/**", - "tests/fixtures/**", - "tmp/**", - "src/domain/models/marketplace-entry.ts" - ], - "ignoreBinaries": ["gh", "icacls"], + "entry": ["tests/**/*.test.ts", "vitest.mutation.config.ts"], + "ignore": ["tests/**/helpers.ts", "tests/helpers/**", "tests/fixtures/**", "scripts/**/*.d.mts"], + "ignoreBinaries": ["icacls"], "ignoreExportsUsedInFile": true, - "ignoreDependencies": ["cli-table3", "gray-matter", "ink", "react"] + "lefthook": { + "config": ["../lefthook.yml"] + } } diff --git a/cli/mutation-scopes.json b/cli/mutation-scopes.json new file mode 100644 index 000000000..dfa9e4f57 --- /dev/null +++ b/cli/mutation-scopes.json @@ -0,0 +1,67 @@ +{ + "$comment": "The one declaration of what mutation testing covers and the floor each scope must hold. scripts/run-mutation.mjs runs a scope by name and fails below its break; tests/architecture/mutation-covers-source.arch.test.ts checks no source file falls outside both maps, that every scope declares a floor, and that package.json has a script per scope. Globs, never file lists: a list goes stale the day a file is added, and the score does not drop, because the mutants that would have died were never generated. A floor is raised to the measured score after a run, never lowered without the reason here. A scope's mutate is one glob or a list where a leading ! excludes; tools is split one scope per tool profile: a profile is a static declaration whose every mutant reruns each test that loads it, and the five together outlasted every other scope on a two-core runner; vscode's single file stays with the rest.", + "scopes": { + "kernel": { + "mutate": "src/kernel/**/*.ts", + "break": 71 + }, + "tools": { + "mutate": [ + "src/contexts/tools/**/*.ts", + "!src/contexts/tools/domain/profiles/claude/**/*.ts", + "!src/contexts/tools/domain/profiles/codex/**/*.ts", + "!src/contexts/tools/domain/profiles/copilot/**/*.ts", + "!src/contexts/tools/domain/profiles/cursor/**/*.ts", + "!src/contexts/tools/domain/profiles/opencode/**/*.ts" + ], + "break": 76 + }, + "telemetry": { + "mutate": "src/contexts/telemetry/**/*.ts", + "break": 75 + }, + "translate": { + "mutate": "src/contexts/translate/**/*.ts", + "break": 85 + }, + "distribution": { + "mutate": "src/contexts/distribution/**/*.ts", + "break": 77 + }, + "framework": { + "mutate": "src/contexts/framework/**/*.ts", + "break": 69 + }, + "presentation": { + "mutate": "src/presentation/**/*.ts", + "break": 95 + }, + "runtime": { + "mutate": "src/runtime/**/*.ts", + "break": 67 + }, + "tools-claude": { + "mutate": "src/contexts/tools/domain/profiles/claude/**/*.ts", + "break": 92 + }, + "tools-codex": { + "mutate": "src/contexts/tools/domain/profiles/codex/**/*.ts", + "break": 87 + }, + "tools-copilot": { + "mutate": "src/contexts/tools/domain/profiles/copilot/**/*.ts", + "break": 80 + }, + "tools-cursor": { + "mutate": "src/contexts/tools/domain/profiles/cursor/**/*.ts", + "break": 94 + }, + "tools-opencode": { + "mutate": "src/contexts/tools/domain/profiles/opencode/**/*.ts", + "break": 81 + } + }, + "excluded": { + "src/cli.ts": "The entry point. It registers commands and returns: no branch a mutant could change that a unit or integration test would see, and what proves it runs is the e2e suite, which mutation excludes because it spawns a built binary no mutant reaches." + } +} diff --git a/cli/package.json b/cli/package.json index f1d198cd4..5b8a025ce 100644 --- a/cli/package.json +++ b/cli/package.json @@ -36,37 +36,50 @@ "node": ">=22.12" }, "packageManager": "pnpm@12.3.4", - "bundleBudgetKB": 603, + "bundleBudgetKB": 654, "scripts": { "build": "tsup && node scripts/check-bundle-size.mjs", "build:check-size": "node scripts/check-bundle-size.mjs", "dev": "tsup --watch", - "test": "pnpm build && vitest run", + "test": "vitest run", + "test:arch": "vitest run --project=architecture", "test:unit": "vitest run --project=unit", "test:integration": "vitest run --project=integration", - "test:e2e": "pnpm build && vitest run --project=e2e", + "test:e2e": "vitest run --project=e2e", + "test:coverage": "vitest run --coverage", "test:kanban": "pnpm --dir ../kanban test", "test:watch": "vitest", "smoke": "pnpm build && bash scripts/smoke-tools.sh", + "smoke:full": "pnpm build && SMOKE_REMOTE=1 bash scripts/smoke-tools.sh", + "smoke:real": "pnpm build && bash scripts/smoke-real.sh", "typecheck": "tsc --noEmit", "lint": "biome check .", "format": "biome format --write .", - "knip:production": "knip --production --exclude exports,types", - "jscpd": "jscpd src/", + "jscpd": "jscpd src/ --threshold 3.3", "pack:local": "pnpm build && pnpm pack --pack-destination ./dist", "install:local": "pnpm run pack:local && npm install -g ./dist/ai-driven-dev-cli-$(node -p \"require('./package.json').version\").tgz --force", - "test:mutation": "stryker run", - "prepare": "lefthook install" + "test:mutation": "node scripts/run-mutation.mjs", + "test:mutation:kernel": "node scripts/run-mutation.mjs kernel", + "test:mutation:tools": "node scripts/run-mutation.mjs tools", + "test:mutation:tools-claude": "node scripts/run-mutation.mjs tools-claude", + "test:mutation:tools-codex": "node scripts/run-mutation.mjs tools-codex", + "test:mutation:tools-copilot": "node scripts/run-mutation.mjs tools-copilot", + "test:mutation:tools-cursor": "node scripts/run-mutation.mjs tools-cursor", + "test:mutation:tools-opencode": "node scripts/run-mutation.mjs tools-opencode", + "test:mutation:telemetry": "node scripts/run-mutation.mjs telemetry", + "test:mutation:translate": "node scripts/run-mutation.mjs translate", + "test:mutation:distribution": "node scripts/run-mutation.mjs distribution", + "test:mutation:framework": "node scripts/run-mutation.mjs framework", + "test:mutation:presentation": "node scripts/run-mutation.mjs presentation", + "test:mutation:runtime": "node scripts/run-mutation.mjs runtime", + "prepare": "lefthook install", + "knip": "knip" }, "dependencies": { "@inquirer/prompts": "^8.5.2", "ajv": "^8.20.0", "ajv-formats": "^3.0.1", - "cli-table3": "0.6.5", "commander": "^15.0.0", - "gray-matter": "^4.0.3", - "ink": "7.1.1", - "react": "19.2.8", "simple-git": "^3.36.0", "smol-toml": "^1.6.1" }, @@ -77,10 +90,8 @@ "@stryker-mutator/core": "^9.6.1", "@stryker-mutator/vitest-runner": "^9.6.1", "@types/node": "^26.1.1", - "@types/react": "19.2.18", "@vitest/coverage-v8": "^3.2.6", "fast-check": "^4.7.0", - "ink-testing-library": "4.0.0", "jscpd": "^5.0.0", "knip": "^6.0.0", "lefthook": "^2.1.10", diff --git a/cli/pnpm-lock.yaml b/cli/pnpm-lock.yaml index 1a4e96127..7c5fa503c 100644 --- a/cli/pnpm-lock.yaml +++ b/cli/pnpm-lock.yaml @@ -124,21 +124,9 @@ importers: ajv-formats: specifier: ^3.0.1 version: 3.0.1(ajv@8.20.0) - cli-table3: - specifier: 0.6.5 - version: 0.6.5 commander: specifier: ^15.0.0 version: 15.0.0 - gray-matter: - specifier: ^4.0.3 - version: 4.0.3 - ink: - specifier: 7.1.1 - version: 7.1.1(@types/react@19.2.18)(react@19.2.8) - react: - specifier: 19.2.8 - version: 19.2.8 simple-git: specifier: ^3.36.0 version: 3.36.0 @@ -164,18 +152,12 @@ importers: '@types/node': specifier: ^26.1.1 version: 26.4.0 - '@types/react': - specifier: 19.2.18 - version: 19.2.18 '@vitest/coverage-v8': specifier: ^3.2.6 version: 3.2.6(vitest@3.2.6(@types/node@26.4.0)) fast-check: specifier: ^4.7.0 version: 4.9.0 - ink-testing-library: - specifier: 4.0.0 - version: 4.0.0(@types/react@19.2.18) jscpd: specifier: ^5.0.0 version: 5.0.16 @@ -197,10 +179,6 @@ importers: packages: - '@alcalzone/ansi-tokenize@0.3.0': - resolution: {integrity: sha512-p+CMKJ93HFmLkjXKlXiVGlMQEuRb6H0MokBSwUsX+S6BRX8eV5naFZpQJFfJHjRZY0Hmnqy1/r6UWl3x+19zYA==} - engines: {node: '>=18'} - '@ampproject/remapping@2.3.0': resolution: {integrity: sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==} engines: {node: '>=6.0.0'} @@ -423,10 +401,6 @@ packages: cpu: [x64] os: [win32] - '@colors/colors@1.5.0': - resolution: {integrity: sha512-ooWCrlZP11i8GImSjTHYHLkvFDP48nS4+204nGb1RiX/WXYHmJA2III9/e2DWVabCESdW7hBAEzHRqUn9OUVvQ==} - engines: {node: '>=0.1.90'} - '@commitlint/cli@21.2.2': resolution: {integrity: sha512-a+6hQxIxnpdvSvS2apvttPNbEliYsVC3PqFYDiiB2kjbwIsQsj1urvQ4Tkf70pKYozPalKAuRQmm/GHwndduqA==} engines: {node: '>=22.12.0'} @@ -1386,9 +1360,6 @@ packages: '@types/node@26.4.0': resolution: {integrity: sha512-faiGnoIrLH/V8cibOMEAZ8pMw6oXqSukl29ra4mN8GdaB2ZewzeaLj+INpV5N+Z1eKWzY+IzaIZH2EIR6YZRNQ==} - '@types/react@19.2.18': - resolution: {integrity: sha512-AnzbBERsrLKtk2XSfTbYRLjQPdy116Sty4q+T+Bp3IC4l6jNBvreVPAHmpq9qhXQM7CXZPjLVmGMw9sy+hxQ3w==} - '@typescript/typescript-aix-ppc64@7.0.2': resolution: {integrity: sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ==} engines: {node: '>=16.20.0'} @@ -1570,10 +1541,6 @@ packages: resolution: {integrity: sha512-++nLNyZwRfHqFh7akH5Gw/JYizoFlMRz0KRigfwfsLqV8ZqlcVRb1LkPEWdYvEKDnbktknM2J4BXaYUGrQZPww==} engines: {node: '>= 14'} - ansi-escapes@7.3.0: - resolution: {integrity: sha512-BvU8nYgGQBxcmMuEeUEmNTvrMVjJNSH7RgW24vXexN4Ven6qCvy4TntnvlnwnMLTVlcRQQdbRY8NKnaIoeWDNg==} - engines: {node: '>=18'} - ansi-regex@5.0.1: resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} engines: {node: '>=8'} @@ -1593,9 +1560,6 @@ packages: any-promise@1.3.0: resolution: {integrity: sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==} - argparse@1.0.10: - resolution: {integrity: sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==} - argparse@2.0.1: resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} @@ -1610,10 +1574,6 @@ packages: ast-v8-to-istanbul@0.3.12: resolution: {integrity: sha512-BRRC8VRZY2R4Z4lFIL35MwNXmwVqBityvOIwETtsCSwvjl0IdgFsy9NhdaA6j74nUdtJJlIypeRhpDam19Wq3g==} - auto-bind@5.0.1: - resolution: {integrity: sha512-ooviqdwwgfIfNmDwo94wlshcdzfO64XV0Cg6oDsDYBJfITDz1EngD2z7DkbvCWn+XIMsIqW27sEVF6qcpJrRcg==} - engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} - balanced-match@1.0.2: resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} @@ -1682,22 +1642,6 @@ packages: resolution: {integrity: sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==} engines: {node: '>= 14.16.0'} - cli-boxes@4.0.1: - resolution: {integrity: sha512-5IOn+jcCEHEraYolBPs/sT4BxYCe2nHg374OPiItB1O96KZFseS2gthU4twyYzeDcFew4DaUM/xwc5BQf08JJw==} - engines: {node: '>=18.20 <19 || >=20.10'} - - cli-cursor@4.0.0: - resolution: {integrity: sha512-VGtlMu3x/4DOtIUwEkRezxUZ2lBacNJCHash0N0WeZDBS+7Ux1dm3XWAgWYxLJFMMdOeXMHXorshEFhbMSGelg==} - engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} - - cli-table3@0.6.5: - resolution: {integrity: sha512-+W/5efTR7y5HRD7gACw9yQjqMVvEMLBHmboM/kPWam+H+Hmyrgjh6YncVKK122YZkXrLudzTuAukUw9FnMf7IQ==} - engines: {node: 10.* || >= 12.*} - - cli-truncate@6.1.1: - resolution: {integrity: sha512-06p9vyLahLa4zkGcgsGxU6iEkSOiuI4fhCH6Emhe2lPAcoUv73n72DnODsnHA+5wwXGnV0n9M9/qOQJSjYhFhw==} - engines: {node: '>=22'} - cli-width@4.1.0: resolution: {integrity: sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ==} engines: {node: '>= 12'} @@ -1706,10 +1650,6 @@ packages: resolution: {integrity: sha512-k7ndgKhwoQveBL+/1tqGJYNz097I7WOvwbmmU2AR5+magtbjPWQTS1C5vzGkBC8Ym8UWRzfKUzUUqFLypY4Q+w==} engines: {node: '>=20'} - code-excerpt@4.0.0: - resolution: {integrity: sha512-xxodCmBen3iy2i0WtAK8FlFNrRzjUqjRsMfho58xT/wvZU1YTM3fCnRjcy1gJPMepaRlgm/0e6w8SpWHpn3/cA==} - engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} - color-convert@2.0.1: resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} engines: {node: '>=7.0.0'} @@ -1757,10 +1697,6 @@ packages: convert-source-map@2.0.0: resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} - convert-to-spaces@2.0.1: - resolution: {integrity: sha512-rcQ1bsQO9799wq24uE5AM2tAILy4gXGIK/njFWcVQkGNZ96edlpY+A7bjwvzjYvLDyzmG1MmMLZhpcsb+klNMQ==} - engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} - cosmiconfig-typescript-loader@6.3.0: resolution: {integrity: sha512-Akr82WH1Wfqatyiqpj8HDkO2o2KmJRu1FhKfSNJP3K4IdXwHfEyL7MOb62i1AGQVLtIQM+iCE9CGOtrfhR+mmA==} engines: {node: '>=v18'} @@ -1782,9 +1718,6 @@ packages: resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} engines: {node: '>= 8'} - csstype@3.2.3: - resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} - debug@4.4.3: resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} engines: {node: '>=6.0'} @@ -1827,10 +1760,6 @@ packages: resolution: {integrity: sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==} engines: {node: '>=6'} - environment@1.1.0: - resolution: {integrity: sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q==} - engines: {node: '>=18'} - error-ex@1.3.4: resolution: {integrity: sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==} @@ -1849,9 +1778,6 @@ packages: resolution: {integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==} engines: {node: '>= 0.4'} - es-toolkit@1.50.0: - resolution: {integrity: sha512-OyZKhUVvEep9ITEiwHn8GKnMRQIVqoSIX7WnRbkWgJkllCujilqP2rD0u979tkl8wqyc8ICwlc1UBVv/Sl1G6w==} - es-toolkit@1.51.0: resolution: {integrity: sha512-zC2lQGkM7QX+Gm6iM3+WIdZJzthsEd14LvRNJneSO2hzyz/zNBENR8+YXWo1cKxgPBtV6ksPYHELbcwBRzmdCw==} @@ -1869,15 +1795,6 @@ packages: resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} engines: {node: '>=6'} - escape-string-regexp@2.0.0: - resolution: {integrity: sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w==} - engines: {node: '>=8'} - - esprima@4.0.1: - resolution: {integrity: sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==} - engines: {node: '>=4'} - hasBin: true - estree-walker@3.0.3: resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} @@ -1889,10 +1806,6 @@ packages: resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==} engines: {node: '>=12.0.0'} - extend-shallow@2.0.1: - resolution: {integrity: sha512-zCnTtlxNoAiDc3gqY2aYAWFx7XWWiasuF2K8Me5WbN8otHKTUKBwjPtNpRs/rbUZm7KxWAaNj7P1a/p52GbVug==} - engines: {node: '>=0.10.0'} - fast-check@4.9.0: resolution: {integrity: sha512-7ms6T7SybUev/PQITciI0yLM2pOSFy5zpG8Ty7tQofcVaQUvrMXp6CBwqF6fThLCLOrfBtuHAtwq6Yu4XPCllg==} engines: {node: '>=12.17.0'} @@ -1988,10 +1901,6 @@ packages: resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} engines: {node: '>= 0.4'} - gray-matter@4.0.3: - resolution: {integrity: sha512-5v6yZd4JK3eMI3FqqCouswVqwugaA9r4dNZB1wwcmrD02QkV5H0y7XBQW8QwQqEaZY1pM9aqORSORhJRdNK44Q==} - engines: {node: '>=6.0'} - has-flag@4.0.0: resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} engines: {node: '>=8'} @@ -2019,10 +1928,6 @@ packages: resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==} engines: {node: '>=6'} - indent-string@5.0.0: - resolution: {integrity: sha512-m6FAo/spmsW2Ab2fU35JTYwtOKa2yAwXSwgjSv1TJzh4Mh7mC3lzAOVLBprb72XsTrgkEIsl7YrFNAiDiRhIGg==} - engines: {node: '>=12'} - inherits@2.0.4: resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} @@ -2030,48 +1935,13 @@ packages: resolution: {integrity: sha512-IBTdIkzZNOpqm7q3dRqJvMaldXjDHWkEDfrwGEQTs5eaQMWV+djAhR+wahyNNMAa+qpbDUhBMVt4ZKNwpPm7xQ==} engines: {node: ^20.17.0 || >=22.9.0} - ink-testing-library@4.0.0: - resolution: {integrity: sha512-yF92kj3pmBvk7oKbSq5vEALO//o7Z9Ck/OaLNlkzXNeYdwfpxMQkSowGTFUCS5MSu9bWfSZMewGpp7bFc66D7Q==} - engines: {node: '>=18'} - peerDependencies: - '@types/react': '>=18.0.0' - peerDependenciesMeta: - '@types/react': - optional: true - - ink@7.1.1: - resolution: {integrity: sha512-Y43xxa1ZSPvpmfLHcN5o+OdP8Rf8ykkNJEuKYOUNZKT8wXVNLFTtEm1nSDMQkfBH+YANF4Xuu0hhZ4ejqAtN2w==} - engines: {node: '>=22'} - peerDependencies: - '@types/react': '>=19.2.0' - react: '>=19.2.0' - react-devtools-core: '>=6.1.2' - peerDependenciesMeta: - '@types/react': - optional: true - react-devtools-core: - optional: true - is-arrayish@0.2.1: resolution: {integrity: sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==} - is-extendable@0.1.1: - resolution: {integrity: sha512-5BMULNob1vgFX6EjQw5izWDxrecWK9AM72rugNr0TFldMOi0fj6Jk+zeKIt0xGj4cEfQIJth4w3OKWOJ4f+AFw==} - engines: {node: '>=0.10.0'} - is-fullwidth-code-point@3.0.0: resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==} engines: {node: '>=8'} - is-fullwidth-code-point@5.1.0: - resolution: {integrity: sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ==} - engines: {node: '>=18'} - - is-in-ci@2.0.0: - resolution: {integrity: sha512-cFeerHriAnhrQSbpAxL37W1wcJKUUX07HyLWZCW1URJT/ra3GyUTzBgUnh24TMVfNTV2Hij2HLxkPHFZfOZy5w==} - engines: {node: '>=20'} - hasBin: true - is-plain-obj@4.1.0: resolution: {integrity: sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==} engines: {node: '>=12'} @@ -2130,10 +2000,6 @@ packages: js-tokens@9.0.1: resolution: {integrity: sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==} - js-yaml@3.15.0: - resolution: {integrity: sha512-ttBQIIQPDeLjpPOohtUdXuXUVoA2uIB6fEH9HyJ7234s5mBJ5wTx20njxplLZQgLaOfpmPQA7X2t5AX6tIPbog==} - hasBin: true - js-yaml@4.3.1: resolution: {integrity: sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ==} hasBin: true @@ -2192,10 +2058,6 @@ packages: engines: {node: '>=6'} hasBin: true - kind-of@6.0.3: - resolution: {integrity: sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==} - engines: {node: '>=0.10.0'} - knip@6.33.0: resolution: {integrity: sha512-gMXWV2bqgJcdZKsaRgl1oH5V2J0VumhJ2ujfP4M6b9ftpca2BNpvlX3Dq++vVtEey7sU67EXCvZGNkQfG2w1RQ==} engines: {node: ^20.19.0 || >=22.12.0} @@ -2292,10 +2154,6 @@ packages: resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} engines: {node: '>= 0.4'} - mimic-fn@2.1.0: - resolution: {integrity: sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==} - engines: {node: '>=6'} - minimalistic-assert@1.0.1: resolution: {integrity: sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==} @@ -2357,10 +2215,6 @@ packages: resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==} engines: {node: '>= 0.4'} - onetime@5.1.2: - resolution: {integrity: sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==} - engines: {node: '>=6'} - oxc-parser@0.147.0: resolution: {integrity: sha512-5xaug6t7GfV3BO5Iv+xHW1rmQkDEQ3BEu3L8g3InsvWO5i8CYGc4tCZ2X985QcwWNycFJam+aOns6Nr2XAThTA==} engines: {node: ^20.19.0 || >=22.12.0} @@ -2386,10 +2240,6 @@ packages: resolution: {integrity: sha512-TXfryirbmq34y8QBwgqCVLi+8oA3oWx2eAnSn62ITyEhEYaWRlVZ2DvMM9eZbMs/RfxPu/PK/aBLyGj4IrqMHw==} engines: {node: '>=18'} - patch-console@2.0.0: - resolution: {integrity: sha512-0YNdUceMdaQwoKce1gatDScmMo5pu/tfABfnzEqeG0gtTmd7mh/WcwgUjtAeOU7N8nFFlbQBnFK2gXW5fGvmMA==} - engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} - path-key@3.1.1: resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} engines: {node: '>=8'} @@ -2464,16 +2314,6 @@ packages: resolution: {integrity: sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==} engines: {node: '>=0.6'} - react-reconciler@0.33.0: - resolution: {integrity: sha512-KetWRytFv1epdpJc3J4G75I4WrplZE5jOL7Yq0p34+OVOKF4Se7WrdIdVC45XsSSmUTlht2FM/fM1FZb1mfQeA==} - engines: {node: '>=0.10.0'} - peerDependencies: - react: ^19.2.0 - - react@19.2.8: - resolution: {integrity: sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw==} - engines: {node: '>=0.10.0'} - readdirp@4.1.2: resolution: {integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==} engines: {node: '>= 14.18.0'} @@ -2493,10 +2333,6 @@ packages: resolve-pkg-maps@1.0.0: resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==} - restore-cursor@4.0.0: - resolution: {integrity: sha512-I9fPXU9geO9bHOt9pHHOhOkYerIMsmVaWB0rA2AI9ERh/+x/i7MV5HKBNrg+ljO5eoPVgCcnFuRjJ9uH6I/3eg==} - engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} - rollup@4.59.0: resolution: {integrity: sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==} engines: {node: '>=18.0.0', npm: '>=8.0.0'} @@ -2508,13 +2344,6 @@ packages: safer-buffer@2.1.2: resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} - scheduler@0.27.0: - resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==} - - section-matter@1.0.0: - resolution: {integrity: sha512-vfD3pmTzGpufjScBh50YHKzEu2lxBWhVEHsNGoEXmCmn2hKGfeNLYMzCJpe8cD7gqX7TJluOVpBkAequ6dgMmA==} - engines: {node: '>=4'} - semver@6.3.1: resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} hasBin: true @@ -2556,9 +2385,6 @@ packages: siginfo@2.0.0: resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} - signal-exit@3.0.7: - resolution: {integrity: sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==} - signal-exit@4.1.0: resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} engines: {node: '>=14'} @@ -2566,10 +2392,6 @@ packages: simple-git@3.36.0: resolution: {integrity: sha512-cGQjLjK8bxJw4QuYT7gxHw3/IouVESbhahSsHrX97MzCL1gu2u7oy38W6L2ZIGECEfIBG4BabsWDPjBxJENv9Q==} - slice-ansi@9.0.0: - resolution: {integrity: sha512-SO/3iYL5S3W57LLEniscOGPZgOqZUPCx6d3dB+52B80yJ0XstzsC/eV8gnA4tM3MHDrKz+OCFSLNjswdSC+/bA==} - engines: {node: '>=22'} - smol-toml@1.8.0: resolution: {integrity: sha512-kCZr2V3ch9i00x8zXRhjUNVcjG9ijES5dDudkXvUVCT5QlJNQWElSJdZqyPemffHoLNUYwOcou0Fy+ojN0uHSQ==} engines: {node: '>= 18'} @@ -2582,13 +2404,6 @@ packages: resolution: {integrity: sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ==} engines: {node: '>= 12'} - sprintf-js@1.0.3: - resolution: {integrity: sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==} - - stack-utils@2.0.6: - resolution: {integrity: sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ==} - engines: {node: '>=10'} - stackback@0.0.2: resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} @@ -2619,10 +2434,6 @@ packages: resolution: {integrity: sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==} engines: {node: '>=12'} - strip-bom-string@1.0.0: - resolution: {integrity: sha512-uCC2VHvQRYu+lMh4My/sFNmF2klFymLX1wHJeXnbEJERpV/ZsVuonzerjfrGpIGF7LBVa1O7i9kjiWvJiFck8g==} - engines: {node: '>=0.10.0'} - strip-final-newline@4.0.0: resolution: {integrity: sha512-aulFJcD6YK8V1G7iRB5tigAP4TsHBZZrOV8pjV++zdUwmeV8uzbY7yn6h9MswN62adStNZFuCIx4haBnRuMDaw==} engines: {node: '>=18'} @@ -2643,14 +2454,6 @@ packages: resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} engines: {node: '>=8'} - tagged-tag@1.0.0: - resolution: {integrity: sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng==} - engines: {node: '>=20'} - - terminal-size@4.0.1: - resolution: {integrity: sha512-avMLDQpUI9I5XFrklECw1ZEUPJhqzcwSWsyyI8blhRLT+8N1jLJWLWWYQpB2q2xthq8xDvjZPISVh53T/+CLYQ==} - engines: {node: '>=18'} - test-exclude@7.0.2: resolution: {integrity: sha512-u9E6A+ZDYdp7a4WnarkXPZOx8Ilz46+kby6p1yZ8zsGTz9gYa6FIS7lj2oezzNKmtdyyJNNmmXDppga5GB7kSw==} engines: {node: '>=18'} @@ -2725,10 +2528,6 @@ packages: resolution: {integrity: sha512-1h/Lnq9yajKY2PEbBadPXj3VxsDDu844OnaAo52UVmIzIvwwtBPIuNvkjuzBlTWpfJyUbG3ez0KSBibQkj4ojg==} engines: {node: '>=0.6.11 <=0.7.0 || >=0.7.3'} - type-fest@5.8.0: - resolution: {integrity: sha512-YGYEVz3Fm5iy/AybuA0oyNFq7H4CgQNfRp/qfe8nurE1kuCeNm3/vfm9X4Mtl+qLyaKJUh5xrFZwogr41SMjYA==} - engines: {node: '>=20'} - typed-inject@5.0.0: resolution: {integrity: sha512-0Ql2ORqBORLMdAW89TQKZsb1PQkFGImFfVmncXWe7a+AA3+7dh7Se9exxZowH4kbnlvKEFkMxUYdHUpjYWFJaA==} engines: {node: '>=18'} @@ -2846,14 +2645,6 @@ packages: engines: {node: '>=8'} hasBin: true - widest-line@6.0.0: - resolution: {integrity: sha512-U89AsyEeAsyoF0zVJBkG9zBgekjgjK7yk9sje3F4IQpXBJ10TF6ByLlIfjMhcmHMJgHZI4KHt4rdNfktzxIAMA==} - engines: {node: '>=20'} - - wrap-ansi@10.0.0: - resolution: {integrity: sha512-SGcvg80f0wUy2/fXES19feHMz8E0JoXv2uNgHOu4Dgi2OrCy1lqwFYEJz1BLbDI0exjPMe/ZdzZ/YpGECBG/aQ==} - engines: {node: '>=20'} - wrap-ansi@7.0.0: resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} engines: {node: '>=10'} @@ -2866,18 +2657,6 @@ packages: resolution: {integrity: sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==} engines: {node: '>=18'} - ws@8.21.1: - resolution: {integrity: sha512-+0NTnW77fFN/DjQi6k/Sq/Yvk4Sgajw7urW8V+asjXnRgDs9gyGkdb7EzgfhA4goXsRIZKE28fzIXBHEzhuiWw==} - engines: {node: '>=10.0.0'} - peerDependencies: - bufferutil: ^4.0.1 - utf-8-validate: '>=5.0.2' - peerDependenciesMeta: - bufferutil: - optional: true - utf-8-validate: - optional: true - y18n@5.0.8: resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==} engines: {node: '>=10'} @@ -2902,19 +2681,11 @@ packages: resolution: {integrity: sha512-CzhO+pFNo8ajLM2d2IW/R93ipy99LWjtwblvC1RsoSUMZgyLbYFr221TnSNT7GjGdYui6P459mw9JH/g/zW2ug==} engines: {node: '>=18'} - yoga-layout@3.2.1: - resolution: {integrity: sha512-0LPOt3AxKqMdFBZA3HBAt/t/8vIKq7VaQYbuA8WxCgung+p9TVyKRYdpvCb80HcdTN2NkbIKbhNwKUfm3tQywQ==} - zod@4.5.4: resolution: {integrity: sha512-sC95tT5iHHH9gtpj6A81kh+NEaRAUFN+qlUPDUbRfOMvNf5QCBqsb3WgvnpVtK5Y+4UfA6KqufotuTvMGiTlsA==} snapshots: - '@alcalzone/ansi-tokenize@0.3.0': - dependencies: - ansi-styles: 6.2.3 - is-fullwidth-code-point: 5.1.0 - '@ampproject/remapping@2.3.0': dependencies: '@jridgewell/gen-mapping': 0.3.13 @@ -3181,9 +2952,6 @@ snapshots: '@biomejs/cli-win32-x64@2.5.11': optional: true - '@colors/colors@1.5.0': - optional: true - '@commitlint/cli@21.2.2(@types/node@26.4.0)(conventional-commits-parser@7.1.2)(typescript@7.0.2)': dependencies: '@commitlint/config-conventional': 21.2.2 @@ -3929,10 +3697,6 @@ snapshots: dependencies: undici-types: 8.3.0 - '@types/react@19.2.18': - dependencies: - csstype: 3.2.3 - '@typescript/typescript-aix-ppc64@7.0.2': optional: true @@ -4076,10 +3840,6 @@ snapshots: angular-html-parser@10.4.0: {} - ansi-escapes@7.3.0: - dependencies: - environment: 1.1.0 - ansi-regex@5.0.1: {} ansi-regex@6.2.2: {} @@ -4092,10 +3852,6 @@ snapshots: any-promise@1.3.0: {} - argparse@1.0.10: - dependencies: - sprintf-js: 1.0.3 - argparse@2.0.1: {} argue-cli@3.1.0: {} @@ -4108,8 +3864,6 @@ snapshots: estree-walker: 3.0.3 js-tokens: 10.0.0 - auto-bind@5.0.1: {} - balanced-match@1.0.2: {} balanced-match@4.0.4: {} @@ -4171,23 +3925,6 @@ snapshots: dependencies: readdirp: 4.1.2 - cli-boxes@4.0.1: {} - - cli-cursor@4.0.0: - dependencies: - restore-cursor: 4.0.0 - - cli-table3@0.6.5: - dependencies: - string-width: 4.2.3 - optionalDependencies: - '@colors/colors': 1.5.0 - - cli-truncate@6.1.1: - dependencies: - slice-ansi: 9.0.0 - string-width: 8.2.2 - cli-width@4.1.0: {} cliui@9.0.1: @@ -4196,10 +3933,6 @@ snapshots: strip-ansi: 7.2.0 wrap-ansi: 9.0.2 - code-excerpt@4.0.0: - dependencies: - convert-to-spaces: 2.0.1 - color-convert@2.0.1: dependencies: color-name: 1.1.4 @@ -4236,8 +3969,6 @@ snapshots: convert-source-map@2.0.0: {} - convert-to-spaces@2.0.1: {} - cosmiconfig-typescript-loader@6.3.0(@types/node@26.4.0)(cosmiconfig@9.0.2(typescript@7.0.2))(typescript@7.0.2): dependencies: '@types/node': 26.4.0 @@ -4260,8 +3991,6 @@ snapshots: shebang-command: 2.0.0 which: 2.0.2 - csstype@3.2.3: {} - debug@4.4.3: dependencies: ms: 2.1.3 @@ -4293,8 +4022,6 @@ snapshots: env-paths@2.2.1: {} - environment@1.1.0: {} - error-ex@1.3.4: dependencies: is-arrayish: 0.2.1 @@ -4309,8 +4036,6 @@ snapshots: dependencies: es-errors: 1.3.0 - es-toolkit@1.50.0: {} - es-toolkit@1.51.0: {} esbuild@0.21.5: @@ -4370,10 +4095,6 @@ snapshots: escalade@3.2.0: {} - escape-string-regexp@2.0.0: {} - - esprima@4.0.1: {} - estree-walker@3.0.3: dependencies: '@types/estree': 1.0.8 @@ -4395,10 +4116,6 @@ snapshots: expect-type@1.3.0: {} - extend-shallow@2.0.1: - dependencies: - is-extendable: 0.1.1 - fast-check@4.9.0: dependencies: pure-rand: 8.4.2 @@ -4498,13 +4215,6 @@ snapshots: gopd@1.2.0: {} - gray-matter@4.0.3: - dependencies: - js-yaml: 3.15.0 - kind-of: 6.0.3 - section-matter: 1.0.0 - strip-bom-string: 1.0.0 - has-flag@4.0.0: {} has-symbols@1.1.0: {} @@ -4526,62 +4236,14 @@ snapshots: parent-module: 1.0.1 resolve-from: 4.0.0 - indent-string@5.0.0: {} - inherits@2.0.4: {} ini@6.0.0: {} - ink-testing-library@4.0.0(@types/react@19.2.18): - optionalDependencies: - '@types/react': 19.2.18 - - ink@7.1.1(@types/react@19.2.18)(react@19.2.8): - dependencies: - '@alcalzone/ansi-tokenize': 0.3.0 - ansi-escapes: 7.3.0 - ansi-styles: 6.2.3 - auto-bind: 5.0.1 - chalk: 5.6.2 - cli-boxes: 4.0.1 - cli-cursor: 4.0.0 - cli-truncate: 6.1.1 - code-excerpt: 4.0.0 - es-toolkit: 1.50.0 - indent-string: 5.0.0 - is-in-ci: 2.0.0 - patch-console: 2.0.0 - react: 19.2.8 - react-reconciler: 0.33.0(react@19.2.8) - scheduler: 0.27.0 - signal-exit: 3.0.7 - slice-ansi: 9.0.0 - stack-utils: 2.0.6 - string-width: 8.2.2 - terminal-size: 4.0.1 - type-fest: 5.8.0 - widest-line: 6.0.0 - wrap-ansi: 10.0.0 - ws: 8.21.1 - yoga-layout: 3.2.1 - optionalDependencies: - '@types/react': 19.2.18 - transitivePeerDependencies: - - bufferutil - - utf-8-validate - is-arrayish@0.2.1: {} - is-extendable@0.1.1: {} - is-fullwidth-code-point@3.0.0: {} - is-fullwidth-code-point@5.1.0: - dependencies: - get-east-asian-width: 1.6.0 - - is-in-ci@2.0.0: {} - is-plain-obj@4.1.0: {} is-stream@4.0.1: {} @@ -4631,11 +4293,6 @@ snapshots: js-tokens@9.0.1: {} - js-yaml@3.15.0: - dependencies: - argparse: 1.0.10 - esprima: 4.0.1 - js-yaml@4.3.1: dependencies: argparse: 2.0.1 @@ -4677,8 +4334,6 @@ snapshots: json5@2.2.3: {} - kind-of@6.0.3: {} - knip@6.33.0: dependencies: fdir: 6.5.0(picomatch@4.0.7) @@ -4770,8 +4425,6 @@ snapshots: math-intrinsics@1.1.0: {} - mimic-fn@2.1.0: {} - minimalistic-assert@1.0.1: {} minimatch@10.2.4: @@ -4826,10 +4479,6 @@ snapshots: object-inspect@1.13.4: {} - onetime@5.1.2: - dependencies: - mimic-fn: 2.1.0 - oxc-parser@0.147.0: dependencies: '@oxc-project/types': 0.147.0 @@ -4893,8 +4542,6 @@ snapshots: parse-ms@4.0.0: {} - patch-console@2.0.0: {} - path-key@3.1.1: {} path-key@4.0.0: {} @@ -4948,13 +4595,6 @@ snapshots: dependencies: side-channel: 1.1.0 - react-reconciler@0.33.0(react@19.2.8): - dependencies: - react: 19.2.8 - scheduler: 0.27.0 - - react@19.2.8: {} - readdirp@4.1.2: {} require-from-string@2.0.2: {} @@ -4965,11 +4605,6 @@ snapshots: resolve-pkg-maps@1.0.0: {} - restore-cursor@4.0.0: - dependencies: - onetime: 5.1.2 - signal-exit: 3.0.7 - rollup@4.59.0: dependencies: '@types/estree': 1.0.8 @@ -5007,13 +4642,6 @@ snapshots: safer-buffer@2.1.2: {} - scheduler@0.27.0: {} - - section-matter@1.0.0: - dependencies: - extend-shallow: 2.0.1 - kind-of: 6.0.3 - semver@6.3.1: {} semver@7.7.4: {} @@ -5056,8 +4684,6 @@ snapshots: siginfo@2.0.0: {} - signal-exit@3.0.7: {} - signal-exit@4.1.0: {} simple-git@3.36.0: @@ -5070,23 +4696,12 @@ snapshots: transitivePeerDependencies: - supports-color - slice-ansi@9.0.0: - dependencies: - ansi-styles: 6.2.3 - is-fullwidth-code-point: 5.1.0 - smol-toml@1.8.0: {} source-map-js@1.2.1: {} source-map@0.7.6: {} - sprintf-js@1.0.3: {} - - stack-utils@2.0.6: - dependencies: - escape-string-regexp: 2.0.0 - stackback@0.0.2: {} std-env@3.10.0: {} @@ -5122,8 +4737,6 @@ snapshots: dependencies: ansi-regex: 6.2.2 - strip-bom-string@1.0.0: {} - strip-final-newline@4.0.0: {} strip-json-comments@5.0.3: {} @@ -5146,10 +4759,6 @@ snapshots: dependencies: has-flag: 4.0.0 - tagged-tag@1.0.0: {} - - terminal-size@4.0.1: {} - test-exclude@7.0.2: dependencies: '@istanbuljs/schema': 0.1.3 @@ -5222,10 +4831,6 @@ snapshots: tunnel@0.0.6: {} - type-fest@5.8.0: - dependencies: - tagged-tag: 1.0.0 - typed-inject@5.0.0: {} typed-rest-client@2.3.1: @@ -5353,16 +4958,6 @@ snapshots: siginfo: 2.0.0 stackback: 0.0.2 - widest-line@6.0.0: - dependencies: - string-width: 8.2.2 - - wrap-ansi@10.0.0: - dependencies: - ansi-styles: 6.2.3 - string-width: 8.2.2 - strip-ansi: 7.2.0 - wrap-ansi@7.0.0: dependencies: ansi-styles: 4.3.0 @@ -5381,8 +4976,6 @@ snapshots: string-width: 7.2.0 strip-ansi: 7.2.0 - ws@8.21.1: {} - y18n@5.0.8: {} yallist@3.1.1: {} @@ -5402,6 +4995,4 @@ snapshots: yoctocolors@2.1.2: {} - yoga-layout@3.2.1: {} - zod@4.5.4: {} diff --git a/cli/scripts/check-bundle-size.mjs b/cli/scripts/check-bundle-size.mjs index 4a8f7c00b..69a7864aa 100644 --- a/cli/scripts/check-bundle-size.mjs +++ b/cli/scripts/check-bundle-size.mjs @@ -5,30 +5,23 @@ import { fileURLToPath } from "node:url"; const root = resolve(fileURLToPath(import.meta.url), "../.."); const pkg = JSON.parse(readFileSync(resolve(root, "package.json"), "utf8")); -// The budget exists to make growth visible, not to be a wall: it is raised -// deliberately when a feature earns it, and the raise is what a reviewer sees. -// 560 was set when measurement across five tools took the bundle to 500.8 KB. -// 590 was set when resolving one person across tools and machines (#661) took -// the bundle to 567.7 KB - tighter headroom than the 560 raise left, on -// purpose, rather than padding past what was actually measured. -// 593 was set when `by_prompt` joined the breakdowns: measured 588.4 -> 590.6 KB, -// +2.2 KB for the axis no host limit can empty. Same tight headroom. -// 596 was set when `by_agent` learned to tell a main thread from a tool that never -// names an agent: measured 592.0 -> 593.8 KB across two changes, the flow axis's own -// tool-stated row included. Same 2.2 KB headroom the raise before it left. -// 598 was set when the journal reader began reading the schema a journal states it was -// written under, and the diagnostic gained the reason for refusing one: measured -// 594.3 -> 595.8 KB. Same 2.2 KB headroom as the two raises before it. -// 601 was set when `aidd ai rules` took over the rule inventory the explore skill used to -// run as its own script: measured 596.6 -> 599.0 KB, +2.4 KB for a use case, a model, a -// display and the subcommand. It deletes 198 lines from a plugin, which the bundle does -// not carry either way - the trade is a plugin script that had drifted for bytes that are -// measured. Same 2.2 KB headroom as the three raises before it. -// 603 was set when a marketplace registry that cannot be read began saying so: measured -// 600.7 -> 601.2 KB, +0.5 KB for one guard and the sentence it prints. The smallest raise so -// far, for the smallest change - and the one that showed the budget had 0.3 KB of headroom -// left, which is less than a correctness fix costs. Same 2.2 KB headroom as the four raises -// before it. +// The budget makes growth visible rather than walling it off: a raise is deliberate, is +// what a reviewer sees, and leaves ~2 % headroom over what was measured, never more. +// The registry of every raise, budget then measurement then what landed: +// 560 KB: 500.8 KB, measurement across five tools. +// 590 KB: 567.7 KB, one person resolved across tools and machines. +// 593 KB: 590.6 KB, the `by_prompt` breakdown. +// 596 KB: 593.8 KB, `by_agent` telling a main thread from a tool that names no agent. +// 598 KB: 595.8 KB, the journal reader on a journal's stated schema, plus the refusal reason. +// 601 KB: 599.0 KB, `aidd ai rules` taking over the rule inventory. +// 557 KB: 545.7 KB, a reset measured against a stale lockfile, remeasured after the merge. +// 567 KB: 555.7 KB, the four telemetry axes and `framework rules`. +// 578 KB: 566.8 KB, OpenCode's hooks bridge. +// 595 KB: 584.55 KB, the marketplace source-conflict guard. +// 610 KB: 597.95 KB, the machine-scope migration and the rollback refusal. +// 625 KB: 612.56 KB, `--scope user` on `setup`, `doctor` and `sync`. +// 641 KB: 628.26 KB, `clean --scope user` and sync's migration of a pre-shared-source project. +// 654 KB: 641.0 KB, the shared-plugin, narrowing, hook and Windows-lookup passes. const budgetKB = pkg.bundleBudgetKB ?? 500; const budgetBytes = budgetKB * 1024; diff --git a/cli/scripts/mutation-scopes-to-run.d.mts b/cli/scripts/mutation-scopes-to-run.d.mts new file mode 100644 index 000000000..45eb1961e --- /dev/null +++ b/cli/scripts/mutation-scopes-to-run.d.mts @@ -0,0 +1,8 @@ +import type { MutationScope } from "./run-mutation.mjs"; + +export const HARNESS: readonly string[]; +export function scopesToRun( + changed: readonly string[], + scopes: Readonly>, + options?: { readonly all?: boolean } +): string[]; diff --git a/cli/scripts/mutation-scopes-to-run.mjs b/cli/scripts/mutation-scopes-to-run.mjs new file mode 100644 index 000000000..e693bc684 --- /dev/null +++ b/cli/scripts/mutation-scopes-to-run.mjs @@ -0,0 +1,45 @@ +#!/usr/bin/env node +import { readFileSync } from "node:fs"; +import { dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +const CLI_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), ".."); + +/** Files that reshape every scope's run at once, relative to the repository root. */ +export const HARNESS = [ + "cli/mutation-scopes.json", + "cli/stryker.conf.json", + "cli/vitest.mutation.config.ts", + "cli/scripts/run-mutation.mjs", + "cli/scripts/mutation-scopes-to-run.mjs", + "cli/package.json", + "cli/pnpm-lock.yaml", + ".github/workflows/cli-ci.yml", +]; + +/** A scope runs when its source, its mirrored tests, a shared test helper or the harness + * changed; with no usable diff, every scope runs. */ +export function scopesToRun(changed, scopes, { all = false } = {}) { + const everything = + all || changed.some((file) => HARNESS.includes(file) || file.startsWith("cli/tests/helpers/")); + return Object.entries(scopes) + .filter(([, { mutate }]) => + [mutate].flat().some((glob) => { + if (glob.startsWith("!")) return false; + const source = `cli/${glob.slice(0, glob.indexOf("/**"))}/`; + const tests = source.replace("cli/src/", "cli/tests/"); + return ( + everything || changed.some((file) => file.startsWith(source) || file.startsWith(tests)) + ); + }) + ) + .map(([name]) => name); +} + +if (process.argv[1] !== undefined && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + const scopes = JSON.parse(readFileSync(join(CLI_ROOT, "mutation-scopes.json"), "utf8")).scopes; + const changed = (process.env.CHANGED ?? "").split("\n").filter(Boolean); + process.stdout.write( + JSON.stringify(scopesToRun(changed, scopes, { all: process.env.ALL === "true" })) + ); +} diff --git a/cli/scripts/run-mutation.d.mts b/cli/scripts/run-mutation.d.mts new file mode 100644 index 000000000..36b9d5278 --- /dev/null +++ b/cli/scripts/run-mutation.d.mts @@ -0,0 +1,23 @@ +export interface MutationScope { + readonly mutate: string | readonly string[]; + readonly break: number; +} + +export interface MutationReport { + readonly files?: Readonly< + Record< + string, + { readonly mutants: readonly { readonly status: string; readonly static?: boolean }[] } + > + >; +} + +export function pruneIncremental(report: T): T; + +export function strykerArgs( + scope: string, + scopes: Readonly>, + options?: { readonly force?: boolean } +): string[]; +export function scoreOf(report: MutationReport): number; +export function breakVerdict(score: number, declared: MutationScope): string | null; diff --git a/cli/scripts/run-mutation.mjs b/cli/scripts/run-mutation.mjs new file mode 100644 index 000000000..3e178e051 --- /dev/null +++ b/cli/scripts/run-mutation.mjs @@ -0,0 +1,135 @@ +#!/usr/bin/env node +import { spawnSync } from "node:child_process"; +import { existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs"; +import { dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +const CLI_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), ".."); +const REPORT_ROOT = join(CLI_ROOT, "reports", "mutation"); + +/** The two paths stryker.conf.json writes, before they are filed by scope. */ +const WRITTEN_REPORTS = ["report.html", "mutation.json"]; + +function loadScopes(root = CLI_ROOT) { + return JSON.parse(readFileSync(join(root, "mutation-scopes.json"), "utf8")).scopes; +} + +/** One incremental file per scope: what a run learned about `kernel` says nothing about + * `tools`, and a shared file would let one scope's result skip another's mutants. */ +export function strykerArgs(scope, scopes, { force = false } = {}) { + const declared = scopes[scope]; + if (declared === undefined) { + throw new Error(`Unknown scope "${scope}". Scopes: ${Object.keys(scopes).join(", ")}`); + } + const args = [ + "run", + "--mutate", + [declared.mutate].flat().join(","), + "--incremental", + "--incrementalFile", + `reports/mutation/${scope}/incremental.json`, + ]; + if (force) args.push("--force"); + return args; +} + +/** Stryker's own score: detected (killed, timed out) over detected plus undetected (survived, + * uncovered); an ignored or errored mutant counts on neither side. No mutant scores zero. */ +export function scoreOf(report) { + let detected = 0; + let undetected = 0; + for (const file of Object.values(report.files ?? {})) { + for (const mutant of file.mutants) { + if (mutant.status === "Killed" || mutant.status === "Timeout") detected += 1; + else if (mutant.status === "Survived" || mutant.status === "NoCoverage") undetected += 1; + } + } + const total = detected + undetected; + return total === 0 ? 0 : (100 * detected) / total; +} + +/** Stryker reuses an incremental result unless the mutant's file or a test that covered it + * changed, so a test written after the fact never reaches a mutant recorded as survived, + * uncovered or static: those rerun every time, and only a kill is carried forward. */ +export function pruneIncremental(report) { + const files = {}; + for (const [name, file] of Object.entries(report.files ?? {})) { + files[name] = { + ...file, + mutants: file.mutants.filter( + (mutant) => !mutant.static && (mutant.status === "Killed" || mutant.status === "Timeout") + ), + }; + } + return { ...report, files }; +} + +/** Below the declared floor is a failure the run itself raises; stryker's own `thresholds` + * would need a config file per scope to say the same thing. */ +export function breakVerdict(score, declared) { + if (score < declared.break) { + return `mutation score ${score.toFixed(1)} is below the ${declared.break} declared in mutation-scopes.json`; + } + return null; +} + +/** Reads first rather than checking first: a file that vanishes in between is simply absent. */ +function pruneIncrementalFile(path) { + let raw; + try { + raw = readFileSync(path, "utf8"); + } catch (error) { + if (error.code === "ENOENT") return; + throw error; + } + writeFileSync(path, JSON.stringify(pruneIncremental(JSON.parse(raw)))); +} + +function usage(problem, scopes) { + console.error(`${problem}\n\nUsage: node scripts/run-mutation.mjs [--force]`); + console.error(`Scopes: ${Object.keys(scopes).join(", ")}`); + process.exit(1); +} + +function main() { + const scopes = loadScopes(); + const [scope, ...flags] = process.argv.slice(2); + if (scope === undefined) usage("No scope given.", scopes); + if (!Object.hasOwn(scopes, scope)) usage(`Unknown scope "${scope}".`, scopes); + const force = flags.includes("--force"); + + const scopeDir = join(REPORT_ROOT, scope); + mkdirSync(scopeDir, { recursive: true }); + pruneIncrementalFile(join(scopeDir, "incremental.json")); + + const result = spawnSync( + join(CLI_ROOT, "node_modules", ".bin", "stryker"), + strykerArgs(scope, scopes, { force }), + { cwd: CLI_ROOT, stdio: "inherit" } + ); + + // A sandbox survives an interrupted run and they grow to hundreds of megabytes. + rmSync(join(CLI_ROOT, ".stryker-tmp"), { recursive: true, force: true }); + + for (const name of WRITTEN_REPORTS) { + const written = join(REPORT_ROOT, name); + if (existsSync(written)) renameSync(written, join(scopeDir, name)); + } + + if (result.status !== 0) process.exit(result.status ?? 1); + + const report = JSON.parse(readFileSync(join(scopeDir, "mutation.json"), "utf8")); + const score = scoreOf(report); + const verdict = breakVerdict(score, scopes[scope]); + console.log( + `\nReport: reports/mutation/${scope}/ (score ${score.toFixed(1)}, floor ${scopes[scope].break})` + ); + if (verdict !== null) { + console.error(verdict); + process.exit(1); + } +} + +if (process.argv[1] !== undefined && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + main(); +} diff --git a/cli/scripts/smoke-real.sh b/cli/scripts/smoke-real.sh new file mode 100644 index 000000000..51ff408b9 --- /dev/null +++ b/cli/scripts/smoke-real.sh @@ -0,0 +1,943 @@ +#!/usr/bin/env bash +# Smoke against REAL AI-tool binaries in the REAL $HOME. `smoke-tools.sh` relocates HOME on +# purpose, so it proves only that this CLI called a host binary, never that the host itself +# registered, saw, or unregistered anything. Never in CI, never in lefthook: `pnpm smoke:real`. +# +# Every marketplace and plugin name is unique per run, and `setup` is never asked to +# auto-register: that flow always takes the reserved name `aidd-framework`, which a real +# machine already carries at every host, and `claude plugin marketplace add` silently +# repoints an existing entry rather than refusing it. A unique name is what keeps this run +# from cornering a real registration, and what stops cleanup mistaking a real entry for one +# this run made. +# +# `HOME` stays real because reaching each host's own registry is the point, but +# `AIDD_USER_CONFIG_DIR` is relocated into the run temp root, exported script-wide before the +# first `aidd` call — per-phase relocation is how a phase added later reaches the real +# profile instead. The `clean --scope user` whitelist deletes `cache/built/`, the self-update +# cache and `references.json` outright, none of which may point at a real profile. The +# variable does not move `identity.json`: `resolveAiddConfigDir()` +# (`kernel/reading/home-dir.ts`) refuses it on purpose, so identity is read from the real one. +# +# Every `--scope user` call passes `--no-default-marketplace`; without it `setup --scope user` +# registers the reserved name machine-wide at every host. +set -uo pipefail + +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +CLI="$ROOT/dist/cli.js" +FRAMEWORK_FIXTURE="$ROOT/tests/fixtures/framework" + +[[ -f "$CLI" ]] || { echo "FATAL: $CLI missing — run 'pnpm build' first"; exit 1; } + +MODE="allow-existing" +[[ "${1:-}" == "--strict" ]] && MODE="strict" + +AI_TOOLS=(claude codex copilot opencode cursor) +declare -A PRESENT=() +# Set to "present" once each host's own cache directory is proven to exist. `set -u` is on, +# so they must be declared before `cleanup`'s trap can read them on an early failure. +CLAUDE_CACHE_BEFORE="" +CODEX_CACHE_BEFORE="" + +PASS=0; FAIL=0; SKIP=0 +FAILURES=() +ok() { PASS=$((PASS+1)); echo " ✓ $1"; } +bad() { FAIL=$((FAIL+1)); FAILURES+=("$1"$'\n'"${2:-}"); echo " ✗ $1"; } +skip() { SKIP=$((SKIP+1)); echo " ~ $1"; } +section() { echo; echo "=== $1 === [$(date +%H:%M:%S)]"; } + +CMD_TIMEOUT="${SMOKE_CMD_TIMEOUT:-180}" + +# run -- +# `< /dev/null` closes stdin so a real binary waiting on a TTY prompt fails fast instead of +# hanging out CMD_TIMEOUT. +run() { + local name="$1" expect_exit="$2" expect="$3" cwd="$4"; shift 4 + [[ "${1:-}" == "--" ]] && shift + local tmpout rc + tmpout=$(mktemp) + ( cd "$cwd" && exec perl -e 'alarm shift; exec @ARGV' "$CMD_TIMEOUT" "$@" ) "$tmpout" 2>&1 + rc=$? + cat "$tmpout" >> "$LOGFILE" + local out; out=$(cat "$tmpout"); rm -f "$tmpout" + if [[ "$rc" -eq 142 ]]; then bad "$name (TIMEOUT >${CMD_TIMEOUT}s)" "$out"; return 1; fi + if [[ "|$expect_exit|" != *"|$rc|"* ]]; then bad "$name (exit $rc, want $expect_exit)" "$out"; return 1; fi + if [[ -n "$expect" ]] && ! grep -qF -- "$expect" <<<"$out"; then bad "$name (missing '$expect')" "$out"; return 1; fi + ok "$name" + return 0 +} + +aidd() { node "$CLI" "$@"; } + +TMPROOT=$(mktemp -d -t aidd-smoke-real-XXXXXXXX) +# Script-wide, before the first `aidd` call: see the header. Per-phase relocation is +# how a phase added later silently reaches the real `~/.config/aidd/` instead. +export AIDD_USER_CONFIG_DIR="$TMPROOT/config" +# Outside TMPROOT on purpose: cleanup() removes TMPROOT before it prints the log +# path, and a log a report can no longer point at proves nothing. +LOGFILE=$(mktemp -t aidd-smoke-real-log-XXXXXXXX.log) +: > "$LOGFILE" + +echo "Using built CLI: $CLI" +echo "Mode: $MODE" +echo "Temp root: $TMPROOT" +echo "Log: $LOGFILE" + +for t in "${AI_TOOLS[@]}"; do + if command -v "$t" >/dev/null 2>&1; then + PRESENT[$t]=1 + echo " found: $t ($("$t" --version 2>&1 | head -1))" + else + skip "$t not installed on PATH" + fi +done + +if [[ ${#PRESENT[@]} -eq 0 ]]; then + echo "No AI-tool binary found on PATH — nothing this script can measure." + echo "PASS: 0 FAIL: 0 SKIP: ${#AI_TOOLS[@]}" + exit 0 +fi + +# --- The guard, and its honest limit ----------------------------------------- +# A unique per-run name protects only against this run colliding with itself, never against +# a state an earlier broken run left that this script cannot tell from a real install. +# `--strict` refuses to run at all when the reserved name is already registered here; +# `allow-existing`, the default, accepts a real daily driver and relies on the unique name. +preexisting_aidd_framework() { + [[ -n "${PRESENT[claude]:-}" ]] && grep -qF '"aidd-framework"' "$HOME/.claude/plugins/installed_plugins.json" 2>/dev/null && return 0 + [[ -n "${PRESENT[codex]:-}" ]] && grep -q '@aidd-framework"\]' "$HOME/.codex/config.toml" 2>/dev/null && return 0 + [[ -n "${PRESENT[copilot]:-}" ]] && grep -qF '@aidd-framework' "$HOME/.copilot/settings.json" 2>/dev/null && return 0 + [[ -n "${PRESENT[cursor]:-}" ]] && compgen -G "$HOME/.cursor/plugins/local/aidd-*" >/dev/null 2>&1 && return 0 + return 1 +} + +if [[ "$MODE" == "strict" ]] && preexisting_aidd_framework; then + echo "FATAL: --strict refuses to run: an 'aidd-framework' registration already exists" + echo "somewhere in \$HOME (measured across claude/codex/copilot/cursor). Re-run without" + echo "--strict to accept that and rely on this run's unique marketplace name instead." + exit 1 +fi + +# --- Unique identity for this run -------------------------------------------- +MKT="aidd-smoke-$(date +%s)-$$" +# Never `$MKT` itself: project scope builds under `$PROJ/.aidd/cache/built/`, machine scope +# under `userConfigDir()/cache/built//` — one host registry key, two paths. +MKT_USER="$MKT-user" +echo "Marketplace/plugin name for this run: $MKT (machine scope: $MKT_USER)" + +# `translate-source.ts`'s `buildPlugin`, the path cursor and opencode install by, resolves a +# plugin directory as `plugins/` and never reads `source`, so the directory must +# be renamed too and `source` kept in step for every resolution path to agree. +derive_fixture() { + local dest="$1" name="$2" + cp -R "$FRAMEWORK_FIXTURE" "$dest" + mv "$dest/plugins/aidd-test" "$dest/plugins/$name" + node -e ' + const fs = require("node:fs"); + const [dir, mkt] = process.argv.slice(1); + const mktPath = `${dir}/.claude-plugin/marketplace.json`; + const mktJson = JSON.parse(fs.readFileSync(mktPath, "utf8")); + mktJson.name = mkt; + mktJson.plugins[0].name = mkt; + mktJson.plugins[0].source = `./plugins/${mkt}`; + fs.writeFileSync(mktPath, JSON.stringify(mktJson)); + const pluginPath = `${dir}/plugins/${mkt}/.claude-plugin/plugin.json`; + const pluginJson = JSON.parse(fs.readFileSync(pluginPath, "utf8")); + pluginJson.name = mkt; + fs.writeFileSync(pluginPath, JSON.stringify(pluginJson)); + ' "$dest" "$name" +} + +DERIVED_FIXTURE="$TMPROOT/fixture" +derive_fixture "$DERIVED_FIXTURE" "$MKT" +USER_FIXTURE="$TMPROOT/fixture-user" +derive_fixture "$USER_FIXTURE" "$MKT_USER" + +PROJ=$(mktemp -d "$TMPROOT/proj.XXXXXX") +(cd "$PROJ" && git init -q) +# Every project this run creates, so `cleanup` cleans them all rather than only the +# first — a phase that dies halfway must still leave every host it touched undone. +PROJECTS=("$PROJ") + +REF="$MKT@$MKT" +REF_USER="$MKT_USER@$MKT_USER" + +# The cwd every `--scope user` call runs from: machine scope writes nothing under it, but a +# command still needs one, and so does `cleanup` however early the run died. +PROJ_U=$(mktemp -d "$TMPROOT/proj-user.XXXXXX") +(cd "$PROJ_U" && git init -q) + +# `setup --scope user` exits 1 for a tool declaring no machine-wide activation +# (`registry.ts`'s `supportsUserScopeActivation`; opencode today), before anything is +# written — so the machine-scope phases carry their own tool list, never `ai_list`. +user_ai_list() { + local ids=() + for t in claude codex copilot cursor; do + [[ -n "${PRESENT[$t]:-}" ]] && ids+=("$t") + done + (IFS=,; echo "${ids[*]}") +} + +# Whether any host still names this run's machine-scope marketplace: what decides, in +# `cleanup`, between "already undone" and "recreate the record and undo it now". +user_marketplace_still_registered() { + grep -qF "\"$MKT_USER\"" "$HOME/.claude/plugins/known_marketplaces.json" 2>/dev/null && return 0 + grep -qF "[marketplaces.$MKT_USER]" "$HOME/.codex/config.toml" 2>/dev/null && return 0 + grep -qF "\"$MKT_USER\"" "$HOME/.copilot/settings.json" 2>/dev/null && return 0 + [[ -e "$HOME/.cursor/plugins/local/$MKT_USER" ]] && return 0 + return 1 +} + +cleanup_user_scope() { + local ids; ids=$(user_ai_list) + if [[ -z "$ids" ]]; then + skip "clean --scope user (no tool on this machine supports user-scope activation)" + return + fi + if [[ ! -f "$AIDD_USER_CONFIG_DIR/manifest.json" ]]; then + if ! user_marketplace_still_registered; then + skip "clean --scope user (nothing registered at user scope is left to undo)" + return + fi + ( cd "$PROJ_U" && exec perl -e 'alarm shift; exec @ARGV' "$CMD_TIMEOUT" node "$CLI" setup \ + --scope user --ai "$ids" --no-default-marketplace --plugins none --yes ) >"$LOGFILE" 2>&1 + ( cd "$PROJ_U" && exec perl -e 'alarm shift; exec @ARGV' "$CMD_TIMEOUT" node "$CLI" sync \ + --scope user ) >"$LOGFILE" 2>&1 + fi + run "clean --scope user --force" 0 "" "$PROJ_U" -- node "$CLI" clean --scope user --force +} + +# Copilot keeps a disabled plugin's key in enabledPlugins at `false` rather than deleting it, +# so only the boolean tells "installed" from "disabled"; a grep for the ref cannot. +copilot_ref_enabled() { + node -e ' + const fs = require("node:fs"); + try { + const settings = JSON.parse(fs.readFileSync(process.argv[1], "utf8")); + console.log(settings.enabledPlugins?.[process.argv[2]] === true ? "true" : "false"); + } catch { + console.log("false"); + } + ' "$HOME/.copilot/settings.json" "${1:-$REF}" +} + +# `aidd clean` never writes a host registry by hand; this script may, because $REF and +# $REF_USER are this run's own unique names and no string a real install could hold. Only a +# key already at `false` is dropped: one still `true` means cleanup failed upstream. +copilot_purge_disabled_run_keys() { + if [[ -z "${PRESENT[copilot]:-}" ]]; then + skip "copilot: purge disabled run keys (copilot not installed)" + return + fi + local settings="$HOME/.copilot/settings.json" + if [[ ! -f "$settings" ]]; then + skip "copilot: purge disabled run keys (no settings.json)" + return + fi + local out + out=$(node -e ' + const fs = require("node:fs"); + const [file, ref1, ref2] = process.argv.slice(1); + const raw = fs.readFileSync(file, "utf8"); + const hadTrailingNewline = raw.endsWith("\n"); + const indentMatch = raw.match(/^\{\r?\n( +)"/); + const indent = indentMatch ? indentMatch[1].length : 2; + const settings = JSON.parse(raw); + const enabled = settings.enabledPlugins; + const lines = []; + let changed = false; + if (enabled && typeof enabled === "object") { + for (const key of [ref1, ref2]) { + if (!(key in enabled)) continue; + if (enabled[key] === false) { + delete enabled[key]; + changed = true; + lines.push(`removed ${key}`); + } else if (enabled[key] === true) { + lines.push(`bad ${key}`); + } + } + } + if (changed) { + const updated = JSON.stringify(settings, null, indent) + (hadTrailingNewline ? "\n" : ""); + const tmp = `${file}.tmp-${process.pid}`; + fs.writeFileSync(tmp, updated); + fs.renameSync(tmp, file); + } + for (const line of lines) console.log(line); + ' "$settings" "$REF" "$REF_USER") + + if [[ -z "$out" ]]; then + skip "copilot: purge disabled run keys ($REF / $REF_USER not present in enabledPlugins)" + return + fi + while IFS=' ' read -r status key; do + [[ -z "$status" ]] && continue + case "$status" in + removed) ok "copilot: removed disabled key $key from enabledPlugins" ;; + bad) bad "copilot: $key is still enabled (true) in enabledPlugins — cleanup failed upstream" ;; + esac + done <<< "$out" +} + +# --- Cleanup runs no matter what happens, and is the only place `clean` is called --- +cleanup() { + # Under its own name: the helpers below assign `rc=` themselves, and bash's dynamic scoping + # would otherwise exit with whatever the last helper measured. + local entry_rc=$? + section "cleanup" + for proj in "${PROJECTS[@]}"; do + if [[ -d "$proj/.aidd" ]]; then + run "clean --force ($(basename "$proj"))" 0 "" "$proj" -- node "$CLI" clean --force + else + skip "clean --force ($(basename "$proj")): no .aidd/ — already cleaned by a phase above, or setup never got that far" + fi + done + + # Machine scope is undone only through the user manifest, which `clean --scope user` reads + # and nothing else does. A run dying between `marketplace add --scope user` and the + # `sync --scope user` that records it strands the registration: recreate the record first. + cleanup_user_scope + + # Presence of THIS run's unique token, never a byte-for-byte diff: everything else in these + # registries belongs to real installs and legitimately keeps changing. + if [[ -n "${PRESENT[claude]:-}" ]]; then + if grep -qF "\"$REF\"" "$HOME/.claude/plugins/installed_plugins.json" 2>/dev/null; then + bad "claude: installed_plugins.json still names $REF after clean" + else + ok "claude: installed_plugins.json carries no trace of $REF" + fi + # A scoped `marketplace remove` can fail to find its own declaration when one name was + # added twice from two sources; this guards that residue out of the global cache. + if grep -qF "\"$MKT\"" "$HOME/.claude/plugins/known_marketplaces.json" 2>/dev/null; then + bad "claude: known_marketplaces.json still carries $MKT after clean" + else + ok "claude: known_marketplaces.json carries no trace of $MKT" + fi + # `activateTool` registers every known marketplace, plugin or not, so `clean --force` above + # already had Phase C2's alias-divergence hostName in nativeRegistrations to unregister. + if [[ -n "${UPSTREAM_NAME:-}" ]]; then + if grep -qF "\"$UPSTREAM_NAME\"" "$HOME/.claude/plugins/known_marketplaces.json" 2>/dev/null; then + bad "claude: known_marketplaces.json still carries $UPSTREAM_NAME after clean" + else + ok "claude: known_marketplaces.json carries no trace of $UPSTREAM_NAME" + fi + fi + # Claude marks an orphaned built tree `.orphaned_at` and never deletes it, so `clean` + # purges it once known_marketplaces.json no longer names it. Checked against + # `CLAUDE_CACHE_BEFORE`: an absent directory proves nothing unless it was seen present. + if [[ "$CLAUDE_CACHE_BEFORE" != "present" ]]; then + bad "claude: plugins/cache/$MKT was never proven present before clean ran" + elif [[ -d "$HOME/.claude/plugins/cache/$MKT" ]]; then + bad "claude: plugins/cache/$MKT still exists after clean" + else + ok "claude: plugins/cache/$MKT is gone after clean, having been proven present before" + fi + # The same cache root, reached through `marketplace add` alone with no plugin ever + # installed under this name, so absence-after is all this can honestly assert. + if [[ -n "${UPSTREAM_NAME:-}" ]]; then + if [[ -d "$HOME/.claude/plugins/cache/$UPSTREAM_NAME" ]]; then + bad "claude: plugins/cache/$UPSTREAM_NAME still exists after clean" + else + ok "claude: plugins/cache/$UPSTREAM_NAME carries no trace after clean" + fi + fi + fi + # The shared-ref guard's negative control: $MKT is unique per run, never the reserved name, + # so `references.json` never tracks it and a non-shared source is torn down in full at every + # host. The guarded path itself is unobservable here and is covered by + # `clean-shared-ref-guard.integration.test.ts` and `tests/e2e/clean-shared-ref-codex.e2e.test.ts`. + if [[ -n "${PRESENT[codex]:-}" ]]; then + if grep -qF "\"$REF\"" "$HOME/.codex/config.toml" 2>/dev/null; then + bad "codex: config.toml still names $REF after clean" + else + ok "codex: config.toml carries no trace of $REF" + fi + # `codex plugin remove` deletes a marketplace's cached content but leaves the empty + # `cache/$MKT/` shell, which `clean` purges. Same non-vacuity guard as claude's above: + # `CODEX_CACHE_BEFORE` makes an absent directory proven gone, not merely never populated. + if [[ "$CODEX_CACHE_BEFORE" != "present" ]]; then + bad "codex: plugins/cache/$MKT was never proven present before clean ran" + elif [[ -d "$HOME/.codex/plugins/cache/$MKT" ]]; then + bad "codex: plugins/cache/$MKT still exists after clean" + else + ok "codex: plugins/cache/$MKT is gone after clean, having been proven present before" + fi + fi + if [[ -n "${PRESENT[copilot]:-}" ]]; then + if [[ "$(copilot_ref_enabled)" == "true" ]]; then + bad "copilot: settings.json still enables $REF after clean" + else + ok "copilot: settings.json no longer enables $REF after clean" + fi + fi + if [[ -n "${PRESENT[cursor]:-}" ]]; then + if [[ -e "$HOME/.cursor/plugins/local/$MKT" ]]; then + bad "cursor: ~/.cursor/plugins/local/$MKT still exists after clean" + else + ok "cursor: ~/.cursor/plugins/local/$MKT is gone after clean" + fi + fi + + # Separate from the block above: `$MKT` is undone by a project's own `clean`, `$MKT_USER` + # only by `clean --scope user`, and a run where one worked and the other did not must say so. + if [[ -n "${PRESENT[claude]:-}" ]]; then + grep -qF "\"$MKT_USER\"" "$HOME/.claude/plugins/known_marketplaces.json" 2>/dev/null \ + && bad "claude: known_marketplaces.json still carries $MKT_USER after clean --scope user" \ + || ok "claude: known_marketplaces.json carries no trace of $MKT_USER" + grep -qF "\"$REF_USER\"" "$HOME/.claude/plugins/installed_plugins.json" 2>/dev/null \ + && bad "claude: installed_plugins.json still names $REF_USER after clean --scope user" \ + || ok "claude: installed_plugins.json carries no trace of $REF_USER" + fi + if [[ -n "${PRESENT[codex]:-}" ]]; then + grep -qF "$MKT_USER" "$HOME/.codex/config.toml" 2>/dev/null \ + && bad "codex: config.toml still names $MKT_USER after clean --scope user" \ + || ok "codex: config.toml carries no trace of $MKT_USER" + fi + if [[ -n "${PRESENT[copilot]:-}" ]]; then + grep -qF "\"$MKT_USER\"" "$HOME/.copilot/settings.json" 2>/dev/null \ + && bad "copilot: settings.json still declares marketplace $MKT_USER after clean --scope user" \ + || ok "copilot: settings.json carries no marketplace $MKT_USER" + [[ "$(copilot_ref_enabled "$REF_USER")" == "true" ]] \ + && bad "copilot: settings.json still enables $REF_USER after clean --scope user" \ + || ok "copilot: settings.json no longer enables $REF_USER" + fi + copilot_purge_disabled_run_keys + if [[ -n "${PRESENT[cursor]:-}" ]]; then + [[ -e "$HOME/.cursor/plugins/local/$MKT_USER" ]] \ + && bad "cursor: ~/.cursor/plugins/local/$MKT_USER still exists after clean --scope user" \ + || ok "cursor: ~/.cursor/plugins/local/$MKT_USER is gone after clean --scope user" + fi + + # `marketplaces.json` survives on purpose: the whitelist deletes the reserved + # `aidd-framework` entry alone out of it. What must be gone is everything else it names. + for leftover in manifest.json references.json cache/built; do + [[ -e "$AIDD_USER_CONFIG_DIR/$leftover" ]] \ + && bad "user config dir: $leftover survives clean --scope user ($AIDD_USER_CONFIG_DIR/$leftover)" \ + || ok "user config dir: no $leftover left behind" + done + + rm -rf "$TMPROOT" + + echo + echo "PASS: $PASS FAIL: $FAIL SKIP: $SKIP" + if [[ "$FAIL" -gt 0 ]]; then + echo; echo "Failures:"; for f in "${FAILURES[@]}"; do echo " • $f"; echo; done + fi + echo "Full command output was logged to: $LOGFILE (not removed — inspect or delete it yourself)" + [[ "$FAIL" -gt 0 ]] && exit 1 + exit "$entry_rc" +} +trap cleanup EXIT + +# --- Phase A: file install, no native registration --------------------------- +section "setup (files only, no marketplace auto-register)" +ai_list=$(IFS=,; echo "${!PRESENT[*]}") +run "setup --no-default-marketplace" 0 "Installed" "$PROJ" -- \ + node "$CLI" setup --source local --path "$FRAMEWORK_FIXTURE" --ai "$ai_list" \ + --no-default-marketplace --plugins none --yes + +# --- Phase B: native registration through this run's own unique name --------- +section "marketplace add + plugin install (unique name: $MKT)" +run "marketplace add $MKT" 0 "" "$PROJ" -- \ + node "$CLI" marketplace add "$MKT" "$DERIVED_FIXTURE" --scope project --yes + +for t in "${!PRESENT[@]}"; do + run "plugin install $MKT -> $t" 0 "" "$PROJ" -- \ + node "$CLI" plugin install "$MKT" --tool "$t" --from "$MKT" --yes +done + +section "each host's own registry now names $REF" +if [[ -n "${PRESENT[claude]:-}" ]]; then + grep -qF "\"$REF\"" "$HOME/.claude/plugins/installed_plugins.json" 2>/dev/null \ + && ok "claude: installed_plugins.json names $REF" \ + || bad "claude: installed_plugins.json does not name $REF" +fi +if [[ -n "${PRESENT[codex]:-}" ]]; then + grep -qF "\"$REF\"" "$HOME/.codex/config.toml" 2>/dev/null \ + && ok "codex: config.toml names $REF" \ + || bad "codex: config.toml does not name $REF" +fi +if [[ -n "${PRESENT[copilot]:-}" ]]; then + [[ "$(copilot_ref_enabled)" == "true" ]] \ + && ok "copilot: settings.json enables $REF" \ + || bad "copilot: settings.json does not enable $REF" +fi +if [[ -n "${PRESENT[cursor]:-}" ]]; then + [[ -f "$HOME/.cursor/plugins/local/$MKT/.cursor-plugin/plugin.json" ]] \ + && ok "cursor: ~/.cursor/plugins/local/$MKT/.cursor-plugin/plugin.json exists" \ + || bad "cursor: ~/.cursor/plugins/local/$MKT/.cursor-plugin/plugin.json missing" +fi + +section "each host lists what the catalog ships" +# A registry naming $REF proves a path was recorded; a host listing the plugin's components +# proves it loaded the build. Expectations are read from the fixture itself, so a fixture +# change moves them. Claude's inventory never counts `agents/` (0 on a plugin shipping two, +# measured 2026-09-09), so agents are not asserted. Cursor and opencode expose no inventory +# command: the file checks above are all this run can prove for them. +FIXTURE_PLUGIN="$DERIVED_FIXTURE/plugins/$MKT" +IFS=$'\t' read -r FIXTURE_SKILLS FIXTURE_HOOKS FIXTURE_MCP FIXTURE_VERSION < <(node -e ' + const fs = require("node:fs"); + const dir = process.argv[1]; + const json = (file) => JSON.parse(fs.readFileSync(`${dir}/${file}`, "utf8")); + const skills = fs.readdirSync(`${dir}/skills`, { withFileTypes: true }) + .filter((e) => e.isDirectory()).map((e) => e.name).sort(); + const hooks = Object.keys(json("hooks/hooks.json").hooks); + const mcp = Object.keys(json(".mcp.json").mcpServers); + const line = (names) => `(${names.length}) ${names.join(", ")}`; + process.stdout.write([line(skills), line(hooks), line(mcp), json(".claude-plugin/plugin.json").version].join("\t")); +' "$FIXTURE_PLUGIN") + +# host_says : one call to a host's own binary, its answer returned and logged. +host_says() { + local out + out=$( ( cd "$PROJ" && exec perl -e 'alarm shift; exec @ARGV' "$CMD_TIMEOUT" "$@" ) &1 ) + printf '%s\n' "$out" >> "$LOGFILE" + printf '%s\n' "$out" +} +# lists : the fragment, fixed-string, anywhere in the answer. +lists() { + local name="$1" expect="$2" answer="$3" + grep -qF -- "$expect" <<<"$answer" && ok "$name" || bad "$name (missing '$expect')" "$answer" +} +if [[ -n "${PRESENT[claude]:-}" ]]; then + answer=$(host_says claude plugin details "$REF") + lists "claude: plugin details lists the skills $FIXTURE_SKILLS" "Skills $FIXTURE_SKILLS" "$answer" + lists "claude: plugin details lists the hook events $FIXTURE_HOOKS" "Hooks $FIXTURE_HOOKS" "$answer" + lists "claude: plugin details lists the MCP servers $FIXTURE_MCP" "MCP servers $FIXTURE_MCP" "$answer" +fi +if [[ -n "${PRESENT[codex]:-}" ]]; then + answer=$(host_says codex plugin list) + grep -qE -- "^$REF +installed, enabled +$FIXTURE_VERSION" <<<"$answer" \ + && ok "codex: plugin list marks $REF installed, enabled, $FIXTURE_VERSION" \ + || bad "codex: plugin list does not mark $REF installed, enabled, $FIXTURE_VERSION" "$answer" +fi +if [[ -n "${PRESENT[copilot]:-}" ]]; then + lists "copilot: plugin list marks $REF v$FIXTURE_VERSION enabled" "$REF (v$FIXTURE_VERSION) (enabled)" "$(host_says copilot plugin list)" +fi + +# Without this, `cleanup`'s cache checks are vacuous: an absent directory after `clean` proves +# nothing unless this run watched it exist first. The path fragments below are literals of what +# each profile declares as `NativeActivation.pluginCacheDir`, since this script cannot read it +# back out of the built CLI; `smoke-real-plugin-cache-path-parity.test.js` pins them to it. +if [[ -n "${PRESENT[claude]:-}" ]]; then + if [[ -d "$HOME/.claude/plugins/cache/$MKT" ]]; then + CLAUDE_CACHE_BEFORE="present" + ok "claude: plugins/cache/$MKT exists before clean" + else + bad "claude: plugins/cache/$MKT does not exist after plugin install" + fi +fi +if [[ -n "${PRESENT[codex]:-}" ]]; then + if [[ -d "$HOME/.codex/plugins/cache/$MKT" ]]; then + CODEX_CACHE_BEFORE="present" + ok "codex: plugins/cache/$MKT exists before clean" + else + bad "codex: plugins/cache/$MKT does not exist after plugin install" + fi +fi + +# Matches doctor's drift message, never its exit code: the fixture installed here ships a +# deliberately broken relative link, which holds doctor at exit 1 on a `Warning` throughout +# the run whatever the native registration says. +doctor_names_ref() { + local label="$1" want="$2" # want: "absent" (registered) or "present" (drift, names the fix) + local proj="${3:-$PROJ}" ref="${4:-$REF}" + local out; out=$(mktemp) + ( cd "$proj" && exec perl -e 'alarm shift; exec @ARGV' "$CMD_TIMEOUT" node "$CLI" doctor ) "$out" 2>&1 + cat "$out" >> "$LOGFILE" + local has_error=0 has_fix=0 + grep -qF "does not carry $ref" "$out" && has_error=1 + grep -qF "aidd sync" "$out" && has_fix=1 + if [[ "$want" == "absent" ]]; then + [[ "$has_error" -eq 0 ]] && ok "$label" || bad "$label" "$(cat "$out")" + else + if [[ "$has_error" -eq 1 && "$has_fix" -eq 1 ]]; then ok "$label"; else bad "$label (missing the drift error or its \`aidd sync\` fix hint)" "$(cat "$out")"; fi + fi + rm -f "$out" +} + +section "doctor sees every registration" +doctor_names_ref "doctor: $REF registered, no drift error" absent + +if [[ -n "${PRESENT[claude]:-}" ]]; then + section "doctor after the HOST's own binary drops a registration (claude)" + # `--scope local` is load-bearing: aidd enables a project-scope plugin there, and a + # scopeless `claude plugin uninstall` defaults to `user` and refuses — non-zero, which + # `"0|1"` accepts, so without the scope this phase would drop no registration at all. + run "claude plugin uninstall $REF" "0|1" "" "$PROJ" -- \ + claude plugin uninstall "$REF" --scope local --yes + doctor_names_ref "doctor: $REF drift detected after host-side uninstall, names aidd sync" present + run "sync --force repairs it" 0 "" "$PROJ" -- node "$CLI" sync --force + doctor_names_ref "doctor: $REF re-registered after sync --force" absent +else + skip "doctor drift/repair round-trip (claude not installed)" +fi + +section "opencode: the bridge it wrote is loadable" +if [[ -n "${PRESENT[opencode]:-}" ]]; then + oc_plugin_dir="$PROJ/.opencode/plugin" + if [[ -d "$oc_plugin_dir" ]] && compgen -G "$oc_plugin_dir/*.js" >/dev/null 2>&1; then + bridge_ok=1 + for f in "$oc_plugin_dir"/*.js; do + node -e ' + import(process.argv[1]).then((mod) => { + const fn = mod.default ?? mod; + if (typeof fn !== "function") { console.error("Plugin export is not a function"); process.exit(1); } + }).catch((e) => { console.error(String(e)); process.exit(1); }); + ' "$f" || bridge_ok=0 + done + [[ "$bridge_ok" -eq 1 ]] \ + && ok "opencode: every bridged module in .opencode/plugin/ exports a function" \ + || bad "opencode: a bridged module does not export a function (Plugin export is not a function)" + else + ok "opencode: .opencode/plugin/ absent (this fixture's plugin maps no bridged event — nothing to load)" + fi + + run_out=$(mktemp) + ( cd "$PROJ" && exec perl -e 'alarm shift; exec @ARGV' 60 opencode run "say ok" ) "$run_out" 2>&1 + oc_rc=$? + cat "$run_out" >> "$LOGFILE" + if grep -qi "Plugin export is not a function" "$run_out"; then + bad "opencode run: bridge threw 'Plugin export is not a function'" "$(cat "$run_out")" + elif [[ "$oc_rc" -eq 0 ]]; then + ok "opencode run: exits 0, host alive, no broken plugin export" + elif grep -qiE "auth|api key|provider|not logged in|credential" "$run_out"; then + skip "opencode run: needs provider auth on this machine (real limitation, not a defect) — exit $oc_rc" + elif [[ "$oc_rc" -eq 142 ]]; then + # Without a configured provider opencode prints its session banner and then hangs with no + # reply and no error, which is evidence neither way about the bridge. + skip "opencode run: timed out after 60s with no auth/error/completion signal (see log) — inconclusive without a configured provider" + else + bad "opencode run: exit $oc_rc, no auth signature (see log)" "$(cat "$run_out")" + fi + rm -f "$run_out" +else + skip "opencode bridge check (opencode not installed)" +fi + +# --- Phase C1: the guard refuses a genuinely different catalog under the same name --- +# Identity is a catalog's declared name plus its plugin set, never a path and never a version +# (`marketplace-source-conflict.ts`), so this fixture keeps the name `$MKT` and drops its one +# plugin. The alias `$MKT-conflict` lets `marketplace add` write the project's own entry: the +# refusal measured here is `sync` re-driving activation, which must refuse identically. +if [[ -n "${PRESENT[claude]:-}" ]]; then + section "sync refuses a different catalog registered under the same name ($MKT, fewer plugins)" + MKT2_FIXTURE="$TMPROOT/fixture-conflict" + cp -R "$DERIVED_FIXTURE" "$MKT2_FIXTURE" + node -e ' + const fs = require("node:fs"); + const dir = process.argv[1]; + const mktPath = `${dir}/.claude-plugin/marketplace.json`; + const mktJson = JSON.parse(fs.readFileSync(mktPath, "utf8")); + mktJson.plugins = []; + fs.writeFileSync(mktPath, JSON.stringify(mktJson)); + ' "$MKT2_FIXTURE" + + add_out=$(mktemp) + ( cd "$PROJ" && exec perl -e 'alarm shift; exec @ARGV' "$CMD_TIMEOUT" node "$CLI" marketplace add "$MKT-conflict" "$MKT2_FIXTURE" --scope project --yes ) "$add_out" 2>&1 + cat "$add_out" >> "$LOGFILE" + rm -f "$add_out" + + sync_out=$(mktemp) + ( cd "$PROJ" && exec perl -e 'alarm shift; exec @ARGV' "$CMD_TIMEOUT" node "$CLI" sync --tool claude ) "$sync_out" 2>&1 + sync_rc=$? + cat "$sync_out" >> "$LOGFILE" + if [[ "$sync_rc" -eq 0 ]]; then + bad "sync --tool claude (expected non-zero: different catalog under the same name never refused)" "$(cat "$sync_out")" + elif grep -qF "$MKT" "$sync_out"; then + ok "sync --tool claude refuses, naming the conflicting catalog $MKT" + else + bad "sync --tool claude exited $sync_rc but did not name $MKT" "$(cat "$sync_out")" + fi + rm -f "$sync_out" + + # Leaves the project as Phase B left it: a conflicting catalog still declared would make + # every later activation re-run into this refusal and measure its own residue. + run "marketplace remove $MKT-conflict (restores the project's state)" 0 "" "$PROJ" -- \ + node "$CLI" marketplace remove "$MKT-conflict" --yes +else + skip "sync refuses a different catalog under the same name (claude not installed)" +fi + +# --- Phase C2: a local alias may differ from the catalog's own declared name ------- +# A supported capability, never a fault. The catalog name is brand new to this run, so +# nothing in Phase C1 can make it collide: alias and declared name are simply different +# strings, and `marketplace add` still succeeds. +if [[ -n "${PRESENT[claude]:-}" ]]; then + section "marketplace add registers freely when the local alias differs from the catalog's own name" + MKT3_FIXTURE="$TMPROOT/fixture-alias" + cp -R "$DERIVED_FIXTURE" "$MKT3_FIXTURE" + UPSTREAM_NAME="$MKT-upstream" + node -e ' + const fs = require("node:fs"); + const [dir, name] = process.argv.slice(1); + const mktPath = `${dir}/.claude-plugin/marketplace.json`; + const mktJson = JSON.parse(fs.readFileSync(mktPath, "utf8")); + // Only the declared catalog name diverges from the alias. The plugin keeps its name + // and its directory, which the source resolver reads literally as plugins/. + mktJson.name = name; + fs.writeFileSync(mktPath, JSON.stringify(mktJson)); + ' "$MKT3_FIXTURE" "$UPSTREAM_NAME" + + run "marketplace add $MKT-alias (catalog declares $UPSTREAM_NAME)" 0 "" "$PROJ" -- \ + node "$CLI" marketplace add "$MKT-alias" "$MKT3_FIXTURE" --scope project --yes + + # `marketplace add` narrows the sync it re-drives to what it registered, so + # `recordNativeRegistrations` must merge by key: a plain replace would drop $MKT's entry. + # Only the manifest can show that — no host registry is asked to remove $MKT here. + if node -e ' + const fs = require("node:fs"); + const [proj, mkt] = process.argv.slice(1); + const manifest = JSON.parse(fs.readFileSync(proj + "/.aidd/manifest.json", "utf8")); + const marketplaces = manifest.tools && manifest.tools.claude && manifest.tools.claude.nativeRegistrations + ? manifest.tools.claude.nativeRegistrations.marketplaces + : []; + const carries = marketplaces.some(function (m) { return m.alias === mkt || m.hostName === mkt; }); + process.exit(carries ? 0 : 1); + ' "$PROJ" "$MKT"; then + ok "claude: .aidd/manifest.json still carries $MKT's nativeRegistrations after the narrowed $MKT-alias add" + else + bad "claude: .aidd/manifest.json lost $MKT's nativeRegistrations after a marketplace add narrowed to $MKT-alias" + fi + + # `activateTool` registers every known marketplace, plugin or not, so the trap's own + # `clean --force` already unregisters $UPSTREAM_NAME. No teardown needed here. +else + skip "marketplace add alias-divergence capability (claude not installed)" +fi + +# --- Phase D: `--scope user` writes nothing under a project --------------------- +# First of the machine-scope phases on purpose: it creates the user manifest, and +# `clean --scope user` reads that and nothing else. Registering `$MKT_USER` first would open +# a window where a crash strands the registration at every host with nothing able to name it. +USER_AI_LIST=$(user_ai_list) +section "setup --scope user (machine-wide, nothing under the project)" +if [[ -n "$USER_AI_LIST" ]]; then + run "setup --scope user --ai $USER_AI_LIST" 0 "" "$PROJ_U" -- \ + node "$CLI" setup --scope user --ai "$USER_AI_LIST" --no-default-marketplace \ + --plugins none --yes + + proj_u_dirty=$(cd "$PROJ_U" && git status --porcelain) + [[ -z "$proj_u_dirty" ]] \ + && ok "scope user: git status --porcelain is empty — nothing was written under the project" \ + || bad "scope user: the project is dirty after a machine-scope setup" "$proj_u_dirty" + + [[ -f "$AIDD_USER_CONFIG_DIR/manifest.json" ]] \ + && ok "scope user: userConfigDir()/manifest.json exists" \ + || bad "scope user: userConfigDir()/manifest.json missing after setup --scope user" + + # A user manifest carrying no `nativeRegistrations` yet gives `DoctorRegistrationUseCase` + # nothing to compare, so this exits 0 — and unlike project scope it never reads a tracked + # file, so the fixture's own broken opencode link cannot reach it. + run "doctor --scope user" 0 "User-scope installation is healthy" "$PROJ_U" -- \ + node "$CLI" doctor --scope user +else + skip "setup --scope user (no tool on this machine supports user-scope activation)" +fi + +# --- Phase E: two projects, one machine-scope source ---------------------------- +# A second project on the same machine must not be refused by codex or copilot: both resolve +# the same built tree under `userConfigDir()/cache/built///`, so no host +# sees a second source under a name it holds. The path is asserted, not only the exit code — a +# build under either project's own `.aidd/cache/` is the pre-migration shape being retired. +section "two projects share one machine-scope marketplace ($MKT_USER)" +if [[ -n "$USER_AI_LIST" ]]; then + PROJ_A=$(mktemp -d "$TMPROOT/proj-a.XXXXXX"); (cd "$PROJ_A" && git init -q) + PROJ_B=$(mktemp -d "$TMPROOT/proj-b.XXXXXX"); (cd "$PROJ_B" && git init -q) + PROJECTS+=("$PROJ_A" "$PROJ_B") + + for p in "$PROJ_A" "$PROJ_B"; do + run "setup --no-default-marketplace ($(basename "$p"))" 0 "Installed" "$p" -- \ + node "$CLI" setup --source local --path "$FRAMEWORK_FIXTURE" --ai "$ai_list" \ + --no-default-marketplace --plugins none --yes + done + + run "marketplace add $MKT_USER --scope user (project A)" 0 "" "$PROJ_A" -- \ + node "$CLI" marketplace add "$MKT_USER" "$USER_FIXTURE" --scope user --yes + + # Between the `add` and the `sync`, so the path measured is the one `marketplace add` wrote: + # both writers resolve `userBuiltMarketplaceDir`, and a divergence would otherwise hide. + user_built_root="$AIDD_USER_CONFIG_DIR/cache/built" + registers_under_user_config() { + local label="$1" file="$2" + if ! grep -qF "$MKT_USER" "$file" 2>/dev/null; then + bad "$label: $file does not name $MKT_USER" + elif grep -F "$MKT_USER" "$file" | grep -qF "$user_built_root"; then + ok "$label: registered from the machine-scope build under $user_built_root" + else + bad "$label: names $MKT_USER but not from $user_built_root — a per-project build is the pre-migration shape" \ + "$(grep -F "$MKT_USER" "$file")" + fi + } + [[ -n "${PRESENT[claude]:-}" ]] && registers_under_user_config "claude" "$HOME/.claude/plugins/known_marketplaces.json" + [[ -n "${PRESENT[codex]:-}" ]] && registers_under_user_config "codex" "$HOME/.codex/config.toml" + [[ -n "${PRESENT[copilot]:-}" ]] && registers_under_user_config "copilot" "$HOME/.copilot/settings.json" + for p in "$PROJ_A" "$PROJ_B"; do + grep -qF "$p" "$HOME/.claude/plugins/known_marketplaces.json" 2>/dev/null \ + && bad "claude: the registration for $MKT_USER points inside $p, not at the machine-scope build" \ + || ok "claude: no registration of $MKT_USER points inside $(basename "$p")" + done + + # Records the registration in the user manifest, the only thing `undoNativeRegistrations` + # reads. Guarded, not assumed: against an empty registry `sync --scope user` would run + # `ensureFrameworkRegistered` and take the reserved name on a real machine. + if grep -qF "\"$MKT_USER\"" "$AIDD_USER_CONFIG_DIR/marketplaces.json" 2>/dev/null; then + run "sync --scope user records the registration" 0 "" "$PROJ_U" -- \ + node "$CLI" sync --scope user + else + bad "sync --scope user refused: $MKT_USER is not in the user registry, and an empty registry would make sync register the reserved aidd-framework name" + fi + + for t in "${!PRESENT[@]}"; do + run "plugin install $MKT_USER -> $t (project A)" 0 "" "$PROJ_A" -- \ + node "$CLI" plugin install "$MKT_USER" --tool "$t" --from "$MKT_USER" --yes + done + + # aidd's own registry refuses a name it already holds at user scope before any host is + # reached. Not a failure: the second project needs no add, only the install below. + run "marketplace add $MKT_USER --scope user (project B) is refused as already registered" \ + 1 "is already registered" "$PROJ_B" -- \ + node "$CLI" marketplace add "$MKT_USER" "$USER_FIXTURE" --scope user --yes + + for t in "${!PRESENT[@]}"; do + run "plugin install $MKT_USER -> $t (project B, second project on this machine)" 0 "" "$PROJ_B" -- \ + node "$CLI" plugin install "$MKT_USER" --tool "$t" --from "$MKT_USER" --yes + done + + section "each host's own registry names $REF_USER for the second project too" + if [[ -n "${PRESENT[claude]:-}" ]]; then + grep -qF "\"$REF_USER\"" "$HOME/.claude/plugins/installed_plugins.json" 2>/dev/null \ + && ok "claude: installed_plugins.json names $REF_USER" \ + || bad "claude: installed_plugins.json does not name $REF_USER" + fi + if [[ -n "${PRESENT[codex]:-}" ]]; then + grep -qF "\"$REF_USER\"" "$HOME/.codex/config.toml" 2>/dev/null \ + && ok "codex: config.toml names $REF_USER" \ + || bad "codex: config.toml does not name $REF_USER" + fi + if [[ -n "${PRESENT[copilot]:-}" ]]; then + [[ "$(copilot_ref_enabled "$REF_USER")" == "true" ]] \ + && ok "copilot: settings.json enables $REF_USER" \ + || bad "copilot: settings.json does not enable $REF_USER" + fi + + # `references.json` tracks the reserved name alone — every write site gates on + # `frameworkSourceIsShared(name, scope)` — so a unique machine-scope name records nothing + # and absence is what this asserts. + if [[ ! -f "$AIDD_USER_CONFIG_DIR/references.json" ]]; then + ok "references.json: absent — only the reserved framework name is ever tracked there" + elif grep -qF "$PROJ_A" "$AIDD_USER_CONFIG_DIR/references.json" 2>/dev/null; then + bad "references.json names $PROJ_A, but no write site records a non-framework user-scope source" \ + "$(cat "$AIDD_USER_CONFIG_DIR/references.json")" + else + ok "references.json: carries no claim for a non-framework machine-scope source" + fi +else + skip "two projects sharing one machine-scope marketplace (no tool supports user-scope activation)" +fi + +# --- Phase F: a project's own clean leaves the shared registration alone --------- +# `undoMarketplaceRegistration` refuses any marketplace whose recorded scope is `"user"`, +# keyed on the scope and not the reserved name. It does not protect the plugin ref: at codex +# and copilot that is one global key, so project B loses its enablement and repairs by `sync`. +section "clean --force in project A leaves the shared marketplace registered" +if [[ -n "$USER_AI_LIST" && -d "${PROJ_A:-}/.aidd" && -n "${PROJ_B:-}" ]]; then + run "clean --force (project A)" 0 "is shared by every project on this machine" "$PROJ_A" -- \ + node "$CLI" clean --force + + survives() { + local label="$1" file="$2" + grep -qF "$MKT_USER" "$file" 2>/dev/null \ + && ok "$label: still declares marketplace $MKT_USER after project A's clean" \ + || bad "$label: lost marketplace $MKT_USER to another project's clean" "$(cat "$file")" + } + [[ -n "${PRESENT[claude]:-}" ]] && survives "claude" "$HOME/.claude/plugins/known_marketplaces.json" + [[ -n "${PRESENT[codex]:-}" ]] && survives "codex" "$HOME/.codex/config.toml" + [[ -n "${PRESENT[copilot]:-}" ]] && survives "copilot" "$HOME/.copilot/settings.json" + + # `doctor_names_ref present` matches `does not carry `, codex's own wording for a lost + # ref; copilot and cursor word the same loss differently. Gated on codex rather than widened + # into a disjunction, so what it measures stays one host's message. + if [[ -n "${PRESENT[codex]:-}" ]]; then + doctor_names_ref "doctor (project B): $REF_USER drift after project A's clean, names aidd sync" \ + present "$PROJ_B" "$REF_USER" + else + skip "doctor (project B): drift wording after project A's clean (codex not installed)" + fi + run "sync --force (project B) repairs its own plugin refs" 0 "" "$PROJ_B" -- \ + node "$CLI" sync --force + doctor_names_ref "doctor (project B): $REF_USER re-registered after sync --force" \ + absent "$PROJ_B" "$REF_USER" +else + skip "clean --force preserves a shared registration (project A never reached .aidd/)" +fi + +# --- Phase G: clean --scope user is what purges machine scope -------------------- +section "clean --scope user --force" +if [[ -n "$USER_AI_LIST" && -f "$AIDD_USER_CONFIG_DIR/manifest.json" && -n "${PROJ_B:-}" ]]; then + # Project B still holds plugin refs on the shared source, so its own `clean` runs first — + # what `clean --scope user` instructs when `references.json` names other projects. + if [[ -d "$PROJ_B/.aidd" ]]; then + run "clean --force (project B) before the machine-scope purge" 0 "" "$PROJ_B" -- \ + node "$CLI" clean --force + fi + run "clean --scope user --force" 0 "" "$PROJ_U" -- node "$CLI" clean --scope user --force + + for gone in manifest.json references.json cache/built; do + [[ -e "$AIDD_USER_CONFIG_DIR/$gone" ]] \ + && bad "clean --scope user: $gone survives under the user config dir" \ + || ok "clean --scope user: $gone is gone" + done + # Not a failure: the whitelist removes the reserved `aidd-framework` entry alone out of + # `marketplaces.json`, so another machine-scope entry is left declared, pointing at a build + # this run just deleted. Asserted as it is, so a change to it is noticed here. + if grep -qF "\"$MKT_USER\"" "$AIDD_USER_CONFIG_DIR/marketplaces.json" 2>/dev/null; then + ok "clean --scope user: marketplaces.json still declares $MKT_USER (only the reserved name is dropped)" + else + bad "clean --scope user: marketplaces.json no longer declares $MKT_USER — the whitelist used to drop the reserved name alone; if that changed on purpose, this assertion is what needs updating" + fi + + # The host-side proof lives in `cleanup`, which checks every registry for both names + # however the run ended. +else + skip "clean --scope user --force (no user manifest — the scope-user phase never ran)" +fi + +# --- Phase G2: clean --scope user with no user manifest at all ------------------- +# The state a plain project-scope `setup` leaves: no user manifest, yet `userConfigDir()` +# still carries the whitelist's occupants, so steps (1)-(3) are skipped and step (4) runs +# regardless. Driven against a config dir of its own, and it reaches no host binary. +section "clean --scope user --force with no user manifest" +NO_MANIFEST_CONFIG="$TMPROOT/config-no-manifest" +NO_MANIFEST_PROJ=$(mktemp -d "$TMPROOT/proj-referencing.XXXXXX") +mkdir -p "$NO_MANIFEST_CONFIG/cache/built/9.9.9/aidd-framework/claude" +node -e ' + const fs = require("node:fs"); + const [dir, proj] = process.argv.slice(1); + fs.writeFileSync(`${dir}/references.json`, JSON.stringify({ "9.9.9": [proj] })); + fs.writeFileSync(`${dir}/marketplaces.json`, JSON.stringify({ version: 1, marketplaces: [] })); +' "$NO_MANIFEST_CONFIG" "$NO_MANIFEST_PROJ" + +no_manifest_out=$(mktemp) +( cd "$NO_MANIFEST_PROJ" && export AIDD_USER_CONFIG_DIR="$NO_MANIFEST_CONFIG" \ + && exec perl -e 'alarm shift; exec @ARGV' \ + "$CMD_TIMEOUT" node "$CLI" clean --scope user --force ) "$no_manifest_out" 2>&1 +no_manifest_rc=$? +cat "$no_manifest_out" >> "$LOGFILE" +if [[ "$no_manifest_rc" -ne 0 ]]; then + bad "clean --scope user --force (no manifest) exited $no_manifest_rc" "$(cat "$no_manifest_out")" +elif ! grep -qF "No host registration was undone" "$no_manifest_out"; then + bad "clean --scope user --force (no manifest) did not say that nothing was registered at user scope" "$(cat "$no_manifest_out")" +elif ! grep -qF "$NO_MANIFEST_PROJ" "$no_manifest_out"; then + bad "clean --scope user --force (no manifest) did not name the project references.json still lists" "$(cat "$no_manifest_out")" +else + ok "clean --scope user --force (no manifest): purges regardless, names the projects to clean first" +fi +rm -f "$no_manifest_out" +for gone in cache/built references.json; do + [[ -e "$NO_MANIFEST_CONFIG/$gone" ]] \ + && bad "clean --scope user (no manifest): $gone survives the whitelist purge" \ + || ok "clean --scope user (no manifest): $gone is gone" +done + +exit 0 diff --git a/cli/scripts/smoke-tools.sh b/cli/scripts/smoke-tools.sh index ffcdad121..da217b49c 100755 --- a/cli/scripts/smoke-tools.sh +++ b/cli/scripts/smoke-tools.sh @@ -1,17 +1,15 @@ #!/usr/bin/env bash -# Full-surface smoke against the REAL remote framework + real built binary. +# Full-surface smoke against the real built binary: every leaf command, the per-tool ones +# looped over every AI tool and IDE tool, reported as a measured coverage percentage. # -# Goal: exercise EVERY leaf command in the CLI surface, with the per-tool -# commands looped over every AI tool (claude, cursor, copilot, codex, opencode) -# and IDE tool (vscode). Prints a measured command-coverage percentage. +# Hermetic by default — every setup reads the local framework fixture, so a run needs neither +# the network nor a token and coverage never depends on one being reachable. SMOKE_REMOTE=1 +# adds the remote-fetch block at the end, which counts toward neither PASS/FAIL nor coverage +# and is the only thing that exercises the fetch -> cache -> catalog-load path a fixture +# cannot reach. # -# Born from a production crash a user hit on install: -# Error: Invalid plugin manifest: "plugins" must be an array -# The hermetic suites never touch the GitHub fetch -> cache -> catalog-load -# path; this smoke does, including deliberate cache corruption. -# -# Requires network + a GitHub token (AIDD_TOKEN or `gh auth token`). -# Without one, the remote sections are SKIPPED (coverage will read low). +# `ALL_COMMANDS` below is compared against what `derived_leaves` reads live off the binary, +# `telemetry identity`'s four verbs counted as leaves of their own. set -uo pipefail @@ -24,13 +22,14 @@ IDE_TOOLS=(vscode) # Canonical leaf-command surface. Coverage = exercised / total. ALL_COMMANDS=( - "setup" "status" "restore" "update" "doctor" "clean" "self-update" - "ai install" "ai uninstall" "ai list" "ai status" "ai update" "ai restore" "ai doctor" - "ide install" "ide uninstall" "ide list" "ide status" "ide update" "ide restore" "ide doctor" - "plugin create" "plugin remove" "plugin list" "plugin install" "plugin search" "plugin update" "plugin doctor" + "setup" "doctor" "sync" "translate" "update" "clean" + "framework install" "framework update" "framework remove" "framework rules" + "plugin remove" "plugin list" "plugin install" "plugin search" "plugin update" "marketplace add" "marketplace list" "marketplace remove" "marketplace refresh" "marketplace check" "auth login" "auth logout" "auth status" - "framework build" + "telemetry on" "telemetry off" "telemetry read" "telemetry report" "telemetry check" + "telemetry forget" + "telemetry identity use" "telemetry identity off" "telemetry identity link" "telemetry identity unlink" ) PASS=0; FAIL=0; SKIP=0 @@ -44,21 +43,26 @@ bad() { FAIL=$((FAIL+1)); FAILURES+=("$1"$'\n'"${2:-}"); echo " ✗ $1"; } skip() { SKIP=$((SKIP+1)); echo " ~ $1"; } section() { echo; echo "=== $1 === [$(date +%H:%M:%S)]"; } -PARENTS=" ai ide plugin marketplace auth framework " +PARENTS=" plugin marketplace auth framework telemetry " +# A parent one level deeper than PARENTS, so a covered key needs three words there, not two. +GRANDPARENTS=" telemetry identity " derive_key() { - local first="$1" second="${2:-}" - if [[ "$PARENTS" == *" $first "* ]]; then echo "$first $second"; else echo "$first"; fi + local first="$1" second="${2:-}" third="${3:-}" + if [[ "$GRANDPARENTS" == *" $first $second "* ]]; then + echo "$first $second $third" + elif [[ "$PARENTS" == *" $first "* ]]; then + echo "$first $second" + else + echo "$first" + fi } CMD_TIMEOUT="${SMOKE_CMD_TIMEOUT:-180}" # hard ceiling per command (seconds) # run -- -# Timeout is enforced by perl's SIGALRM, which survives exec: perl arms alarm(), -# execs node (replacing its own image), and the still-pending timer kills the node -# process if it overruns. This is synchronous — no background job, no watchdog -# subshell, no `wait` — so a hung or slow command can never wedge the harness; it -# just surfaces as a TIMEOUT. Output goes to a tempfile (not a `$(...)` pipe), so a -# grandchild that outlives the CLI cannot block on an inherited fd. +# perl's SIGALRM survives the exec into node, so the timeout is synchronous — no background +# job to wedge the harness. Output goes to a tempfile, never a `$(...)` pipe, so a grandchild +# outliving the CLI cannot block on an inherited fd. run() { local name="$1" expect_exit="$2" expect="$3" cwd="$4"; shift 4 [[ "${1:-}" == "--" ]] && shift @@ -67,20 +71,17 @@ run() { tmpout=$(mktemp) ( cd "$cwd" && exec perl -e 'alarm shift; exec @ARGV' "$CMD_TIMEOUT" node "$CLI" "$@" ) >"$tmpout" 2>&1 rc=$? - # Content guards scan the captured FILE with grep (C-level, O(n)). Do NOT fold the - # output into a bash var and test it with `${out//[[:space:]]/}`: bash 3.2's global - # pattern substitution with a character class is pathological (100% CPU, minutes) - # on a multi-KB report — e.g. `doctor`/`ai doctor` exiting 1 with a large drift - # report — and would wedge the entire harness instead of the command it guards. + # Content guards grep the captured FILE. Never fold it into a bash var and test with + # `${out//[[:space:]]/}`: bash 3.2's global pattern substitution with a character class is + # pathological on a multi-KB report and wedges the harness rather than the command. local silent=0 missing=0 if [[ "$rc" -ne 0 ]] && ! grep -q '[^[:space:]]' "$tmpout"; then silent=1; fi if [[ -n "$expect" ]] && ! grep -qF -- "$expect" "$tmpout"; then missing=1; fi out=$(cat "$tmpout"); rm -f "$tmpout" # SIGALRM from perl's alarm surfaces as 142 (128+14) — a real overrun, not a normal exit. if [[ "$rc" -eq 142 ]]; then bad "$name (TIMEOUT >${CMD_TIMEOUT}s)" "$out"; return 1; fi - # Universal guard: any non-zero exit that prints NOTHING is a silent failure, - # whatever the command. Generalized form of the plugin-doctor bug (exit 1, empty - # stdout+stderr) — depends on no framework-specific strings. + # Any non-zero exit printing nothing is a silent failure, whatever the command — a guard + # that depends on no framework-specific string. if [[ "$silent" -eq 1 ]]; then bad "$name (silent exit $rc, no output)" "$out"; return 1; fi if [[ "|$expect_exit|" != *"|$rc|"* ]]; then bad "$name (exit $rc, want $expect_exit)" "$out"; return 1; fi if [[ "$missing" -eq 1 ]]; then bad "$name (missing '$expect')" "$out"; return 1; fi @@ -90,22 +91,31 @@ run() { } new_project() { local p; p=$(mktemp -d "$TMPROOT/proj.XXXXXX"); (cd "$p" && git init -q); echo "$p"; } -# Only the marketplaces catalog — NOT the per-target built-marketplace cache -# (.aidd/cache/built/.../marketplace.json), which also matches a bare *marketplace.json glob. -# `find` answers in directory order, which is neither sorted nor the same on two machines. -# Every case below damages the file this returns, so an unsorted pick runs a different case -# on every run - and a case nobody can name is a case nobody can debug. +# Deterministic pick from an unsorted listing: sort, then take the first line. first_file() { LC_ALL=C sort | head -1; } +# The marketplaces catalog alone, never the per-target built cache a bare `*marketplace.json` +# glob would also match. `find` answers in directory order, so `first_file` decides. cache_catalog() { find "$1/.aidd/cache/marketplaces" -path "*marketplace.json" 2>/dev/null | first_file; } -# The drift every restore case writes, and the string its check looks for again afterwards. # A marker rather than a bare newline: a blank line is invisible to `grep`, so a case that # appended one could only ever assert an exit code. DRIFT_MARK="SMOKE_DRIFT" -# A restore that exits 0 having restored nothing is the exact failure #762 fixed in the -# command. The exit code is checked by `run`; this is what checks the repair. +# The file a case damages, picked from the manifest in a fixed order rather than by `find`, +# so it is the same on every machine — and so it survives a tool whose project directory +# holds nothing but a settings file its own CLI registered. +tracked_file() { + node -e ' + const manifest = JSON.parse(require("fs").readFileSync(process.argv[1], "utf-8")); + const tools = process.argv[2] ? [manifest.tools[process.argv[2]]] : Object.values(manifest.tools); + const files = tools.filter(Boolean).flatMap((t) => t.files.map((f) => f.relativePath)).sort(); + if (files[0]) console.log(files[0]); + ' "$1/.aidd/manifest.json" "${2:-}" +} + +# A restore exiting 0 having restored nothing is the failure this guards. `run` checks the +# exit code; this checks the repair. repaired() { local name="$1" file="$2" if [[ -z "$file" ]]; then bad "$name (nothing was drifted to repair)"; return 1; fi @@ -114,27 +124,24 @@ repaired() { } # ── build ─────────────────────────────────────────────────────── -echo "Building dist…" -(cd "$ROOT" && pnpm build) >/dev/null 2>&1 || { echo "FATAL: build failed"; exit 1; } -echo "Build OK · CLI: $CLI" +# `pnpm smoke`/`pnpm smoke:full`, this script's only callers, already built. Check the +# artifact exists instead of building it twice. +[[ -f "$CLI" ]] || { echo "FATAL: $CLI missing — run 'pnpm build' first"; exit 1; } +echo "Using built CLI: $CLI" TMPROOT=$(mktemp -d -t aidd-smoke-tools-XXXXXXXX) export AIDD_USER_CONFIG_DIR="$TMPROOT/cfg"; mkdir -p "$AIDD_USER_CONFIG_DIR" trap 'rm -rf "$TMPROOT"' EXIT +# Read the token before HOME moves: `gh` looks for its credentials under the real one. TOKEN="${AIDD_TOKEN:-$(gh auth token 2>/dev/null || true)}" export AIDD_TOKEN="$TOKEN" # Only now, never earlier: `gh auth token` above reads the REAL home, and moving it first -# makes every authenticated case silently unauthenticated. -# -# Three of the tools this harness loops over activate plugins through their own CLI, and -# those write into the USER's home, not the project directory - a fresh /tmp project -# isolates nothing there. Until this existed the harness sandboxed AIDD_USER_CONFIG_DIR for -# every case and HOME for exactly one, so `plugin install --tool codex|copilot|claude` ran -# against the real ~/.claude, ~/.codex and ~/.copilot of whoever typed `pnpm smoke`. -# CODEX_HOME is separate because HOME does not move Codex: it reads that variable, and falls -# back to the real ~/.codex when it is unset. +# makes every authenticated case silently unauthenticated. Three of the tools looped over +# below activate plugins through their own CLI, which writes into the user's home and not the +# project, so a temp project isolates nothing without this. CODEX_HOME is separate because +# HOME does not move Codex: it reads that variable and falls back to the real `~/.codex`. export HOME="$TMPROOT/home"; mkdir -p "$HOME" export CODEX_HOME="$TMPROOT/codex-home"; mkdir -p "$CODEX_HOME" @@ -147,15 +154,15 @@ run "--version" 0 "aidd/" "$ROOT" -- --version run "unknown command exits non-zero" 1 "" "$ROOT" -- definitely-not-a-command # (version/help are not counted leaves) -section "framework build (local fixture)" +section "translate (local fixture)" FW_OUT="$TMPROOT/fw-out" -if run "framework build --target claude" 0 "" "$ROOT" -- \ - framework build --source "$FRAMEWORK_FIXTURE" --target claude --out "$FW_OUT"; then :; fi +if run "translate --to claude" 0 "" "$ROOT" -- \ + translate "$FRAMEWORK_FIXTURE" --to claude --out "$FW_OUT"; then :; fi -section "plugin create (scaffold)" -PC_OUT="$TMPROOT/pc" -run "plugin create demo --type full --yes" 0 "" "$ROOT" -- \ - plugin create demo --output "$PC_OUT" --type full --yes +# `--as flat` is the other build mode. +FW_FLAT=$(mktemp -d "$TMPROOT/fw-flat.XXXXXX") +run "translate --as flat" 0 "" "$ROOT" -- \ + translate "$FRAMEWORK_FIXTURE" --to claude --as flat --out "$FW_FLAT" --force section "auth (isolated config)" AUTH_HOME="$TMPROOT/auth-home"; mkdir -p "$AUTH_HOME" @@ -165,144 +172,332 @@ run "auth status (no creds)" 0 "" "$P_AUTH" -- auth status out=$(cd "$P_AUTH" && env HOME="$AUTH_HOME" node "$CLI" auth login --token deadbeefdeadbeef --level project 2>&1); rc=$? if [[ "$rc" -eq 0 || "$rc" -eq 1 ]]; then mark_covered "auth login"; ok "auth login (bogus token, no crash, exit $rc)"; else bad "auth login crashed (exit $rc)" "$out"; fi run "auth logout" 0 "" "$P_AUTH" -- auth logout - -section "self-update --check" -out=$(cd "$ROOT" && node "$CLI" self-update --check 2>&1); rc=$? -if [[ "$rc" -eq 0 || "$rc" -eq 1 ]]; then mark_covered "self-update"; ok "self-update --check (exit $rc)"; else bad "self-update crashed (exit $rc)" "$out"; fi +# With no `gh` token reachable, `--gh` must refuse cleanly rather than hang or crash. +run "auth login --gh (no credentials)" "0|1" "" "$P_AUTH" -- auth login --gh --level project + + +section "update --check" +out=$(cd "$ROOT" && node "$CLI" update --check 2>&1); rc=$? +if [[ "$rc" -eq 0 || "$rc" -eq 1 ]]; then mark_covered "update"; ok "update --check (exit $rc)"; else bad "update crashed (exit $rc)" "$out"; fi + +# Comparing the file list before and after is the only assertion that proves `--dry-run` +# wrote nothing. +P_DRY=$(new_project) +(cd "$P_DRY" && node "$CLI" setup --source local --path "$FRAMEWORK_FIXTURE" --ai claude --plugins none --yes >/dev/null 2>&1) +before_dry=$(cd "$P_DRY" && find . -type f | sort | md5) +run "update --dry-run" "0|1" "" "$P_DRY" -- update --dry-run +after_dry=$(cd "$P_DRY" && find . -type f | sort | md5) +if [[ "$before_dry" == "$after_dry" ]]; then + ok "--dry-run wrote nothing" +else + bad "--dry-run changed the project tree" +fi section "marketplace add/list/remove (local source)" P_MKT=$(new_project) MKT_SRC="$TMPROOT/mkt-src"; mkdir -p "$MKT_SRC/.claude-plugin" -printf '%s' '{"name":"local-mkt","version":"1.0.0","plugins":[]}' > "$MKT_SRC/.claude-plugin/marketplace.json" +# `owner` is required by the real claude binary's own marketplace schema; without it every +# native registration against this fixture fails best-effort, aidd still exiting 0. +printf '%s' '{"name":"local-mkt","owner":{"name":"smoke"},"version":"1.0.0","plugins":[]}' > "$MKT_SRC/.claude-plugin/marketplace.json" (cd "$P_MKT" && node "$CLI" setup --source local --path "$FRAMEWORK_FIXTURE" --ai claude --plugins none --yes >/dev/null 2>&1) run "marketplace add (local)" 0 "" "$P_MKT" -- marketplace add local "$MKT_SRC" --yes run "marketplace list" 0 "" "$P_MKT" -- marketplace list run "marketplace check" 0 "" "$P_MKT" -- marketplace check run "marketplace refresh" 0 "" "$P_MKT" -- marketplace refresh +# `--overwrite` replaces a name already registered; without it the second add must refuse. +run "marketplace add (duplicate, no --overwrite)" 1 "" "$P_MKT" -- marketplace add local "$MKT_SRC" --yes +run "marketplace add --overwrite" 0 "" "$P_MKT" -- marketplace add local "$MKT_SRC" --yes --overwrite +# Passing `--scope` is not enough: the two values must write to different places. +P_SCOPE=$(new_project) +(cd "$P_SCOPE" && node "$CLI" setup --source local --path "$FRAMEWORK_FIXTURE" --ai claude --plugins none --yes >/dev/null 2>&1) +run "marketplace add --scope project" 0 "" "$P_SCOPE" -- marketplace add scoped "$MKT_SRC" --yes --scope project +proj_reg="$P_SCOPE/.aidd/marketplaces.json" +if [[ -f "$proj_reg" ]] && grep -q "scoped" "$proj_reg"; then + ok "--scope project writes the project registry" +else + bad "--scope project did not write $proj_reg" +fi +# A second source with its own manifest name: the tool keys its registry by the name inside +# the marketplace, so two aidd marketplaces sharing a source would collide rather than scope. +USER_MKT_SRC="$TMPROOT/user-mkt-src"; mkdir -p "$USER_MKT_SRC/.claude-plugin" +printf '%s' '{"name":"user-mkt","owner":{"name":"smoke"},"version":"1.0.0","plugins":[]}' > "$USER_MKT_SRC/.claude-plugin/marketplace.json" +run "marketplace add --scope user" 0 "" "$P_SCOPE" -- marketplace add userscoped "$USER_MKT_SRC" --yes --scope user +if grep -q "userscoped" "$proj_reg" 2>/dev/null; then + bad "--scope user leaked into the project registry" +else + ok "--scope user stays out of the project registry" +fi +# Where the registration reached the TOOL, which the checks above cannot see: claude declares +# a project marketplace at local scope and a user one in the home settings. The e2e nets are +# blind here by design — they strip the tool binaries from PATH. +if command -v claude >/dev/null 2>&1; then + claude_local="$P_SCOPE/.claude/settings.local.json" + claude_home="$HOME/.claude/settings.json" + # Names the marketplace, not the generic `extraKnownMarketplaces` key any declaration would + # satisfy. Keyed by `local-mkt`, the catalog's own declared name: this file is written by + # `hostName`, never by `scoped`, aidd's local alias for the same entry. + if [[ -f "$claude_local" ]] && grep -q '"local-mkt"' "$claude_local"; then + ok "claude declares the project marketplace at local scope" + else + bad "claude has no local-scope declaration in $claude_local" + fi + if [[ -f "$claude_home" ]] && grep -q '"user-mkt"' "$claude_home"; then + ok "claude declares the user marketplace in the home settings" + else + bad "claude wrote no user-scope declaration in $claude_home" + fi + # The NAME only: a user-scope marketplace is built inside the project that registered it, so + # the home settings legitimately name that project's directory. `"user-mkt"` must be present + # as well as `"local-mkt"` absent, or a negative grep passes vacuously against a + # $claude_home nothing ever wrote. + if [[ -f "$claude_home" ]] && grep -q '"user-mkt"' "$claude_home" \ + && ! grep -q '"local-mkt"' "$claude_home"; then + ok "the project registration stayed out of the home settings" + else + bad "a project-scope registration leaked into the home settings, or $claude_home was never written" + fi +else + skip "claude scope placement (binary not installed)" +fi + +run "marketplace remove (scoped)" 0 "" "$P_SCOPE" -- marketplace remove scoped --yes run "marketplace remove" 0 "removed" "$P_MKT" -- marketplace remove local --yes # ════════════════════════════════════════════════════════════════ -# REMOTE — requires a token +# MAIN MATRIX — local fixture, no network, no token # ════════════════════════════════════════════════════════════════ -if [[ -z "$TOKEN" ]]; then - section "remote sections" - skip "remote setup / per-tool matrix / fault injection skipped (no token)" -else +# Always the local fixture: coverage cannot depend on a token being available, or this suite +# could not gate a build. The genuinely remote path is opted into separately, at the end. +if true; then section "setup — full AI+IDE matrix (--ai all --ide all)" BASE=$(new_project) run "setup --ai all --ide all --plugins recommended" 0 "Installed" "$BASE" -- \ - setup --source remote --ai all --ide all --plugins recommended --yes + setup --source local --path "$FRAMEWORK_FIXTURE" --ai all --ide all --plugins recommended --yes + # A local source ignores `--release`, so this pins that passing it is accepted. + P_REL=$(new_project) + run "setup --release (local source)" 0 "" "$P_REL" -- \ + setup --source local --path "$FRAMEWORK_FIXTURE" --release v1.0.0 --ai claude --plugins none --yes for t in "${AI_TOOLS[@]}"; do [[ -d "$BASE/.${t}" || ( "$t" == copilot && -d "$BASE/.github" ) ]] \ && ok "$t dir present" || bad "$t dir missing after --ai all" done [[ -d "$BASE/.vscode" ]] && ok "vscode dir present" || bad "vscode dir missing" + # Cursor is `installScope: "user"`, so its plugin files land under $HOME and never under + # the project every other tool is checked in. The path is a literal of what this exact + # `--plugins recommended` run produces, never a `find`: the isolation test forbids one. + cursor_plugin_file="$HOME/.cursor/plugins/local/aidd-test/.cursor-plugin/plugin.json" + [[ -f "$cursor_plugin_file" ]] \ + && ok "cursor user-scope plugin file present under \$HOME" \ + || bad "cursor user-scope plugin file missing: $cursor_plugin_file" + + # OpenCode's loader imports `.opencode/plugin/` in-process, so nothing but a real plugin + # module belongs there; hook scripts are namespaced under `.opencode/hooks//`. + # Counting non-.js files never depends on find's order, and the directory existing at all + # is not asserted: this fixture's plugin maps no bridged event. + oc_plugin_dir="$BASE/.opencode/plugin" + oc_hooks_dir="$BASE/.opencode/hooks" + if [[ -d "$oc_plugin_dir" ]]; then + non_js=$(find "$oc_plugin_dir" -maxdepth 1 -type f ! -name '*.js' | wc -l | tr -d ' ') + [[ "$non_js" == "0" ]] \ + && ok "opencode .opencode/plugin/ holds only .js modules" \ + || bad "opencode .opencode/plugin/ holds $non_js non-.js file(s)" + else + ok "opencode .opencode/plugin/ absent (this fixture's plugin maps no bridged event)" + fi + [[ -d "$oc_hooks_dir" && -n "$(ls -A "$oc_hooks_dir" 2>/dev/null)" ]] \ + && ok "opencode .opencode/hooks/ populated" \ + || bad "opencode .opencode/hooks/ missing or empty" section "global read-only commands (no crash)" - run "status" 0 "" "$BASE" -- status - # doctor exits 1 by design when it finds drift (e.g. framework-shipped broken - # references on a fresh --ai all install); 0 or 1 are both non-crash here, and - # the silent-exit guard above still rejects an exit 1 that prints nothing. + # doctor exits 1 by design on drift, so 0 and 1 are both non-crash here; the silent-exit + # guard above still rejects an exit 1 that prints nothing. run "doctor" "0|1" "" "$BASE" -- doctor - run "update" 0 "" "$BASE" -- update - - section "global restore" - tgt=$(find "$BASE/.claude" -name "*.md" | first_file) - if [[ -n "$tgt" ]]; then printf '\n%s\n' "$DRIFT_MARK" >> "$tgt"; fi - run "restore --force" 0 "" "$BASE" -- restore --force - repaired "restore --force" "$tgt" - - section "ai per-tool commands × all 5 tools" - run "ai list" 0 "" "$BASE" -- ai list - run "ai status" 0 "" "$BASE" -- ai status - run "ai doctor" "0|1" "" "$BASE" -- ai doctor - run "ai update (all)" 0 "" "$BASE" -- ai update - d=$(find "$BASE/.cursor" -name "*.md" 2>/dev/null | first_file) - [[ -n "$d" ]] && printf '\n%s\n' "$DRIFT_MARK" >> "$d" - run "ai restore --force" 0 "" "$BASE" -- ai restore --force - repaired "ai restore --force" "$d" + for t in "${AI_TOOLS[@]}" vscode; do + run "doctor --tool $t" "0|1" "" "$BASE" -- doctor --tool "$t" + done + + section "global sync" + tgt=$(tracked_file "$BASE") + [[ -n "$tgt" ]] && tgt="$BASE/$tgt" && printf '\n%s\n' "$DRIFT_MARK" >> "$tgt" + run "sync --force" 0 "" "$BASE" -- sync --force + repaired "sync --force" "$tgt" + + section "framework install/update/remove --tool × all 5 AI tools + vscode" + run "framework update (all)" 0 "" "$BASE" -- framework update + run "framework rules" 0 "" "$BASE" -- framework rules + run "framework rules --json" 0 "" "$BASE" -- framework rules --json + d=$(tracked_file "$BASE" cursor) + [[ -n "$d" ]] && d="$BASE/$d" && printf '\n%s\n' "$DRIFT_MARK" >> "$d" + run "sync --tool cursor" 0 "" "$BASE" -- sync --tool cursor --force + repaired "sync --tool cursor" "$d" + run "sync --plugin" 0 "" "$BASE" -- sync --force --plugin aidd-test for t in "${AI_TOOLS[@]}"; do - run "ai update $t" 0 "" "$BASE" -- ai update "$t" + run "framework update --tool $t" 0 "" "$BASE" -- framework update --tool "$t" done - # install/uninstall lifecycle per tool in an isolated project + run "framework update --tool vscode" 0 "" "$BASE" -- framework update --tool vscode + # install/remove lifecycle per tool in an isolated project P_AI=$(new_project) - (cd "$P_AI" && node "$CLI" setup --source remote --ai claude --yes >/dev/null 2>&1) + (cd "$P_AI" && node "$CLI" setup --source local --path "$FRAMEWORK_FIXTURE" --ai claude --yes >/dev/null 2>&1) for t in "${AI_TOOLS[@]}"; do - run "ai install $t" 0 "" "$P_AI" -- ai install "$t" --force - run "ai uninstall $t" 0 "" "$P_AI" -- ai uninstall "$t" + run "framework install --tool $t" 0 "" "$P_AI" -- framework install --tool "$t" --force + run "framework install --tool $t --no-plugins" 0 "" "$P_AI" -- framework install --tool "$t" --force --no-plugins + run "framework remove --tool $t" 0 "" "$P_AI" -- framework remove --tool "$t" done - - section "ide per-tool commands (vscode)" - run "ide list" 0 "" "$BASE" -- ide list - run "ide status" 0 "" "$BASE" -- ide status - run "ide doctor" 0 "" "$BASE" -- ide doctor - run "ide update" 0 "" "$BASE" -- ide update vscode - # A blank line was the drift here, and `grep` cannot see one - so this case could only ever - # assert an exit code. The same mark as the other two, for the same reason. - i=$(find "$BASE/.vscode" -type f | first_file) - [[ -n "$i" ]] && printf '\n%s\n' "$DRIFT_MARK" >> "$i" - run "ide restore --force" 0 "" "$BASE" -- ide restore --force - repaired "ide restore --force" "$i" P_IDE=$(new_project) - (cd "$P_IDE" && node "$CLI" setup --source remote --ide vscode --plugins none --yes >/dev/null 2>&1) - run "ide uninstall vscode" 0 "" "$P_IDE" -- ide uninstall vscode - run "ide install vscode" 0 "" "$P_IDE" -- ide install vscode --force + (cd "$P_IDE" && node "$CLI" setup --source local --path "$FRAMEWORK_FIXTURE" --ide vscode --plugins none --yes >/dev/null 2>&1) + run "framework remove --tool vscode" 0 "" "$P_IDE" -- framework remove --tool vscode + run "framework install --tool vscode" 0 "" "$P_IDE" -- framework install --tool vscode --force section "plugin commands × tools" run "plugin list" 0 "" "$BASE" -- plugin list - # plugin doctor is plugin-scoped: a fresh install has healthy plugins, so it - # must print "healthy" and exit 0. This pins the silent-exit-1 regression fix. - run "plugin doctor" 0 "healthy" "$BASE" -- plugin doctor + # doctor --plugin is plugin-scoped: a fresh install has healthy plugins, so it + # must print "healthy" and exit 0. This pins the silent-exit-1 regression fix + # `plugin doctor` used to guard (folded into `doctor --plugin` in phase 18). + run "doctor --plugin" 0 "healthy" "$BASE" -- doctor --plugin aidd-test run "plugin search aidd" 0 "" "$BASE" -- plugin search aidd + run "plugin search --recommended" 0 "" "$BASE" -- plugin search aidd --recommended + run "plugin search --marketplace" 0 "" "$BASE" -- plugin search aidd --marketplace aidd-framework run "plugin update (all)" 0 "" "$BASE" -- plugin update P_PLUG=$(new_project) - (cd "$P_PLUG" && node "$CLI" setup --source remote --ai all --plugins none --yes >/dev/null 2>&1) + (cd "$P_PLUG" && node "$CLI" setup --source local --path "$FRAMEWORK_FIXTURE" --ai all --plugins none --yes >/dev/null 2>&1) for t in "${AI_TOOLS[@]}"; do - run "plugin install aidd-dev → $t" 0 "" "$P_PLUG" -- plugin install aidd-dev --tool "$t" --yes + run "plugin install aidd-test → $t" 0 "" "$P_PLUG" -- plugin install aidd-test --tool "$t" --yes + run "plugin remove → $t" 0 "" "$P_PLUG" -- plugin remove aidd-test --tool "$t" + # `--from` names the marketplace explicitly. + run "plugin install --from → $t" 0 "" "$P_PLUG" -- \ + plugin install aidd-test --tool "$t" --from aidd-framework --yes done - run "plugin remove aidd-dev (claude)" 0 "" "$P_PLUG" -- plugin remove aidd-dev --tool claude - - # ── #286 update conflict guard ──────────────────────────────── - # The hermetic e2e proves the guard on a fake tree; this pins it against the - # REAL remote framework files: a user-modified tracked file must BLOCK update - # in non-TTY (exit 1, demand --force) and --force must overwrite it (exit 0). - # Covers all three fan-outs: top-level `update`, `ai update`, `ide update`. - section "update conflict guard (#286) — modified file blocks, --force overwrites" - # Pick the FIRST manifest-tracked file for a tool (any extension) — deterministic, - # unlike a `.md` find heuristic which is empty with --plugins none. - first_tracked() { - node -e 'const m=require(process.argv[1]);const f=(m.tools?.[process.argv[2]]?.files||[])[0];process.stdout.write(f?f.relativePath:"")' \ - "$1/.aidd/manifest.json" "$2" 2>/dev/null - } + run "plugin remove aidd-test (claude)" 0 "" "$P_PLUG" -- plugin remove aidd-test --tool claude + + # ── update conflict guard ───────────────────────────────────── + # A user-modified tracked file must block `framework update` in a non-TTY (exit 1, demanding + # --force) and --force must overwrite it. Bare `update` is self-update and touches no + # project file, so the project-wide sweep this guards lives at `framework update`. + section "framework update conflict guard (#286) — modified file blocks, --force overwrites" P_GUARD=$(new_project) - (cd "$P_GUARD" && node "$CLI" setup --source remote --ai claude --ide vscode --plugins none --yes >/dev/null 2>&1) - gc=$(first_tracked "$P_GUARD" claude) + (cd "$P_GUARD" && node "$CLI" setup --source local --path "$FRAMEWORK_FIXTURE" --ai claude --ide vscode --plugins none --yes >/dev/null 2>&1) + # The blocking half runs outside `run()`: a drift plant must sit directly above the run that + # repairs it, since the isolation test pairs the two by source position. It keeps its own + # exit-code and message assertions, plus the check that the file is still drifted. + gc=$(tracked_file "$P_GUARD" claude) if [[ -z "$gc" ]]; then bad "no tracked claude file in manifest (#286 guard)" else - printf '\nUSER EDIT\n' >> "$P_GUARD/$gc" - run "update (modified, non-TTY) → exit 1, demands --force" 1 "force" "$P_GUARD" -- update - run "update --force overwrites modified file" 0 "" "$P_GUARD" -- update --force - printf '\nUSER EDIT 2\n' >> "$P_GUARD/$gc" - run "ai update (modified, non-TTY) → exit 1, demands --force" 1 "force" "$P_GUARD" -- ai update - run "ai update --force overwrites modified file" 0 "" "$P_GUARD" -- ai update --force + gcf="$P_GUARD/$gc" + printf '\n%s\n' "$DRIFT_MARK" >> "$gcf" + out=$(cd "$P_GUARD" && node "$CLI" framework update 2>&1); rc=$? + if [[ "$rc" -eq 1 && "$out" == *"force"* ]] && grep -qF -- "$DRIFT_MARK" "$gcf"; then + ok "framework update (all, modified, non-TTY) blocks, file intact" + else + bad "framework update (all, modified, non-TTY) did not block cleanly (exit $rc)" "$out" + fi + run "framework update --force overwrites modified file" 0 "" "$P_GUARD" -- framework update --force + repaired "framework update --force overwrites modified file" "$gcf" + + printf '\n%s\n' "$DRIFT_MARK" >> "$gcf" + out=$(cd "$P_GUARD" && node "$CLI" framework update --tool claude 2>&1); rc=$? + if [[ "$rc" -eq 1 && "$out" == *"force"* ]] && grep -qF -- "$DRIFT_MARK" "$gcf"; then + ok "framework update --tool claude (modified, non-TTY) blocks, file intact" + else + bad "framework update --tool claude (modified, non-TTY) did not block cleanly (exit $rc)" "$out" + fi + run "framework update --tool claude --force overwrites modified file" 0 "" "$P_GUARD" -- framework update --tool claude --force + repaired "framework update --tool claude --force overwrites modified file" "$gcf" fi - gv=$(first_tracked "$P_GUARD" vscode) + gv=$(tracked_file "$P_GUARD" vscode) if [[ -z "$gv" ]]; then - skip "ide update guard (no tracked vscode file in manifest)" + skip "framework update --tool vscode guard (no tracked vscode file in manifest)" else - printf '\n; user edit\n' >> "$P_GUARD/$gv" - run "ide update (modified, non-TTY) → exit 1, demands --force" 1 "force" "$P_GUARD" -- ide update - run "ide update --force overwrites modified file" 0 "" "$P_GUARD" -- ide update --force + gvf="$P_GUARD/$gv" + printf '\n%s\n' "$DRIFT_MARK" >> "$gvf" + out=$(cd "$P_GUARD" && node "$CLI" framework update --tool vscode 2>&1); rc=$? + if [[ "$rc" -eq 1 && "$out" == *"force"* ]] && grep -qF -- "$DRIFT_MARK" "$gvf"; then + ok "framework update --tool vscode (modified, non-TTY) blocks, file intact" + else + bad "framework update --tool vscode (modified, non-TTY) did not block cleanly (exit $rc)" "$out" + fi + run "framework update --tool vscode --force overwrites modified file" 0 "" "$P_GUARD" -- framework update --tool vscode --force + repaired "framework update --tool vscode --force overwrites modified file" "$gvf" fi section "clean" P_CLEAN=$(new_project) - (cd "$P_CLEAN" && node "$CLI" setup --source remote --ai claude --plugins none --yes >/dev/null 2>&1) + (cd "$P_CLEAN" && node "$CLI" setup --source local --path "$FRAMEWORK_FIXTURE" --ai claude --plugins none --yes >/dev/null 2>&1) + # A machine-local file is installed outside the manifest, so a clean that reads only tracked + # files leaves it behind. + mkdir -p "$P_CLEAN/.claude" && echo '{}' > "$P_CLEAN/.claude/settings.local.json" run "clean --force" 0 "" "$P_CLEAN" -- clean --force [[ ! -d "$P_CLEAN/.aidd" ]] && ok ".aidd removed after clean" || bad ".aidd survived clean" + [[ ! -f "$P_CLEAN/.claude/settings.local.json" ]] \ + && ok ".claude/settings.local.json removed after clean" \ + || bad ".claude/settings.local.json survived clean" + + # ── telemetry ──────────────────────────────────────────────── + # HOME and AIDD_USER_CONFIG_DIR are both under TMPROOT, so `forget` deletes a sandbox and + # never a person's own profile. + section "telemetry" + P_TEL=$(new_project) + run "telemetry check (before anything)" "0|1" "" "$P_TEL" -- telemetry check + run "telemetry on --yes" 0 "" "$P_TEL" -- telemetry on --yes + [[ -f "$P_TEL/.aidd/config.json" ]] && ok "telemetry on writes the switch" || bad "no .aidd/config.json after telemetry on" + run "telemetry identity use" 0 "" "$P_TEL" -- telemetry identity use + run "telemetry identity off" 0 "" "$P_TEL" -- telemetry identity off + # link/unlink need a real identifier, so `--help` pins the leaf without mutating a profile. + run "telemetry identity link --help" 0 "" "$P_TEL" -- telemetry identity link --help + run "telemetry identity unlink --help" 0 "" "$P_TEL" -- telemetry identity unlink --help + run "telemetry read" 0 "" "$P_TEL" -- telemetry read + run "telemetry report" 0 "" "$P_TEL" -- telemetry report + run "telemetry off" 0 "" "$P_TEL" -- telemetry off + run "telemetry forget --yes" 0 "" "$P_TEL" -- telemetry forget --yes + + # A lefthook-owned repository regenerates prepare-commit-msg on every install, wiping any + # line `telemetry on` appends, so it must print the job to add by hand instead of promising + # a trailer. Detection reads a root marker file, never a real lefthook binary. + P_TEL_LEFTHOOK=$(new_project) + cat > "$P_TEL_LEFTHOOK/lefthook.yml" <<'LEFTHOOK_YML' +pre-commit: + commands: + example: + run: echo hi +LEFTHOOK_YML + run "telemetry on --yes (lefthook-owned hook)" 0 "prepare-commit-msg:" "$P_TEL_LEFTHOOK" -- telemetry on --yes + + # Once a manager owns prepare-commit-msg, `on` writes the delegate to the common git dir and + # ignores core.hooksPath, which husky routes under `.husky/`; `off` must resolve the same way + # or it finds nothing to delete under exactly this divergence. + P_TEL_HUSKY=$(new_project) + mkdir -p "$P_TEL_HUSKY/.husky" + cat > "$P_TEL_HUSKY/.husky/prepare-commit-msg" <<'HUSKY_HOOK' +#!/bin/sh +echo husky-owned +HUSKY_HOOK + chmod +x "$P_TEL_HUSKY/.husky/prepare-commit-msg" + (cd "$P_TEL_HUSKY" && git config core.hooksPath .husky) + run "telemetry on --yes (husky core.hooksPath)" 0 "husky" "$P_TEL_HUSKY" -- telemetry on --yes + [[ -f "$P_TEL_HUSKY/.git/hooks/aidd-session-trailer.sh" ]] \ + && ok "telemetry on writes the delegate to the common hooks dir under husky" \ + || bad "delegate missing from .git/hooks under husky's core.hooksPath" + run "telemetry off (husky core.hooksPath)" 0 "" "$P_TEL_HUSKY" -- telemetry off + [[ ! -f "$P_TEL_HUSKY/.git/hooks/aidd-session-trailer.sh" ]] \ + && ok "telemetry off removes the delegate even when core.hooksPath diverges" \ + || bad "delegate survived telemetry off under husky's core.hooksPath" + +fi - # ── corrupt-cache fault injection (seed regression) ─────────── +# ════════════════════════════════════════════════════════════════ +# REMOTE — opt-in, proves the fetch path only +# ════════════════════════════════════════════════════════════════ +# What a fixture cannot prove: that fetching a framework from a real remote source works. +# Opted into explicitly, never triggered by whatever credentials the machine happens to hold. +if [[ -n "${SMOKE_REMOTE:-}" ]]; then + section "remote fetch (opt-in)" + P_REMOTE=$(new_project) + run "setup --source remote" 0 "" "$P_REMOTE" -- setup --source remote --ai claude --plugins none --yes + + # ── corrupt-cache fault injection ───────────────────────────── + # Remote on purpose: it corrupts the fetched catalog cache, which only a remote source + # populates — a local source is read directly and its built cache regenerated. The plugin is + # `aidd-dev` because the published marketplace does not serve the fixture's own. section "corrupt-cache fault injection × malformed shapes" BAD_SHAPES=( '{"message":"API rate limit exceeded"}' @@ -312,20 +507,69 @@ else ) for shape in "${BAD_SHAPES[@]}"; do p=$(new_project) - (cd "$p" && node "$CLI" setup --source remote --ai claude --plugins recommended --yes >/dev/null 2>&1) + # `--plugins none` on purpose: an already-installed plugin makes the install below refuse + # before it ever reads the corrupt catalog. + (cd "$p" && node "$CLI" setup --source remote --ai claude --plugins none --yes >/dev/null 2>&1) catalog=$(cache_catalog "$p") if [[ -z "$catalog" ]]; then bad "no cached catalog (shape: $shape)"; continue; fi printf '%s' "$shape" > "$catalog" out=$(cd "$p" && node "$CLI" plugin install aidd-dev --yes 2>&1); rc=$? - if [[ "$rc" -eq 0 ]]; then bad "install should fail on corrupt cache (shape: $shape)" "$out" - elif [[ "$out" == *"marketplace refresh --force"* ]]; then ok "corrupt → actionable error (${shape:0:22})" - else bad "corrupt → non-actionable (shape: $shape)" "$out"; fi + # A fetched catalog is a cache, and a CLI-owned file is regenerated rather than errored + # over. Either outcome is coherent — recover silently, or fail naming what to run — + # and failing with neither is the defect. + if [[ "$rc" -eq 0 ]]; then + # What a user sees, never whether the cache file was rewritten: only some shapes rewrite + # it, an internal difference that would make such an assertion flap. + if (cd "$p" && node "$CLI" plugin list >/dev/null 2>&1); then + ok "corrupt → recovered, CLI still usable (${shape:0:22})" + else + bad "install succeeded but left the CLI broken (shape: $shape)" "$out" + fi + elif [[ "$out" == *"marketplace refresh --force"* ]]; then + ok "corrupt → actionable error (${shape:0:22})" + else + bad "corrupt → failed without an actionable message (shape: $shape)" "$out" + fi (cd "$p" && node "$CLI" marketplace refresh --force >/dev/null 2>&1) (cd "$p" && node "$CLI" plugin list >/dev/null 2>&1) && ok "refresh --force heals (${shape:0:22})" || bad "heal failed (shape: $shape)" done +else + section "remote fetch (opt-in)" + skip "remote fetch not exercised (set SMOKE_REMOTE=1)" fi # ── coverage report ───────────────────────────────────────────── +# `ALL_COMMANDS` is written by hand and the binary is what a person gets, so a command added +# to one and not the other reads as covered while nothing ever ran it. Recursive rather than +# one level deep: a parent whose own children are parents would otherwise read as one leaf. +has_subcommands() { + node "$CLI" "$@" --help 2>/dev/null | grep -q '^Commands:' +} + +leaves_under() { + local path=("$@") name + # `cut -d'|'`: commander prints an alias as `update|upgrade`, one command with two names. + for name in $(node "$CLI" "${path[@]}" --help 2>/dev/null | awk '/^Commands:/{f=1;next} f && /^ [a-z]/{print $1}' | cut -d'|' -f1); do + [[ "$name" == "help" ]] && continue + if has_subcommands "${path[@]}" "$name"; then + leaves_under "${path[@]}" "$name" + else + echo "${path[*]} $name" | sed 's/^ *//' + fi + done +} + +derived_leaves() { leaves_under; } + +section "command list" +declared=$(printf '%s\n' "${ALL_COMMANDS[@]}" | sort) +actual=$(derived_leaves | sort) +if [[ "$declared" == "$actual" ]]; then + ok "the list this suite exercises is the list the binary offers" +else + bad "ALL_COMMANDS has drifted from the binary" "$(diff <(echo "$declared") <(echo "$actual") || true)" +fi + section "command coverage" covered=0; total=${#ALL_COMMANDS[@]}; missing=() for c in "${ALL_COMMANDS[@]}"; do @@ -344,7 +588,8 @@ echo "PASS: $PASS FAIL: $FAIL SKIP: $SKIP · coverage ${pct}%" if [[ "$FAIL" -gt 0 ]]; then echo; echo "Failures:"; for f in "${FAILURES[@]}"; do echo " • $f"; echo; done fi -# Fail the smoke if anything broke OR coverage fell below 95% while a token was present. +# Neither the failure check nor the 95% coverage floor is gated on a token: the hermetic +# matrix above runs the same either way. if [[ "$FAIL" -gt 0 ]]; then exit 1; fi -if [[ -n "$TOKEN" && "$pct" -lt 95 ]]; then echo "Coverage below 95% threshold."; exit 1; fi +if [[ "$pct" -lt 95 ]]; then echo "Coverage below 95% threshold."; exit 1; fi exit 0 diff --git a/cli/src/application/commands/ai.ts b/cli/src/application/commands/ai.ts deleted file mode 100644 index 434482308..000000000 --- a/cli/src/application/commands/ai.ts +++ /dev/null @@ -1,292 +0,0 @@ -import type { Command } from "commander"; -import { DOCS_DIR } from "../../domain/models/paths.js"; -import type { AiToolId } from "../../domain/models/tool-ids.js"; -import { AI_TOOL_IDS, isAiToolId } from "../../domain/models/tool-ids.js"; -import type { ToolId } from "../../domain/tools/registry.js"; -import { createDeps, createMenuDeps } from "../../infrastructure/deps.js"; -import { - printInstalledRules, - printInstalledRulesJson, -} from "../display/installed-rules-display.js"; -import { printUnrestorable } from "../display/restore-display.js"; -import { ErrorHandler } from "../error-handler.js"; -import { NoManifestError } from "../errors.js"; -import { parseGlobalOptions } from "./global-options.js"; -import { spawnCliCommand } from "./shared/spawn-cli-command.js"; - -function assertAiToolId(toolId: string): asserts toolId is AiToolId { - if (!isAiToolId(toolId)) { - throw new Error(`Unknown AI tool: ${toolId}. Valid AI tools: ${AI_TOOL_IDS.join(", ")}`); - } -} - -export function registerAiCommand(program: Command): void { - const ai = program - .command("ai") - .description("Manage AI tools (claude, cursor, copilot, codex, opencode)"); - - ai.action(async () => { - if (!process.stdout.isTTY) { - ai.help(); - return; - } - const { prompter } = createMenuDeps(process.cwd()); - const choice = await prompter.select("ai: what do you want to do?", [ - { name: "Install an AI tool", value: "install", description: "requires tool arg" }, - { name: "Uninstall an AI tool", value: "uninstall", description: "requires tool arg" }, - { name: "List installed AI tools", value: "list" }, - { name: "Show AI tool status", value: "status" }, - { name: "Update AI tools", value: "update" }, - { name: "Restore AI tool files", value: "restore" }, - { name: "Doctor AI tools", value: "doctor" }, - ]); - await spawnCliCommand(["ai", choice]); - }); - - ai.command("install ") - .description("Install an AI tool runtime configuration from bundled assets") - .option("-f, --force", "Overwrite already-installed tool", false) - .option("--no-plugins", "Skip propagation of already-installed plugins onto the new tool") - .action(async (toolArg: string, cmdOptions: { force: boolean; plugins: boolean }) => { - const { verbose, output, projectRoot } = parseGlobalOptions(program); - const errorHandler = new ErrorHandler(output); - try { - assertAiToolId(toolArg); - const deps = await createDeps(projectRoot, { verbose }, output); - const version = deps.currentVersionProvider.get(); - const result = await deps.installAiToolUseCase.execute({ - toolId: toolArg, - projectRoot, - force: cmdOptions.force, - version, - propagatePlugins: cmdOptions.plugins, - }); - if (result.runtimeResult.skipped) { - output.warn(`${toolArg} is already installed. Use \`--force\` to reinstall.`); - return; - } - for (const w of result.runtimeResult.warnings) output.warn(w); - for (const w of result.propagationWarnings) output.warn(w); - output.success(`Installed ${toolArg} (${result.runtimeResult.fileCount} files)`); - } catch (error) { - errorHandler.handle(error); - } - }); - - ai.command("uninstall ") - .description("Remove an AI tool's generated configuration files") - .action(async (toolArg: string) => { - const { verbose, output, projectRoot } = parseGlobalOptions(program); - const errorHandler = new ErrorHandler(output); - try { - assertAiToolId(toolArg); - const deps = await createDeps(projectRoot, { verbose }, output); - const results = await deps.uninstallUseCase.execute({ - toolIds: [toolArg as ToolId], - projectRoot, - mcpFilter: [], - }); - const totalFileCount = results.reduce((sum, r) => sum + r.fileCount, 0); - output.success(`Uninstalled ${results[0].toolId} (${totalFileCount} files removed)`); - } catch (error) { - errorHandler.handle(error); - } - }); - - ai.command("list") - .description("List installed AI tools") - .action(async () => { - const { verbose, output, projectRoot } = parseGlobalOptions(program); - const errorHandler = new ErrorHandler(output); - try { - const deps = await createDeps(projectRoot, { verbose }, output); - const manifest = await deps.manifestRepo.load(); - if (!manifest) { - output.info("No tools installed. Run `aidd setup` to get started."); - return; - } - const aiIds = manifest.getInstalledToolIds().filter(isAiToolId); - if (aiIds.length === 0) { - output.info("No AI tools installed."); - return; - } - for (const id of aiIds) output.print(id); - } catch (error) { - errorHandler.handle(error); - } - }); - - ai.command("rules") - .description("List the rules installed in this project, across every AI tool") - .option("--json", "Print the inventory as JSON") - .action(async (cmdOptions: { json?: boolean }) => { - const { verbose, output, projectRoot } = parseGlobalOptions(program); - const errorHandler = new ErrorHandler(output); - try { - const deps = await createDeps(projectRoot, { verbose }, output); - const { rules } = await deps.listInstalledRulesUseCase.execute({ projectRoot }); - if (cmdOptions.json) printInstalledRulesJson(output, rules); - else printInstalledRules(output, rules); - } catch (error) { - errorHandler.handle(error); - } - }); - - ai.command("status") - .description("Show drift for AI tools (optionally filtered by tool and/or plugin)") - .option("--tool ", "Limit status to a specific AI tool") - .option("--plugin ", "Limit status to a specific plugin") - .action(async (cmdOptions: { tool?: string; plugin?: string }) => { - const { verbose, output, projectRoot } = parseGlobalOptions(program); - const errorHandler = new ErrorHandler(output); - try { - if (cmdOptions.tool !== undefined) assertAiToolId(cmdOptions.tool); - const deps = await createDeps(projectRoot, { verbose }, output); - const report = await deps.statusUseCase.execute({ - projectRoot, - filterToolId: cmdOptions.tool as AiToolId | undefined, - category: "ai", - pluginName: cmdOptions.plugin, - }); - if (report.inSync) { - output.success("All AI tool files are in sync"); - return; - } - for (const tool of report.tools) { - if (tool.drifted.length === 0) { - output.print(`${tool.toolId} (v${tool.version}): in sync`); - continue; - } - output.print(`${tool.toolId} (v${tool.version}):`); - for (const f of tool.drifted) output.print(` ${f.status} ${f.relativePath}`); - } - for (const entry of report.pluginDrift) { - output.print( - ` plugin ${entry.pluginName} (${entry.toolId}): ${entry.driftedFiles.length} file(s) modified` - ); - } - } catch (error) { - errorHandler.handle(error); - } - }); - - ai.command("update [tool]") - .description("Re-install AI tool configs from bundled CLI assets") - .option("-f, --force", "Overwrite modified files without prompting", false) - .action(async (toolArg: string | undefined, cmdOptions: { force: boolean }) => { - const { verbose, output, projectRoot } = parseGlobalOptions(program); - const errorHandler = new ErrorHandler(output); - try { - if (toolArg !== undefined) assertAiToolId(toolArg); - const deps = await createDeps(projectRoot, { verbose }, output); - const result = await deps.updateAiToolsUseCase.execute({ - toolArg: toolArg as AiToolId | undefined, - projectRoot, - userForce: cmdOptions.force, - interactive: process.stdout.isTTY ?? false, - }); - if (result.updatedTools.length === 0 && result.errors.length === 0) { - output.info("No AI tools installed."); - return; - } - for (const t of result.updatedTools) { - output.success(`Updated ${t.toolId} (${t.fileCount} files)`); - } - for (const e of result.errors) { - output.warn(`[${e.scope}] ${e.message}`); - } - } catch (error) { - errorHandler.handle(error); - } - }); - - ai.command("restore [files...]") - .description("Restore AI tool tracked files to their installed version") - .option("-f, --force", "Restore without prompting", false) - .option("--tool ", "Limit restore to a specific AI tool") - .option("--plugin ", "Limit restore to a specific plugin") - .action( - async ( - fileArgs: string[], - cmdOptions: { force: boolean; tool?: string; plugin?: string } - ) => { - const { verbose, output, projectRoot } = parseGlobalOptions(program); - const errorHandler = new ErrorHandler(output); - try { - if (cmdOptions.tool !== undefined) { - assertAiToolId(cmdOptions.tool); - } - const deps = await createDeps(projectRoot, { verbose }, output); - const manifest = await deps.manifestRepo.load(); - if (!manifest) throw new NoManifestError(); - const version = - manifest - .getInstalledToolIds() - .map((id) => manifest.getToolVersion(id)) - .find((v) => v !== undefined) ?? deps.currentVersionProvider.get(); - const toolIds: ToolId[] | undefined = cmdOptions.tool - ? [cmdOptions.tool as ToolId] - : manifest.getInstalledToolIds().filter(isAiToolId); - const result = await deps.restoreUseCase.execute({ - version, - docsDir: DOCS_DIR, - projectRoot, - toolIds, - files: fileArgs.length > 0 ? fileArgs : undefined, - force: cmdOptions.force, - interactive: process.stdout.isTTY, - manifest, - pluginName: cmdOptions.plugin, - }); - const nothingDone = result.tools.every((t) => t.nothingToRestore); - if (nothingDone) { - output.success("Nothing to restore — all files are unmodified."); - return; - } - const restored = result.totalRestored; - const kept = result.totalKept; - output.success( - `Restored ${restored} ${restored === 1 ? "file" : "files"}, kept ${kept} ${kept === 1 ? "file" : "files"}` - ); - printUnrestorable(output, result.unrestorable); - } catch (error) { - errorHandler.handle(error); - } - } - ); - - ai.command("doctor") - .description("Check AI tool installation health (optionally filtered by plugin)") - .option("--plugin ", "Limit doctor to a specific plugin") - .action(async (cmdOptions: { plugin?: string }) => { - const { verbose, output, projectRoot } = parseGlobalOptions(program); - const errorHandler = new ErrorHandler(output); - try { - const deps = await createDeps(projectRoot, { verbose }, output); - const report = await deps.doctorUseCase.execute({ - projectRoot, - category: "ai", - pluginName: cmdOptions.plugin, - }); - if (report.healthy) { - output.success("AI tool installation is healthy"); - return; - } - for (const issue of report.issues) { - const text = `${issue.message}\n Fix: ${issue.fix}`; - if (issue.severity === "error") output.error(text); - else output.warn(text); - } - // Health also accounts for plugin issues; render them too so an exit - // driven solely by a plugin defect never goes silent. - for (const pi of report.pluginIssues) { - output.error( - `Plugin ${pi.pluginName} (${pi.toolId}): ${pi.issue} — ${pi.filePath}\n Fix: Run \`aidd ai restore\` to restore.` - ); - } - process.exit(1); - } catch (error) { - errorHandler.handle(error); - } - }); -} diff --git a/cli/src/application/commands/clean.ts b/cli/src/application/commands/clean.ts deleted file mode 100644 index b4fa31676..000000000 --- a/cli/src/application/commands/clean.ts +++ /dev/null @@ -1,50 +0,0 @@ -import type { Command } from "commander"; -import { createDeps } from "../../infrastructure/deps.js"; -import { ErrorHandler } from "../error-handler.js"; -import { parseGlobalOptions } from "./global-options.js"; - -export function registerCleanCommand(program: Command): void { - program - .command("clean") - .description("Remove all AIDD-managed files from the project") - .option("--force", "Confirm file removal (skip dry-run)", false) - .action(async (cmdOptions: { force: boolean }) => { - const { verbose, output, projectRoot } = parseGlobalOptions(program); - const errorHandler = new ErrorHandler(output); - - try { - const deps = await createDeps(projectRoot, { verbose }, output); - const result = await deps.cleanUseCase.execute({ - projectRoot, - force: cmdOptions.force, - interactive: process.stdout.isTTY, - }); - - if (!result.manifestFound) { - output.success("Nothing to clean"); - return; - } - - if (result.dryRun) { - output.print("The following will be removed:"); - for (const tool of result.preview.tools) { - output.print(` ${tool.toolId}: ${tool.fileCount} files`); - } - output.print(" manifest: .aidd/ (config.json, if present, is kept)"); - const toolCount = result.preview.tools.length; - if (process.stdout.isTTY) { - output.print("No files removed."); - } else { - output.success( - `Would remove ${result.preview.totalFileCount} ${result.preview.totalFileCount === 1 ? "file" : "files"} across ${toolCount} ${toolCount === 1 ? "tool" : "tools"}. Use --force to confirm.` - ); - } - return; - } - - output.success(`Cleaned all AIDD files (${result.fileCount} files removed)`); - } catch (error) { - errorHandler.handle(error); - } - }); -} diff --git a/cli/src/application/commands/doctor.ts b/cli/src/application/commands/doctor.ts deleted file mode 100644 index 754973607..000000000 --- a/cli/src/application/commands/doctor.ts +++ /dev/null @@ -1,34 +0,0 @@ -import type { Command } from "commander"; -import { createDeps } from "../../infrastructure/deps.js"; -import { printPluginIssues, printScopeIssues } from "../display/doctor-display.js"; -import { ErrorHandler } from "../error-handler.js"; -import { parseGlobalOptions } from "./global-options.js"; - -export function registerDoctorCommand(program: Command): void { - program - .command("doctor") - .description("Check installation health and detect issues across all tools and plugins") - .action(async () => { - const { verbose, output, projectRoot } = parseGlobalOptions(program); - const errorHandler = new ErrorHandler(output); - - try { - const deps = await createDeps(projectRoot, { verbose }, output); - const result = await deps.doctorAllUseCase.execute(projectRoot); - - for (const e of result.errors) output.warn(`[${e.scope}] ${e.message}`); - - if (result.healthy) { - output.success("Installation is healthy"); - return; - } - - printScopeIssues(output, "AI", result.ai); - printScopeIssues(output, "IDE", result.ide); - printPluginIssues(output, result.pluginIssues); - process.exit(1); - } catch (error) { - errorHandler.handle(error); - } - }); -} diff --git a/cli/src/application/commands/framework.ts b/cli/src/application/commands/framework.ts deleted file mode 100644 index 9c0eba7a0..000000000 --- a/cli/src/application/commands/framework.ts +++ /dev/null @@ -1,78 +0,0 @@ -import { resolve } from "node:path"; -import type { Command } from "commander"; -import { - type FrameworkBuildMode, - type FrameworkBuildTarget, - SUPPORTED_BUILD_TARGETS, -} from "../../domain/models/framework-build.js"; -import { createDeps, createFrameworkBuildUseCase } from "../../infrastructure/deps.js"; -import { ErrorHandler } from "../error-handler.js"; -import { parseGlobalOptions } from "./global-options.js"; - -export function registerFrameworkCommand(program: Command): void { - const framework = program - .command("framework") - .description("Framework build and management tools"); - - framework - .command("build") - .description( - "Build a Claude-format framework into a target-native plugin marketplace tree or project workspace" - ) - .requiredOption("--source ", "Path to the source framework directory") - .requiredOption("--target ", "Build target (claude, cursor, copilot, codex, opencode)") - .requiredOption("--out ", "Output directory (marketplace dist or project root)") - .option("--flat", "Materialize directly into project workspace, bypass marketplace") - .option("--force", "Overwrite existing files at canonical paths (flat mode only)") - .action( - async (cmdOptions: { - source: string; - target: string; - out: string; - flat?: boolean; - force?: boolean; - }) => { - const { verbose, output, projectRoot } = parseGlobalOptions(program); - const errorHandler = new ErrorHandler(output); - - if (!(SUPPORTED_BUILD_TARGETS as readonly string[]).includes(cmdOptions.target)) { - output.error( - `Unsupported target '${cmdOptions.target}'. Supported targets: ${SUPPORTED_BUILD_TARGETS.join(", ")}.` - ); - process.exit(1); - } - if (cmdOptions.force && !cmdOptions.flat) { - output.error("--force requires --flat."); - process.exit(1); - } - const sourceDir = resolve(projectRoot, cmdOptions.source); - const outDir = resolve(projectRoot, cmdOptions.out); - const target = cmdOptions.target as FrameworkBuildTarget; - const mode: FrameworkBuildMode = cmdOptions.flat ? "flat" : "marketplace"; - const force = cmdOptions.force ?? false; - - try { - const deps = await createDeps(projectRoot, { verbose }, output); - const useCase = createFrameworkBuildUseCase(deps, { target, mode, outDir, force }); - if (useCase === undefined) { - output.error( - `Unsupported target/mode combination: --target ${target}${cmdOptions.flat ? " --flat" : ""}.` - ); - process.exit(1); - } - const result = await useCase.execute({ sourceDir, outDir, target, mode }); - if (mode === "flat") { - output.success( - `Flat-installed ${result.plugins.length} plugins, ${result.totalFiles} files written under ${result.outDir}` - ); - } else { - output.success( - `Built ${result.plugins.length} plugins, ${result.totalFiles} files written to ${result.outDir}` - ); - } - } catch (error) { - errorHandler.handle(error); - } - } - ); -} diff --git a/cli/src/application/commands/global-options.ts b/cli/src/application/commands/global-options.ts deleted file mode 100644 index e7e791531..000000000 --- a/cli/src/application/commands/global-options.ts +++ /dev/null @@ -1,18 +0,0 @@ -import type { Command } from "commander"; -import { CLIOutput } from "../output.js"; - -export interface GlobalOptions { - verbose: boolean; - output: CLIOutput; - projectRoot: string; -} - -export function parseGlobalOptions(program: Command): GlobalOptions { - const opts = program.opts<{ verbose?: boolean }>(); - const verbose = opts.verbose ?? false; - return { - verbose, - output: new CLIOutput(verbose), - projectRoot: process.cwd(), - }; -} diff --git a/cli/src/application/commands/ide.ts b/cli/src/application/commands/ide.ts deleted file mode 100644 index 75191fa80..000000000 --- a/cli/src/application/commands/ide.ts +++ /dev/null @@ -1,247 +0,0 @@ -import type { Command } from "commander"; -import { Manifest } from "../../domain/models/manifest.js"; -import { DOCS_DIR } from "../../domain/models/paths.js"; -import { IDE_TOOL_IDS, type IdeToolId } from "../../domain/models/tool-ids.js"; -import { createDeps, createMenuDeps } from "../../infrastructure/deps.js"; -import { printUnrestorable } from "../display/restore-display.js"; -import { ErrorHandler } from "../error-handler.js"; -import { NoManifestError } from "../errors.js"; -import { parseGlobalOptions } from "./global-options.js"; -import { spawnCliCommand } from "./shared/spawn-cli-command.js"; - -function assertIdeToolId(toolId: string): asserts toolId is IdeToolId { - if (!(IDE_TOOL_IDS as readonly string[]).includes(toolId)) { - throw new Error(`Unknown IDE tool: ${toolId}. Valid IDE tools: ${IDE_TOOL_IDS.join(", ")}`); - } -} - -export function registerIdeCommand(program: Command): void { - const ide = program.command("ide").description("Manage IDE integrations (vscode)"); - - ide.action(async () => { - if (!process.stdout.isTTY) { - ide.help(); - return; - } - const { prompter } = createMenuDeps(process.cwd()); - const choice = await prompter.select("ide: what do you want to do?", [ - { name: "Install an IDE tool", value: "install", description: "requires tool arg" }, - { name: "Uninstall an IDE tool", value: "uninstall", description: "requires tool arg" }, - { name: "List installed IDE tools", value: "list" }, - { name: "Show IDE tool status", value: "status" }, - { name: "Update IDE tools", value: "update" }, - { name: "Restore IDE tool files", value: "restore" }, - { name: "Doctor IDE tools", value: "doctor" }, - ]); - await spawnCliCommand(["ide", choice]); - }); - - ide - .command("install ") - .description("Install an IDE integration from bundled assets") - .option("-f, --force", "Overwrite already-installed tool", false) - .action(async (toolArg: string, cmdOptions: { force: boolean }) => { - const { verbose, output, projectRoot } = parseGlobalOptions(program); - const errorHandler = new ErrorHandler(output); - try { - assertIdeToolId(toolArg); - const deps = await createDeps(projectRoot, { verbose }, output); - const manifest = (await deps.manifestRepo.load()) ?? Manifest.create(); - const version = deps.currentVersionProvider.get(); - const result = await deps.installIdeToolUseCase.execute({ - toolId: toolArg, - projectRoot, - manifest, - force: cmdOptions.force, - version, - }); - if (result.skipped) { - output.warn(`${result.toolId} is already installed. Use \`--force\` to reinstall.`); - return; - } - for (const w of result.warnings) output.warn(w); - output.success(`Installed ${result.toolId} (${result.fileCount} files)`); - } catch (error) { - errorHandler.handle(error); - } - }); - - ide - .command("uninstall ") - .description("Remove an IDE tool from the manifest") - .action(async (toolArg: string) => { - const { verbose, output, projectRoot } = parseGlobalOptions(program); - const errorHandler = new ErrorHandler(output); - try { - assertIdeToolId(toolArg); - const deps = await createDeps(projectRoot, { verbose }, output); - const result = await deps.uninstallIdeUseCase.execute({ toolId: toolArg, projectRoot }); - output.success(`Uninstalled ${result.toolId} (${result.fileCount} files removed)`); - } catch (error) { - errorHandler.handle(error); - } - }); - - ide - .command("list") - .description("List installed IDE tools") - .action(async () => { - const { verbose, output, projectRoot } = parseGlobalOptions(program); - const errorHandler = new ErrorHandler(output); - try { - const deps = await createDeps(projectRoot, { verbose }, output); - const manifest = await deps.manifestRepo.load(); - if (!manifest) { - output.info("No tools installed. Run `aidd setup` to get started."); - return; - } - const ideIds = manifest - .getInstalledToolIds() - .filter((id) => (IDE_TOOL_IDS as readonly string[]).includes(id)); - if (ideIds.length === 0) { - output.info("No IDE tools installed."); - return; - } - for (const id of ideIds) output.print(id); - } catch (error) { - errorHandler.handle(error); - } - }); - - ide - .command("status") - .description("Show drift for IDE tools") - .action(async () => { - const { verbose, output, projectRoot } = parseGlobalOptions(program); - const errorHandler = new ErrorHandler(output); - try { - const deps = await createDeps(projectRoot, { verbose }, output); - const report = await deps.statusUseCase.execute({ - projectRoot, - filterToolId: undefined, - category: "ide", - }); - if (report.inSync) { - output.success("All IDE tool files are in sync"); - return; - } - for (const tool of report.tools) { - if (tool.drifted.length === 0) { - output.print(`${tool.toolId} (v${tool.version}): in sync`); - continue; - } - output.print(`${tool.toolId} (v${tool.version}):`); - for (const f of tool.drifted) output.print(` ${f.status} ${f.relativePath}`); - } - } catch (error) { - errorHandler.handle(error); - } - }); - - ide - .command("update [tool]") - .description("Re-install IDE tool configs from bundled CLI assets") - .option("-f, --force", "Overwrite modified files without prompting", false) - .action(async (toolArg: string | undefined, cmdOptions: { force: boolean }) => { - const { verbose, output, projectRoot } = parseGlobalOptions(program); - const errorHandler = new ErrorHandler(output); - try { - if (toolArg !== undefined) assertIdeToolId(toolArg); - const deps = await createDeps(projectRoot, { verbose }, output); - const result = await deps.updateIdeToolsUseCase.execute({ - toolArg: toolArg as IdeToolId | undefined, - projectRoot, - userForce: cmdOptions.force, - interactive: process.stdout.isTTY ?? false, - }); - if (result.updatedTools.length === 0 && result.errors.length === 0) { - output.info("No IDE tools installed."); - return; - } - for (const t of result.updatedTools) { - output.success(`Updated ${t.toolId} (${t.fileCount} files)`); - } - for (const e of result.errors) { - output.warn(`[${e.scope}] ${e.message}`); - } - } catch (error) { - errorHandler.handle(error); - } - }); - - ide - .command("restore [files...]") - .description("Restore IDE tool tracked files to their installed version") - .option("-f, --force", "Restore without prompting", false) - .option("--tool ", "Limit restore to a specific IDE tool") - .action(async (fileArgs: string[], cmdOptions: { force: boolean; tool?: string }) => { - const { verbose, output, projectRoot } = parseGlobalOptions(program); - const errorHandler = new ErrorHandler(output); - try { - if (cmdOptions.tool !== undefined) assertIdeToolId(cmdOptions.tool); - const deps = await createDeps(projectRoot, { verbose }, output); - const manifest = await deps.manifestRepo.load(); - if (!manifest) throw new NoManifestError(); - const version = - manifest - .getInstalledToolIds() - .map((id) => manifest.getToolVersion(id)) - .find((v) => v !== undefined) ?? deps.currentVersionProvider.get(); - const installedIdeIds = manifest - .getInstalledToolIds() - .filter((id) => (IDE_TOOL_IDS as readonly string[]).includes(id)) as IdeToolId[]; - const toolIds: IdeToolId[] = cmdOptions.tool - ? [cmdOptions.tool as IdeToolId] - : installedIdeIds; - if (toolIds.length === 0) { - output.info("No IDE tools installed."); - return; - } - const result = await deps.restoreUseCase.execute({ - version, - docsDir: DOCS_DIR, - projectRoot, - toolIds, - files: fileArgs.length > 0 ? fileArgs : undefined, - force: cmdOptions.force, - interactive: process.stdout.isTTY, - manifest, - }); - const nothingDone = result.tools.every((t) => t.nothingToRestore); - if (nothingDone) { - output.success("Nothing to restore — all files are unmodified."); - return; - } - output.success( - `Restored ${result.totalRestored} ${result.totalRestored === 1 ? "file" : "files"}, kept ${result.totalKept} ${result.totalKept === 1 ? "file" : "files"}` - ); - printUnrestorable(output, result.unrestorable); - } catch (error) { - errorHandler.handle(error); - } - }); - - ide - .command("doctor") - .description("Check IDE tool installation health and detect issues") - .action(async () => { - const { verbose, output, projectRoot } = parseGlobalOptions(program); - const errorHandler = new ErrorHandler(output); - try { - const deps = await createDeps(projectRoot, { verbose }, output); - const report = await deps.doctorUseCase.execute({ projectRoot, category: "ide" }); - if (report.healthy) { - output.success("IDE tool installation is healthy"); - return; - } - for (const issue of report.issues) { - const text = `${issue.message}\n Fix: ${issue.fix}`; - if (issue.severity === "error") output.error(text); - else output.warn(text); - } - process.exit(1); - } catch (error) { - errorHandler.handle(error); - } - }); -} diff --git a/cli/src/application/commands/kanban.ts b/cli/src/application/commands/kanban.ts deleted file mode 100644 index f2985354a..000000000 --- a/cli/src/application/commands/kanban.ts +++ /dev/null @@ -1,33 +0,0 @@ -import type { Command } from "commander"; -import { registerInteractiveCommand } from "../../../../kanban/src/presentation/commands/interactive-command.js"; -import { registerListCommand } from "../../../../kanban/src/presentation/commands/list-command.js"; -import type { KanbanCommandDeps } from "../../../../kanban/src/presentation/kanban-deps.js"; -import { DOCS_DIR } from "../../domain/models/paths.js"; -import { ErrorHandler } from "../error-handler.js"; -import type { CLIOutput } from "../output.js"; -import { parseGlobalOptions } from "./global-options.js"; - -export function registerKanbanCommand(program: Command): void { - // Hidden on purpose: the command runs, but it is not ready to be offered to users and - // must not appear in `aidd --help`. Unhide once its product direction is settled. - const kanban = program - .command("kanban", { hidden: true }) - .description("Experimental. View the project's task documents as status columns"); - - // Resolved per action, not at registration: `--verbose` is only parsed once - // `program.parse()` has run, long after this function returns. - const resolveOutput = (): CLIOutput => parseGlobalOptions(program).output; - - const deps: KanbanCommandDeps = { - docsDirectoryName: DOCS_DIR, - output: { print: (message: string) => resolveOutput().print(message) }, - onError: (error) => new ErrorHandler(resolveOutput()).handle(error), - }; - - // Both views declare the same option names. Mounting the interactive one directly on - // `kanban` would make the parent capture `--type`/`--status`/`--progress`/`--all` and - // leave `list`'s own options undefined, so it gets its own default subcommand instead: - // `aidd kanban [path]` still lands on it, and each view owns its options. - registerListCommand(kanban, deps); - registerInteractiveCommand(kanban.command("interactive", { isDefault: true }), deps); -} diff --git a/cli/src/application/commands/marketplace.ts b/cli/src/application/commands/marketplace.ts deleted file mode 100644 index 4777a7597..000000000 --- a/cli/src/application/commands/marketplace.ts +++ /dev/null @@ -1,196 +0,0 @@ -import type { Command } from "commander"; -import type { MarketplaceScope } from "../../domain/models/marketplace.js"; -import { - describePluginSource, - parsePluginSourceShorthand, -} from "../../domain/models/plugin-source.js"; -import { createDeps, createMenuDeps } from "../../infrastructure/deps.js"; -import { ErrorHandler } from "../error-handler.js"; -import { parseGlobalOptions } from "./global-options.js"; -import { spawnCliCommand } from "./shared/spawn-cli-command.js"; - -export function registerMarketplaceCommand(program: Command): void { - const marketplace = program.command("marketplace").description("Manage plugin marketplaces"); - - marketplace.action(async () => { - if (!process.stdout.isTTY) { - marketplace.help(); - return; - } - const { prompter } = createMenuDeps(process.cwd()); - const choice = await prompter.select("marketplace: what do you want to do?", [ - { name: "List marketplaces", value: "list" }, - { name: "Add marketplace", value: "add" }, - { name: "Refresh marketplaces", value: "refresh" }, - { name: "Remove marketplace", value: "remove", description: "requires name arg" }, - { name: "Check marketplaces", value: "check" }, - ]); - await spawnCliCommand(["marketplace", choice]); - }); - - marketplace - .command("add [name] [source]") - .description("Register a plugin marketplace") - .option("--scope ", "Registration scope (default: project)", "project") - .option("--yes", "Skip the trust + cleanup prompts") - .option("--overwrite", "Replace an existing marketplace with the same name") - .option("--token ", "Auth token (host detected from source URL at fetch time)") - .action( - async ( - nameArg: string | undefined, - sourceArg: string | undefined, - cmdOptions: { - scope?: string; - yes?: boolean; - overwrite?: boolean; - token?: string; - } - ) => { - const { verbose, output, projectRoot } = parseGlobalOptions(program); - const errorHandler = new ErrorHandler(output); - const interactive = process.stdout.isTTY; - if (!interactive && (!nameArg || !sourceArg)) { - output.error("name and source are required in non-interactive mode."); - process.exit(1); - } - if ( - cmdOptions.scope !== undefined && - cmdOptions.scope !== "project" && - cmdOptions.scope !== "user" - ) { - output.error(`Invalid --scope '${cmdOptions.scope}'. Expected 'project' or 'user'.`); - process.exit(1); - } - try { - if (cmdOptions.token) process.env.AIDD_TOKEN = cmdOptions.token; - const scope: MarketplaceScope = cmdOptions.scope === "user" ? "user" : "project"; - const deps = await createDeps(projectRoot, { verbose }, output); - const name = nameArg ?? (await deps.prompter.input("Marketplace name:")); - const rawSource = sourceArg ?? (await deps.prompter.input("Source (path or user/repo):")); - const source = parsePluginSourceShorthand(rawSource); - const result = await deps.marketplaceAddUseCase.execute({ - source, - name, - scope, - projectRoot, - autoTrust: cmdOptions.yes ?? false, - overwrite: cmdOptions.overwrite ?? false, - }); - await deps.marketplaceSyncSettingsUseCase.execute({ projectRoot }); - output.success(`Marketplace '${result.marketplace.name}' registered.`); - } catch (error) { - errorHandler.handle(error); - } - } - ); - - marketplace - .command("list") - .description("List registered plugin marketplaces") - .option("--plugins", "Also fetch and print all plugins from each marketplace catalog") - .action(async (cmdOptions: { plugins?: boolean }) => { - const { verbose, output, projectRoot } = parseGlobalOptions(program); - const errorHandler = new ErrorHandler(output); - try { - const deps = await createDeps(projectRoot, { verbose }, output); - const { marketplaces, catalogs } = await deps.marketplaceListUseCase.execute({ - projectRoot, - withCatalogs: cmdOptions.plugins ?? false, - }); - if (marketplaces.length === 0) output.info("No marketplaces registered."); - for (const m of marketplaces) { - const ver = m.version !== undefined ? ` v${m.version}` : ""; - output.print(`${m.name}${ver} [${m.scope}]`); - if (catalogs !== undefined) printCatalogEntries(m.name, catalogs, output); - } - } catch (error) { - errorHandler.handle(error); - } - }); - - marketplace - .command("remove ") - .description("Remove a registered plugin marketplace") - .option("--yes", "Skip the orphan-cleanup prompt") - .action(async (name: string, cmdOptions: { yes?: boolean }) => { - const { verbose, output, projectRoot } = parseGlobalOptions(program); - const errorHandler = new ErrorHandler(output); - try { - const deps = await createDeps(projectRoot, { verbose }, output); - const result = await deps.marketplaceRemoveUseCase.execute({ - name, - projectRoot, - autoConfirm: cmdOptions.yes ?? false, - }); - await deps.marketplaceSyncSettingsUseCase.execute({ projectRoot }); - output.success( - `Marketplace '${result.marketplace.name}' removed (${result.removedPluginCount} plugin(s) cleaned up).` - ); - } catch (error) { - errorHandler.handle(error); - } - }); - - marketplace - .command("refresh [name]") - .description("Refresh registered marketplaces") - .option("--force", "Clear cache before re-fetching") - .action(async (name: string | undefined, cmdOptions: { force?: boolean }) => { - const { verbose, output, projectRoot } = parseGlobalOptions(program); - const errorHandler = new ErrorHandler(output); - try { - const deps = await createDeps(projectRoot, { verbose }, output); - const { results, failedCount } = await deps.marketplaceRefreshUseCase.execute({ - projectRoot, - name, - force: cmdOptions.force, - }); - await deps.marketplaceSyncSettingsUseCase.execute({ projectRoot }); - for (const r of results) - output.print(`${r.name}: ${r.status}${r.error ? ` (${r.error})` : ""}`); - if (failedCount > 0) process.exit(1); - } catch (error) { - errorHandler.handle(error); - } - }); - - marketplace - .command("check") - .description("Report stale marketplaces and upstream-removed plugins") - .action(async () => { - const { verbose, output, projectRoot } = parseGlobalOptions(program); - const errorHandler = new ErrorHandler(output); - try { - const deps = await createDeps(projectRoot, { verbose }, output); - const { stale, upstreamRemoved, skipped } = await deps.marketplaceCheckUseCase.execute({ - projectRoot, - }); - for (const m of stale) output.print(`stale: ${m.name}`); - for (const r of upstreamRemoved) - output.print(`removed: ${r.marketplace}/${r.plugin} (${r.toolId})`); - for (const s of skipped) output.warn(`skipped: ${s.marketplace} — ${s.error}`); - if (stale.length === 0 && upstreamRemoved.length === 0 && skipped.length === 0) - output.success("All marketplaces fresh."); - } catch (error) { - errorHandler.handle(error); - } - }); -} - -function printCatalogEntries( - marketplaceName: string, - catalogs: Map, - output: ReturnType["output"] -): void { - const catalog = catalogs.get(marketplaceName); - if (catalog === undefined) { - output.warn(` (could not fetch catalog for '${marketplaceName}')`); - return; - } - for (const e of catalog.plugins) { - const flag = e.recommended ? " (recommended)" : ""; - output.print( - ` ${e.name}@${e.version ?? "?"} — ${e.description ?? ""} — ${describePluginSource(e.source)}${flag}` - ); - } -} diff --git a/cli/src/application/commands/menu.ts b/cli/src/application/commands/menu.ts deleted file mode 100644 index ac45f0592..000000000 --- a/cli/src/application/commands/menu.ts +++ /dev/null @@ -1,59 +0,0 @@ -import readline from "node:readline"; -import { createMenuDeps } from "../../infrastructure/deps.js"; -import { resolveProjectRoot } from "../../infrastructure/project-root.js"; -import { ErrorHandler } from "../error-handler.js"; -import { CLIOutput } from "../output.js"; -import { InteractiveMenuUseCase } from "../use-cases/menu-use-case.js"; -import { spawnCliCommand } from "./shared/spawn-cli-command.js"; - -async function waitForEnter(): Promise { - const rl = readline.createInterface({ input: process.stdin, output: process.stdout }); - await new Promise((resolve) => { - rl.question("\nPress ENTER to continue...", () => { - rl.close(); - resolve(); - }); - }); -} - -const BANNER = ` - _ ___ ___ ___ - /_\\ |_ _| \\| \\ - / _ \\ | || |) | |) | -/_/ \\_\\|___|___/|___/ - - AI-Driven Development CLI -`; - -function printBanner(): void { - process.stdout.write(BANNER); -} - -/** The name inquirer gives the error it throws when the user hits Ctrl-C at a prompt. */ -const USER_ABORT_ERROR_NAME = "ExitPromptError"; - -function isUserAbort(error: unknown): boolean { - return error instanceof Error && error.name === USER_ABORT_ERROR_NAME; -} - -export function routeMenuError(error: unknown, errorHandler: ErrorHandler): never { - if (isUserAbort(error)) process.exit(0); - return errorHandler.handle(error); -} - -export async function runMenuLoop(): Promise { - printBanner(); - const { manifestRepo, prompter } = createMenuDeps(resolveProjectRoot()); - const errorHandler = new ErrorHandler(new CLIOutput()); - for (;;) { - try { - const result = await new InteractiveMenuUseCase(manifestRepo, prompter).execute(); - if (result.command[0] === "exit") process.exit(0); - const exitCode = await spawnCliCommand(result.command); - await waitForEnter(); - if (exitCode !== 0 && result.command[0] === "setup") process.exit(exitCode); - } catch (error) { - routeMenuError(error, errorHandler); - } - } -} diff --git a/cli/src/application/commands/plugin.ts b/cli/src/application/commands/plugin.ts deleted file mode 100644 index 175253320..000000000 --- a/cli/src/application/commands/plugin.ts +++ /dev/null @@ -1,262 +0,0 @@ -import { join } from "node:path"; -import type { Command } from "commander"; -import { parseInstallScope } from "../../domain/models/install-scope.js"; -import { parsePluginComponentKind } from "../../domain/models/plugin-component-kind.js"; -import { assertValidAiToolId, parseToolOption } from "../../domain/models/tool-ids.js"; -import { createDeps, createMenuDeps } from "../../infrastructure/deps.js"; -import { ErrorHandler } from "../error-handler.js"; -import { parseGlobalOptions } from "./global-options.js"; -import { spawnCliCommand } from "./shared/spawn-cli-command.js"; - -export function registerPluginCommand(program: Command): void { - const plugin = program.command("plugin").description("Manage plugins for AI tools"); - - plugin.action(async () => { - if (!process.stdout.isTTY) { - plugin.help(); - return; - } - const { prompter } = createMenuDeps(process.cwd()); - const choice = await prompter.select("plugin: what do you want to do?", [ - { name: "Create a plugin", value: "create", description: "scaffold a new plugin" }, - { name: "Install plugin", value: "install" }, - { name: "List installed plugins", value: "list" }, - { name: "Search plugins", value: "search", description: "requires query arg" }, - { name: "Update plugins", value: "update" }, - { name: "Remove a plugin", value: "remove", description: "requires name arg" }, - { name: "Plugin doctor", value: "doctor" }, - ]); - await spawnCliCommand(["plugin", choice]); - }); - - plugin - .command("create [name]") - .description("Scaffold a new plugin in the given output directory") - .option("--output ", "Output directory (default: current directory)") - .option("--type ", "Plugin type: full, skills, agents, hooks, mcp (default: full)") - .option("--force", "Overwrite existing directory") - .option("--yes", "Skip all interactive prompts (CI mode)") - .action( - async ( - nameArg: string | undefined, - cmdOptions: { output?: string; type?: string; force?: boolean; yes?: boolean } - ) => { - const { verbose, output, projectRoot } = parseGlobalOptions(program); - const errorHandler = new ErrorHandler(output); - if (nameArg === undefined && !process.stdout.isTTY) { - output.error("Plugin name is required in non-interactive mode."); - process.exit(1); - } - const kind = - cmdOptions.type !== undefined ? parsePluginComponentKind(cmdOptions.type) : undefined; - const resolvedName = nameArg ?? ""; - try { - const deps = await createDeps(projectRoot, { verbose }, output); - const result = await deps.pluginCreateUseCase.execute({ - name: resolvedName, - kind, - outputDir: cmdOptions.output ?? join(projectRoot, "plugins"), - force: cmdOptions.force ?? false, - yes: cmdOptions.yes ?? false, - interactive: process.stdout.isTTY, - projectRoot, - }); - output.success( - `Plugin '${resolvedName}' created at ${result.pluginDir} (${result.filesWritten} files).` - ); - if (result.marketplaceUpdated) output.info("marketplace.json updated."); - } catch (error) { - errorHandler.handle(error); - } - } - ); - - plugin - .command("remove ") - .description("Remove a plugin from one or all AI tools") - .option("--tool ", "Target AI tool (default: all installed)") - .action(async (name: string, cmdOptions: { tool?: string }) => { - const { verbose, output, projectRoot } = parseGlobalOptions(program); - const errorHandler = new ErrorHandler(output); - try { - assertValidAiToolId(cmdOptions.tool); - const deps = await createDeps(projectRoot, { verbose }, output); - await deps.pluginRemoveUseCase.execute({ - pluginName: name, - toolIds: parseToolOption(cmdOptions.tool), - projectRoot, - }); - await deps.marketplaceSyncSettingsUseCase.execute({ projectRoot }); - output.success(`Plugin '${name}' removed.`); - } catch (error) { - errorHandler.handle(error); - } - }); - - plugin - .command("list") - .description("List installed plugins for one or all AI tools") - .option("--tool ", "Target AI tool (default: all installed)") - .action(async (cmdOptions: { tool?: string }) => { - const { verbose, output, projectRoot } = parseGlobalOptions(program); - const errorHandler = new ErrorHandler(output); - try { - assertValidAiToolId(cmdOptions.tool); - const deps = await createDeps(projectRoot, { verbose }, output); - const result = await deps.pluginListUseCase.execute({ - toolIds: parseToolOption(cmdOptions.tool), - }); - let printed = false; - for (const [toolId, plugins] of result) { - if (plugins.length === 0) continue; - output.print(`${toolId}:`); - for (const p of plugins) output.print(` ${p.name}@${p.version}`); - printed = true; - } - if (!printed) output.info("No plugins installed."); - } catch (error) { - errorHandler.handle(error); - } - }); - - plugin - .command("install [plugin]") - .description("Install a plugin (marketplace name, local path, or interactive pick)") - .option("--from ", "Marketplace name (when multiple match)") - .option("--tool ", "Target AI tool (default: all installed)") - .option("--token ", "Auth token (host detected from source URL at fetch time)") - .option("--scope ", "Install scope; must match the tool's supported scope") - .option("--yes", "Auto-resolve interactive prompts (CI mode)") - .action( - async ( - pluginArg: string | undefined, - cmdOptions: { - from?: string; - tool?: string; - token?: string; - scope?: string; - yes?: boolean; - } - ) => { - const { verbose, output, projectRoot } = parseGlobalOptions(program); - const errorHandler = new ErrorHandler(output); - try { - assertValidAiToolId(cmdOptions.tool); - const scope = parseInstallScope(cmdOptions.scope); - const deps = await createDeps(projectRoot, { verbose }, output); - const result = await deps.pluginInstallUseCase.execute({ - pluginArg, - toolIds: parseToolOption(cmdOptions.tool), - projectRoot, - interactive: process.stdout.isTTY, - fromMarketplace: cmdOptions.from, - token: cmdOptions.token, - yes: cmdOptions.yes, - scope, - }); - await deps.marketplaceSyncSettingsUseCase.execute({ projectRoot }); - if (result.kind === "picked") { - if (result.installed.length === 0) { - output.info("No plugins selected."); - } else { - output.success( - `Installed ${result.installed.length} plugin(s): ${result.installed.join(", ")}` - ); - } - } else if (result.kind === "local") { - output.success("Plugin added successfully."); - } else { - output.success(`Installed '${result.installed[0]}'.`); - } - } catch (error) { - errorHandler.handle(error); - } - } - ); - - plugin - .command("search ") - .description("Search registered marketplaces for plugins") - .option("--recommended", "Show only recommended plugins") - .option("--marketplace ", "Limit to a single marketplace") - .action(async (query: string, cmdOptions: { recommended?: boolean; marketplace?: string }) => { - const { verbose, output, projectRoot } = parseGlobalOptions(program); - const errorHandler = new ErrorHandler(output); - try { - const deps = await createDeps(projectRoot, { verbose }, output); - const { hits } = await deps.pluginSearchUseCase.execute({ - query, - recommendedOnly: cmdOptions.recommended ?? false, - marketplace: cmdOptions.marketplace, - projectRoot, - }); - if (hits.length === 0) output.info("No matches."); - for (const h of hits) { - const flag = h.entry.recommended ? " (recommended)" : ""; - output.print( - `${h.entry.name}@${h.entry.version ?? "?"} — ${h.entry.description ?? ""} — marketplace: ${h.marketplace.name}${flag}` - ); - } - } catch (error) { - errorHandler.handle(error); - } - }); - - plugin - .command("update [name]") - .description("Update one or all plugins for one or all AI tools") - .option("--tool ", "Target AI tool (default: all installed)") - .action(async (name: string | undefined, cmdOptions: { tool?: string }) => { - const { verbose, output, projectRoot } = parseGlobalOptions(program); - const errorHandler = new ErrorHandler(output); - try { - assertValidAiToolId(cmdOptions.tool); - const deps = await createDeps(projectRoot, { verbose }, output); - const updated = await deps.pluginUpdateUseCase.execute({ - pluginNames: name !== undefined ? [name] : undefined, - toolIds: parseToolOption(cmdOptions.tool), - projectRoot, - }); - await deps.marketplaceSyncSettingsUseCase.execute({ projectRoot }); - if (updated.length === 0) { - output.success("All plugins are up to date."); - } else { - output.success(`Updated: ${updated.join(", ")}.`); - } - } catch (error) { - errorHandler.handle(error); - } - }); - - plugin - .command("doctor") - .description("Check plugin installation health") - .option("--plugin ", "Filter check to one plugin") - .action(async (cmdOptions: { plugin?: string }) => { - const { verbose, output, projectRoot } = parseGlobalOptions(program); - const errorHandler = new ErrorHandler(output); - try { - const deps = await createDeps(projectRoot, { verbose }, output); - const report = await deps.doctorUseCase.execute({ - projectRoot, - pluginName: cmdOptions.plugin, - }); - // Plugin doctor is plugin-scoped: gate on plugin issues only, never on - // unrelated tracked-file / reference / layout warnings the full report - // also carries. Otherwise it exits non-zero while printing nothing (it - // only renders pluginIssues) — a silent failure. - if (report.pluginIssues.length === 0) { - output.success("Plugin installation is healthy"); - return; - } - for (const pi of report.pluginIssues) { - output.error( - `Plugin ${pi.pluginName} (${pi.toolId}): ${pi.issue} — ${pi.filePath}\n Fix: Run \`aidd ai restore\` to restore.` - ); - } - process.exit(1); - } catch (error) { - errorHandler.handle(error); - } - }); -} diff --git a/cli/src/application/commands/restore.ts b/cli/src/application/commands/restore.ts deleted file mode 100644 index db1ca5953..000000000 --- a/cli/src/application/commands/restore.ts +++ /dev/null @@ -1,52 +0,0 @@ -import type { Command } from "commander"; -import { createDeps } from "../../infrastructure/deps.js"; -import { printUnrestorable } from "../display/restore-display.js"; -import { ErrorHandler } from "../error-handler.js"; -import { parseGlobalOptions } from "./global-options.js"; - -export function registerRestoreCommand(program: Command): void { - program - .command("restore") - .description("Restore tracked files to their installed version (from manifest hashes)") - .option("-f, --force", "Restore without prompting", false) - .action(async (cmdOptions: { force: boolean }) => { - const { verbose, output, projectRoot } = parseGlobalOptions(program); - const errorHandler = new ErrorHandler(output); - - try { - const deps = await createDeps(projectRoot, { verbose }, output); - const interactive = !cmdOptions.force && process.stdout.isTTY; - const result = await deps.restoreAllUseCase.execute( - projectRoot, - interactive, - cmdOptions.force - ); - - for (const e of result.errors) output.warn(`[${e.scope}] ${e.message}`); - - // A run that errored restored nothing, and saying "nothing to restore" would - // report that as the healthy state. Nothing was restored *because* it failed. - if (result.errors.length > 0) process.exit(1); - - if ( - result.totalRestored === 0 && - result.pluginNamesRestored.length === 0 && - result.unrestorable.length === 0 - ) { - output.success("Nothing to restore — all files are unmodified."); - return; - } - if (result.totalRestored > 0) { - output.success( - `Restored ${result.totalRestored} file(s), kept ${result.totalKept} file(s)` - ); - } - if (result.pluginNamesRestored.length > 0) { - output.success(`Restored plugins: ${result.pluginNamesRestored.join(", ")}`); - } - printUnrestorable(output, result.unrestorable); - } catch (error) { - errorHandler.handle(error); - } - }); -} diff --git a/cli/src/application/commands/self-update.ts b/cli/src/application/commands/self-update.ts deleted file mode 100644 index 351cbe121..000000000 --- a/cli/src/application/commands/self-update.ts +++ /dev/null @@ -1,52 +0,0 @@ -import type { Command } from "commander"; -import { createDeps } from "../../infrastructure/deps.js"; -import { ErrorHandler } from "../error-handler.js"; -import { parseGlobalOptions } from "./global-options.js"; - -export function registerSelfUpdateCommand(program: Command): void { - program - .command("self-update") - .description("Update the aidd CLI to the latest version") - .option("--check", "Check if a newer version is available without installing", false) - .option("--dry-run", "Preview the update without installing", false) - .option("-f, --force", "Reinstall even if already up to date", false) - .action(async (cmdOptions: { check: boolean; dryRun: boolean; force: boolean }) => { - const { verbose, output, projectRoot } = parseGlobalOptions(program); - const errorHandler = new ErrorHandler(output); - - try { - const deps = await createDeps(projectRoot, { verbose }, output); - - const result = await deps.selfUpdateUseCase.execute({ - check: cmdOptions.check, - dryRun: cmdOptions.dryRun, - force: cmdOptions.force, - }); - - switch (result.kind) { - case "up-to-date": - case "check-current": - output.success(`Already up to date (${result.version})`); - break; - case "check-available": - output.info( - `New version available: ${result.latestVersion} (current: ${result.currentVersion})` - ); - break; - case "dry-run": - output.info(`Would install @ai-driven-dev/cli@${result.latestVersion}`); - break; - case "updated": { - const binaryPart = result.binaryPath ? ` (${result.binaryPath})` : ""; - output.success(`Successfully updated to version ${result.latestVersion}${binaryPart}`); - if (result.changelog) { - output.info(`\nChangelog:\n${result.changelog}`); - } - break; - } - } - } catch (error) { - errorHandler.handle(error); - } - }); -} diff --git a/cli/src/application/commands/setup.ts b/cli/src/application/commands/setup.ts deleted file mode 100644 index ecc1ec858..000000000 --- a/cli/src/application/commands/setup.ts +++ /dev/null @@ -1,184 +0,0 @@ -import { resolve } from "node:path"; -import type { Command } from "commander"; -import { MarketplaceSourceMode } from "../../domain/models/marketplace-source-mode.js"; -import { SetupFlow } from "../../domain/models/setup-flow.js"; -import { - AI_TOOL_IDS, - assertToolIdsMatchCategory, - IDE_TOOL_IDS, - type ToolId, -} from "../../domain/tools/registry.js"; -import { createDeps } from "../../infrastructure/deps.js"; -import { displayInstall, printNextSteps, printWelcomeBanner } from "../display/setup-display.js"; -import { ErrorHandler } from "../error-handler.js"; -import type { CLIOutput } from "../output.js"; -import { SetupUseCase } from "../use-cases/setup-use-case.js"; -import { parseGlobalOptions } from "./global-options.js"; - -interface SetupCmdOptions { - source?: "remote" | "local"; - path?: string; - release?: string; - ai?: string; - ide?: string; - plugins?: string; - yes?: boolean; - defaultMarketplace?: boolean; -} - -function parseSourceFlag( - cmdOptions: SetupCmdOptions, - output: CLIOutput -): MarketplaceSourceMode | undefined { - if (!cmdOptions.source) return undefined; - if (cmdOptions.source === "local") { - if (!cmdOptions.path) { - output.error("--source local requires --path "); - process.exit(1); - } - return MarketplaceSourceMode.local(resolve(cmdOptions.path)); - } - return MarketplaceSourceMode.remote(undefined, cmdOptions.release); -} - -function expandAllKeyword(raw: string | undefined, all: readonly ToolId[]): ToolId[] { - if (raw === undefined) return []; - if (raw.trim() === "all") return [...all]; - return raw - .split(",") - .map((s) => s.trim()) - .filter(Boolean) as ToolId[]; -} - -function parseToolIds( - cmdOptions: SetupCmdOptions, - errorHandler: ErrorHandler -): { aiTools: ToolId[]; ideTools: ToolId[] } | null { - const aiIds = expandAllKeyword(cmdOptions.ai, AI_TOOL_IDS); - const ideIds = expandAllKeyword(cmdOptions.ide, IDE_TOOL_IDS); - try { - if (aiIds.length > 0 && cmdOptions.ai?.trim() !== "all") - assertToolIdsMatchCategory(aiIds, "ai"); - if (ideIds.length > 0 && cmdOptions.ide?.trim() !== "all") - assertToolIdsMatchCategory(ideIds, "ide"); - } catch (e) { - errorHandler.handle(e); - return null; - } - return { aiTools: aiIds, ideTools: ideIds }; -} - -type PluginsMode = "interactive" | "all" | "recommended" | "named" | "none"; - -function parsePluginsFlag( - raw: string | undefined, - interactive: boolean -): { mode: PluginsMode; names: string[] } { - if (raw === undefined) return { mode: interactive ? "interactive" : "none", names: [] }; - const value = raw.trim(); - if (value === "none") return { mode: "none", names: [] }; - if (value === "all") return { mode: "all", names: [] }; - if (value === "recommended") return { mode: "recommended", names: [] }; - const names = value - .split(",") - .map((s) => s.trim()) - .filter(Boolean); - return { mode: "named", names }; -} - -export function registerSetupCommand(program: Command): void { - program - .command("setup") - .description("Set up or update the project to a correct state") - .option("--source ", "Framework source: remote or local") - .option("--path ", "Absolute path to local framework (required with --source local)") - .option("--release ", "Marketplace release tag to fetch (e.g., v1.2.3)") - .option("--ai ", "Comma-separated AI tool IDs, or 'all' (e.g., claude,cursor or all)") - .option("--ide ", "Comma-separated IDE tool IDs, or 'all' (e.g., vscode or all)") - .option( - "--plugins ", - "Plugin install mode: none | all | recommended | comma-separated names" - ) - .option( - "--no-default-marketplace", - "Skip auto-registering aidd-framework (no source prompt, no plugin install)" - ) - .option("--yes", "Accept defaults without prompting") - .action(async (cmdOptions: SetupCmdOptions) => { - const { verbose, output, projectRoot } = parseGlobalOptions(program); - const errorHandler = new ErrorHandler(output); - - const source = parseSourceFlag(cmdOptions, output); - const toolIds = parseToolIds(cmdOptions, errorHandler); - if (toolIds === null) return; - - const hasScriptingFlags = !!( - cmdOptions.source || - cmdOptions.release || - cmdOptions.ai || - cmdOptions.ide || - cmdOptions.plugins || - cmdOptions.yes - ); - const interactive = process.stdout.isTTY && !hasScriptingFlags; - - const { mode: pluginMode, names: pluginNames } = parsePluginsFlag( - cmdOptions.plugins, - interactive - ); - - const registerDefaultMarketplace = cmdOptions.defaultMarketplace !== false; - const flow = new SetupFlow({ - projectRoot, - source, - aiTools: toolIds.aiTools, - ideTools: toolIds.ideTools, - pluginMode, - pluginNames, - interactive, - force: false, - registerDefaultMarketplace, - }); - - if (interactive) printWelcomeBanner(output); - - try { - const deps = await createDeps(projectRoot, { verbose }, output); - - const result = await new SetupUseCase( - deps.fs, - deps.manifestRepo, - deps.setupMarketplaceSourceUseCase, - deps.marketplaceRegisterFrameworkUseCase, - deps.marketplaceRefreshUseCase, - deps.marketplaceSyncSettingsUseCase, - deps.setupToolsUseCase, - deps.setupPluginsPromptUseCase, - deps.currentVersionProvider, - deps.authReader, - deps.setupToolsPromptUseCase, - deps.projectContextDetector, - deps.releaseResolver - ).execute(flow); - - if (interactive && result.context !== undefined) { - output.info(`Detected: ${result.context.describe()}.`); - } - switch (result.kind) { - case "initialized": { - output.success("Project initialized."); - displayInstall(output, result.install.results, verbose); - break; - } - case "up-to-date": { - output.info("Project is up to date."); - displayInstall(output, result.install.results, verbose); - break; - } - } - if (interactive) printNextSteps(output, result.install.results.length > 0); - } catch (error) { - errorHandler.handle(error); - } - }); -} diff --git a/cli/src/application/commands/status.ts b/cli/src/application/commands/status.ts deleted file mode 100644 index a7183aa24..000000000 --- a/cli/src/application/commands/status.ts +++ /dev/null @@ -1,39 +0,0 @@ -import type { Command } from "commander"; -import { createDeps } from "../../infrastructure/deps.js"; -import { printPluginDrift, printScopeReport } from "../display/status-display.js"; -import { ErrorHandler } from "../error-handler.js"; -import { parseGlobalOptions } from "./global-options.js"; - -export function registerStatusCommand(program: Command): void { - program - .command("status") - .description("Show drift across all installed tools and plugins") - .action(async () => { - const { verbose, output, projectRoot } = parseGlobalOptions(program); - const errorHandler = new ErrorHandler(output); - - try { - const deps = await createDeps(projectRoot, { verbose }, output); - const result = await deps.statusAllUseCase.execute(projectRoot); - - for (const e of result.errors) output.warn(`[${e.scope}] ${e.message}`); - - const allInSync = result.aiTools.inSync && result.ideTools.inSync; - - if (allInSync && result.errors.length === 0) { - output.success("All files are in sync"); - return; - } - - output.print("\nAI tools:"); - printScopeReport(output, result.aiTools); - output.print("\nIDE tools:"); - printScopeReport(output, result.ideTools); - output.print("\nPlugins:"); - printPluginDrift(output, { pluginDrift: result.pluginDrift }); - output.print("\nLegend: ~ modified - deleted + added"); - } catch (error) { - errorHandler.handle(error); - } - }); -} diff --git a/cli/src/application/commands/telemetry.ts b/cli/src/application/commands/telemetry.ts deleted file mode 100644 index bca6c861b..000000000 --- a/cli/src/application/commands/telemetry.ts +++ /dev/null @@ -1,315 +0,0 @@ -import type { Command } from "commander"; -import { toCostReportEnvelope } from "../../domain/models/cost-report-envelope.js"; -import { DEFAULT_REPORT_DAYS, resolveReportPeriod } from "../../domain/models/report-period.js"; -import { telemetryRemovalIsEmpty } from "../../domain/models/telemetry-removal.js"; -import { createDeps } from "../../infrastructure/deps.js"; -import { ARTEFACT_AXES, buildCostReportArtefact } from "../display/cost-report-artefact.js"; -import { printCostReport } from "../display/cost-report-display.js"; -import { printTelemetryCheckReport } from "../display/telemetry-check-display.js"; -import { - printLocalCostReadReport, - printPersonIdentityLink, - printPersonIdentityOff, - printPersonIdentityStatus, - printPersonIdentityUnlink, - printPersonIdentityUse, - printTelemetryOffReport, - printTelemetryOnReport, - warnIfFiguresMoveTheTokenToo, -} from "../display/telemetry-display.js"; -import { - printTelemetryForgetPreview, - printTelemetryForgetRefused, - printTelemetryForgetResult, -} from "../display/telemetry-forget-display.js"; -import { ErrorHandler } from "../error-handler.js"; -import { parseGlobalOptions } from "./global-options.js"; - -export function registerTelemetryCommand(program: Command): void { - const telemetry = program - .command("telemetry") - .description("Control whether AIDD may measure this project"); - - telemetry - .command("on") - .description("Turn on the AIDD telemetry switch and git-ignore the run journal") - .option( - "--yes", - "Confirm writing the git-tracked switch — this turns measurement on for everyone who clones", - false - ) - .action(async (cmdOptions: { yes: boolean }) => { - const { verbose, output, projectRoot } = parseGlobalOptions(program); - const errorHandler = new ErrorHandler(output); - try { - const deps = await createDeps(projectRoot, { verbose }, output); - const result = await deps.telemetryOnUseCase.execute({ - projectRoot, - confirmed: cmdOptions.yes, - }); - printTelemetryOnReport(output, result); - } catch (error) { - errorHandler.handle(error); - } - }); - - telemetry - .command("read") - .description( - "Read what sessions cost from the files their tools already wrote, with no process running" - ) - .option( - "--session ", - "One session to read. Omitted, every session the run journal knows is read" - ) - .action(async (cmdOptions: { session?: string }) => { - const { verbose, output, projectRoot } = parseGlobalOptions(program); - const errorHandler = new ErrorHandler(output); - try { - const deps = await createDeps(projectRoot, { verbose }, output); - warnIfFiguresMoveTheTokenToo(output, deps.telemetrySink); - const result = await deps.readLocalCostUseCase.execute({ - projectRoot, - env: process.env, - ...(cmdOptions.session === undefined ? {} : { sessionId: cmdOptions.session }), - }); - printLocalCostReadReport(output, result); - } catch (error) { - errorHandler.handle(error); - } - }); - - registerTelemetryIdentityCommand(telemetry, program); - registerTelemetryCheckCommand(telemetry, program); - - telemetry - .command("report") - .description( - "Report what a period, or one task inside it, cost — tokens, models and steps, with how strongly each was attributed" - ) - .option("--from ", "First UTC day to report, as YYYY-MM-DD") - .option("--to ", "Last UTC day to report, as YYYY-MM-DD (default today)") - .option( - "--days ", - `How many days back to report, ending at --to (default ${DEFAULT_REPORT_DAYS})` - ) - .option( - "--task ", - "Restrict to the sessions that wrote into this task, as /" - ) - .option("--project ", "Restrict to this project") - .option("--step ", "Restrict to this step") - .option("--model ", "Restrict to this model") - .option("--tool ", "Restrict to this tool") - .option( - "--axis ", - `Print one axis as a table to paste elsewhere: ${ARTEFACT_AXES.join(" | ")}` - ) - .option("--json", "Print one object a program can parse, instead of text for a person") - .action( - async (cmdOptions: { - from?: string; - to?: string; - days?: string; - task?: string; - project?: string; - step?: string; - model?: string; - tool?: string; - axis?: string; - json?: boolean; - }) => { - const { verbose, output, projectRoot } = parseGlobalOptions(program); - const errorHandler = new ErrorHandler(output); - try { - // The clock is read once, here, and never again: everything downstream works from - // the two absolute days this resolves to, so the same call answers the same twice. - const period = resolveReportPeriod(cmdOptions, new Date()); - const deps = await createDeps(projectRoot, { verbose }, output); - warnIfFiguresMoveTheTokenToo(output, deps.telemetrySink); - const report = await deps.reportCostUseCase.execute({ - period, - projectRoot, - env: process.env, - ...(cmdOptions.task === undefined ? {} : { task: cmdOptions.task }), - filters: { - ...(cmdOptions.project === undefined ? {} : { project: cmdOptions.project }), - ...(cmdOptions.step === undefined ? {} : { step: cmdOptions.step }), - ...(cmdOptions.model === undefined ? {} : { model: cmdOptions.model }), - ...(cmdOptions.tool === undefined ? {} : { tool: cmdOptions.tool }), - }, - }); - // One value, three renderings. None derives a figure the others cannot see: - // `--json` and `--axis` both read the envelope, and the terminal rendering reads - // the report the envelope is built from. - if (cmdOptions.json) output.print(JSON.stringify(toCostReportEnvelope(report), null, 2)); - else if (cmdOptions.axis !== undefined) - output.print(buildCostReportArtefact(toCostReportEnvelope(report), cmdOptions.axis)); - else printCostReport(output, report); - } catch (error) { - errorHandler.handle(error); - } - } - ); - - telemetry - .command("off") - .description( - "Turn off the AIDD telemetry switch, warning if a tool's own settings file still exports" - ) - .action(async () => { - const { verbose, output, projectRoot } = parseGlobalOptions(program); - const errorHandler = new ErrorHandler(output); - try { - const deps = await createDeps(projectRoot, { verbose }, output); - const result = await deps.telemetryOffUseCase.execute({ projectRoot }); - printTelemetryOffReport(output, result); - } catch (error) { - errorHandler.handle(error); - } - }); - - telemetry - .command("forget") - .description( - "Irreversibly remove what this tool measured: this project's run journal, this " + - "machine's stored records, and this machine's identity file" - ) - .option( - "--yes", - "Confirm removal after seeing what would go — without it, nothing is removed", - false - ) - .action(async (cmdOptions: { yes: boolean }) => { - const { verbose, output, projectRoot } = parseGlobalOptions(program); - const errorHandler = new ErrorHandler(output); - try { - const deps = await createDeps(projectRoot, { verbose }, output); - const preview = await deps.forgetTelemetryUseCase.preview({ projectRoot }); - printTelemetryForgetPreview(output, preview); - if (telemetryRemovalIsEmpty(preview)) return; - if (!cmdOptions.yes) { - printTelemetryForgetRefused(output); - return; - } - const result = await deps.forgetTelemetryUseCase.remove(preview); - printTelemetryForgetResult(output, result); - } catch (error) { - errorHandler.handle(error); - } - }); -} - -/** Whether the measurement chain is actually recording, not merely installed — a hook - * that fired, a session that closed, a tool's own files that can be read, and the two - * joining. Wiring only: gathers through `deps.diagnoseTelemetryUseCase`, prints through - * `printTelemetryCheckReport`, and every failure routes through `errorHandler.handle`. */ -function registerTelemetryCheckCommand(telemetry: Command, program: Command): void { - telemetry - .command("check") - .description("Check whether the measurement chain is actually recording for this project") - .action(async () => { - const { verbose, output, projectRoot } = parseGlobalOptions(program); - const errorHandler = new ErrorHandler(output); - try { - const deps = await createDeps(projectRoot, { verbose }, output); - const result = await deps.diagnoseTelemetryUseCase.execute({ - projectRoot, - env: process.env, - }); - printTelemetryCheckReport(output, result); - } catch (error) { - errorHandler.handle(error); - } - }); -} - -/** Whether this person's own identifier is attached to what `aidd telemetry read` stores — - * never a project's choice, and never the `telemetry on`/`off` switch beside it. Wiring - * only: every verb reads through `deps.personIdentityUseCase`, and every failure routes - * through `errorHandler.handle`. */ -function registerTelemetryIdentityCommand(telemetry: Command, program: Command): void { - const identity = telemetry - .command("identity") - .description("Whether this person's own identifier is attached to records read locally"); - // The bare noun answers with state rather than a help screen: `aidd telemetry identity` is - // a question, and a command surface that replies to it by describing itself is talking - // about the wrong thing. `--help` still prints the help. - identity.action(async () => { - const { verbose, output, projectRoot } = parseGlobalOptions(program); - const errorHandler = new ErrorHandler(output); - try { - const deps = await createDeps(projectRoot, { verbose }, output); - printPersonIdentityStatus(output, await deps.personIdentityUseCase.status()); - } catch (error) { - errorHandler.handle(error); - } - }); - - identity - .command("use [identifier]") - .description( - "Mint this person's identifier, or take one minted on another machine. --name attaches a display name" - ) - .option("--name ", "A display name for whichever identifier this call settles on") - .action(async (identifier: string | undefined, cmdOptions: { name?: string }) => { - const { verbose, output, projectRoot } = parseGlobalOptions(program); - const errorHandler = new ErrorHandler(output); - try { - const deps = await createDeps(projectRoot, { verbose }, output); - printPersonIdentityUse( - output, - await deps.personIdentityUseCase.use({ - ...(identifier === undefined ? {} : { identifier }), - ...(cmdOptions.name === undefined ? {} : { displayName: cmdOptions.name }), - }) - ); - } catch (error) { - errorHandler.handle(error); - } - }); - - identity - .command("off") - .description("Opt out: new records carry no person, from now on") - .action(async () => { - const { verbose, output, projectRoot } = parseGlobalOptions(program); - const errorHandler = new ErrorHandler(output); - try { - const deps = await createDeps(projectRoot, { verbose }, output); - printPersonIdentityOff(output, await deps.personIdentityUseCase.off()); - } catch (error) { - errorHandler.handle(error); - } - }); - - identity - .command("link ") - .description( - "Add an identifier this person cannot choose onto this same person - one row, not two, in a report" - ) - .action(async (rawIdentity: string) => { - const { verbose, output, projectRoot } = parseGlobalOptions(program); - const errorHandler = new ErrorHandler(output); - try { - const deps = await createDeps(projectRoot, { verbose }, output); - printPersonIdentityLink(output, await deps.personIdentityUseCase.link(rawIdentity)); - } catch (error) { - errorHandler.handle(error); - } - }); - - identity - .command("unlink ") - .description("Withdraw an added identifier from this person") - .action(async (rawIdentity: string) => { - const { verbose, output, projectRoot } = parseGlobalOptions(program); - const errorHandler = new ErrorHandler(output); - try { - const deps = await createDeps(projectRoot, { verbose }, output); - printPersonIdentityUnlink(output, await deps.personIdentityUseCase.unlink(rawIdentity)); - } catch (error) { - errorHandler.handle(error); - } - }); -} diff --git a/cli/src/application/commands/update.ts b/cli/src/application/commands/update.ts deleted file mode 100644 index efe36220a..000000000 --- a/cli/src/application/commands/update.ts +++ /dev/null @@ -1,42 +0,0 @@ -import type { Command } from "commander"; -import { createDeps } from "../../infrastructure/deps.js"; -import { ErrorHandler } from "../error-handler.js"; -import { parseGlobalOptions } from "./global-options.js"; - -export function registerUpdateCommand(program: Command): void { - program - .command("update") - .description("Re-install runtime configs, update plugins, and refresh marketplaces") - .option("-f, --force", "Overwrite modified files without prompting", false) - .action(async (cmdOptions: { force: boolean }) => { - const { verbose, output, projectRoot } = parseGlobalOptions(program); - const errorHandler = new ErrorHandler(output); - - try { - const deps = await createDeps(projectRoot, { verbose }, output); - const result = await deps.updateAllUseCase.execute({ - projectRoot, - userForce: cmdOptions.force, - interactive: process.stdout.isTTY ?? false, - }); - - for (const t of result.updatedTools) { - output.success(`Updated ${t.toolId} (${t.fileCount} files)`); - } - if (result.updatedTools.length === 0) { - output.info("All tools up to date."); - } - if (result.updatedPlugins.length > 0) { - output.success(`Updated plugins: ${result.updatedPlugins.join(", ")}`); - } - if (result.marketplaceRefreshFailed) { - output.warn("One or more marketplace refreshes failed."); - } - for (const e of result.errors) { - output.warn(`[${e.scope}] ${e.message}`); - } - } catch (error) { - errorHandler.handle(error); - } - }); -} diff --git a/cli/src/application/display/cost-report-artefact.ts b/cli/src/application/display/cost-report-artefact.ts deleted file mode 100644 index 4d99ab9da..000000000 --- a/cli/src/application/display/cost-report-artefact.ts +++ /dev/null @@ -1,501 +0,0 @@ -import type { - CostReportEmptySelection, - CostReportFilterName, - CostReportFilters, -} from "../../domain/models/cost-report.js"; -import { fromMicroUsd } from "../../domain/models/cost-report.js"; -import type { - CostReportEnvelope, - CostReportEnvelopePersonRow, - CostReportEnvelopeTotals, -} from "../../domain/models/cost-report-envelope.js"; -import { bareOrchestratingSkillNames } from "../../domain/models/flow-attribution.js"; -import type { PersonResolution } from "../../domain/models/person-resolution.js"; -import { getAiToolConfig } from "../../domain/tools/registry.js"; -import { - ATTRIBUTION_LABELS, - BACKLOG_DECLARATION_LABELS, - TASK_ATTRIBUTION_LABELS, - TASK_UNATTRIBUTED_LABELS, -} from "./cost-report-display.js"; - -/** - * One axis of a report, rendered as something a person pastes elsewhere. - * - * Distinct from `cost-report-display.ts`, which prints every axis at once for a terminal. - * This prints one axis, as a markdown table, and drops the share and attribution columns - * the inline reading adds: a table meant to leave the session that made it carries the - * figures, not a computed percentage of them. - * - * It reads the envelope rather than the domain report, because the envelope is what a - * consumer already parses, and because that is the shape the plugin script this replaces - * rendered from — which is how the two were pinned byte-for-byte before the script went. - */ -export const ARTEFACT_AXES = [ - "total", - "day", - "step", - "model", - "agent", - "prompt", - "task", - "backlog", - "flow", - "tool", - "project", - "person", -] as const; - -export type ArtefactAxis = (typeof ARTEFACT_AXES)[number]; - -const NO_PROMPT_LABEL = "no prompt named"; -const UNKNOWN_AMOUNT = "amount unknown"; -const NOTHING_MEASURED = "nothing in this period"; -const NOTHING_IN_SELECTION = "nothing in this selection"; -const SESSION_TOTAL_LABEL = "session total, not requests"; -const NO_KNOWN_PROJECT = "no known project"; -const NO_KNOWN_MODEL = "no known model"; -// Not "no agent": the main thread is where a session starts, not an absence. -const MAIN_THREAD = "the main thread"; -// A tool that never names an agent has said nothing about which one ran. Labelling that row -// "the main thread" would state a fact nothing observed - the reading this axis used to give -// every Codex, Copilot and OpenCode record. -const AGENT_NOT_STATED = "the tool names no agent"; -// Distinct on purpose, per the contract's own three-way shape: an unresolved row names an -// identity that is real but unplaced, and repeats once per such identity since each is its -// own row; the no-identity row is singular and says nobody opted in at all. Neither label -// may be swapped for the other, and neither reads as a shared bucket. -const NO_PERSON_IDENTIFIER = "no identity — nobody opted in"; -function unresolvedPersonLabel(identity: string): string { - return `unresolved — not mapped to anyone (${identity})`; -} - -const UNKNOWN_REASON: Partial> = { - task: "no journal has ever declared it or written into it", - tool: "it is not one of the tools this build knows", -}; - -function count(value: number): string { - return value.toLocaleString("en-US"); -} - -function amount(microUsd: number): string { - return `$${fromMicroUsd(microUsd).toFixed(2)}`; -} - -/** The four counters are disjoint on every reader here, so adding them counts nothing twice. */ -function envelopeTokens(totals: CostReportEnvelopeTotals): number { - return ( - (totals.input_tokens ?? 0) + - (totals.output_tokens ?? 0) + - (totals.cache_read_tokens ?? 0) + - (totals.cache_creation_tokens ?? 0) - ); -} - -function hasSelection(envelope: CostReportEnvelope): boolean { - return envelope.task !== undefined || envelope.filters !== undefined; -} - -function nothingLabel(envelope: CostReportEnvelope): string { - return hasSelection(envelope) ? NOTHING_IN_SELECTION : NOTHING_MEASURED; -} - -function figure(totals: CostReportEnvelopeTotals, envelope: CostReportEnvelope): string { - if (totals.requests === 0) return nothingLabel(envelope); - const cost = totals.cost_micro_usd === undefined ? UNKNOWN_AMOUNT : amount(totals.cost_micro_usd); - return `${cost} — ${count(envelopeTokens(totals))} tokens, ${count(totals.requests)} requests`; -} - -function filtersSuffix(filters: CostReportFilters | undefined): string { - if (!filters) return ""; - const parts = Object.entries(filters).map(([name, value]) => `${name}=${value}`); - return parts.length === 0 ? "" : `, filters: ${parts.join(", ")}`; -} - -/** States the period, the selection and the axis on every artefact, for the same reason a - * chart names its own axes: a figure copied out of the session that made it has to stay - * placeable without the command that produced it. */ -// Carried on every axis's own header, never only on the terminal rendering: a table meant -// to leave the session that made it must say this on its own, the same reason the -// attribution column exists beside it. See cost-report-display.ts's printHeader for why -// the wording never says "measurement is off" bare — the sink below is scoped to this -// person, not to this project, so an off switch never contradicts a real figure beside it. -function measurementSuffix(envelope: CostReportEnvelope): string { - return envelope.measurement_enabled - ? "" - : " — this project's switch is off, figures are the whole sink, not scoped to it"; -} - -function header(envelope: CostReportEnvelope, axisLabel: string): string { - const { from_day, to_day } = envelope.period; - const task = envelope.task === undefined ? "" : `, task ${envelope.task}`; - return ( - `period ${from_day} to ${to_day}${task}${filtersSuffix(envelope.filters)} — axis: ${axisLabel}` + - measurementSuffix(envelope) - ); -} - -function unknownReason(filter: CostReportFilterName): string { - return UNKNOWN_REASON[filter] ?? `no record has ever named this ${filter}`; -} - -function emptySelectionMessage({ - filter, - value, - known, - combination, -}: CostReportEmptySelection): string { - if (!known) return `${filter} '${value}' matched nothing — ${unknownReason(filter)}`; - if (combination) - return `${filter} '${value}' matched nothing combined with the rest of this selection`; - return `${filter} '${value}' matched nothing in this selection — known, but no work here`; -} - -/** What the read could not do travels with what it did, on the artefact as on the terminal: - * a total assembled from a partial read is indistinguishable from a complete one without it. - * - * `identity_unusable === "absent"` is the exception: it is every user's ordinary default - * state, not a degraded read, so it is never printed as a caveat here. Only the person axis - * (`personArtefact`, via `includeAbsentIdentityCaveat: true`) says it, because that is the - * one place the reader is already looking at identity resolution and the fact is relevant. - * `"unreadable"` is real damage on every axis and always prints. */ -function caveats( - envelope: CostReportEnvelope, - { includeAbsentIdentityCaveat = false }: { includeAbsentIdentityCaveat?: boolean } = {} -): readonly string[] { - const lines: string[] = []; - if (envelope.empty_selection !== undefined) { - lines.push(emptySelectionMessage(envelope.empty_selection)); - } - if (envelope.read.undated_records > 0) { - lines.push( - `${count(envelope.read.undated_records)} records carry no moment and are in no period` - ); - } - if (envelope.read.unreadable_lines > 0) { - lines.push(`${count(envelope.read.unreadable_lines)} lines could not be read`); - } - if (envelope.read.identity_unusable === "unreadable") { - lines.push( - "this machine's own identity could not be read; every identifier is reported unresolved" - ); - } else if (envelope.read.identity_unusable === "absent" && includeAbsentIdentityCaveat) { - lines.push("no identity was declared; every identifier is reported unresolved"); - } - return lines; -} - -function table( - envelope: CostReportEnvelope, - axisLabel: string, - column: string, - rows: readonly string[] -): string { - return [ - header(envelope, axisLabel), - "", - `| ${column} | Total |`, - "| --- | --- |", - ...rows, - ...caveats(envelope), - ].join("\n"); -} - -/** One total, in a line: the answer to "what did this cost". */ -function totalArtefact(envelope: CostReportEnvelope): string { - return [ - header(envelope, "total"), - "", - figure(envelope.totals, envelope), - ...caveats(envelope), - ].join("\n"); -} - -/** Every day the period spans, gap included, and never capped the way the terminal rendering - * caps a long series: a file is where a long series belongs, and dropping rows there would be - * the same false continuity the cap exists to prevent in a terminal. */ -function dayArtefact(envelope: CostReportEnvelope): string { - return table( - envelope, - "by day", - "Day", - envelope.by_day.map((row) => `| ${row.day} | ${figure(row.totals, envelope)} |`) - ); -} - -/** Two rows can share one step name — the same skill reached once from the tool's own - * statement and once from a journal interval is two different claims about the same step, - * never one the report is free to merge (`by_step` is keyed on `step` and `attribution` - * together; see `cost-report-contract.md`). Dropping the attribution column here would - * paste a table where two such rows are indistinguishable from one step double-counted - - * so unlike every other axis below, this one carries a third column rather than the - * generic `table()` helper's two. */ -function stepArtefact(envelope: CostReportEnvelope): string { - const rows = envelope.by_step.map((row) => { - const step = row.step ?? "unattributed"; - return `| ${step} | ${ATTRIBUTION_LABELS[row.attribution]} | ${figure(row.totals, envelope)} |`; - }); - return [ - header(envelope, "by step"), - "", - "| Step | Attribution | Total |", - "| --- | --- | --- |", - ...rows, - ...caveats(envelope), - ].join("\n"); -} - -/** A third column beside the generic `table()` helper's two, for the same reason - * `stepArtefact` carries one: the row for a named task rests on a closed interval, and a - * pasted table says so on its own rather than only in a document elsewhere. Each row for - * what fell in no declared interval carries no attribution to show, only its own reason. */ -function taskArtefact(envelope: CostReportEnvelope): string { - const rows = envelope.by_task.map((row) => { - const task = row.task ?? (row.reason === undefined ? "" : TASK_UNATTRIBUTED_LABELS[row.reason]); - const strength = row.attribution === undefined ? "—" : TASK_ATTRIBUTION_LABELS[row.attribution]; - return `| ${task} | ${strength} | ${figure(row.totals, envelope)} |`; - }); - return [ - header(envelope, "by task"), - "", - "| Task | Attribution | Total |", - "| --- | --- | --- |", - ...rows, - ...caveats(envelope), - ].join("\n"); -} - -/** No third column, unlike `taskArtefact`: a backlog row carries no attribution to show — - * every named row rests on the same single route (reading the declaration), so there is no - * second strength to distinguish the way a task's closed interval has. */ -function backlogArtefact(envelope: CostReportEnvelope): string { - const rows = envelope.by_backlog.map((row) => { - const name = - row.backlog ?? - (row.declaration !== undefined - ? BACKLOG_DECLARATION_LABELS[row.declaration] - : row.reason !== undefined - ? TASK_UNATTRIBUTED_LABELS[row.reason] - : ""); - return `| ${name} | ${figure(row.totals, envelope)} |`; - }); - return table(envelope, "by backlog", "Backlog item", rows); -} - -const OUTSIDE_EVERY_FLOW_LABEL = "outside any flow"; - -/** What this axis cannot tell apart, printed with the figures rather than left in a doc - * comment no reader of a report ever opens. Every line here is a standing property of how a - * flow is read, never a damaged read the way `caveats()`'s own lines are - which is why they - * are assembled here and not there. - * - * Each set is gated on a row it actually describes being present. A limit is a statement - * about a mechanism that ran: printing the journal's own two for a period whose only flow - * came from a record's own tool would name a reading nothing here performed, and a report - * that lists what could have gone wrong with an answer it did not give is noise. */ -function flowLimits(envelope: CostReportEnvelope): readonly string[] { - return [...journalFlowLimits(envelope), ...toolStatedFlowLimits(envelope)]; -} - -/** Both properties of walking the journal's own step sequence, so both are gated on a row - * that walk produced. */ -function journalFlowLimits(envelope: CostReportEnvelope): readonly string[] { - if (!envelope.by_flow.some((row) => row.attribution === "journal-interval")) return []; - return [ - "a skill run by hand while a flow was open is counted inside it: the orchestrator's own " + - "call and a person's write the identical step_start line", - `a skill of this project named ${orAny(bareOrchestratingSkillNames())} opens a flow of ` + - "its own: outside a plugin a host names a skill by its folder alone, and this axis " + - "has only that name to go on", - ]; -} - -/** The one property of a flow no interval bounded: it is a name, and a name cannot say how - * many runs it stands for. Stated wherever such a row is printed, because a reader who takes - * it for a single run reads its total as one orchestration's cost. */ -function toolStatedFlowLimits(envelope: CostReportEnvelope): readonly string[] { - if (!envelope.by_flow.some((row) => row.attribution === "tool-stated")) return []; - return [ - "a flow only a record's own tool named is every run of that skill at once: its journal " + - "opened no flow to bound one run from the next, so the row has no opening moment and " + - "its total is not one orchestration's", - ]; -} - -/** `a`, `a or b`, `a, b or c` - the names read as a sentence rather than as a list a program - * printed. Takes however many `bareOrchestratingSkillNames` holds, so the sentence stays - * grammatical when a project adds a fourth orchestrator to the set. */ -function orAny(names: readonly string[]): string { - if (names.length <= 1) return names[0] ?? ""; - return `${names.slice(0, -1).join(", ")} or ${names[names.length - 1]}`; -} - -/** Two columns beside the generic `table()` helper's two, for the same reason - * `stepArtefact` carries one: two rows can share a `flow` name - the same orchestrating - * skill run twice in one session, or a run the journal witnessed beside one only the tool - * named - and this table must never let them read as one flow double-counted. `Attribution` - * and `Opened at` are what tell them apart; a `tool-stated` row is a bucket drawn from - * however many runs the tool named, so it has no single opening moment and prints an em - * dash there, as does the row for work outside every flow - the same way `taskArtefact` - * does for a reason-only row's own missing attribution. */ -function flowArtefact(envelope: CostReportEnvelope): string { - const rows = envelope.by_flow.map((row) => { - const flow = row.flow ?? OUTSIDE_EVERY_FLOW_LABEL; - const openedAt = row.started_at ?? "—"; - const attribution = ATTRIBUTION_LABELS[row.attribution]; - return `| ${flow} | ${attribution} | ${openedAt} | ${figure(row.totals, envelope)} |`; - }); - return [ - header(envelope, "by flow"), - "", - "| Flow | Attribution | Opened at | Total |", - "| --- | --- | --- | --- |", - ...rows, - ...flowLimits(envelope), - ...caveats(envelope), - ].join("\n"); -} - -function agentArtefact(envelope: CostReportEnvelope): string { - return table( - envelope, - "by agent", - "Agent", - envelope.by_agent.map( - (row) => - `| ${row.agent ?? (row.attribution === "main-thread" ? MAIN_THREAD : AGENT_NOT_STATED)} | ${figure(row.totals, envelope)} |` - ) - ); -} - -/** An id and the moment its turn began: the id alone is opaque, and the moment is what a - * person greps for in their own transcript. `—` where a row carries none, which is the row - * for records that named no prompt - never a moment borrowed from another turn. */ -function promptArtefact(envelope: CostReportEnvelope): string { - const rows = envelope.by_prompt.map( - (row) => - `| ${row.prompt ?? NO_PROMPT_LABEL} | ${row.started_at ?? "—"} | ${figure(row.totals, envelope)} |` - ); - return [ - header(envelope, "by prompt"), - "", - "| Prompt | Started at | Total |", - "| --- | --- | --- |", - ...rows, - ...caveats(envelope), - ].join("\n"); -} - -function modelArtefact(envelope: CostReportEnvelope): string { - return table( - envelope, - "by model", - "Model", - envelope.by_model.map( - (row) => `| ${row.model ?? NO_KNOWN_MODEL} | ${figure(row.totals, envelope)} |` - ) - ); -} - -/** A mapped row's own label: its display name when one was set, its canonical identifier - * otherwise — never a raw identity, since a mapped row may carry several. */ -function mappedPersonLabel(row: CostReportEnvelopePersonRow): string { - return row.display_name ?? row.person ?? ""; -} - -/** One label per resolution, exhaustively - a `Record` rather than an if-chain with a - * fallback, so a value added to `PersonResolution` fails to compile here instead of - * reaching a reader as "nobody opted in". That is not hypothetical: `this-machine` was - * added on 2026-09-04 and the fallback swallowed it silently, printing rows a declared - * identity claims as rows nobody claimed. Same mechanism `TASK_UNATTRIBUTED_LABELS` uses - * one axis over. */ -const PERSON_LABELS: Record string> = { - mapped: mappedPersonLabel, - // This machine's own person, reached because the record named nobody. The same label a - // mapped row gets: it is the same person, and the row's `resolution` already carries how - // it was reached for a reader who needs that. - "this-machine": mappedPersonLabel, - unresolved: (row) => unresolvedPersonLabel(row.identities[0] ?? ""), - none: () => NO_PERSON_IDENTIFIER, -}; - -function personLabel(row: CostReportEnvelopePersonRow): string { - return PERSON_LABELS[row.resolution](row); -} - -/** A third column beside every other axis's two, because a person line the contract can - * audit has to carry its own evidence: the raw identities behind it, not only its label. */ -function personArtefact(envelope: CostReportEnvelope): string { - const rows = envelope.by_person.map((row) => { - const identities = row.identities.length > 0 ? row.identities.join(", ") : "—"; - return `| ${personLabel(row)} | ${identities} | ${figure(row.totals, envelope)} |`; - }); - return [ - header(envelope, "by person"), - "", - "| Person | Identities | Total |", - "| --- | --- | --- |", - ...rows, - ...caveats(envelope, { includeAbsentIdentityCaveat: true }), - ].join("\n"); -} - -function projectArtefact(envelope: CostReportEnvelope): string { - return table( - envelope, - "by project", - "Project", - envelope.by_project.map( - (row) => `| ${row.project ?? NO_KNOWN_PROJECT} | ${figure(row.totals, envelope)} |` - ) - ); -} - -/** A tool that cannot be read at all is never a zero: its row says so instead of printing a - * figure nothing measured. A tool carrying only a session total prints that rather than - * "nothing in this period" — present because it was measured, absent from `totals` because it - * is not a sum of requests. */ -function toolArtefact(envelope: CostReportEnvelope): string { - const rows = envelope.by_tool.map((row) => { - const because = row.reason ? ` — ${row.reason}` : ""; - let value: string; - if (row.coverage === "not-covered") { - value = `not covered${because}`; - } else if (row.totals.requests === 0 && row.session_totals) { - value = `${count(envelopeTokens(row.session_totals))} tokens (${SESSION_TOTAL_LABEL})${because}`; - } else { - value = `${figure(row.totals, envelope)}${because}`; - } - return `| ${getAiToolConfig(row.tool).displayName} | ${value} |`; - }); - return table(envelope, "by tool", "Tool", rows); -} - -const BUILDERS: Record string> = { - total: totalArtefact, - day: dayArtefact, - step: stepArtefact, - model: modelArtefact, - agent: agentArtefact, - prompt: promptArtefact, - task: taskArtefact, - backlog: backlogArtefact, - flow: flowArtefact, - tool: toolArtefact, - project: projectArtefact, - person: personArtefact, -}; - -export function isArtefactAxis(value: string): value is ArtefactAxis { - return (ARTEFACT_AXES as readonly string[]).includes(value); -} - -/** An axis name in, the artefact that answers it out. An axis this does not know is refused - * by name, with the ones it does — never guessed at. */ -export function buildCostReportArtefact(envelope: CostReportEnvelope, axis: string): string { - if (!isArtefactAxis(axis)) { - throw new Error(`Unknown axis '${axis}'. Expected one of: ${ARTEFACT_AXES.join(", ")}.`); - } - return BUILDERS[axis](envelope); -} diff --git a/cli/src/application/display/cost-report-display.ts b/cli/src/application/display/cost-report-display.ts deleted file mode 100644 index 994532441..000000000 --- a/cli/src/application/display/cost-report-display.ts +++ /dev/null @@ -1,603 +0,0 @@ -import type { - CostReport, - CostReportAttributionRow, - CostReportBacklogRow, - CostReportDayRow, - CostReportEmptySelection, - CostReportFilterName, - CostReportFilters, - CostReportProjectRow, - CostReportStepRow, - CostReportTaskAttributionRow, - CostReportTaskRow, - CostReportToolRow, - CostTotals, -} from "../../domain/models/cost-report.js"; -import { fromMicroUsd } from "../../domain/models/cost-report.js"; -import type { StepAttributionSource } from "../../domain/models/step-attribution.js"; -import type { - TaskAttributionSource, - TaskUnattributedReason, -} from "../../domain/models/task-attribution.js"; -import { getAiToolConfig } from "../../domain/tools/registry.js"; -import type { CLIOutput } from "../output.js"; - -/** What each strength of attribution is called where a person reads it. `unattributed` - * says nothing could attribute this, and deliberately not that the work ran outside every - * step: on at least one measured tool the two are indistinguishable, and the stronger - * reading would be a fact this layer invented. */ -export const ATTRIBUTION_LABELS: Record = { - "tool-stated": "stated by the tool", - "prompt-matched": "matched on the prompt", - "journal-interval": "from a journal interval", - unattributed: "unattributed", -}; - -export const TASK_ATTRIBUTION_LABELS: Record = { - declared: "declared by the flow", - inferred: "inferred from a written file", -}; - -/** What each reason a record fell in no declared interval is called where a person reads - * it - never one label standing in for all of them, which is the fault this breakdown - * exists to avoid (see `CostReportTaskRow`). - * - * The first names a fact about the read, the second a fact about the record's own age, the - * rest facts about how the work behaved, and the wording keeps all three apart: "no usable - * run journal" says this layer never had a journal it could attach to this session - none - * read, or one read whose header was torn - where "no usable task declaration" says it had - * one and found no declaration in it. - * - * The second is worded as a fact about the record's age, not about declaring, because that - * is what it is: a resumed transcript's inherited turns were billed before the session that - * read them ever opened a journal. Saying "before this session declared a task" of them - - * which this breakdown did until 2026-09-04, for 96.2% of a real period - reads as a - * complaint about the flow. */ -export const TASK_UNATTRIBUTED_LABELS: Record = { - "no-journal": "no usable run journal for this session", - "precedes-journal": "older than anything this session's journal witnessed", - "no-declaration": "no usable task declaration in this session", - "precedes-declaration": "before the next task this session declares", - "journal-silent": "the journal falls silent before this record", -}; - -/** What a known task's own two non-item states are called where a person reads them - - * distinct from `TASK_UNATTRIBUTED_LABELS`, which names why a record belongs to no task at - * all. Both of these are about a task that *is* known, whose folder either names nothing or - * whose declaration could not be parsed. */ -export const BACKLOG_DECLARATION_LABELS: Record<"none" | "unreadable", string> = { - none: "this task declares no backlog item", - unreadable: "this task's backlog declaration could not be read", -}; - -/** Printed where a figure is genuinely not known, never as `$0.00`. A tool whose own files - * carry no amount has an unknown cost, not a free one. Exported so another renderer of the - * same report — the interactive telemetry screen — prints the identical words rather than - * a second literal that could drift from this one. */ -export const UNKNOWN_AMOUNT = "amount unknown"; -/** A covered tool with no records, and a wholly unfiltered period with none at all. - * Distinct from both an unknown amount and a zero: this one really did measure nothing, - * and saying so is the only reading the records support. Not exported: every reader outside - * this module reaches this string through `nothingLabel`, which decides between this and - * `NOTHING_IN_SELECTION` — never through the literal itself. */ -const NOTHING_MEASURED = "nothing in this period"; -/** The same zero, under a selection narrower than the whole period. `task` and every - * generic filter already narrow the record set before any breakdown is computed, so a - * zero row under either is caused by the selection, not by real idleness - saying - * "period" there would be a false statement about time. */ -const NOTHING_IN_SELECTION = "nothing in this selection"; -/** What a tool's `sessionTotals` figure is called wherever it is printed - never merged - * into the request-based figure beside it, and never called "cost" or "requests" since it - * is neither. */ -const SESSION_TOTAL_LABEL = "session total, not requests"; -const LABEL_WIDTH = 26; -const NO_KNOWN_PROJECT = "no known project"; -const NO_KNOWN_MODEL = "no known model"; -// Not "no agent": the main thread is where a session starts, not an absence. -const MAIN_THREAD = "the main thread"; -// And not the main thread either: a tool that never names an agent has said nothing about -// which one ran, so a row labelled "the main thread" would state a fact nothing observed. -const AGENT_NOT_STATED = "the tool names no agent"; - -/** What a row that names no agent is called, by which of the two silences it is. */ -export function agentRowLabel(row: CostReport["byAgents"][number]): string { - if (row.agent !== undefined) return row.agent; - return row.attribution === "main-thread" ? MAIN_THREAD : AGENT_NOT_STATED; -} - -// A prompt id is a uuid, wider than `LABEL_WIDTH`, and `padTo` never truncates - so the -// column is its own width rather than colliding with the one every named label shares. -const PROMPT_WIDTH = 38; -const NO_PROMPT = "no prompt named"; -// One row per turn, unbounded where every other axis has a small vocabulary: 12 on one -// session, 31,435 over the measured history. Truncated rather than suppressed the way -// `printDays` suppresses - a partial series is a lie about continuity, a top N of a ranking -// is not, and the line below says how many it withheld. The envelope still carries them all. -const MAX_PRINTED_PROMPTS = 10; - -// A year asked for by day is 365 rows - the envelope always carries every one of them, but -// a terminal is not the place to read that many. Above this, the text rendering names the -// count and points at --json rather than printing a screen nobody can scan. This used to -// carry a second sentence pinning it to a plugin script's own copy of the number, held -// equal by a byte-compare e2e test; both went when the CLI took the read path, so this is -// the only place the limit lives. -const MAX_PRINTED_DAYS = 31; - -/** Exported alongside `formatAmount` and `totalTokens` so the interactive telemetry screen - * renders the same figures this text report does, rather than a second formatting routine - * that could read a count differently from this one. */ -export function formatCount(value: number): string { - return value.toLocaleString("en-US"); -} - -export function formatAmount(microUsd: number): string { - return `$${fromMicroUsd(microUsd).toFixed(2)}`; -} - -/** Every token a record counted, across the four disjoint counters — a tool's `input` is - * exclusive of its cache figures on every reader here, so adding them counts nothing - * twice. Exported for the same reason `formatCount` is. */ -export function totalTokens(totals: CostTotals): number { - return ( - (totals.inputTokens ?? 0) + - (totals.outputTokens ?? 0) + - (totals.cacheReadTokens ?? 0) + - (totals.cacheCreationTokens ?? 0) - ); -} - -/** What a share is taken of. Cost where the period has one, tokens where it does not — a - * period made only of tools that carry no amount still breaks down, by the quantity it - * does have. Named in the output so nobody has to guess which. Exported so the interactive - * telemetry screen takes a row's share by the identical rule this text report already - * applies to every breakdown, rather than a second percentage rule that could drift from - * this one. */ -export function shareBasis(totals: CostTotals): { readonly label: string; readonly of: number } { - return totals.costMicroUsd === undefined - ? { label: "of tokens", of: totalTokens(totals) } - : { label: "of cost", of: totals.costMicroUsd }; -} - -/** Exported for the same reason `shareBasis` is. */ -export function shareOf(totals: CostTotals, basis: number, useCost: boolean): string { - if (basis === 0) return " - "; - const part = useCost ? (totals.costMicroUsd ?? 0) : totalTokens(totals); - return `${Math.round((part / basis) * 100) - .toString() - .padStart(3)}%`; -} - -/** A label, in a column of `width` — and always followed by something. - * - * `padEnd` returns a longer string unchanged, so a label wider than the column would run - * straight into whatever comes after it: a real project id is the repository's own remote, - * and `git@github.com:…/framework.git` printed as `…framework.git100%`. Measured on a live - * report; no fixture had ever carried an identifier that long. Exported so every - * column-padded reader of a label — this file's own `pad`, and the interactive telemetry - * screen's row list, whose own attribution-carrying labels (F2) are wider still — shares - * this one guarantee rather than each risking the same collision at its own width. */ -export function padTo(label: string, width: number): string { - return label.length >= width ? `${label} ` : label.padEnd(width); -} - -function pad(label: string): string { - return padTo(label, LABEL_WIDTH); -} - -/** `task` and the four generic filters both narrow the record set before any breakdown - * runs, so either one - alone or together - means every zero downstream is the selection - * talking, not the period. Every row measured against this reads unambiguously: nothing - * a filter can produce here escapes being counted as in-scope or out, so there is no row - * this call cannot decide for. */ -function hasSelection(report: Pick): boolean { - return report.task !== undefined || report.filters !== undefined; -} - -/** Never a bare `0` and never `NOTHING_MEASURED` under a selection: `task` and the four - * generic filters both narrow the record set before any breakdown runs, so a zero under - * either reads as the selection's own doing, not the period's. Exported so the interactive - * telemetry screen tells the two absences apart the identical way this text report already - * does, rather than a second rule that could drift from this one. */ -export function nothingLabel(report: Pick): string { - return hasSelection(report) ? NOTHING_IN_SELECTION : NOTHING_MEASURED; -} - -/** `name=value` for every active generic filter, in the fixed order `cost-report.ts` - * gives them - empty for an unfiltered period. */ -function filtersSuffix(filters: CostReportFilters | undefined): string { - if (!filters) return ""; - const parts = Object.entries(filters).map(([name, value]) => `${name}=${value}`); - return parts.length === 0 ? "" : ` filters: ${parts.join(", ")}`; -} - -// What "never known" means differs by filter: `task` and `tool` are checked against -// journals and a declared list, never against a record, so saying "no record" for either -// would claim a check this layer never ran. -const UNKNOWN_REASON: Partial> = { - task: "no journal has ever declared it or written into it", - tool: "it is not one of the tools this build knows", -}; - -function unknownReason(filter: CostReportFilterName): string { - return UNKNOWN_REASON[filter] ?? `no record has ever named this ${filter}`; -} - -/** What a filter matching nothing says, told apart from a period that genuinely holds no - * work: that case never reaches here, since the report only ever carries an - * `emptySelection` when a filter - not the period itself - is what emptied it. Exported so - * the interactive telemetry screen names the same culprit in the same words, rather than a - * second rendering of `CostReportEmptySelection` that could disagree with this one. */ -export function emptySelectionMessage({ - filter, - value, - known, - combination, -}: CostReportEmptySelection): string { - if (!known) return ` ${filter} '${value}' matched nothing — ${unknownReason(filter)}`; - if (combination) - return ` ${filter} '${value}' matched nothing combined with the rest of this selection`; - return ` ${filter} '${value}' matched nothing in this selection — known, but no work here`; -} - -/** Never a bare `0`: a period nothing was measured in reads as "nothing in this period" - * (or selection), the same refusal `requests` already makes below - a session count is no - * less a claim about what was measured than a request count is. Exported for the same - * reason `formatCount` is. */ -export function sessionsFigure(report: CostReport): string { - return report.sessions === 0 ? nothingLabel(report) : formatCount(report.sessions); -} - -/** Cache reads' share of `tokens`, rounded to a whole percent - `0` when there are no - * tokens to divide, never `NaN`. `tokens` arrives as a parameter rather than being - * recomputed here: every caller already has its own `totalTokens(totals)` at hand, and - * this stays the one place the rounding happens rather than a formula copied at each call - * site. Exported so the interactive telemetry screen reads the same figure through this one - * function rather than a second copy that could drift from it. */ -export function cacheReadSharePercent(totals: CostTotals, tokens: number): number { - return tokens === 0 ? 0 : Math.round(((totals.cacheReadTokens ?? 0) / tokens) * 100); -} - -function printTotals(output: CLIOutput, report: CostReport): void { - const { totals } = report; - if (totals.requests === 0) { - output.print(` ${pad("sessions")}${sessionsFigure(report)}`); - output.print(` ${pad("requests")}${nothingLabel(report)}`); - return; - } - const tokens = totalTokens(totals); - const cacheShare = cacheReadSharePercent(totals, tokens); - output.print(` ${pad("sessions")}${sessionsFigure(report)}`); - output.print(` ${pad("requests")}${formatCount(totals.requests)}`); - output.print(` ${pad("tokens")}${formatCount(tokens)} ${cacheShare}% cache`); - output.print( - ` ${pad("cost")}${totals.costMicroUsd === undefined ? UNKNOWN_AMOUNT : formatAmount(totals.costMicroUsd)}` - ); - if (report.activeTimeSeconds !== undefined) { - const minutes = Math.round(report.activeTimeSeconds / 60); - output.print( - ` ${pad("active time")}${formatCount(minutes)} min per session; not attributable to steps` - ); - } -} - -function figureFor(totals: CostTotals, useCost: boolean): string { - if (!useCost) return `${formatCount(totalTokens(totals))} tokens`; - return totals.costMicroUsd === undefined ? UNKNOWN_AMOUNT : formatAmount(totals.costMicroUsd); -} - -function printStepRows( - output: CLIOutput, - rows: readonly CostReportStepRow[], - basis: number, - useCost: boolean -): void { - for (const row of rows) { - const name = row.step ?? ATTRIBUTION_LABELS.unattributed; - const strength = row.step === undefined ? "" : ` ${ATTRIBUTION_LABELS[row.attribution]}`; - output.print( - ` ${pad(name)}${shareOf(row.totals, basis, useCost)} ${figureFor(row.totals, useCost)}${strength}` - ); - } -} - -function printAttributionRows( - output: CLIOutput, - rows: readonly CostReportAttributionRow[], - basis: number, - useCost: boolean -): void { - for (const row of rows) { - output.print( - ` ${pad(ATTRIBUTION_LABELS[row.attribution])}${shareOf(row.totals, basis, useCost)}` - ); - } -} - -/** Every declared tool, including the ones that can say nothing. A tool missing from this - * list is a tool a reader takes for one that did nothing, and for an unreadable one that - * is the false zero this whole layer exists to prevent. */ -function printToolRows( - output: CLIOutput, - rows: readonly CostReportToolRow[], - report: Pick -): void { - for (const row of rows) { - const name = getAiToolConfig(row.tool).displayName; - if (row.coverage === "not-covered") { - output.print(` ${pad(name)}not covered${row.reason ? ` — ${row.reason}` : ""}`); - continue; - } - if (row.totals.requests === 0 && row.sessionTotals) { - const tokens = `${formatCount(totalTokens(row.sessionTotals))} tokens (${SESSION_TOTAL_LABEL})`; - output.print(` ${pad(name)}${tokens}${row.reason ? ` — ${row.reason}` : ""}`); - continue; - } - if (row.totals.requests === 0) { - output.print( - ` ${pad(name)}${nothingLabel(report)}${row.reason ? ` — ${row.reason}` : ""}` - ); - continue; - } - const figure = - row.totals.costMicroUsd === undefined - ? UNKNOWN_AMOUNT - : formatAmount(row.totals.costMicroUsd); - const tokens = `${formatCount(totalTokens(row.totals))} tokens`; - output.print(` ${pad(name)}${figure} ${tokens}${row.reason ? ` — ${row.reason}` : ""}`); - } -} - -function printCaveats(output: CLIOutput, report: CostReport): void { - if (report.undatedRecords > 0) { - output.print( - ` ${formatCount(report.undatedRecords)} records carry no moment and are in no period` - ); - } - if (report.unreadableLines > 0) { - output.print(` ${formatCount(report.unreadableLines)} lines could not be read`); - } -} - -/** A breakdown reads as a group: a blank line, a heading naming what its shares are taken - * of, then its rows. Empty groups print nothing at all rather than a heading over silence. */ -interface Basis { - readonly label: string; - readonly of: number; - readonly useCost: boolean; -} - -/** Only where `--task` narrowed the report - a session without one carries no per-record - * task identity to break down (see metrics-contract.md), so there is nothing here to print - * for the unfiltered period. */ -function printTaskAttribution(output: CLIOutput, report: CostReport, basis: Basis): void { - if (report.taskAttributionMix === undefined) return; - output.print(""); - output.print(` ticket known ${basis.label}`); - printTaskAttributionRows(output, report.taskAttributionMix, basis.of, basis.useCost); -} - -function printTaskAttributionRows( - output: CLIOutput, - rows: readonly CostReportTaskAttributionRow[], - basis: number, - useCost: boolean -): void { - for (const row of rows) { - output.print( - ` ${pad(TASK_ATTRIBUTION_LABELS[row.attribution])}${shareOf(row.totals, basis, useCost)}` - ); - } -} - -function printStepsAndAttribution(output: CLIOutput, report: CostReport, basis: Basis): void { - if (report.bySteps.length === 0) return; - output.print(""); - output.print(` by step ${basis.label}`); - printStepRows(output, report.bySteps, basis.of, basis.useCost); - output.print(""); - output.print(` attribution ${basis.label}`); - printAttributionRows(output, report.attributionMix, basis.of, basis.useCost); -} - -/** Printed straight after the steps, because it answers what they cannot: on a session that - * delegates, the step axis names a few percent and this one names the rest. Measured — ten - * subagent files held 432M of a live session's 466M tokens. */ -function printAgents(output: CLIOutput, report: CostReport, basis: Basis): void { - if (report.byAgents.length === 0) return; - output.print(""); - output.print(` by agent ${basis.label}`); - for (const row of report.byAgents) { - const name = agentRowLabel(row); - const share = shareOf(row.totals, basis.of, basis.useCost); - output.print( - ` ${padTo(name, LABEL_WIDTH)}${share} ${figureFor(row.totals, basis.useCost)}` - ); - } -} - -/** The one axis no host limit can empty: every record the transcript reader resolves carries - * a `prompt_id`, where a skill name, an identity and a declaration each may be missing. */ -function printPrompts(output: CLIOutput, report: CostReport, basis: Basis): void { - if (report.byPrompts.length === 0) return; - output.print(""); - output.print(` by prompt ${basis.label}`); - for (const row of report.byPrompts.slice(0, MAX_PRINTED_PROMPTS)) { - const share = shareOf(row.totals, basis.of, basis.useCost); - output.print( - ` ${padTo(row.prompt ?? NO_PROMPT, PROMPT_WIDTH)}${padTo(row.startedAt ?? "", 22)}${share} ${figureFor(row.totals, basis.useCost)}` - ); - } - const withheld = report.byPrompts.length - MAX_PRINTED_PROMPTS; - if (withheld > 0) { - output.print(` ${formatCount(withheld)} more prompts — see --json for all of them`); - } -} - -function printModels(output: CLIOutput, report: CostReport, basis: Basis): void { - if (report.byModels.length === 0) return; - output.print(""); - output.print(` by model ${basis.label}`); - for (const row of report.byModels) { - const name = row.model ?? NO_KNOWN_MODEL; - const share = shareOf(row.totals, basis.of, basis.useCost); - output.print(` ${pad(name)}${share} ${figureFor(row.totals, basis.useCost)}`); - } -} - -function printProjects( - output: CLIOutput, - rows: readonly CostReportProjectRow[], - basis: Basis -): void { - if (rows.length === 0) return; - output.print(""); - output.print(` by project ${basis.label}`); - for (const row of rows) { - const name = row.project ?? NO_KNOWN_PROJECT; - const share = shareOf(row.totals, basis.of, basis.useCost); - output.print(` ${pad(name)}${share} ${figureFor(row.totals, basis.useCost)}`); - } -} - -/** One row per task a record's own moment fell inside, plus up to four rows for what fell - * in none - one per reason present, always after every named task and in - * `TASK_UNATTRIBUTED_REASONS`' own fixed order, the same placement `byPeople` gives its own - * no-identifier row. Carries the attribution beside a named row for the same reason - * `printStepRows` does: a figure that rests on a closed interval says so next to the - * number, not only in a document elsewhere. */ -function printTasks(output: CLIOutput, rows: readonly CostReportTaskRow[], basis: Basis): void { - if (rows.length === 0) return; - output.print(""); - output.print(` by task ${basis.label}`); - for (const row of rows) { - const name = row.task ?? (row.reason === undefined ? "" : TASK_UNATTRIBUTED_LABELS[row.reason]); - const strength = - row.attribution === undefined ? "" : ` ${TASK_ATTRIBUTION_LABELS[row.attribution]}`; - output.print( - ` ${pad(name)}${shareOf(row.totals, basis.of, basis.useCost)} ${figureFor(row.totals, basis.useCost)}${strength}` - ); - } -} - -/** One row per backlog item a task in the period declared, plus the two rows for a known - * task that named none or could not be read, plus up to four reason rows for a record in - * no task at all - the same tail order `printTasks` gives its own remainder. No attribution - * column: unlike a task's closed interval, a backlog row rests on one route only. */ -function printBacklog( - output: CLIOutput, - rows: readonly CostReportBacklogRow[], - basis: Basis -): void { - if (rows.length === 0) return; - output.print(""); - output.print(` by backlog item ${basis.label}`); - for (const row of rows) { - const name = - row.backlog ?? - (row.declaration !== undefined - ? BACKLOG_DECLARATION_LABELS[row.declaration] - : row.reason !== undefined - ? TASK_UNATTRIBUTED_LABELS[row.reason] - : ""); - output.print( - ` ${pad(name)}${shareOf(row.totals, basis.of, basis.useCost)} ${figureFor(row.totals, basis.useCost)}` - ); - } -} - -/** Chronological, never sorted by size: a series read out of order is not a series. Above - * `MAX_PRINTED_DAYS`, a person reads a count and where to get the rest - the envelope - * still carries every day, since suppressing a row there would be the same false - * continuity this layer refuses everywhere else. */ -function printDays( - output: CLIOutput, - rows: readonly CostReportDayRow[], - report: Pick -): void { - if (rows.length === 0) return; - output.print(""); - output.print(" by day"); - if (rows.length > MAX_PRINTED_DAYS) { - output.print( - ` ${formatCount(rows.length)} days in this period — see --json for the daily breakdown` - ); - return; - } - for (const row of rows) { - if (row.totals.requests === 0) { - output.print(` ${pad(row.day)}${nothingLabel(report)}`); - continue; - } - const figure = - row.totals.costMicroUsd === undefined - ? UNKNOWN_AMOUNT - : formatAmount(row.totals.costMicroUsd); - output.print(` ${pad(row.day)}${figure} ${formatCount(totalTokens(row.totals))} tokens`); - } -} - -/** - * One period's cost, as a person reads it. - * - * Prints no amount it was not given: the rates live outside this repository, so a tool - * whose files carry none says so rather than showing zero. Prints every declared tool, - * including the ones nothing here can read, with the reason from their own declaration. - * Carries no prompt, code, diff or file path - a task appears by its identity, never by - * the paths it was derived from. - */ -function printHeader(output: CLIOutput, report: CostReport): void { - const scope = report.task === undefined ? "period" : `task ${report.task}`; - output.print(`${scope} ${report.fromDay} to ${report.toDay}${filtersSuffix(report.filters)}`); - // Only the off state is worth a line: a person reading a report with the switch on - // already sees it working, in every figure below - stating "measurement is on" beside - // them would be the same noise `identityUnusableCause` avoids by never printing on the - // ordinary path. What this refuses is the false continuity of showing a stale or empty - // period with no word that nothing new can be recorded right now. - // - // Named for what it is, not "measurement is off for this project": the sink this report - // reads is scoped to this person, not to this project, so the figures below can be real - // work from anywhere the switch was ever on - the category error a person ran into - // reading a genuine count under a sentence claiming nothing was measured. This says - // exactly what the switch and the figures each actually are, so neither reads as - // contradicting the other (review.md, "one route, and every sentence about it true", - // finding 2). - if (!report.measurementEnabled) { - output.print( - "this project's own switch is off — the figures below are not scoped to it, they are " + - "the whole sink; turn this project's measurement on with `aidd telemetry on`" - ); - } - output.print(""); - if (report.emptySelection !== undefined) { - output.print(emptySelectionMessage(report.emptySelection)); - output.print(""); - } -} - -// A filter-emptied selection has nothing under any breakdown to show - every row would -// read "nothing in this period", which is exactly the false zero this layer refuses. -function printBreakdowns(output: CLIOutput, report: CostReport): void { - const basis: Basis = { - ...shareBasis(report.totals), - useCost: report.totals.costMicroUsd !== undefined, - }; - printTaskAttribution(output, report, basis); - printStepsAndAttribution(output, report, basis); - printAgents(output, report, basis); - printPrompts(output, report, basis); - printModels(output, report, basis); - printProjects(output, report.byProjects, basis); - printTasks(output, report.byTasks, basis); - printBacklog(output, report.byBacklog, basis); - output.print(""); - output.print(" by tool"); - printToolRows(output, report.byTools, report); - printDays(output, report.byDays, report); -} - -export function printCostReport(output: CLIOutput, report: CostReport): void { - printHeader(output, report); - printTotals(output, report); - if (report.emptySelection === undefined) printBreakdowns(output, report); - printCaveats(output, report); -} diff --git a/cli/src/application/display/doctor-display.ts b/cli/src/application/display/doctor-display.ts deleted file mode 100644 index 88396922e..000000000 --- a/cli/src/application/display/doctor-display.ts +++ /dev/null @@ -1,32 +0,0 @@ -import type { CLIOutput } from "../output.js"; - -type PluginIssue = { pluginName: string; toolId: string; issue: string; filePath: string }; - -export function printScopeIssues( - output: CLIOutput, - label: string, - report: { - issues: { severity: string; message: string; fix: string }[]; - } | null -): void { - if (report === null || report.issues.length === 0) return; - output.print(`\n${label}:`); - for (const issue of report.issues.filter((i) => i.severity === "info")) { - output.warn(` ${issue.message}\n Fix: ${issue.fix}`); - } - for (const issue of report.issues.filter((i) => i.severity !== "info")) { - const text = ` ${issue.message}\n Fix: ${issue.fix}`; - if (issue.severity === "error") output.error(text); - else output.warn(text); - } -} - -export function printPluginIssues(output: CLIOutput, pluginIssues: readonly PluginIssue[]): void { - if (pluginIssues.length === 0) return; - output.print("\nPlugins:"); - for (const pi of pluginIssues) { - output.error( - ` Plugin ${pi.pluginName} (${pi.toolId}): ${pi.issue} — ${pi.filePath}\n Fix: Run \`aidd ai restore\`` - ); - } -} diff --git a/cli/src/application/display/installed-rules-display.ts b/cli/src/application/display/installed-rules-display.ts deleted file mode 100644 index 742f8b502..000000000 --- a/cli/src/application/display/installed-rules-display.ts +++ /dev/null @@ -1,26 +0,0 @@ -import type { InstalledRule } from "../../domain/models/installed-rule.js"; -import type { CLIOutput } from "../output.js"; - -/** The machine-readable form, and the contract the explore skill reads: the same array the - * `list-rules.mjs` this replaced printed, field for field, so a skill consuming it did not - * change when the implementation moved. Two spaces, and a trailing newline, for the same - * reason — a diff of the two outputs is the evidence that the move changed nothing. */ -export function printInstalledRulesJson(output: CLIOutput, rules: readonly InstalledRule[]): void { - output.print(JSON.stringify(rules, null, 2)); -} - -/** One line per rule, the tool first. A project with no rule at all says so rather than - * printing nothing: an empty answer and a command that did not run look identical on a - * terminal, and only one of them is a fact about the project. */ -export function printInstalledRules(output: CLIOutput, rules: readonly InstalledRule[]): void { - if (rules.length === 0) { - output.info("No rules installed for any AI tool."); - return; - } - for (const rule of rules) { - const scope = rule.paths === undefined ? "every file" : rule.paths.join(", "); - output.print(`${rule.tool} ${rule.path}`); - output.print(` ${rule.description === "" ? "(no description)" : rule.description}`); - output.print(` applies to: ${scope}`); - } -} diff --git a/cli/src/application/display/restore-display.ts b/cli/src/application/display/restore-display.ts deleted file mode 100644 index 99a963602..000000000 --- a/cli/src/application/display/restore-display.ts +++ /dev/null @@ -1,8 +0,0 @@ -import type { CLIOutput } from "../output.js"; - -export function printUnrestorable(output: CLIOutput, unrestorable: readonly string[]): void { - if (unrestorable.length === 0) return; - output.warn( - `Could not restore ${unrestorable.length} file(s) no longer part of the current distribution: ${unrestorable.join(", ")}` - ); -} diff --git a/cli/src/application/display/setup-display.ts b/cli/src/application/display/setup-display.ts deleted file mode 100644 index 19fa4dd24..000000000 --- a/cli/src/application/display/setup-display.ts +++ /dev/null @@ -1,42 +0,0 @@ -import type { CLIOutput } from "../output.js"; -import type { ToolInstallResult } from "../use-cases/setup-use-case.js"; - -export function displayInstall( - output: CLIOutput, - results: readonly ToolInstallResult[], - verbose: boolean -): void { - const skipped = results.filter((r) => r.skipped); - const installed = results.filter((r) => !r.skipped); - for (const r of skipped) output.warn(`${r.toolId} is already installed.`); - for (const r of installed) for (const w of r.warnings) output.warn(w); - if (verbose) { - for (const r of installed) { - output.debug(`Tool: ${r.toolId}`); - for (const f of r.files) output.debug(` + ${f.relativePath}`); - } - } - if (installed.length === 1) { - output.success(`Installed ${installed[0].toolId} (${installed[0].fileCount} files)`); - } else if (installed.length > 1) { - const total = installed.reduce((s, r) => s + r.fileCount, 0); - output.success(`Installed ${installed.map((r) => r.toolId).join(", ")} (${total} files)`); - } -} - -export function printWelcomeBanner(output: CLIOutput): void { - output.print(""); - output.print("AI-Driven Development setup"); - output.print("Wires your AI tools, registers the framework marketplace, installs plugins."); - output.print("Press Ctrl-C any time to abort."); - output.print(""); -} - -export function printNextSteps(output: CLIOutput, installedAnything: boolean): void { - output.print(""); - output.print("Next steps:"); - if (installedAnything) output.print(" aidd ai status # verify drift"); - output.print(" aidd marketplace list # see registered marketplaces"); - output.print(" aidd plugin install # add plugins"); - output.print(" aidd --help # explore commands"); -} diff --git a/cli/src/application/display/status-display.ts b/cli/src/application/display/status-display.ts deleted file mode 100644 index df0b26755..000000000 --- a/cli/src/application/display/status-display.ts +++ /dev/null @@ -1,57 +0,0 @@ -import type { CLIOutput } from "../output.js"; - -const STATUS_SYMBOL: Record = { - modified: "~", - deleted: "-", - added: "+", -}; - -export function printDriftStats(output: CLIOutput, drifted: { status: string }[]): void { - const modified = drifted.filter((f) => f.status === "modified").length; - const deleted = drifted.filter((f) => f.status === "deleted").length; - const added = drifted.filter((f) => f.status === "added").length; - output.print(` ${modified} modified, ${deleted} deleted, ${added} added`); -} - -export function printScopeReport( - output: CLIOutput, - report: { - tools: { - toolId: string; - version: string; - drifted: { status: string; relativePath: string }[]; - }[]; - } -): void { - if (report.tools.length === 0) { - output.print(" (none installed)"); - return; - } - for (const tool of report.tools) { - if (tool.drifted.length === 0) { - output.print(` ${tool.toolId} (v${tool.version}): in sync`); - continue; - } - output.print(` ${tool.toolId} (v${tool.version}):`); - for (const file of tool.drifted) { - output.print(` ${STATUS_SYMBOL[file.status] ?? "?"} ${file.relativePath}`); - } - printDriftStats(output, tool.drifted); - } -} - -export function printPluginDrift( - output: CLIOutput, - report: { pluginDrift: { pluginName: string; toolId: string; driftedFiles: string[] }[] } -): void { - if (report.pluginDrift.length === 0) { - output.print(" (all in sync)"); - return; - } - for (const entry of report.pluginDrift) { - output.print(` plugin ${entry.pluginName} (${entry.toolId}):`); - for (const f of entry.driftedFiles) { - output.print(` ~ ${f}`); - } - } -} diff --git a/cli/src/application/display/telemetry-check-display.ts b/cli/src/application/display/telemetry-check-display.ts deleted file mode 100644 index 8c7b9816c..000000000 --- a/cli/src/application/display/telemetry-check-display.ts +++ /dev/null @@ -1,257 +0,0 @@ -import type { - TelemetryClaim, - TelemetryClaimId, - TelemetryClaimVerdict, -} from "../../domain/models/telemetry-claim.js"; -import type { TelemetryExportLeftover } from "../../domain/models/telemetry-export-leftover.js"; -import type { - TelemetryAllowedSetup, - TelemetryCommitTrailerSetup, - TelemetryHostRegistrationAnswer, - TelemetryHostRegistrationSetup, - TelemetryIdentitySetup, - TelemetryPluginVersionSetup, - TelemetryRecorderDeclarationSetup, - TelemetrySetup, -} from "../../domain/models/telemetry-setup.js"; -import type { CLIOutput } from "../output.js"; -import type { - DiagnoseTelemetryResult, - DiagnoseTelemetryUncoveredTool, -} from "../use-cases/telemetry/diagnose-telemetry-use-case.js"; - -const LABEL_WIDTH = 22; - -// The label strings a check report prints. They were pinned, word for word, to the -// plugin's own `diagnose.cjs` so a report read identically whichever side answered it; -// that script is gone and this is the only side, so these are now simply the names - -// changing one changes every report, and nothing else has to be changed with it. -const CLAIM_LABELS: Record = { - "hook-fired": "hook fired", - "session-journalled": "session journalled", - "tool-files-readable": "tool files readable", - "records-join": "records join", -}; - -const VERDICT_TOKENS: Record = { - ok: "ok", - fail: "FAIL", - unknown: "--", -}; - -function pad(label: string): string { - return label.padEnd(LABEL_WIDTH); -} - -// A sentence, never the claims' `ok`/`FAIL`/`--` verdict column — that vocabulary is -// reserved for a grade, and nothing here is graded yet. Naming the location a fact came -// from is what lets a person go and change it. -function printSetupRow(output: CLIOutput, label: string, detail: string): void { - output.print(` ${pad(label)}${detail}`); -} - -function describeAllowed(allowed: TelemetryAllowedSetup): string { - if (!allowed.readable) return `could not be read — ${allowed.location}`; - if (allowed.decidedBy === "person-refusal") { - return `no — this person's own refusal (${allowed.location})`; - } - return `${allowed.allowed ? "yes" : "no"} — ${allowed.location}`; -} - -function describeIdentity(identity: TelemetryIdentitySetup): string { - if (!identity.readable) return `could not be read — ${identity.path}`; - return `${identity.attached ? "yes" : "no"} — ${identity.path}`; -} - -function indentedPaths(paths: readonly string[]): string { - return paths.map((path) => `\n ${path}`).join(""); -} - -function describeRecorderDeclaration(declaration: TelemetryRecorderDeclarationSetup): string { - if (declaration.declared) return `yes — ${declaration.declaredAt.join(", ")}`; - if (declaration.unreadable.length > 0) { - return `could not be read — ${declaration.unreadable.join(", ")}`; - } - // Five absolute paths on one ~500-char line is the hardest possible form to act on — - // this is the row a person reads specifically to go add the declaration somewhere, so - // each candidate gets its own indented line rather than a single comma-joined run-on. - return `nowhere this build checks — looked in:${indentedPaths(declaration.locationsChecked)}`; -} - -// Printed first, and printed whether or not measurement is on — a person switched off -// needs this exactly as much as one who is on, and today they get nothing. Visibly -// distinct from the four claims below: a sentence naming a location, never the claims' -// own `ok`/`FAIL`/`--` verdict column. -/** What produced the lines a person is reading, and — when nothing did — why. - * - * Read back out of the journal rather than re-derived, so this can only ever say what the - * hook itself said. `"unrecorded"` names the one case that is a real problem: a hook that - * ran, wrote lines, and could not name its own build. That happens when the plugin arrived - * by neither install route — copied in by hand — and the sentence says so, because a person - * seeing a bare "unknown" has no way to guess what to do about it. - */ -function describePluginVersion(plugin: TelemetryPluginVersionSetup): string { - if (plugin.kind === "recorded") return `${plugin.version} (as the hook recorded it)`; - if (plugin.kind === "nothing-journalled") return "no session journalled yet"; - return ( - "unknown — no journalled session names one. The plugin's own manifest was not beside " + - "its hooks and no `aidd` install recorded it; `aidd plugin install aidd-telemetry` " + - "would make it known." - ); -} - -/** The other half of `recorder declared`: whether the host will act on the declaration. - * - * One line per plugin rather than a single verdict, because the answer is genuinely per - * plugin and a rolled-up "some are not registered" is the kind of sentence a person cannot - * act on. Ordered so what a person must fix comes first: anything that will not load, then - * what nobody could ask about, then what is fine — a reader who stops after one line has - * still read the problem. - * - * `nothing installed` is a real, healthy answer and says so, rather than printing an empty - * block that reads like a failure to look. */ -function describeHostRegistration(registration: TelemetryHostRegistrationSetup): string { - if (registration.manifestUnreadable !== undefined) { - return `AIDD's own manifest could not be read — ${registration.manifestUnreadable}`; - } - const entries = registration.entries; - if (entries.length === 0) return "no plugin recorded for any tool"; - // Keyed on the answer type, not on `string`: a fifth answer then fails to compile here - // rather than sorting silently last, which is how a new "will not load" state would end up - // printed below the ones that are fine. - const rank: Record = { - "not-registered": 0, - "registered-disabled": 1, - unanswerable: 2, - registered: 3, - }; - const ordered = [...entries].sort((a, b) => rank[a.answer] - rank[b.answer]); - // A sentence first, then the lines. Every other setup row leads with one, and a label - // followed by padding and a newline reads as a value the command failed to produce. - const trouble = ordered.filter((entry) => entry.answer !== "registered").length; - const headline = - trouble === 0 - ? `all ${ordered.length} will load` - : `${trouble} of ${ordered.length} will not load, or could not be answered`; - return ordered.reduce( - (text, entry) => - `${text}\n ${entry.tool}/${entry.plugin}: ${entry.answer} — ${entry.detail}`, - headline - ); -} - -/** The trailer, in one sentence that leads with the only fact about the chain rather than - * about its parts: how many recent commits actually carry it. A person reading one line has - * then read the answer; the pieces below it say why, and only when there is a why. */ -function describeCommitTrailer(trailer: TelemetryCommitTrailerSetup): string { - // Outside a repository there is nothing to say about hooks — the same fact the claims - // below refuse to read as a failure. Saying "nothing installed" here would describe a - // repository this project is not in. - if (trailer.hooksDirMissing === "no-repository") { - return "no repository here, so no hook to carry it"; - } - // A repository whose git could not name its hooks directory still has a history, and the - // count is the fact that matters. Dropping it and saying "no repository" was measured - // wrong on a git that rejects `--git-path`: one true fact replaced by one false one. - if (trailer.hooksDir === undefined) { - return `${describeTrailerCount(trailer)} — git could not say where it runs hooks from`; - } - - const parts: string[] = []; - if (trailer.delegate === "absent") parts.push("nothing installed to write it"); - if (trailer.delegate === "not-executable") { - parts.push("its script is not executable, so git will not run it"); - } - if (trailer.callSite === "missing") parts.push("prepare-commit-msg does not call it"); - if (trailer.hookExecutable === false) { - parts.push("prepare-commit-msg is not executable, so git ignores it"); - } - if (trailer.callSite === "no-hook-file") parts.push("there is no prepare-commit-msg"); - // Said, never named. Which tool owns the file changes nothing a person does about it, and - // naming one would be a guess read out of its contents. - if (trailer.hookHasOtherContent) parts.push("that hook is somebody else's too"); - - return `${describeTrailerCount(trailer)}${parts.length === 0 ? "" : ` — ${parts.join("; ")}`}\n hooks run from ${trailer.hooksDir}`; -} - -/** The count, and what it is not. - * - * A commit no session made carries no trailer, by design — the delegate writes nothing - * without a session variable, and skips merges outright. So a number below the total is not - * by itself a fault, and a bare "4 of 20" invites reading it as one. The qualifier is added - * exactly when it could mislead: some commits carrying it, and every part in place. */ -function describeTrailerCount(trailer: TelemetryCommitTrailerSetup): string { - const carried = trailer.recentlyCarrying; - if (carried === undefined) return "no commit history to read"; - const count = `${carried.carrying} of the last ${carried.examined} commits carry it`; - const everyPartWorks = trailer.delegate === "executable" && trailer.callSite === "present"; - // `carrying > 0` and not `>= 0`: zero with every part in place is the finding this whole - // row exists to surface, and excusing it as by-design is the one thing that must not - // happen. The docstring above says "some", and this is what makes that true. - const someCarry = carried.carrying > 0 && carried.carrying < carried.examined; - if (!everyPartWorks || !someCarry) return count; - return `${count} — a commit no session made carries none, by design`; -} - -function printSetup(output: CLIOutput, setup: TelemetrySetup): void { - printSetupRow(output, "measurement allowed", describeAllowed(setup.allowed)); - printSetupRow(output, "identity attached", describeIdentity(setup.identity)); - printSetupRow( - output, - "records kept at", - `${setup.recordsLocation.path} (override with AIDD_TELEMETRY_DIR)` - ); - printSetupRow( - output, - "recorder declared", - describeRecorderDeclaration(setup.recorderDeclaration) - ); - printSetupRow(output, "plugins registered", describeHostRegistration(setup.hostRegistration)); - printSetupRow(output, "commit trailer", describeCommitTrailer(setup.commitTrailer)); - printSetupRow(output, "cli version", setup.versions.cli); - printSetupRow(output, "plugin version", describePluginVersion(setup.versions.plugin)); - output.print(""); -} - -function printClaim(output: CLIOutput, claim: TelemetryClaim): void { - const label = pad(CLAIM_LABELS[claim.claim]); - const verdict = VERDICT_TOKENS[claim.verdict].padEnd(4); - output.print(` ${label}${verdict} ${claim.detail}`); -} - -function printUncovered(output: CLIOutput, uncovered: DiagnoseTelemetryUncoveredTool): void { - const label = pad(`not covered: ${uncovered.tool}`); - output.print(` ${label}${"--".padEnd(4)} ${uncovered.reason}`); -} - -// Never a claim, and never printed on stdout beside the four: a stale export lives in a -// tool's own settings file, not in anything the hook, the journal or a reader can see, so -// it is named on stderr as a warning rather than folded into the health count "no claim -// mentions exporting" guards. See DiagnoseTelemetryResult's own doc for why it is gathered -// independently of the gate above. -function printLeftoverExportConfig( - output: CLIOutput, - leftovers: readonly TelemetryExportLeftover[] -): void { - for (const leftover of leftovers) { - output.warn( - `${leftover.path} still sets ${leftover.keys.join(", ")} — delete these keys from ` + - "its `env` block by hand to stop that export; nothing here can do it for you." - ); - } -} - -export function printTelemetryCheckReport( - output: CLIOutput, - result: DiagnoseTelemetryResult -): void { - printSetup(output, result.setup); - if (result.gate !== undefined) { - output.print(` ${result.gate}`); - printLeftoverExportConfig(output, result.leftoverExportConfig); - return; - } - for (const claim of result.claims) printClaim(output, claim); - for (const uncovered of result.uncovered) printUncovered(output, uncovered); - printLeftoverExportConfig(output, result.leftoverExportConfig); -} diff --git a/cli/src/application/errors.ts b/cli/src/application/errors.ts deleted file mode 100644 index 1ff7000bc..000000000 --- a/cli/src/application/errors.ts +++ /dev/null @@ -1,103 +0,0 @@ -export class NoManifestError extends Error { - constructor() { - super("No AIDD manifest found. Run `aidd setup` to initialize your project."); - this.name = "NoManifestError"; - } -} - -export class AiddFilesDetectedError extends Error { - constructor() { - super( - "AIDD files detected but no manifest found.\nRun `aidd setup` to register existing files." - ); - this.name = "AiddFilesDetectedError"; - } -} - -export class AdoptRequiresVersionError extends Error { - constructor(diagnostic = "") { - const suffix = diagnostic ? `\n\n${diagnostic}` : ""; - super( - `--from is required for adopt.\nExample: aidd setup --ai claude --from 3.6.0${suffix}` - ); - this.name = "AdoptRequiresVersionError"; - } -} - -export class NotAuthenticatedError extends Error { - constructor() { - super("Not authenticated. Run `aidd auth login`."); - this.name = "NotAuthenticatedError"; - } -} - -export class AlreadyInitializedError extends Error { - constructor(message = "Already initialized. Use `aidd update` to upgrade.") { - super(message); - this.name = "AlreadyInitializedError"; - } -} - -export class InputRequiredError extends Error { - constructor(message: string) { - super(message); - this.name = "InputRequiredError"; - } -} - -export class ToolNotInstalledError extends Error { - constructor(toolId: string, context?: string) { - super(context ? `${context} '${toolId}' is not installed.` : `${toolId} is not installed`); - this.name = "ToolNotInstalledError"; - } -} - -export class InvalidCategoryError extends Error { - constructor(category: string) { - super(`Invalid category '${category}'. Use 'ai' or 'ide'.`); - this.name = "InvalidCategoryError"; - } -} - -export class InvalidTelemetryPeriodError extends Error { - constructor(value: string, maxDays: number) { - super(`Invalid --days '${value}'. Expected an integer between 1 and ${maxDays}.`); - this.name = "InvalidTelemetryPeriodError"; - } -} - -/** One sentence for one consequence: writing a git-tracked file that turns telemetry on for - * everyone who clones. `endpoint --scope project` and `telemetry on` both have exactly this - * consequence — the parameterised `action` and `trackedPath` are the only two things that - * differ between them, so they share the one error rather than each writing its own - * sentence for the same fact. */ -export class TelemetryProjectScopeRequiresYesError extends Error { - constructor(action: string, trackedPath: string) { - super( - `${action} writes the git-tracked ${trackedPath}, turning telemetry on for ` + - "everyone who clones. Pass --yes to confirm." - ); - this.name = "TelemetryProjectScopeRequiresYesError"; - } -} - -export class EmptyDisplayNameError extends Error { - constructor() { - super("`aidd telemetry identity use --name` needs a non-empty value."); - this.name = "EmptyDisplayNameError"; - } -} - -export class IdentityRequiredToLinkError extends Error { - constructor() { - super("No identity to link onto yet. Run `aidd telemetry identity use` first."); - this.name = "IdentityRequiredToLinkError"; - } -} - -export class EmptyIdentifierError extends Error { - constructor(command: "use" | "link") { - super(`\`aidd telemetry identity ${command}\` needs a non-empty value.`); - this.name = "EmptyIdentifierError"; - } -} diff --git a/cli/src/application/use-cases/.gitkeep b/cli/src/application/use-cases/.gitkeep deleted file mode 100644 index e69de29bb..000000000 diff --git a/cli/src/application/use-cases/auth/auth-login-use-case.ts b/cli/src/application/use-cases/auth/auth-login-use-case.ts deleted file mode 100644 index 9674d32d2..000000000 --- a/cli/src/application/use-cases/auth/auth-login-use-case.ts +++ /dev/null @@ -1,15 +0,0 @@ -import type { AuthCredential, AuthLevel } from "../../../domain/models/auth.js"; -import type { AuthLoginResult, CredentialStore } from "../../../domain/ports/credential-store.js"; - -interface AuthLoginOptions { - credential: AuthCredential; - level: AuthLevel; -} - -export class AuthLoginUseCase { - constructor(private readonly authProvider: CredentialStore) {} - - async execute(options: AuthLoginOptions): Promise { - return await this.authProvider.login(options.credential, options.level); - } -} diff --git a/cli/src/application/use-cases/auth/auth-logout-use-case.ts b/cli/src/application/use-cases/auth/auth-logout-use-case.ts deleted file mode 100644 index d267cd963..000000000 --- a/cli/src/application/use-cases/auth/auth-logout-use-case.ts +++ /dev/null @@ -1,9 +0,0 @@ -import type { AuthLogoutResult, CredentialStore } from "../../../domain/ports/credential-store.js"; - -export class AuthLogoutUseCase { - constructor(private readonly authProvider: CredentialStore) {} - - async execute(): Promise { - return await this.authProvider.logout(); - } -} diff --git a/cli/src/application/use-cases/auth/auth-status-use-case.ts b/cli/src/application/use-cases/auth/auth-status-use-case.ts deleted file mode 100644 index 50967632e..000000000 --- a/cli/src/application/use-cases/auth/auth-status-use-case.ts +++ /dev/null @@ -1,9 +0,0 @@ -import type { AuthStatus, CredentialStore } from "../../../domain/ports/credential-store.js"; - -export class AuthStatusUseCase { - constructor(private readonly authProvider: CredentialStore) {} - - async execute(): Promise { - return await this.authProvider.status(); - } -} diff --git a/cli/src/application/use-cases/auth/require-auth-use-case.ts b/cli/src/application/use-cases/auth/require-auth-use-case.ts deleted file mode 100644 index 47fda50b5..000000000 --- a/cli/src/application/use-cases/auth/require-auth-use-case.ts +++ /dev/null @@ -1,12 +0,0 @@ -import type { TokenProvider } from "../../../domain/ports/token-provider.js"; -import { NotAuthenticatedError } from "../../errors.js"; - -export class RequireAuthUseCase { - constructor(private readonly authReader: TokenProvider) {} - - async execute(): Promise { - if ((await this.authReader.resolve()) === null) { - throw new NotAuthenticatedError(); - } - } -} diff --git a/cli/src/application/use-cases/check-update-use-case.ts b/cli/src/application/use-cases/check-update-use-case.ts deleted file mode 100644 index 4c70cb455..000000000 --- a/cli/src/application/use-cases/check-update-use-case.ts +++ /dev/null @@ -1,83 +0,0 @@ -import { homedir } from "node:os"; -import { join } from "node:path"; -import { compareSemver, isSemver } from "../../domain/models/semver.js"; -import type { FileReader } from "../../domain/ports/file-reader.js"; -import type { FileWriter } from "../../domain/ports/file-writer.js"; -import type { Logger } from "../../domain/ports/logger.js"; -import type { SelfUpdater } from "../../domain/ports/self-updater.js"; -import type { VersionReader } from "../../domain/ports/version-reader.js"; - -interface CachedCheck { - checkedAt: number; - latest: string; -} - -function isOutdated(version: string, latest: string): boolean { - return isSemver(version) && compareSemver(version, latest) < 0; -} - -function resolveConfigDir(): string { - return process.env.AIDD_USER_CONFIG_DIR ?? join(homedir(), ".config", "aidd"); -} - -/** Where this cache is written: under `cache/`, beside every other disposable thing, rather - * than loose among the files a person chose. Nothing here is a choice — it is the last - * version seen and when, refetched whenever it is missing. */ -function resolveCachePath(): string { - return join(resolveConfigDir(), "cache", "update-check.json"); -} - -/** Where it used to be written, read when the current path holds nothing. A cache that - * appeared to be missing would cost one needless network call on the next online command — - * harmless, and still worth not doing to every existing install at once. Never written to, - * so the old file simply stops being touched and can be deleted by hand. */ -function legacyCachePath(): string { - return join(resolveConfigDir(), "update-check.json"); -} - -export class CheckUpdateUseCase { - constructor( - private readonly cliUpdater: SelfUpdater, - private readonly versionReader: VersionReader, - private readonly logger: Logger, - private readonly fs: FileReader & FileWriter - ) {} - - /** Hot path: print the update notice from cached value only — fresh OR stale, never network. */ - async printFromCacheOnly(): Promise { - const cached = await this.readCacheRaw(); - if (cached === null) return; - const current = this.versionReader.get(); - if (!isOutdated(current, cached.latest)) return; - this.logger.warn( - `CLI update available: v${current.replace(/^v/, "")} → v${cached.latest.replace(/^v/, "")}` - ); - this.logger.warn("Run `aidd self-update`."); - } - - /** Online piggyback path: fetch the latest release and persist the cache. Awaited. */ - async refresh(): Promise { - const { version: latest } = await this.cliUpdater.fetchLatestRelease(); - await this.writeCache(latest); - } - - private async readCacheRaw(): Promise { - return (await this.readCacheAt(resolveCachePath())) ?? this.readCacheAt(legacyCachePath()); - } - - private async readCacheAt(path: string): Promise { - if (!(await this.fs.fileExists(path))) return null; - try { - const raw = await this.fs.readFile(path); - return JSON.parse(raw) as CachedCheck; - } catch { - return null; - } - } - - private async writeCache(latest: string): Promise { - const path = resolveCachePath(); - await this.fs.createDirectory(join(path, "..")); - await this.fs.writeFile(path, JSON.stringify({ checkedAt: Date.now(), latest })); - } -} diff --git a/cli/src/application/use-cases/clean-use-case.ts b/cli/src/application/use-cases/clean-use-case.ts deleted file mode 100644 index d5938c846..000000000 --- a/cli/src/application/use-cases/clean-use-case.ts +++ /dev/null @@ -1,185 +0,0 @@ -import { dirname, join } from "node:path"; -import type { Manifest } from "../../domain/models/manifest.js"; -import { - isMergeContentEmpty, - type MergeFileEntry, - removeEntriesFromJson, -} from "../../domain/models/merge.js"; -import { - AIDD_CONFIG_FILENAME, - AIDD_DIR, - AIDD_MARKETPLACES_FILENAME, - PLUGIN_CACHE_SUBDIR, -} from "../../domain/models/paths.js"; -import { isAiToolId } from "../../domain/models/tool-ids.js"; -import type { FileReader } from "../../domain/ports/file-reader.js"; -import type { FileWriter } from "../../domain/ports/file-writer.js"; -import type { Logger } from "../../domain/ports/logger.js"; -import type { ManifestRepository } from "../../domain/ports/manifest-repository.js"; -import type { Prompter } from "../../domain/ports/prompter.js"; -import type { ToolId } from "../../domain/tools/registry.js"; -import type { GitignoreUseCase } from "./shared/gitignore-use-case.js"; - -interface CleanOptions { - projectRoot: string; - force: boolean; - interactive?: boolean; -} - -interface CleanPreview { - tools: Array<{ toolId: ToolId; fileCount: number }>; - totalFileCount: number; -} - -interface CleanResult { - dryRun: boolean; - manifestFound: boolean; - preview: CleanPreview; - fileCount: number; -} - -export class CleanUseCase { - constructor( - private readonly fs: FileReader & FileWriter, - private readonly manifestRepo: ManifestRepository, - private readonly logger: Logger, - private readonly gitignoreUseCase: GitignoreUseCase, - private readonly prompter?: Prompter - ) {} - - async execute(options: CleanOptions): Promise { - const manifest = await this.manifestRepo.load(); - if (manifest === null) { - const emptyPreview: CleanPreview = { tools: [], totalFileCount: 0 }; - return { dryRun: false, manifestFound: false, preview: emptyPreview, fileCount: 0 }; - } - const preview = this.buildPreview(manifest); - const dryRunResult = await this.confirmOrDryRun(options, preview); - if (dryRunResult !== null) return dryRunResult; - const deleted = await this.deleteAllToolFiles(manifest, options.projectRoot); - await this.removeAiddState(options.projectRoot); - await this.gitignoreUseCase.remove(options.projectRoot, [`${AIDD_DIR}/cache/`]); - return { dryRun: false, manifestFound: true, preview, fileCount: deleted }; - } - - // config.json is the committed telemetry switch: a file clean did not write, - // so clean never removes it. marketplaces.json is the opposite case and goes: - // the CLI wrote it itself, and left behind it kept `.aidd` alive after a run - // that reported it had cleaned all AIDD files. Every directory and file clean - // did write must go before the emptiness check, or its own presence blocks a - // removal that should happen. - private async removeAiddState(projectRoot: string): Promise { - const aiddDir = join(projectRoot, AIDD_DIR); - const configKept = await this.fs.fileExists(join(aiddDir, AIDD_CONFIG_FILENAME)); - - await this.fs.deleteDirectory(join(aiddDir, "cache")); - await this.fs.deleteDirectory(join(projectRoot, PLUGIN_CACHE_SUBDIR)); - await this.fs.deleteFile(join(aiddDir, AIDD_MARKETPLACES_FILENAME)); - await this.manifestRepo.delete(); - - if (!(await this.fs.fileExists(aiddDir))) return; - const remaining = await this.fs.listDirectory(aiddDir); - if (remaining.length === 0) { - await this.fs.deleteDirectory(aiddDir); - return; - } - if (configKept) this.logger.info(`Kept ${AIDD_DIR}/${AIDD_CONFIG_FILENAME}`); - } - - private buildPreview(manifest: Manifest): CleanPreview { - const tools = manifest.getInstalledToolIds().map((toolId) => ({ - toolId, - fileCount: manifest.getToolFiles(toolId).length + manifest.getMergeFiles(toolId).length, - })); - const totalFileCount = tools.reduce((s, t) => s + t.fileCount, 0); - return { tools, totalFileCount }; - } - - private async confirmOrDryRun( - options: CleanOptions, - preview: CleanPreview - ): Promise { - if (options.force) return null; - if (options.interactive && this.prompter) { - const confirmed = await this.prompter.confirm("Remove all AIDD files?"); - if (!confirmed) return { dryRun: true, manifestFound: true, preview, fileCount: 0 }; - return null; - } - return { dryRun: true, manifestFound: true, preview, fileCount: 0 }; - } - - private async deleteAllToolFiles(manifest: Manifest, projectRoot: string): Promise { - let deleted = 0; - for (const toolId of manifest.getInstalledToolIds()) { - this.logger.info(`Removing ${toolId} files...`); - deleted += await this.deleteFiles(manifest.getToolFiles(toolId), projectRoot); - deleted += await this.cleanMergeFileKeys(manifest.getMergeFiles(toolId), projectRoot); - if (isAiToolId(toolId)) { - deleted += await this.deleteToolPluginFiles(manifest, toolId, projectRoot); - } - } - return deleted; - } - - private async deleteToolPluginFiles( - manifest: Manifest, - toolId: ToolId, - projectRoot: string - ): Promise { - let count = 0; - for (const plugin of manifest.getPlugins(toolId as Parameters[0])) { - for (const relativePath of plugin.files.keys()) { - const fullPath = join(projectRoot, relativePath); - await this.fs.deleteFile(fullPath); - await this.fs.deleteEmptyDirectories(dirname(fullPath)); - count++; - } - } - return count; - } - - private async cleanMergeFileKeys( - mergeFiles: readonly MergeFileEntry[], - projectRoot: string - ): Promise { - let count = 0; - for (const mergeFile of mergeFiles) { - const fullPath = join(projectRoot, mergeFile.relativePath); - if (!(await this.fs.fileExists(fullPath))) continue; - await this.applyMergeFileCleaning(fullPath, mergeFile); - count++; - } - return count; - } - - private async applyMergeFileCleaning(fullPath: string, mergeFile: MergeFileEntry): Promise { - const keys = Object.keys(mergeFile.entries); - if (keys.length === 0) { - await this.fs.deleteFile(fullPath); - await this.fs.deleteEmptyDirectories(dirname(fullPath)); - return; - } - const content = await this.fs.readFile(fullPath); - const cleaned = removeEntriesFromJson(content, mergeFile.sectionKey, keys); - if (isMergeContentEmpty(cleaned, mergeFile.sectionKey)) { - await this.fs.deleteFile(fullPath); - await this.fs.deleteEmptyDirectories(dirname(fullPath)); - } else { - await this.fs.writeFile(fullPath, cleaned); - } - } - - private async deleteFiles( - files: ReadonlyArray<{ relativePath: string }>, - projectRoot: string - ): Promise { - let count = 0; - for (const file of files) { - const fullPath = join(projectRoot, file.relativePath); - await this.fs.deleteFile(fullPath); - await this.fs.deleteEmptyDirectories(dirname(fullPath)); - count++; - } - return count; - } -} diff --git a/cli/src/application/use-cases/doctor/doctor-plugin-use-case.ts b/cli/src/application/use-cases/doctor/doctor-plugin-use-case.ts deleted file mode 100644 index fba6bd4fe..000000000 --- a/cli/src/application/use-cases/doctor/doctor-plugin-use-case.ts +++ /dev/null @@ -1,35 +0,0 @@ -import type { PluginIssueEntry } from "../../../domain/models/doctor.js"; -import type { Manifest } from "../../../domain/models/manifest.js"; -import type { DetectPluginDriftUseCase } from "../shared/detect-plugin-drift-use-case.js"; - -export interface DoctorPluginOptions { - manifest: Manifest; - projectRoot: string; - allowedIds: Set | null; - pluginName?: string; -} - -export class DoctorPluginUseCase { - constructor(private readonly detectPluginDrift: DetectPluginDriftUseCase) {} - - async execute(options: DoctorPluginOptions): Promise { - const { manifest, projectRoot, allowedIds, pluginName } = options; - const toolIds = manifest - .getInstalledToolIds() - .filter((toolId) => allowedIds === null || allowedIds.has(toolId)); - const drifts = await this.detectPluginDrift.execute({ - manifest, - projectRoot, - toolIds, - pluginName, - }); - return drifts.flatMap((drift) => - drift.files.map((file) => ({ - toolId: drift.toolId, - pluginName: drift.pluginName, - issue: file.kind, - filePath: file.relativePath, - })) - ); - } -} diff --git a/cli/src/application/use-cases/framework/assert-no-tools-placeholder.ts b/cli/src/application/use-cases/framework/assert-no-tools-placeholder.ts deleted file mode 100644 index af2e952a2..000000000 --- a/cli/src/application/use-cases/framework/assert-no-tools-placeholder.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { FrameworkPlaceholderInPluginError } from "../../../domain/errors.js"; - -const TOOLS_PLACEHOLDER = "@{{TOOLS}}/"; - -/** - * Guards against @{{TOOLS}}/ references inside plugin content. - * Used by all BuildOutputStrategy implementations. - */ -export function assertNoToolsPlaceholder( - content: string, - pluginName: string, - relPath: string -): void { - if (content.includes(TOOLS_PLACEHOLDER)) { - throw new FrameworkPlaceholderInPluginError(pluginName, relPath); - } -} diff --git a/cli/src/application/use-cases/framework/framework-build-use-case.ts b/cli/src/application/use-cases/framework/framework-build-use-case.ts deleted file mode 100644 index 4e03c186c..000000000 --- a/cli/src/application/use-cases/framework/framework-build-use-case.ts +++ /dev/null @@ -1,134 +0,0 @@ -import { join, resolve } from "node:path"; -import { InvalidBuildPathsError, InvalidSourceMarketplaceError } from "../../../domain/errors.js"; -import { - type BuildPluginResult, - type FrameworkBuildOptions, - type FrameworkBuildResult, - OUT_OF_SCOPE_PLUGIN_SECTIONS, - SOURCE_MARKETPLACE_RELATIVE, - SOURCE_PLUGIN_MANIFEST_RELATIVE, -} from "../../../domain/models/framework-build.js"; -import { pathsOverlap } from "../../../domain/models/paths.js"; -import type { AssetProvider } from "../../../domain/ports/asset-provider.js"; -import type { FileReader } from "../../../domain/ports/file-reader.js"; -import type { FileWriter } from "../../../domain/ports/file-writer.js"; -import type { JsonSchemaValidator } from "../../../domain/ports/json-schema-validator.js"; -import type { Logger } from "../../../domain/ports/logger.js"; -import type { - BuildOutputStrategy, - SourceMarketplace, - SourcePluginEntry, -} from "./strategies/build-output-strategy.js"; - -/** Running one framework build, as its callers need it. */ -export interface FrameworkBuild { - execute(options: FrameworkBuildOptions): Promise; -} - -export class FrameworkBuildUseCase implements FrameworkBuild { - constructor( - private readonly fs: FileReader & FileWriter, - private readonly jsonSchemaValidator: JsonSchemaValidator, - private readonly assetProvider: AssetProvider, - private readonly logger: Logger, - private readonly strategy: BuildOutputStrategy - ) {} - - async execute(options: FrameworkBuildOptions): Promise { - const sourceDir = resolve(options.sourceDir); - const outDir = resolve(options.outDir); - this.guardPaths(sourceDir, outDir); - const sourceMarketplace = await this.readSourceMarketplace(sourceDir); - await this.strategy.preBuild(outDir, sourceDir); - const builtPlugins: BuildPluginResult[] = []; - for (const entry of sourceMarketplace.plugins) { - const plugin = await this.buildPlugin(entry, sourceDir, outDir); - builtPlugins.push(plugin); - } - const extraFiles = await this.strategy.postBuild(sourceMarketplace, builtPlugins, outDir); - const totalFiles = builtPlugins.reduce((sum, p) => sum + p.filesWritten, 0) + extraFiles; - return { outDir, plugins: builtPlugins, totalFiles }; - } - - private guardPaths(sourceDir: string, outDir: string): void { - if (pathsOverlap(sourceDir, outDir)) throw new InvalidBuildPathsError(sourceDir, outDir); - } - - private async readSourceMarketplace(sourceDir: string): Promise { - const marketplacePath = join(sourceDir, SOURCE_MARKETPLACE_RELATIVE); - let raw: string; - try { - raw = await this.fs.readFile(marketplacePath); - } catch { - throw new InvalidSourceMarketplaceError(`cannot read ${marketplacePath}`); - } - let parsed: unknown; - try { - parsed = JSON.parse(raw); - } catch (err) { - throw new InvalidSourceMarketplaceError(`malformed JSON: ${(err as Error).message}`); - } - return this.validateSourceMarketplace(parsed); - } - - private validateSourceMarketplace(parsed: unknown): SourceMarketplace { - if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { - throw new InvalidSourceMarketplaceError("root must be an object"); - } - const obj = parsed as Record; - if (!Array.isArray(obj.plugins)) { - throw new InvalidSourceMarketplaceError("missing 'plugins' array"); - } - for (const entry of obj.plugins as unknown[]) { - if ( - !entry || - typeof entry !== "object" || - typeof (entry as Record).name !== "string" - ) { - throw new InvalidSourceMarketplaceError("each plugin entry must have a 'name' string"); - } - } - return obj as unknown as SourceMarketplace; - } - - private async buildPlugin( - entry: SourcePluginEntry, - sourceDir: string, - outDir: string - ): Promise { - const pluginSrc = join(sourceDir, "plugins", entry.name); - if (!(await this.fs.fileExists(pluginSrc))) { - throw new InvalidSourceMarketplaceError(`plugin '${entry.name}' not found at ${pluginSrc}`); - } - await this.validateManifest(pluginSrc); - let filesWritten = 0; - filesWritten += await this.strategy.writePluginManifest(entry.name, pluginSrc, outDir); - filesWritten += await this.strategy.writeAgents(entry.name, pluginSrc, outDir); - filesWritten += await this.strategy.writeSkills(entry.name, pluginSrc, outDir); - filesWritten += await this.strategy.writeHooks(entry.name, pluginSrc, outDir); - filesWritten += await this.strategy.writeMcp(entry.name, pluginSrc, outDir); - const skippedSections = await this.warnOutOfScopeSections(entry.name, pluginSrc); - return { name: entry.name, filesWritten, skippedSections }; - } - - private async validateManifest(pluginSrc: string): Promise { - const manifestPath = join(pluginSrc, SOURCE_PLUGIN_MANIFEST_RELATIVE); - const raw = await this.fs.readFile(manifestPath); - const data = JSON.parse(raw) as unknown; - this.jsonSchemaValidator.validate(this.assetProvider.loadSchema("plugin-manifest"), data); - } - - private async warnOutOfScopeSections( - pluginName: string, - pluginSrc: string - ): Promise { - const skipped: string[] = []; - for (const section of OUT_OF_SCOPE_PLUGIN_SECTIONS) { - if (await this.fs.fileExists(join(pluginSrc, section))) { - this.logger.warn(`Skipping ${section}/ in plugin '${pluginName}' (out of scope for MVP1).`); - skipped.push(section); - } - } - return skipped; - } -} diff --git a/cli/src/application/use-cases/framework/strategies/build-output-strategy.ts b/cli/src/application/use-cases/framework/strategies/build-output-strategy.ts deleted file mode 100644 index c696805f3..000000000 --- a/cli/src/application/use-cases/framework/strategies/build-output-strategy.ts +++ /dev/null @@ -1,68 +0,0 @@ -import type { BuildPluginResult } from "../../../../domain/models/framework-build.js"; -import type { - SourceMarketplaceRef, - SourcePluginEntryRef, -} from "../../../../domain/tools/build-contract.js"; - -/** - * Source marketplace catalog entry from the framework's .claude-plugin/marketplace.json. - * Shared by orchestrator and marketplace strategy. The build contract already describes - * this shape for tool authors, so the orchestrator speaks the same type rather than a - * near-identical twin that only a cast could bridge. - */ -export type SourcePluginEntry = SourcePluginEntryRef; - -/** - * Parsed source marketplace catalog from the framework root. - */ -export type SourceMarketplace = SourceMarketplaceRef; - -/** - * Port-style interface for the output layout strategy used by FrameworkBuildUseCase. - * - * Mode A (marketplace) and Mode B flat each implement this. The orchestrator calls - * these methods in order for every plugin; the strategy owns all path computation - * and file I/O for its layout. - */ -export interface BuildOutputStrategy { - /** - * Called once before iterating plugins. Mode A wipes and recreates outDir. - * Flat mode validates outDir exists and is a directory. - */ - preBuild(outDir: string, sourceDir: string): Promise; - - /** - * Write a synthesized or pass-through plugin manifest. Returns files written (0 or 1). - */ - writePluginManifest(pluginName: string, pluginSrc: string, outDir: string): Promise; - - /** - * Write all agent files for a plugin. Returns files written. - */ - writeAgents(pluginName: string, pluginSrc: string, outDir: string): Promise; - - /** - * Write all skill files for a plugin. Returns files written. - */ - writeSkills(pluginName: string, pluginSrc: string, outDir: string): Promise; - - /** - * Write hooks artifacts for a plugin. Returns files written. - */ - writeHooks(pluginName: string, pluginSrc: string, outDir: string): Promise; - - /** - * Write MCP artifacts for a plugin. Returns files written. - */ - writeMcp(pluginName: string, pluginSrc: string, outDir: string): Promise; - - /** - * Called once after all plugins are built. Returns extra files written (e.g. - * marketplace.json = 1 for Mode A; 0 for flat mode). - */ - postBuild( - sourceMarketplace: SourceMarketplace, - builtPlugins: readonly BuildPluginResult[], - outDir: string - ): Promise; -} diff --git a/cli/src/application/use-cases/framework/strategies/codex-marketplace-catalog.ts b/cli/src/application/use-cases/framework/strategies/codex-marketplace-catalog.ts deleted file mode 100644 index 1fccb965e..000000000 --- a/cli/src/application/use-cases/framework/strategies/codex-marketplace-catalog.ts +++ /dev/null @@ -1,47 +0,0 @@ -// ── Codex-native marketplace catalog (for `codex plugin marketplace add`) ────── -// Shape verified 2026-07-05 against https://github.com/openai/plugins -// .agents/plugins/marketplace.json and https://developers.openai.com/codex/plugins/build. - -/** Default category when the source marketplace entry does not specify one. */ -export const CODEX_DEFAULT_CATEGORY = "Developer Tools"; -/** - * Default per-plugin auth policy. AIDD plugins bundle skills/agents/hooks with no - * external OAuth, so auth is deferred to first use rather than forced at install. - */ -export const CODEX_DEFAULT_AUTHENTICATION = "ON_USE"; -const CODEX_INSTALLATION_AVAILABLE = "AVAILABLE"; - -/** - * Build a Codex marketplace catalog: `{ name, interface: { displayName }, plugins }`. - * `displayName` falls back to the marketplace name when the source omits it. - */ -export function buildCodexMarketplace( - source: { name: string; displayName?: string }, - pluginEntries: readonly Record[] -): Record { - const displayName = typeof source.displayName === "string" ? source.displayName : source.name; - return { name: source.name, interface: { displayName }, plugins: pluginEntries }; -} - -/** - * Build a single Codex marketplace entry. `installation`/`authentication`/`category` - * are required per the plugin-creator spec; `authentication` and `category` accept a - * source-entry override, else fall back to the AIDD-shaped defaults. - */ -export function buildCodexMarketplaceEntry( - name: string, - srcEntry: Record | undefined -): Record { - const authentication = - typeof srcEntry?.authentication === "string" - ? srcEntry.authentication - : CODEX_DEFAULT_AUTHENTICATION; - const category = - typeof srcEntry?.category === "string" ? srcEntry.category : CODEX_DEFAULT_CATEGORY; - return { - name, - source: { source: "local", path: `./plugins/${name}` }, - policy: { installation: CODEX_INSTALLATION_AVAILABLE, authentication }, - category, - }; -} diff --git a/cli/src/application/use-cases/framework/strategies/default-plugin-catalog.ts b/cli/src/application/use-cases/framework/strategies/default-plugin-catalog.ts deleted file mode 100644 index b9729545f..000000000 --- a/cli/src/application/use-cases/framework/strategies/default-plugin-catalog.ts +++ /dev/null @@ -1,69 +0,0 @@ -import type { PluginPresenceFlags } from "./plugin-source-tree-reader.js"; - -export interface SynthesizeDefaultPluginManifestOpts { - /** When true, include `agents` as a list of `./agents/*.md` file paths if agents are present. */ - readonly agentsField: boolean; - /** - * Whether the tool needs `hooks` to point at the standard `hooks/hooks.json`. - * - * Codex does. Claude Code loads that path by its own convention and, since 2.1.240, - * rejects the plugin outright when a manifest names it as well: "Duplicate hooks file - * detected ... The standard hooks/hooks.json is loaded automatically, so manifest.hooks - * should only reference additional hook files." The hooks still fire, so the plugin reads - * as failed while working — measured, and worse than either honest outcome. - */ - readonly hooksField: boolean; -} - -export function synthesizeDefaultPluginManifest( - source: Record, - presence: PluginPresenceFlags, - opts: SynthesizeDefaultPluginManifestOpts -): Record { - const manifest: Record = {}; - if (typeof source.name === "string") manifest.name = source.name; - if (typeof source.description === "string") manifest.description = source.description; - if (typeof source.version === "string") manifest.version = source.version; - if (typeof source.author === "string" || typeof source.author === "object") - manifest.author = source.author; - if (typeof source.homepage === "string") manifest.homepage = source.homepage; - if (typeof source.repository === "string") manifest.repository = source.repository; - if (typeof source.license === "string") manifest.license = source.license; - if (Array.isArray(source.keywords)) manifest.keywords = source.keywords; - if (opts.agentsField && presence.agentsList.length > 0) - manifest.agents = presence.agentsList.map((n) => `./agents/${n}`); - if (presence.skillsList.length > 0) - manifest.skills = presence.skillsList.map((n) => `./skills/${n}`); - if (opts.hooksField && presence.hasHooksJson) manifest.hooks = "./hooks/hooks.json"; - if (presence.hasMcpJson) manifest.mcpServers = "./.mcp.json"; - return manifest; -} - -export function buildDefaultMarketplace( - source: { name: string; version?: string; description?: string; owner?: unknown }, - pluginEntries: readonly Record[] -): Record { - const obj: Record = { name: source.name }; - if (typeof source.version === "string") obj.version = source.version; - if (typeof source.description === "string") obj.description = source.description; - if (source.owner !== undefined) obj.owner = source.owner; - obj.plugins = pluginEntries; - return obj; -} - -export function buildDefaultCatalogEntry( - name: string, - description: string, - version: string, - srcEntry: Record | undefined -): Record { - const entry: Record = { - name, - source: `./plugins/${name}`, - description, - version, - }; - if (typeof srcEntry?.strict === "boolean") entry.strict = srcEntry.strict; - if (typeof srcEntry?.recommended === "boolean") entry.recommended = srcEntry.recommended; - return entry; -} diff --git a/cli/src/application/use-cases/framework/strategies/plugin-source-tree-reader.ts b/cli/src/application/use-cases/framework/strategies/plugin-source-tree-reader.ts deleted file mode 100644 index d127d7bf9..000000000 --- a/cli/src/application/use-cases/framework/strategies/plugin-source-tree-reader.ts +++ /dev/null @@ -1,96 +0,0 @@ -import { join, relative } from "node:path"; -import { InvalidSourceMarketplaceError } from "../../../../domain/errors.js"; -import { - PLUGIN_AGENT_INPUT_EXT, - PLUGIN_HOOKS_RELATIVE, - PLUGIN_MCP_RELATIVE, -} from "../../../../domain/models/framework-build.js"; -import type { FileReader } from "../../../../domain/ports/file-reader.js"; - -export interface PluginPresenceFlags { - readonly hasAgents: boolean; - /** Agent markdown files relative to the plugin's `agents/` dir (e.g. "planner.md"), sorted. */ - readonly agentsList: readonly string[]; - readonly skillsList: readonly string[]; - readonly hasHooksJson: boolean; - readonly hasMcpJson: boolean; -} - -export async function listAgentFiles( - fs: FileReader, - agentsDir: string -): Promise { - if (!(await fs.fileExists(agentsDir))) return []; - const files = await fs.listFilesRecursive(agentsDir); - return files - .filter((f) => f.endsWith(PLUGIN_AGENT_INPUT_EXT)) - .map((f) => relative(agentsDir, f).replace(/\\/g, "/")) - .sort(); -} - -export async function listSkillNames( - fs: FileReader, - pluginSrc: string -): Promise { - const skillsDir = join(pluginSrc, "skills"); - if (!(await fs.fileExists(skillsDir))) return []; - const files = await fs.listFilesRecursive(skillsDir); - const names = new Set(); - for (const f of files) { - if (!f.endsWith("/SKILL.md") && !f.endsWith("\\SKILL.md") && !f.endsWith("SKILL.md")) { - continue; - } - const rel = relative(skillsDir, f); - const parts = rel.replace(/\\/g, "/").split("/"); - if (parts.length >= 2) names.add(parts[0]); - } - return [...names].sort(); -} - -export async function detectPluginPresenceFlags( - fs: FileReader, - pluginSrc: string -): Promise { - const agentsDir = join(pluginSrc, "agents"); - const agentsList = await listAgentFiles(fs, agentsDir); - const skillsList = await listSkillNames(fs, pluginSrc); - const hasHooksJson = await fs.fileExists(join(pluginSrc, PLUGIN_HOOKS_RELATIVE)); - const hasMcpJson = await fs.fileExists(join(pluginSrc, PLUGIN_MCP_RELATIVE)); - return { hasAgents: agentsList.length > 0, agentsList, skillsList, hasHooksJson, hasMcpJson }; -} - -export async function resolveVersion( - fs: FileReader, - name: string, - srcEntry: { version?: string } | undefined, - outDir: string, - outputManifestRelative: string -): Promise { - if (srcEntry?.version) return srcEntry.version; - const manifestPath = join(outDir, "plugins", name, outputManifestRelative); - const raw = await fs.readFile(manifestPath); - const manifest = JSON.parse(raw) as Record; - if (typeof manifest.version === "string") return manifest.version; - throw new InvalidSourceMarketplaceError( - `plugin '${name}' has no version in marketplace entry or plugin.json` - ); -} - -export async function resolveDescription( - fs: FileReader, - name: string, - srcEntry: { description?: string } | undefined, - outDir: string, - outputManifestRelative: string -): Promise { - if (srcEntry?.description) return srcEntry.description; - const manifestPath = join(outDir, "plugins", name, outputManifestRelative); - const raw = await fs.readFile(manifestPath); - const manifest = JSON.parse(raw) as Record; - if (typeof manifest.description === "string" && manifest.description.length > 0) { - return manifest.description; - } - throw new InvalidSourceMarketplaceError( - `plugin '${name}' has no description in marketplace entry or plugin.json` - ); -} diff --git a/cli/src/application/use-cases/framework/strategies/tool-contracts.ts b/cli/src/application/use-cases/framework/strategies/tool-contracts.ts deleted file mode 100644 index c0020261f..000000000 --- a/cli/src/application/use-cases/framework/strategies/tool-contracts.ts +++ /dev/null @@ -1,854 +0,0 @@ -/** - * Per-tool ToolBuildContract implementations. - * - * Each function returns a ToolBuildContract describing how the tool handles each - * artifact kind in both marketplace and flat modes. The two orchestrators - * (MarketplaceBuildStrategy, FlatBuildStrategy) read these contracts — no - * per-tool if-branches live in the orchestrators. - * - * All content transforms, path computations, and merge helpers are pure - * functions reused from domain/formats/. The contracts are thin wiring. - */ - -import { - stripAgentFrontmatter, - stripCursorAgentFrontmatter, -} from "../../../../domain/formats/agent-frontmatter-strip.js"; -import { - OUTPUT_CLAUDE_MANIFEST_RELATIVE, - OUTPUT_CLAUDE_MARKETPLACE_RELATIVE, -} from "../../../../domain/formats/claude-build-paths.js"; -import { codexAgentMarkdownToToml } from "../../../../domain/formats/codex-agent-toml.js"; -import { - OUTPUT_CODEX_AGENTS_DIR, - OUTPUT_CODEX_MANIFEST_RELATIVE, - OUTPUT_CODEX_MARKETPLACE_RELATIVE, -} from "../../../../domain/formats/codex-paths.js"; -import { - OUTPUT_CURSOR_MANIFEST_RELATIVE, - OUTPUT_CURSOR_MARKETPLACE_RELATIVE, -} from "../../../../domain/formats/cursor-paths.js"; -import { - flattenCopilotHooksShape, - mergeClaudeSettingsHooks, - mergeCodexFrameworkHooksJson, - mergeCursorFlatHooks, - renameCodexHookEvents, -} from "../../../../domain/formats/flat-hooks-merge.js"; -import { - flatHooksSharedDirPath, - flatMcpKeyPrefix, - genericFlatAgentPath, - genericFlatHooksFile, - genericFlatHooksScriptPath, - genericFlatSkillPath, - genericFlatSkillTreePath, -} from "../../../../domain/formats/flat-paths.js"; -import { parseFrontmatter, serializeFrontmatter } from "../../../../domain/formats/markdown.js"; -import { buildOpencodeFlatConfig } from "../../../../domain/formats/opencode-mcp-merge.js"; -import { rewriteRelativeLinks } from "../../../../domain/formats/relative-link-rewrite.js"; -import { stringifyToml } from "../../../../domain/formats/toml.js"; -import { mergeVscodeMcp } from "../../../../domain/formats/vscode-mcp-merge.js"; -import { - FLAT_AGENT_OUTPUT_EXT, - FLAT_GITHUB_AGENTS_PREFIX, - FLAT_GITHUB_HOOKS_PREFIX, - FLAT_GITHUB_SKILLS_PREFIX, - FLAT_VSCODE_MCP_PATH, - OUTPUT_MARKETPLACE_RELATIVE, - OUTPUT_PLUGIN_MANIFEST_RELATIVE, -} from "../../../../domain/models/framework-build.js"; -import type { FileReader } from "../../../../domain/ports/file-reader.js"; -import type { FileWriter } from "../../../../domain/ports/file-writer.js"; -import { claude } from "../../../../domain/tools/ai/claude.js"; -import { - codex, - mergeCodexConfigToml, - stripCodexSkillFrontmatter, -} from "../../../../domain/tools/ai/codex.js"; -import { copilot } from "../../../../domain/tools/ai/copilot.js"; -import { cursor } from "../../../../domain/tools/ai/cursor.js"; -import { opencode, transformMcpToOpencode } from "../../../../domain/tools/ai/opencode.js"; -import type { - ArtifactContract, - PluginPresence, - ToolBuildContract, -} from "../../../../domain/tools/build-contract.js"; -import { buildCodexMarketplace, buildCodexMarketplaceEntry } from "./codex-marketplace-catalog.js"; -import { - buildDefaultCatalogEntry, - buildDefaultMarketplace, - synthesizeDefaultPluginManifest, -} from "./default-plugin-catalog.js"; -import { resolveDescription, resolveVersion } from "./plugin-source-tree-reader.js"; - -type FsType = FileReader & FileWriter; -type SrcEntry = - | { version?: string; description?: string; strict?: boolean; recommended?: boolean } - | undefined; - -// ── Agent transform helpers ─────────────────────────────────────────────────── - -function transformClaudeAgent(content: string, _plugin: string, outName: string): string { - const { frontmatter, body } = parseFrontmatter(content); - const rewrittenBody = rewriteRelativeLinks(body, { - currentFilePluginRelative: `agents/${outName}`, - }); - return serializeFrontmatter(frontmatter, rewrittenBody); -} - -function transformCursorAgent(content: string, _plugin: string, outName: string): string { - const { frontmatter, body } = parseFrontmatter(content); - const stripped = stripCursorAgentFrontmatter(frontmatter); - const rewrittenBody = rewriteRelativeLinks(body, { - currentFilePluginRelative: `agents/${outName}`, - }); - return serializeFrontmatter(stripped, rewrittenBody); -} - -// ── Shared catalog builders ──────────────────────────────────────────────────── - -async function buildDefaultEntry( - name: string, - outDir: string, - srcEntry: SrcEntry, - manifestRelative: string, - fs: FsType -): Promise> { - const args = [fs, name, srcEntry, outDir, manifestRelative] as const; - const version = await resolveVersion(...args); - const description = await resolveDescription(...args); - return buildDefaultCatalogEntry( - name, - description, - version, - srcEntry as Record | undefined - ); -} - -// ── Claude contract ──────────────────────────────────────────────────────────── - -export function buildClaudeContract(): ToolBuildContract { - const manifestRelative = OUTPUT_CLAUDE_MANIFEST_RELATIVE; - const marketplaceRelative = OUTPUT_CLAUDE_MARKETPLACE_RELATIVE; - return { - manifestDir: ".claude-plugin", - marketplaceRelative, - pluginRootToken: claude.capabilities.plugins.pluginRootToken, - manifestFileRelative: manifestRelative, - synthesizeManifest: (source, presence) => - synthesizeDefaultPluginManifest(source, presence, { - agentsField: true, - hooksField: false, - }), - manifestSchemaName: "plugin-manifest", - artifacts: { - skills: { - supported: true, - source: { kind: "fullTree", srcDir: "skills" }, - path: (_p, rel) => rel, - }, - agents: { - supported: true, - source: { kind: "filteredTree", srcDir: "agents", inputExt: ".md" }, - path: (_p, rel) => rel, - transform: transformClaudeAgent, - }, - mcp: { - supported: true, - source: { kind: "configFile", srcPath: ".mcp.json" }, - path: () => ".mcp.json", - }, - hooks: { - supported: true, - source: { kind: "hooksBundle", jsonPath: "hooks/hooks.json", scriptDir: "hooks" }, - path: (_p, rel) => rel, - }, - rules: { supported: false }, - commands: { supported: false }, - }, - buildMarketplaceCatalog: async (source, entries, _fs) => ({ - catalog: buildDefaultMarketplace( - source as Parameters[0], - entries - ), - schemaName: "claude-marketplace", - destRelPath: marketplaceRelative, - }), - buildMarketplaceEntry: async (name, _src, outDir, srcEntry, fs) => - buildDefaultEntry(name, outDir, srcEntry, manifestRelative, fs), - }; -} - -// ── Cursor contract ──────────────────────────────────────────────────────────── - -export function buildCursorContract(): ToolBuildContract { - const manifestRelative = OUTPUT_CURSOR_MANIFEST_RELATIVE; - const marketplaceRelative = OUTPUT_CURSOR_MARKETPLACE_RELATIVE; - return { - manifestDir: ".cursor-plugin", - marketplaceRelative, - pluginRootToken: cursor.capabilities.plugins.pluginRootToken, - manifestFileRelative: manifestRelative, - synthesizeManifest: (source, presence) => - synthesizeDefaultPluginManifest(source, presence, { - agentsField: true, - hooksField: true, - }), - manifestSchemaName: "plugin-manifest", - artifacts: { - skills: { - supported: true, - source: { kind: "fullTree", srcDir: "skills" }, - path: (_p, rel) => rel, - }, - agents: { - supported: true, - source: { kind: "filteredTree", srcDir: "agents", inputExt: ".md" }, - path: (_p, rel) => rel, - transform: transformCursorAgent, - }, - mcp: { - supported: true, - source: { kind: "configFile", srcPath: ".mcp.json" }, - path: () => ".mcp.json", - }, - hooks: { - supported: true, - source: { kind: "hooksBundle", jsonPath: "hooks/hooks.json", scriptDir: "hooks" }, - path: (_p, rel) => rel, - }, - rules: { supported: false }, - commands: { supported: false }, - }, - buildMarketplaceCatalog: async (source, entries, _fs) => ({ - catalog: buildDefaultMarketplace( - source as Parameters[0], - entries - ), - schemaName: "claude-marketplace", - destRelPath: marketplaceRelative, - }), - buildMarketplaceEntry: async (name, _src, outDir, srcEntry, fs) => - buildDefaultEntry(name, outDir, srcEntry, manifestRelative, fs), - }; -} - -// ── Copilot marketplace contract (OpenPlugin format) ────────────────────────── - -export function buildCopilotMarketplaceContract(): ToolBuildContract { - const manifestRelative = OUTPUT_PLUGIN_MANIFEST_RELATIVE; - const marketplaceRelative = OUTPUT_MARKETPLACE_RELATIVE; - return { - manifestDir: ".plugin", - marketplaceRelative, - pluginRootToken: copilot.capabilities.plugins.pluginRootToken, - manifestFileRelative: manifestRelative, - synthesizeManifest: (source, presence) => - synthesizeDefaultPluginManifest(source, presence, { - agentsField: true, - hooksField: true, - }), - manifestSchemaName: null, // Copilot does not use AJV for the plugin manifest - artifacts: { - skills: { - supported: true, - source: { kind: "fullTree", srcDir: "skills" }, - path: (_p, rel) => rel, - }, - agents: { - supported: true, - source: { kind: "filteredTree", srcDir: "agents", inputExt: ".md" }, - path: (_p, rel) => rel, - transform: transformClaudeAgent, - }, - mcp: { - supported: true, - source: { kind: "configFile", srcPath: ".mcp.json" }, - path: () => ".mcp.json", - }, - hooks: { - supported: true, - source: { kind: "hooksBundle", jsonPath: "hooks/hooks.json", scriptDir: "hooks" }, - path: (_p, rel) => rel, - }, - rules: { supported: false }, - commands: { supported: false }, - }, - buildMarketplaceCatalog: async (source, entries, _fs) => ({ - catalog: { - name: source.name, - metadata: { - description: source.description, - version: source.version, - pluginRoot: "./plugins", - }, - owner: source.owner, - plugins: entries, - }, - schemaName: "marketplace", - destRelPath: marketplaceRelative, - }), - buildMarketplaceEntry: async (name, _src, outDir, srcEntry, fs) => { - const args = [fs, name, srcEntry, outDir, manifestRelative] as const; - const version = await resolveVersion(...args); - const description = await resolveDescription(...args); - return { name, source: name, description, version }; - }, - }; -} - -// ── Codex marketplace contract ───────────────────────────────────────────────── - -const CODEX_MANIFEST_STRING_KEYS = [ - "name", - "description", - "version", - "homepage", - "repository", - "license", -] as const; - -function copyCodexManifestStringFields( - source: Record, - manifest: Record -): void { - for (const key of CODEX_MANIFEST_STRING_KEYS) { - if (typeof source[key] === "string") manifest[key] = source[key]; - } - if (typeof source.author === "string" || typeof source.author === "object") { - manifest.author = source.author; - } - if (Array.isArray(source.keywords)) manifest.keywords = source.keywords; -} - -function buildCodexManifest( - source: Record, - presence: PluginPresence -): Record { - const manifest: Record = {}; - copyCodexManifestStringFields(source, manifest); - // agents field intentionally omitted: Codex plugin schema does not support it - // Codex requires `skills` as a STRING dir (like the official gmail plugin); the array - // form makes `codex plugin add` fail with "missing or invalid plugin.json". - if (presence.skillsList.length > 0) manifest.skills = "./skills"; - if (presence.hasHooksJson) manifest.hooks = "./hooks/hooks.json"; - if (presence.hasMcpJson) manifest.mcpServers = "./.mcp.json"; - return manifest; -} - -function transformCodexSkill(content: string): string { - const { frontmatter, body } = parseFrontmatter(content); - return serializeFrontmatter(stripCodexSkillFrontmatter(frontmatter), body); -} - -export function buildCodexContract(): ToolBuildContract { - const manifestRelative = OUTPUT_CODEX_MANIFEST_RELATIVE; - const marketplaceRelative = OUTPUT_CODEX_MARKETPLACE_RELATIVE; - return { - manifestDir: ".codex-plugin", - marketplaceRelative, - pluginRootToken: codex.capabilities.plugins.pluginRootToken, - manifestFileRelative: manifestRelative, - synthesizeManifest: buildCodexManifest, - manifestSchemaName: "codex-plugin-manifest", - artifacts: { - skills: { - supported: true, - source: { kind: "fullTree", srcDir: "skills" }, - path: (_p, rel) => rel, - transform: transformCodexSkill, - }, - agents: { - supported: true, - source: { kind: "filteredTree", srcDir: "agents", inputExt: ".md" }, - path: (_p, rel) => - `${OUTPUT_CODEX_AGENTS_DIR}/${rel.replace(/^agents\//, "").replace(/\.md$/, ".toml")}`, - transform: (content, plugin, outName) => codexAgentMarkdownToToml(content, plugin, outName), - }, - mcp: { - supported: true, - source: { kind: "configFile", srcPath: ".mcp.json" }, - path: () => ".mcp.json", - }, - hooks: { - supported: true, - source: { kind: "hooksBundle", jsonPath: "hooks/hooks.json", scriptDir: "hooks" }, - path: (_p, rel) => rel, - // The same rename the merged install route applies. Codex has no `Stop`, so without - // this the built tree subscribes the turn-end hook to an event that never arrives - // and the turn is never closed, in silence. - transform: (content, _plugin, base) => - base === "hooks.json" ? renameCodexHookEvents(content) : content, - }, - rules: { supported: false }, - commands: { supported: false }, - }, - buildMarketplaceCatalog: async (source, entries, _fs) => ({ - catalog: buildCodexMarketplace( - source as Parameters[0], - entries - ), - schemaName: "codex-marketplace", - destRelPath: marketplaceRelative, - }), - buildMarketplaceEntry: async (name, _src, _outDir, srcEntry, _fs) => - buildCodexMarketplaceEntry(name, srcEntry as Record | undefined), - }; -} - -// ── Copilot flat contract (for FlatBuildStrategy) ───────────────────────────── - -function copilotFlatAgentPath(plugin: string, rel: string): string { - return genericFlatAgentPath( - FLAT_GITHUB_AGENTS_PREFIX, - plugin, - rel.replace(/^agents\//, ""), - FLAT_AGENT_OUTPUT_EXT - ); -} - -function copilotFlatSkillPath(plugin: string, rel: string): string { - return genericFlatSkillPath(FLAT_GITHUB_SKILLS_PREFIX, plugin, rel.replace(/^skills\//, "")); -} - -function copilotFlatHooksPath(plugin: string, rel: string): string { - const rest = rel.replace(/^hooks\//, ""); - if (rest === `${plugin}.hooks.json`) - return genericFlatHooksFile(FLAT_GITHUB_HOOKS_PREFIX, plugin); - return genericFlatHooksScriptPath(FLAT_GITHUB_HOOKS_PREFIX, plugin, rest); -} - -function transformCopilotFlatAgent(content: string, plugin: string, outName: string): string { - const { frontmatter, body } = parseFrontmatter(content); - const stripped = stripAgentFrontmatter(frontmatter); - const flatRelPath = copilotFlatAgentPath(plugin, `agents/${outName}`); - const rewrittenBody = rewriteRelativeLinks(body, { - currentFilePluginRelative: flatRelPath, - resolveTargetPath: (rel) => copilotFlatResolveTarget(plugin, rel), - }); - const prefixedName = `${plugin}-${outName.replace(/\.md$/, "")}`; - return serializeFrontmatter({ ...stripped, name: prefixedName }, rewrittenBody); -} - -function copilotFlatResolveTarget(plugin: string, rel: string): string { - if (rel.startsWith("agents/")) return copilotFlatAgentPath(plugin, rel); - if (rel.startsWith("skills/")) return copilotFlatSkillPath(plugin, rel); - return rel; -} - -export function buildCopilotFlatContract(): ToolBuildContract { - return { - manifestDir: null, - marketplaceRelative: null, - manifestFileRelative: null, - synthesizeManifest: null, - manifestSchemaName: null, - artifacts: { - skills: { - supported: true, - source: { kind: "fullTree", srcDir: "skills" }, - path: copilotFlatSkillPath, - // VS Code Copilot requires SKILL.md frontmatter name === parent folder name. - rewriteSkillName: true, - }, - agents: { - supported: true, - source: { kind: "filteredTree", srcDir: "agents", inputExt: ".md" }, - ext: FLAT_AGENT_OUTPUT_EXT, - path: copilotFlatAgentPath, - transform: transformCopilotFlatAgent, - }, - mcp: { - supported: true, - source: { kind: "configFile", srcPath: ".mcp.json" }, - path: () => FLAT_VSCODE_MCP_PATH, - merge: (existing, incoming, force) => mergeVscodeMcp(existing, incoming, force), - mcpServersKey: "servers", - mergeDest: (outDir) => `${outDir}/${FLAT_VSCODE_MCP_PATH}`, - }, - hooks: { - supported: true, - source: { kind: "hooksBundle", jsonPath: "hooks/hooks.json", scriptDir: "hooks" }, - path: copilotFlatHooksPath, - hooksTransform: (rewrittenJson) => flattenCopilotHooksShape(rewrittenJson), - }, - rules: { supported: false }, - commands: { supported: false }, - }, - buildMarketplaceCatalog: null, - buildMarketplaceEntry: null, - }; -} - -// ── Claude flat contract ─────────────────────────────────────────────────────── - -function claudeFlatAgentPath(plugin: string, rel: string): string { - return genericFlatAgentPath(".claude/agents/", plugin, rel.replace(/^agents\//, ""), ".md"); -} - -function claudeFlatSkillPath(plugin: string, rel: string): string { - return genericFlatSkillPath(".claude/skills/", plugin, rel.replace(/^skills\//, "")); -} - -function claudeFlatHooksPath(plugin: string, rel: string): string { - const rest = rel.replace(/^hooks\//, ""); - if (rest === `${plugin}.hooks.json`) return genericFlatHooksFile(".claude/hooks/", plugin); - return genericFlatHooksScriptPath(".claude/hooks/", plugin, rest); -} - -function claudeFlatResolveTarget(plugin: string, rel: string): string { - if (rel.startsWith("agents/")) return claudeFlatAgentPath(plugin, rel); - if (rel.startsWith("skills/")) return claudeFlatSkillPath(plugin, rel); - return rel; -} - -function transformClaudeFlatAgent(content: string, plugin: string, outName: string): string { - const { frontmatter, body } = parseFrontmatter(content); - const flatRelPath = claudeFlatAgentPath(plugin, `agents/${outName}`); - const rewrittenBody = rewriteRelativeLinks(body, { - currentFilePluginRelative: flatRelPath, - resolveTargetPath: (rel) => claudeFlatResolveTarget(plugin, rel), - }); - const prefixedName = `${plugin}-${outName.replace(/\.md$/, "")}`; - return serializeFrontmatter({ ...frontmatter, name: prefixedName }, rewrittenBody); -} - -export function buildClaudeFlatContract(): ToolBuildContract { - return { - manifestDir: null, - marketplaceRelative: null, - manifestFileRelative: null, - synthesizeManifest: null, - manifestSchemaName: null, - artifacts: { - skills: { - supported: true, - source: { kind: "fullTree", srcDir: "skills" }, - path: claudeFlatSkillPath, - rewriteSkillName: true, - }, - agents: { - supported: true, - source: { kind: "filteredTree", srcDir: "agents", inputExt: ".md" }, - path: claudeFlatAgentPath, - transform: transformClaudeFlatAgent, - }, - mcp: { - supported: true, - source: { kind: "configFile", srcPath: ".mcp.json" }, - path: () => ".mcp.json", - merge: (existing, incoming, force) => - mergeVscodeMcp(existing, incoming, force, "mcpServers"), - mcpServersKey: "mcpServers", - mergeDest: (outDir) => `${outDir}/.mcp.json`, - }, - hooks: { - supported: true, - source: { kind: "hooksBundle", jsonPath: "hooks/hooks.json", scriptDir: "hooks" }, - path: claudeFlatHooksPath, - hooksMerge: (existing, incoming) => mergeClaudeSettingsHooks(existing, incoming), - hooksMergeDest: (outDir) => `${outDir}/.claude/settings.json`, - }, - rules: { supported: false }, - commands: { supported: false }, - }, - buildMarketplaceCatalog: null, - buildMarketplaceEntry: null, - }; -} - -// ── Cursor flat contract ─────────────────────────────────────────────────────── - -function cursorFlatAgentPath(plugin: string, rel: string): string { - return genericFlatAgentPath(".cursor/agents/", plugin, rel.replace(/^agents\//, ""), ".md"); -} - -function cursorFlatSkillPath(plugin: string, rel: string): string { - return genericFlatSkillPath(".cursor/skills/", plugin, rel.replace(/^skills\//, "")); -} - -function cursorFlatHooksPath(plugin: string, rel: string): string { - const rest = rel.replace(/^hooks\//, ""); - if (rest === `${plugin}.hooks.json`) return genericFlatHooksFile(".cursor/hooks/", plugin); - return genericFlatHooksScriptPath(".cursor/hooks/", plugin, rest); -} - -function cursorFlatResolveTarget(plugin: string, rel: string): string { - if (rel.startsWith("agents/")) return cursorFlatAgentPath(plugin, rel); - if (rel.startsWith("skills/")) return cursorFlatSkillPath(plugin, rel); - return rel; -} - -function transformCursorFlatAgent(content: string, plugin: string, outName: string): string { - const { frontmatter, body } = parseFrontmatter(content); - const stripped = stripCursorAgentFrontmatter(frontmatter); - const flatRelPath = cursorFlatAgentPath(plugin, `agents/${outName}`); - const rewrittenBody = rewriteRelativeLinks(body, { - currentFilePluginRelative: flatRelPath, - resolveTargetPath: (rel) => cursorFlatResolveTarget(plugin, rel), - }); - const prefixedName = `${plugin}-${outName.replace(/\.md$/, "")}`; - return serializeFrontmatter({ ...stripped, name: prefixedName }, rewrittenBody); -} - -export function buildCursorFlatContract(): ToolBuildContract { - return { - manifestDir: null, - marketplaceRelative: null, - manifestFileRelative: null, - synthesizeManifest: null, - manifestSchemaName: null, - artifacts: { - skills: { - supported: true, - source: { kind: "fullTree", srcDir: "skills" }, - path: cursorFlatSkillPath, - rewriteSkillName: true, - }, - agents: { - supported: true, - source: { kind: "filteredTree", srcDir: "agents", inputExt: ".md" }, - path: cursorFlatAgentPath, - transform: transformCursorFlatAgent, - }, - mcp: { - supported: true, - source: { kind: "configFile", srcPath: ".mcp.json" }, - path: () => ".cursor/mcp.json", - merge: (existing, incoming, force) => - mergeVscodeMcp(existing, incoming, force, "mcpServers"), - mcpServersKey: "mcpServers", - mergeDest: (outDir) => `${outDir}/.cursor/mcp.json`, - }, - hooks: { - supported: true, - source: { kind: "hooksBundle", jsonPath: "hooks/hooks.json", scriptDir: "hooks" }, - path: cursorFlatHooksPath, - hooksMerge: (existing, incoming) => mergeCursorFlatHooks(existing, incoming), - hooksMergeDest: (outDir) => `${outDir}/.cursor/hooks.json`, - }, - rules: { supported: false }, - commands: { supported: false }, - }, - buildMarketplaceCatalog: null, - buildMarketplaceEntry: null, - }; -} - -// ── Codex flat contract ──────────────────────────────────────────────────────── - -// Codex scans `.agents/skills/` (cwd → repo root) for workspace skills — the documented -// project skill root (developers.openai.com/codex/skills). Verified live on codex-cli 0.136: -// a SKILL.md there appears in Codex's "Available skills" context. (`.codex/skills/` also -// resolves on 0.136 but is undocumented, so we target the documented root.) -const CODEX_SKILLS_PREFIX = ".agents/skills/"; - -function codexFlatSkillPath(plugin: string, rel: string): string { - return genericFlatSkillPath(CODEX_SKILLS_PREFIX, plugin, rel.replace(/^skills\//, "")); -} - -function codexFlatAgentPath(plugin: string, rel: string): string { - const base = rel.replace(/^agents\//, "").replace(/\.md$/, ".toml"); - return `.codex/agents/${plugin}-${base}`; -} - -function codexFlatHooksPath(plugin: string, rel: string): string { - const rest = rel.replace(/^hooks\//, ""); - return genericFlatHooksScriptPath(".codex/hooks/", plugin, rest); -} - -async function collectPrefixedMcpServers( - builtPlugins: readonly string[], - sourceDir: string, - fs: FsType -): Promise> { - const mcpServers: Record = {}; - for (const plugin of builtPlugins) { - const mcpSrc = `${sourceDir}/plugins/${plugin}/.mcp.json`; - if (!(await fs.fileExists(mcpSrc))) continue; - const raw = await fs.readFile(mcpSrc); - const parsed = JSON.parse(raw) as { mcpServers?: Record }; - const prefix = flatMcpKeyPrefix(plugin); - for (const [k, v] of Object.entries(parsed.mcpServers ?? {})) { - mcpServers[`${prefix}${k}`] = v; - } - } - return mcpServers; -} - -function buildCodexConfigPayload(mcpServers: Record): string { - if (Object.keys(mcpServers).length === 0) return ""; - return stringifyToml({ mcp_servers: mcpServers } as Record); -} - -export function buildCodexFlatContract(): ToolBuildContract { - return { - manifestDir: null, - marketplaceRelative: null, - manifestFileRelative: null, - synthesizeManifest: null, - manifestSchemaName: null, - artifacts: { - skills: { - supported: true, - source: { kind: "fullTree", srcDir: "skills" }, - path: codexFlatSkillPath, - rewriteSkillName: true, - }, - agents: { - supported: true, - source: { kind: "filteredTree", srcDir: "agents", inputExt: ".md" }, - path: codexFlatAgentPath, - transform: (content, plugin, outName) => - codexAgentMarkdownToToml(content, plugin, outName, true), - }, - mcp: { supported: false }, // handled by emitConfigArtifact (config.toml mcp_servers) - hooks: { - supported: true, - source: { kind: "hooksBundle", jsonPath: "hooks/hooks.json", scriptDir: "hooks" }, - path: codexFlatHooksPath, - hooksMerge: (existing, incoming) => mergeCodexFrameworkHooksJson(existing, incoming), - hooksMergeDest: (outDir) => `${outDir}/.codex/hooks.json`, - }, - rules: { supported: false }, - commands: { supported: false }, - }, - buildMarketplaceCatalog: null, - buildMarketplaceEntry: null, - emitConfigArtifact: async (builtPlugins, outDir, sourceDir, fs) => { - const configPath = `${outDir}/.codex/config.toml`; - const existing = (await fs.fileExists(configPath)) ? await fs.readFile(configPath) : ""; - const mcpServers = await collectPrefixedMcpServers(builtPlugins, sourceDir, fs); - const aiddPayload = buildCodexConfigPayload(mcpServers); - const merged = mergeCodexConfigToml(existing, aiddPayload); - await fs.writeFile(configPath, merged); - return 1; - }, - }; -} - -// ── Opencode flat contract ───────────────────────────────────────────────────── - -function opencodeFlatAgentPath(plugin: string, rel: string): string { - return genericFlatAgentPath(".opencode/agents/", plugin, rel.replace(/^agents\//, ""), ".md"); -} - -// Nested, not hyphen-flat like the other four tools' skill paths — see -// genericFlatSkillTreePath's doc comment. This is the shape already installed for OpenCode, -// so it stays; nothing under skills/ depends on it any more. Must produce the same relative paths as -// `aidd plugin install --tool opencode`'s route (PluginContentTranslator.translateFlat, -// mode-b-flat-materialization-translator.ts), pinned equal by -// built-tree-vs-modeb-skills-agree.unit.test.ts. -function opencodeFlatSkillPath(plugin: string, rel: string): string { - return genericFlatSkillTreePath(".opencode/skills/", plugin, rel.replace(/^skills\//, "")); -} - -function opencodeFlatResolveTarget(plugin: string, rel: string): string { - if (rel.startsWith("agents/")) return opencodeFlatAgentPath(plugin, rel); - if (rel.startsWith("skills/")) return opencodeFlatSkillPath(plugin, rel); - return rel; -} - -// OpenCode's loader scans one directory non-recursively (flatHooksDir), so a hook script -// lands there directly — no plugin-name segment, the same shape `translateFlat` delivers -// for the install route (plugin-content-translator.ts's flatHooksFiles, via the same -// flatHooksSharedDirPath). -function makeOpencodeFlatHooksPath(flatHooksDir: string): (plugin: string, rel: string) => string { - return (_plugin, rel) => flatHooksSharedDirPath(flatHooksDir, rel); -} - -function transformOpencodeFlatAgent(content: string, plugin: string, outName: string): string { - const { frontmatter, body } = parseFrontmatter(content); - const flatRelPath = opencodeFlatAgentPath(plugin, `agents/${outName}`); - const rewrittenBody = rewriteRelativeLinks(body, { - currentFilePluginRelative: flatRelPath, - resolveTargetPath: (rel) => opencodeFlatResolveTarget(plugin, rel), - }); - const prefixedName = `${plugin}-${outName.replace(/\.md$/, "")}`; - // mode: subagent ensures opencode treats copied agents as subagents, not primary agents. - return serializeFrontmatter( - { ...frontmatter, name: prefixedName, mode: "subagent" }, - rewrittenBody - ); -} - -async function resolveOpencodeJsonPath(outDir: string, fs: FsType): Promise { - const jsoncExists = await fs.fileExists(`${outDir}/opencode.jsonc`); - if (jsoncExists) return `${outDir}/opencode.jsonc`; - return `${outDir}/opencode.json`; -} - -async function collectOpencodeMcp( - builtPlugins: readonly string[], - sourceDir: string, - fs: FsType -): Promise> { - const incoming: Record = {}; - for (const plugin of builtPlugins) { - const mcpSrc = `${sourceDir}/plugins/${plugin}/.mcp.json`; - if (!(await fs.fileExists(mcpSrc))) continue; - const raw = await fs.readFile(mcpSrc); - const transformed = JSON.parse(transformMcpToOpencode(raw)) as { - mcp?: Record; - }; - const prefix = flatMcpKeyPrefix(plugin); - for (const [k, v] of Object.entries(transformed.mcp ?? {})) { - incoming[`${prefix}${k}`] = v; - } - } - return incoming; -} - -// Delivers what `aidd plugin install --tool opencode` delivers: `flatHooksDir` is the -// tool's own declaration (opencode.ts), read here rather than restated, so the two -// routes cannot fall out of sync the way they did before this fix. -function buildOpencodeFlatHooksArtifact(): ArtifactContract { - const { flatHooksDir } = opencode.capabilities.plugins; - if (flatHooksDir === null) return { supported: false }; - return { - supported: true, - source: { kind: "hooksBundle", jsonPath: "hooks/hooks.json", scriptDir: "hooks" }, - path: makeOpencodeFlatHooksPath(flatHooksDir), - skipHooksJson: true, - }; -} - -export function buildOpencodeFlatContract(): ToolBuildContract { - return { - manifestDir: null, - marketplaceRelative: null, - manifestFileRelative: null, - synthesizeManifest: null, - manifestSchemaName: null, - artifacts: { - skills: { - supported: true, - source: { kind: "fullTree", srcDir: "skills" }, - path: opencodeFlatSkillPath, - rewriteSkillName: true, - }, - agents: { - supported: true, - source: { kind: "filteredTree", srcDir: "agents", inputExt: ".md" }, - path: opencodeFlatAgentPath, - transform: transformOpencodeFlatAgent, - }, - mcp: { supported: false }, // handled by emitConfigArtifact (opencode.json mcp) - hooks: buildOpencodeFlatHooksArtifact(), - rules: { supported: false }, - commands: { supported: false }, - }, - buildMarketplaceCatalog: null, - buildMarketplaceEntry: null, - emitConfigArtifact: async (builtPlugins, outDir, sourceDir, fs, _validator, assetProvider) => { - const configPath = await resolveOpencodeJsonPath(outDir, fs); - const existing = (await fs.fileExists(configPath)) ? await fs.readFile(configPath) : null; - const incoming = await collectOpencodeMcp(builtPlugins, sourceDir, fs); - const baseAsset = assetProvider.loadConfigAsset("opencode", "opencode.json"); - const base = typeof baseAsset === "string" ? baseAsset : JSON.stringify(baseAsset); - await fs.writeFile(configPath, buildOpencodeFlatConfig(base, existing, incoming)); - return 1; - }, - }; -} diff --git a/cli/src/application/use-cases/global/doctor-all-use-case.ts b/cli/src/application/use-cases/global/doctor-all-use-case.ts deleted file mode 100644 index b4edcae59..000000000 --- a/cli/src/application/use-cases/global/doctor-all-use-case.ts +++ /dev/null @@ -1,49 +0,0 @@ -import type { DoctorReport } from "../../../domain/models/doctor.js"; -import type { DoctorUseCase } from "../doctor/doctor-use-case.js"; -import type { GlobalExecutionError } from "./update-all-use-case.js"; - -export interface DoctorAllResult { - ai: DoctorReport | null; - ide: DoctorReport | null; - /** Plugin issues only. Plugins hang off AI tools, so the ai scope already carries them all. */ - pluginIssues: DoctorReport["pluginIssues"]; - healthy: boolean; - errors: GlobalExecutionError[]; -} - -export class DoctorAllUseCase { - constructor(private readonly doctorUseCase: DoctorUseCase) {} - - async execute(projectRoot: string): Promise { - const errors: GlobalExecutionError[] = []; - const ai = await this.runScope( - () => this.doctorUseCase.execute({ projectRoot, category: "ai" }), - "ai", - errors - ); - const ide = await this.runScope( - () => this.doctorUseCase.execute({ projectRoot, category: "ide" }), - "ide", - errors - ); - const healthy = this.computeHealthy(ai, ide); - return { ai, ide, pluginIssues: ai?.pluginIssues ?? [], healthy, errors }; - } - - private async runScope( - fn: () => Promise, - scope: string, - errors: GlobalExecutionError[] - ): Promise { - try { - return await fn(); - } catch (err) { - errors.push({ scope, message: err instanceof Error ? err.message : String(err) }); - return null; - } - } - - private computeHealthy(ai: DoctorReport | null, ide: DoctorReport | null): boolean { - return (ai === null || ai.healthy) && (ide === null || ide.healthy); - } -} diff --git a/cli/src/application/use-cases/global/restore-all-use-case.ts b/cli/src/application/use-cases/global/restore-all-use-case.ts deleted file mode 100644 index bd590339b..000000000 --- a/cli/src/application/use-cases/global/restore-all-use-case.ts +++ /dev/null @@ -1,122 +0,0 @@ -import { DOCS_DIR } from "../../../domain/models/paths.js"; -import type { ManifestRepository } from "../../../domain/ports/manifest-repository.js"; -import type { Prompter } from "../../../domain/ports/prompter.js"; -import { NoManifestError } from "../../errors.js"; -import type { RestoreUseCase } from "../restore/restore-use-case.js"; -import type { StatusUseCase } from "../status-use-case.js"; -import type { GlobalExecutionError } from "./update-all-use-case.js"; - -export interface RestoreAllResult { - totalRestored: number; - totalKept: number; - pluginNamesRestored: string[]; - errors: GlobalExecutionError[]; - unrestorable: string[]; -} - -export class RestoreAllUseCase { - constructor( - private readonly manifestRepo: ManifestRepository, - private readonly prompter: Prompter, - private readonly statusUseCase: StatusUseCase, - private readonly restoreUseCase: RestoreUseCase - ) {} - - async execute( - projectRoot: string, - interactive: boolean, - force: boolean - ): Promise { - const errors: GlobalExecutionError[] = []; - const manifest = await this.manifestRepo.load(); - if (manifest === null) throw new NoManifestError(); - - const effectiveFiles = interactive ? await this.promptForFiles(projectRoot) : undefined; - const version = this.resolveVersion(manifest); - const restoreResult = await this.runConfigRestore( - projectRoot, - version, - effectiveFiles, - interactive, - force, - manifest, - errors - ); - - return { - totalRestored: restoreResult.totalRestored, - totalKept: restoreResult.totalKept, - pluginNamesRestored: restoreResult.restoredPluginNames, - errors, - unrestorable: restoreResult.unrestorable, - }; - } - - private resolveVersion(manifest: ReturnType): string { - return ( - manifest - .getInstalledToolIds() - .map((id: string) => manifest.getToolVersion(id)) - .find((v: string | undefined) => v !== undefined) ?? "unknown" - ); - } - - private async promptForFiles(projectRoot: string): Promise { - const report = await this.statusUseCase.execute({ projectRoot }); - const driftedFiles = report.tools.flatMap((t) => - t.drifted - .filter((d) => d.status === "modified" || d.status === "deleted") - .map((d) => d.relativePath) - ); - if (driftedFiles.length === 0) return []; - const selected = await this.prompter.checkbox( - "Select files to restore:", - driftedFiles.map((f) => ({ name: f, value: f })) - ); - return selected.length === 0 ? [] : selected; - } - - private async runConfigRestore( - projectRoot: string, - version: string, - files: string[] | undefined, - interactive: boolean, - force: boolean, - manifest: Awaited>, - errors: GlobalExecutionError[] - ): Promise<{ - totalRestored: number; - totalKept: number; - restoredPluginNames: string[]; - unrestorable: string[]; - }> { - const empty = { totalRestored: 0, totalKept: 0, restoredPluginNames: [], unrestorable: [] }; - try { - if (manifest === null) return empty; - const result = await this.restoreUseCase.execute({ - version, - docsDir: DOCS_DIR, - projectRoot, - files, - // Consent to overwrite a modified file comes from one of two places: the - // checkbox the interactive run already made the user answer, or --force - // when there is no TTY to ask. Neither is a reason to ask a second time. - force: force || interactive, - interactive, - manifest, - }); - return { - totalRestored: result.totalRestored, - totalKept: result.totalKept, - restoredPluginNames: result.restoredPluginNames, - unrestorable: result.unrestorable, - }; - } catch (err) { - errors.push({ - scope: "config-restore", - message: err instanceof Error ? err.message : String(err), - }); - return empty; - } - } -} diff --git a/cli/src/application/use-cases/global/update-ai-tools-use-case.ts b/cli/src/application/use-cases/global/update-ai-tools-use-case.ts deleted file mode 100644 index 852c3675e..000000000 --- a/cli/src/application/use-cases/global/update-ai-tools-use-case.ts +++ /dev/null @@ -1,20 +0,0 @@ -import type { AiToolId } from "../../../domain/models/tool-ids.js"; -import { isAiToolId } from "../../../domain/models/tool-ids.js"; -import type { ManifestRepository } from "../../../domain/ports/manifest-repository.js"; -import type { VersionReader } from "../../../domain/ports/version-reader.js"; -import type { UpdateOneToolUseCase } from "../shared/update-one-tool-use-case.js"; -import type { UpdateToolsInput, UpdateToolsResult } from "./update-tools-use-case.js"; -import { UpdateToolsUseCase } from "./update-tools-use-case.js"; - -export type UpdateAiToolsInput = UpdateToolsInput; -export type UpdateAiToolsResult = UpdateToolsResult; - -export class UpdateAiToolsUseCase extends UpdateToolsUseCase { - constructor( - manifestRepo: ManifestRepository, - versionReader: VersionReader, - updateOneToolUseCase: UpdateOneToolUseCase - ) { - super(manifestRepo, versionReader, updateOneToolUseCase, isAiToolId); - } -} diff --git a/cli/src/application/use-cases/global/update-all-use-case.ts b/cli/src/application/use-cases/global/update-all-use-case.ts deleted file mode 100644 index 5ff4f1ca6..000000000 --- a/cli/src/application/use-cases/global/update-all-use-case.ts +++ /dev/null @@ -1,102 +0,0 @@ -import { Manifest } from "../../../domain/models/manifest.js"; -import type { ManifestRepository } from "../../../domain/ports/manifest-repository.js"; -import type { VersionReader } from "../../../domain/ports/version-reader.js"; -import type { ToolId } from "../../../domain/tools/registry.js"; -import type { MarketplaceRefreshUseCase } from "../marketplace/marketplace-refresh-use-case.js"; -import type { PluginUpdateUseCase } from "../plugin/plugin-update-use-case.js"; -import { BulkConflictState } from "../shared/resolve-update-decision-use-case.js"; -import type { - GlobalExecutionError, - UpdateOneToolUseCase, -} from "../shared/update-one-tool-use-case.js"; - -export type { GlobalExecutionError }; - -export interface UpdateAllInput { - projectRoot: string; - userForce: boolean; - interactive: boolean; -} - -export interface UpdateAllResult { - updatedTools: { toolId: ToolId; fileCount: number }[]; - updatedPlugins: string[]; - marketplaceRefreshFailed: boolean; - errors: GlobalExecutionError[]; -} - -export class UpdateAllUseCase { - constructor( - private readonly manifestRepo: ManifestRepository, - private readonly versionReader: VersionReader, - private readonly pluginUpdateUseCase: PluginUpdateUseCase, - private readonly marketplaceRefreshUseCase: MarketplaceRefreshUseCase, - private readonly updateOneToolUseCase: UpdateOneToolUseCase - ) {} - - async execute(input: UpdateAllInput): Promise { - const { projectRoot, userForce, interactive } = input; - const manifest = (await this.manifestRepo.load()) ?? Manifest.create(); - const version = this.versionReader.get(); - const errors: GlobalExecutionError[] = []; - const bulkState = new BulkConflictState(); - const updatedTools = await this.updateTools(manifest, projectRoot, version, errors, { - userForce, - interactive, - bulkState, - }); - const updatedPlugins = await this.updatePlugins(projectRoot, errors); - const marketplaceRefreshFailed = await this.refreshMarketplaces(projectRoot, errors); - return { updatedTools, updatedPlugins, marketplaceRefreshFailed, errors }; - } - - private async updateTools( - manifest: Manifest, - projectRoot: string, - version: string, - errors: GlobalExecutionError[], - options: { userForce: boolean; interactive: boolean; bulkState: BulkConflictState } - ): Promise<{ toolId: ToolId; fileCount: number }[]> { - const updated: { toolId: ToolId; fileCount: number }[] = []; - for (const toolId of manifest.getInstalledToolIds()) { - const entry = await this.updateOneToolUseCase.execute( - toolId, - manifest, - projectRoot, - version, - errors, - options - ); - if (entry) updated.push(entry); - } - return updated; - } - - private async updatePlugins( - projectRoot: string, - errors: GlobalExecutionError[] - ): Promise { - try { - return await this.pluginUpdateUseCase.execute({ toolIds: "all", projectRoot }); - } catch (err) { - errors.push({ scope: "plugins", message: err instanceof Error ? err.message : String(err) }); - return []; - } - } - - private async refreshMarketplaces( - projectRoot: string, - errors: GlobalExecutionError[] - ): Promise { - try { - const { failedCount } = await this.marketplaceRefreshUseCase.execute({ projectRoot }); - return failedCount > 0; - } catch (err) { - errors.push({ - scope: "marketplace-refresh", - message: err instanceof Error ? err.message : String(err), - }); - return true; - } - } -} diff --git a/cli/src/application/use-cases/global/update-ide-tools-use-case.ts b/cli/src/application/use-cases/global/update-ide-tools-use-case.ts deleted file mode 100644 index 495558d86..000000000 --- a/cli/src/application/use-cases/global/update-ide-tools-use-case.ts +++ /dev/null @@ -1,20 +0,0 @@ -import type { IdeToolId } from "../../../domain/models/tool-ids.js"; -import type { ManifestRepository } from "../../../domain/ports/manifest-repository.js"; -import type { VersionReader } from "../../../domain/ports/version-reader.js"; -import { isIdeToolId } from "../../../domain/tools/registry.js"; -import type { UpdateOneToolUseCase } from "../shared/update-one-tool-use-case.js"; -import type { UpdateToolsInput, UpdateToolsResult } from "./update-tools-use-case.js"; -import { UpdateToolsUseCase } from "./update-tools-use-case.js"; - -export type UpdateIdeToolsInput = UpdateToolsInput; -export type UpdateIdeToolsResult = UpdateToolsResult; - -export class UpdateIdeToolsUseCase extends UpdateToolsUseCase { - constructor( - manifestRepo: ManifestRepository, - versionReader: VersionReader, - updateOneToolUseCase: UpdateOneToolUseCase - ) { - super(manifestRepo, versionReader, updateOneToolUseCase, isIdeToolId); - } -} diff --git a/cli/src/application/use-cases/global/update-tools-use-case.ts b/cli/src/application/use-cases/global/update-tools-use-case.ts deleted file mode 100644 index 4f2c1113d..000000000 --- a/cli/src/application/use-cases/global/update-tools-use-case.ts +++ /dev/null @@ -1,88 +0,0 @@ -import { Manifest } from "../../../domain/models/manifest.js"; -import type { ManifestRepository } from "../../../domain/ports/manifest-repository.js"; -import type { VersionReader } from "../../../domain/ports/version-reader.js"; -import type { ToolId } from "../../../domain/tools/registry.js"; -import { BulkConflictState } from "../shared/resolve-update-decision-use-case.js"; -import type { - GlobalExecutionError, - UpdateOneToolUseCase, -} from "../shared/update-one-tool-use-case.js"; - -export interface UpdateToolsInput { - toolArg?: T; - projectRoot: string; - userForce: boolean; - interactive: boolean; -} - -export interface UpdateToolsResult { - updatedTools: { toolId: ToolId; fileCount: number }[]; - errors: GlobalExecutionError[]; -} - -/** - * Fans out an update across every installed tool of one category (AI or IDE). - * The category is fixed by the `isTargetToolId` predicate injected at construction, - * so subclasses only need to supply that predicate — the orchestration is identical - * for both categories. - */ -export class UpdateToolsUseCase { - constructor( - private readonly manifestRepo: ManifestRepository, - private readonly versionReader: VersionReader, - private readonly updateOneToolUseCase: UpdateOneToolUseCase, - private readonly isTargetToolId: (id: string) => id is T - ) {} - - async execute(input: UpdateToolsInput): Promise { - const { toolArg, projectRoot, userForce, interactive } = input; - const manifest = (await this.manifestRepo.load()) ?? Manifest.create(); - const targetIds = this.resolveTargetIds(manifest, toolArg); - const version = this.versionReader.get(); - const errors: GlobalExecutionError[] = []; - // Scoped to this invocation only: a fresh instance per execute() call, never a field, - // so an "overwrite all" choice cannot leak into a later, unrelated update run. - const bulkState = new BulkConflictState(); - const updatedTools = await this.updateTargets( - targetIds, - manifest, - projectRoot, - version, - errors, - { - userForce, - interactive, - bulkState, - } - ); - return { updatedTools, errors }; - } - - private resolveTargetIds(manifest: Manifest, toolArg: T | undefined): T[] { - if (toolArg !== undefined) return [toolArg]; - return manifest.getInstalledToolIds().filter(this.isTargetToolId); - } - - private async updateTargets( - targetIds: T[], - manifest: Manifest, - projectRoot: string, - version: string, - errors: GlobalExecutionError[], - options: { userForce: boolean; interactive: boolean; bulkState: BulkConflictState } - ): Promise<{ toolId: ToolId; fileCount: number }[]> { - const updated: { toolId: ToolId; fileCount: number }[] = []; - for (const toolId of targetIds) { - const entry = await this.updateOneToolUseCase.execute( - toolId, - manifest, - projectRoot, - version, - errors, - options - ); - if (entry) updated.push(entry); - } - return updated; - } -} diff --git a/cli/src/application/use-cases/init-use-case.ts b/cli/src/application/use-cases/init-use-case.ts deleted file mode 100644 index 4b5f77e63..000000000 --- a/cli/src/application/use-cases/init-use-case.ts +++ /dev/null @@ -1,76 +0,0 @@ -import { Manifest } from "../../domain/models/manifest.js"; -import { AIDD_DIR } from "../../domain/models/paths.js"; -import type { FileReader } from "../../domain/ports/file-reader.js"; -import type { FileWriter } from "../../domain/ports/file-writer.js"; -import type { ManifestRepository } from "../../domain/ports/manifest-repository.js"; -import { getAllRegisteredTools, hasToolSignals } from "../../domain/tools/registry.js"; -import { AiddFilesDetectedError, AlreadyInitializedError, NoManifestError } from "../errors.js"; -import { GitignoreUseCase } from "./shared/gitignore-use-case.js"; - -interface InitOptions { - projectRoot: string; - force?: boolean; -} - -interface InitResult { - manifest: Manifest; -} - -export class InitUseCase { - constructor( - private readonly fs: FileReader & FileWriter, - private readonly manifestRepo: ManifestRepository - ) {} - - async checkPreconditions(options: Pick): Promise { - const { projectRoot, force = false } = options; - const existing = await this.manifestRepo.load(); - - if (force) { - if (existing === null) { - throw new NoManifestError(); - } - return; - } - - if (existing !== null) { - throw new AlreadyInitializedError( - `Already initialized. Use \`aidd init --force\` to reinitialize, or \`aidd clean --force\` to reset completely.` - ); - } - - if (await this.hasAiddSignals(projectRoot)) { - throw new AiddFilesDetectedError(); - } - } - - private async hasAiddSignals(projectRoot: string): Promise { - for (const tool of getAllRegisteredTools().values()) { - if ((await hasToolSignals(this.fs, tool, projectRoot)).length > 0) return true; - } - return false; - } - - async execute(options: InitOptions): Promise { - const { projectRoot, force = false } = options; - - const existing = await this.manifestRepo.load(); - await this.checkPreconditions({ projectRoot, force }); - - const manifest = force && existing !== null ? existing : Manifest.create(); - await this.persistInit(manifest, projectRoot, force); - return { manifest }; - } - - /** Saves manifest and conditionally adds gitignore entry. */ - private async persistInit( - manifest: Manifest, - projectRoot: string, - force: boolean - ): Promise { - await this.manifestRepo.save(manifest); - if (!force) { - await new GitignoreUseCase(this.fs).execute(projectRoot, [`${AIDD_DIR}/cache/`]); - } - } -} diff --git a/cli/src/application/use-cases/install/install-agents-use-case.ts b/cli/src/application/use-cases/install/install-agents-use-case.ts deleted file mode 100644 index f9a9b6fc3..000000000 --- a/cli/src/application/use-cases/install/install-agents-use-case.ts +++ /dev/null @@ -1,36 +0,0 @@ -import type { AgentsCapability } from "../../../domain/capabilities/agents-capability.js"; -import type { InstallationFile } from "../../../domain/models/file.js"; -import type { ContentSection } from "../../../domain/models/framework.js"; -import type { Hasher } from "../../../domain/ports/hasher.js"; -import type { AiTool, HasAgents } from "../../../domain/tools/contracts.js"; -import { - type ContentSectionDescriptor, - InstallContentSectionUseCase, -} from "./install-content-section-use-case.js"; - -const agentsDescriptor: ContentSectionDescriptor<"agents", AgentsCapability> = { - key: "agents", - acceptsFileName: (cap, fileName, allToolSuffixes) => - cap.acceptsFileName(fileName, allToolSuffixes), - convertFrontmatter: (cap, frontmatter, relativeFileName) => - cap.convertFrontmatter(frontmatter, relativeFileName), -}; - -interface InstallAgentsOptions { - toolConfig: AiTool; - section: ContentSection; - contentFiles: Map; - docsDir: string; -} - -export class InstallAgentsUseCase { - private readonly inner: InstallContentSectionUseCase<"agents", AgentsCapability>; - - constructor(hasher: Hasher) { - this.inner = new InstallContentSectionUseCase(hasher, agentsDescriptor); - } - - execute(options: InstallAgentsOptions): InstallationFile[] { - return this.inner.execute(options); - } -} diff --git a/cli/src/application/use-cases/install/install-commands-use-case.ts b/cli/src/application/use-cases/install/install-commands-use-case.ts deleted file mode 100644 index c0efea8ae..000000000 --- a/cli/src/application/use-cases/install/install-commands-use-case.ts +++ /dev/null @@ -1,35 +0,0 @@ -import type { CommandsCapability } from "../../../domain/capabilities/commands-capability.js"; -import type { InstallationFile } from "../../../domain/models/file.js"; -import type { ContentSection } from "../../../domain/models/framework.js"; -import type { Hasher } from "../../../domain/ports/hasher.js"; -import type { AiTool, HasCommands } from "../../../domain/tools/contracts.js"; -import { - type ContentSectionDescriptor, - InstallContentSectionUseCase, -} from "./install-content-section-use-case.js"; - -const commandsDescriptor: ContentSectionDescriptor<"commands", CommandsCapability> = { - key: "commands", - acceptsFileName: (cap, fileName) => cap.acceptsFileName(fileName), - convertFrontmatter: (cap, frontmatter, relativeFileName) => - cap.convertFrontmatter(frontmatter, relativeFileName), -}; - -interface InstallCommandsOptions { - toolConfig: AiTool; - section: ContentSection; - contentFiles: Map; - docsDir: string; -} - -export class InstallCommandsUseCase { - private readonly inner: InstallContentSectionUseCase<"commands", CommandsCapability>; - - constructor(hasher: Hasher) { - this.inner = new InstallContentSectionUseCase(hasher, commandsDescriptor); - } - - execute(options: InstallCommandsOptions): InstallationFile[] { - return this.inner.execute(options); - } -} diff --git a/cli/src/application/use-cases/install/install-content-section-use-case.ts b/cli/src/application/use-cases/install/install-content-section-use-case.ts deleted file mode 100644 index 93c0bc83e..000000000 --- a/cli/src/application/use-cases/install/install-content-section-use-case.ts +++ /dev/null @@ -1,131 +0,0 @@ -import { parseFrontmatter } from "../../../domain/formats/markdown.js"; -import { InstallationFile } from "../../../domain/models/file.js"; -import type { ContentSection } from "../../../domain/models/framework.js"; -import { GITKEEP_FILE } from "../../../domain/models/framework.js"; -import type { Hasher } from "../../../domain/ports/hasher.js"; -import type { AiTool, UserFileSection } from "../../../domain/tools/contracts.js"; -import { AI_TOOL_IDS } from "../../../domain/tools/registry.js"; - -const ALL_TOOL_SUFFIXES: readonly string[] = AI_TOOL_IDS.map((id) => `.${id}.md`); - -/** - * Shape every content-section capability (agents/commands/rules/skills) exposes - * with identical arity, so this engine can call them directly without per-section - * branching. Where arity genuinely differs (acceptsFileName, convertFrontmatter), - * a ContentSectionDescriptor supplies the per-section adapter instead. - */ -export interface ContentSectionCapability { - buildInstallPath(fileName: string): string | null; - serialize(frontmatter: Record, body: string): string; -} - -/** - * Per-section behaviour that cannot be expressed with a uniform signature across - * agents/commands/rules/skills capabilities. `key` also drives which capability - * is read off `toolConfig.capabilities`, keeping K, Cap and the toolConfig type - * correlated through generics instead of an `as` cast at the call site. - */ -export interface ContentSectionDescriptor< - K extends UserFileSection, - Cap extends ContentSectionCapability, -> { - readonly key: K; - acceptsFileName(cap: Cap, fileName: string, allToolSuffixes: readonly string[]): boolean; - convertFrontmatter( - cap: Cap, - frontmatter: Record, - relativeFileName: string - ): Record; -} - -export interface InstallContentSectionOptions< - K extends UserFileSection, - Cap extends ContentSectionCapability, -> { - toolConfig: AiTool>; - section: ContentSection; - contentFiles: Map; - docsDir: string; -} - -export class InstallContentSectionUseCase< - K extends UserFileSection, - Cap extends ContentSectionCapability, -> { - constructor( - private readonly hasher: Hasher, - private readonly descriptor: ContentSectionDescriptor - ) {} - - execute(options: InstallContentSectionOptions): InstallationFile[] { - const { toolConfig, section, contentFiles, docsDir } = options; - const cap = toolConfig.capabilities[this.descriptor.key]; - const results: InstallationFile[] = []; - for (const [filePath, rawContent] of contentFiles) { - const file = this.processFile(filePath, rawContent, section, cap, toolConfig, docsDir); - if (file !== null) results.push(file); - } - return results; - } - - private processFile( - filePath: string, - rawContent: string, - section: ContentSection, - cap: Cap, - toolConfig: AiTool>, - docsDir: string - ): InstallationFile | null { - if (!filePath.startsWith(`${section.directory}/`)) return null; - const relativeFileName = filePath.slice(`${section.directory}/`.length); - if (!this.descriptor.acceptsFileName(cap, relativeFileName, ALL_TOOL_SUFFIXES)) return null; - if (section.entryFile !== null) { - const basename = relativeFileName.split("/").at(-1) ?? relativeFileName; - if (basename !== section.entryFile) return null; - } - const outputPath = cap.buildInstallPath(relativeFileName); - if (outputPath === null) return null; - if (relativeFileName.endsWith(GITKEEP_FILE)) { - return new InstallationFile({ - relativePath: outputPath, - content: "", - hash: this.hasher.hash(""), - frameworkPath: filePath, - }); - } - return this.buildFile( - filePath, - outputPath, - relativeFileName, - rawContent, - cap, - toolConfig, - docsDir - ); - } - - private buildFile( - filePath: string, - outputPath: string, - relativeFileName: string, - rawContent: string, - cap: Cap, - toolConfig: AiTool>, - docsDir: string - ): InstallationFile { - const rewrittenRaw = toolConfig.rewriteContent(rawContent, docsDir); - const { frontmatter, body } = parseFrontmatter(rewrittenRaw); - const convertedFrontmatter = this.descriptor.convertFrontmatter( - cap, - frontmatter, - relativeFileName - ); - const outputContent = cap.serialize(convertedFrontmatter, body); - return new InstallationFile({ - relativePath: outputPath, - content: outputContent, - hash: this.hasher.hash(outputContent), - frameworkPath: filePath, - }); - } -} diff --git a/cli/src/application/use-cases/install/install-rules-use-case.ts b/cli/src/application/use-cases/install/install-rules-use-case.ts deleted file mode 100644 index be4819a1c..000000000 --- a/cli/src/application/use-cases/install/install-rules-use-case.ts +++ /dev/null @@ -1,34 +0,0 @@ -import type { RulesCapability } from "../../../domain/capabilities/rules-capability.js"; -import type { InstallationFile } from "../../../domain/models/file.js"; -import type { ContentSection } from "../../../domain/models/framework.js"; -import type { Hasher } from "../../../domain/ports/hasher.js"; -import type { AiTool, HasRules } from "../../../domain/tools/contracts.js"; -import { - type ContentSectionDescriptor, - InstallContentSectionUseCase, -} from "./install-content-section-use-case.js"; - -const rulesDescriptor: ContentSectionDescriptor<"rules", RulesCapability> = { - key: "rules", - acceptsFileName: (cap, fileName) => cap.acceptsFileName(fileName), - convertFrontmatter: (cap, frontmatter) => cap.convertFrontmatter(frontmatter), -}; - -interface InstallRulesOptions { - toolConfig: AiTool; - section: ContentSection; - contentFiles: Map; - docsDir: string; -} - -export class InstallRulesUseCase { - private readonly inner: InstallContentSectionUseCase<"rules", RulesCapability>; - - constructor(hasher: Hasher) { - this.inner = new InstallContentSectionUseCase(hasher, rulesDescriptor); - } - - execute(options: InstallRulesOptions): InstallationFile[] { - return this.inner.execute(options); - } -} diff --git a/cli/src/application/use-cases/install/install-skills-use-case.ts b/cli/src/application/use-cases/install/install-skills-use-case.ts deleted file mode 100644 index f31f4acd3..000000000 --- a/cli/src/application/use-cases/install/install-skills-use-case.ts +++ /dev/null @@ -1,34 +0,0 @@ -import type { SkillsCapability } from "../../../domain/capabilities/skills-capability.js"; -import type { InstallationFile } from "../../../domain/models/file.js"; -import type { ContentSection } from "../../../domain/models/framework.js"; -import type { Hasher } from "../../../domain/ports/hasher.js"; -import type { AiTool, HasSkills } from "../../../domain/tools/contracts.js"; -import { - type ContentSectionDescriptor, - InstallContentSectionUseCase, -} from "./install-content-section-use-case.js"; - -const skillsDescriptor: ContentSectionDescriptor<"skills", SkillsCapability> = { - key: "skills", - acceptsFileName: (cap, fileName) => cap.acceptsFileName(fileName), - convertFrontmatter: (cap, frontmatter) => cap.convertFrontmatter(frontmatter), -}; - -interface InstallSkillsOptions { - toolConfig: AiTool; - section: ContentSection; - contentFiles: Map; - docsDir: string; -} - -export class InstallSkillsUseCase { - private readonly inner: InstallContentSectionUseCase<"skills", SkillsCapability>; - - constructor(hasher: Hasher) { - this.inner = new InstallContentSectionUseCase(hasher, skillsDescriptor); - } - - execute(options: InstallSkillsOptions): InstallationFile[] { - return this.inner.execute(options); - } -} diff --git a/cli/src/application/use-cases/list-installed-rules-use-case.ts b/cli/src/application/use-cases/list-installed-rules-use-case.ts deleted file mode 100644 index e8363bf7e..000000000 --- a/cli/src/application/use-cases/list-installed-rules-use-case.ts +++ /dev/null @@ -1,75 +0,0 @@ -import { join, relative } from "node:path"; -import type { InstalledRule } from "../../domain/models/installed-rule.js"; -import { toInstalledRule } from "../../domain/models/installed-rule.js"; -import { AI_TOOL_IDS, type AiToolId } from "../../domain/models/tool-ids.js"; -import type { FileReader } from "../../domain/ports/file-reader.js"; -import { hasRules } from "../../domain/tools/contracts.js"; -import { getToolConfig, isAiTool } from "../../domain/tools/registry.js"; - -export interface ListInstalledRulesInput { - readonly projectRoot: string; -} - -export interface ListInstalledRulesResult { - readonly rules: readonly InstalledRule[]; -} - -/** Where this tool's installed rules live, asked of the tool. `undefined` for one that - * registers no rules capability at all, and for one whose installer answers no path — both - * mean there is nothing to scan, and neither is a directory guessed here. */ -function locationOf(toolId: AiToolId): { directory: string; extension: string } | undefined { - const tool = getToolConfig(toolId); - if (!isAiTool(tool) || !hasRules(tool)) return undefined; - return tool.capabilities.rules.installedLocation() ?? undefined; -} - -/** `/`-separated whatever the platform hands back, because the path is data a caller reads - * and compares, not a path it opens. A Windows checkout answering `.claude\rules\a.md` - * would make the same project's rules read differently on two machines. */ -function projectRelative(projectRoot: string, absolutePath: string): string { - return relative(projectRoot, absolutePath).replaceAll("\\", "/"); -} - -/** - * Every rule installed in a project, across every tool that installs any. - * - * Replaces `list-rules.mjs`, which the explore skill shipped and ran directly. That script - * carried its own table of tool directories and extensions, and its own frontmatter parser; - * both already existed here, and the table had drifted — it knew four tools and stated that - * Codex supports no rules, while `plugin-content-translator.ts` installs a plugin's `rules/` - * into every tool whose capability accepts them. Asking each tool where it installs is what - * makes a fifth tool, or a moved directory, impossible to miss. - */ -export class ListInstalledRulesUseCase { - constructor(private readonly files: FileReader) {} - - async execute(input: ListInstalledRulesInput): Promise { - const rules: InstalledRule[] = []; - for (const toolId of AI_TOOL_IDS) { - const location = locationOf(toolId); - if (location === undefined) continue; - rules.push(...(await this.rulesUnder(input.projectRoot, toolId, location))); - } - return { rules }; - } - - /** A directory that is not there yields nothing: `listFilesRecursive` answers an empty - * list for one it cannot read, so a project with a single tool installed is the ordinary - * case here and not a branch. */ - private async rulesUnder( - projectRoot: string, - toolId: AiToolId, - location: { directory: string; extension: string } - ): Promise { - const absolute = join(projectRoot, location.directory); - const found = await this.files.listFilesRecursive(absolute); - const rules: InstalledRule[] = []; - for (const file of found.filter((path) => path.endsWith(location.extension))) { - const content = await this.files.readFile(file); - rules.push( - toInstalledRule(toolId, projectRelative(projectRoot, file), location.extension, content) - ); - } - return rules; - } -} diff --git a/cli/src/application/use-cases/marketplace/marketplace-register-framework-use-case.ts b/cli/src/application/use-cases/marketplace/marketplace-register-framework-use-case.ts deleted file mode 100644 index bae447edf..000000000 --- a/cli/src/application/use-cases/marketplace/marketplace-register-framework-use-case.ts +++ /dev/null @@ -1,50 +0,0 @@ -import { FRAMEWORK_MARKETPLACE_NAME, Marketplace } from "../../../domain/models/marketplace.js"; -import type { PluginSource } from "../../../domain/models/plugin-source.js"; -import type { MarketplaceRegistry } from "../../../domain/ports/marketplace-registry.js"; - -export interface MarketplaceRegisterFrameworkOptions { - projectRoot: string; - force?: boolean; - frameworkPath?: string; - /** Explicit plugin source — when provided, deriveSource() is skipped. */ - pluginSource?: PluginSource; -} - -export interface MarketplaceRegisterFrameworkResult { - registered: boolean; -} - -/** Registering the bundled framework marketplace, as its callers need it. */ -export interface MarketplaceRegisterFramework { - execute( - options: MarketplaceRegisterFrameworkOptions - ): Promise; -} - -export class MarketplaceRegisterFrameworkUseCase implements MarketplaceRegisterFramework { - constructor(private readonly registry: MarketplaceRegistry) {} - - async execute( - options: MarketplaceRegisterFrameworkOptions - ): Promise { - const list = await this.registry.list(options.projectRoot); - const alreadyRegistered = list.some((m) => m.name === FRAMEWORK_MARKETPLACE_NAME); - if (alreadyRegistered && !options.force) return { registered: false }; - if (alreadyRegistered && options.force) { - await this.registry.delete(options.projectRoot, FRAMEWORK_MARKETPLACE_NAME, "project"); - } - const source = options.pluginSource ?? this.deriveSource(options.frameworkPath); - const marketplace = Marketplace.create({ - name: FRAMEWORK_MARKETPLACE_NAME, - source, - scope: "project", - addedAt: new Date().toISOString(), - }); - await this.registry.save(options.projectRoot, marketplace); - return { registered: true }; - } - - private deriveSource(frameworkPath?: string): PluginSource { - return { kind: "local", path: frameworkPath ?? "." }; - } -} diff --git a/cli/src/application/use-cases/marketplace/marketplace-remove-use-case.ts b/cli/src/application/use-cases/marketplace/marketplace-remove-use-case.ts deleted file mode 100644 index 73a16dddf..000000000 --- a/cli/src/application/use-cases/marketplace/marketplace-remove-use-case.ts +++ /dev/null @@ -1,96 +0,0 @@ -import { dirname, join } from "node:path"; -import { MarketplaceNotFoundError } from "../../../domain/errors.js"; -import type { Manifest } from "../../../domain/models/manifest.js"; -import type { Marketplace } from "../../../domain/models/marketplace.js"; -import type { Plugin } from "../../../domain/models/plugin.js"; -import { AI_TOOL_IDS, type AiToolId } from "../../../domain/models/tool-ids.js"; -import type { FileWriter } from "../../../domain/ports/file-writer.js"; -import type { ManifestRepository } from "../../../domain/ports/manifest-repository.js"; -import type { MarketplaceRegistry } from "../../../domain/ports/marketplace-registry.js"; -import type { Prompter } from "../../../domain/ports/prompter.js"; - -export interface MarketplaceRemoveOptions { - name: string; - projectRoot: string; - autoConfirm: boolean; -} - -export interface MarketplaceRemoveResult { - marketplace: Marketplace; - removedPluginCount: number; - orphanCount: number; -} - -interface OrphanRef { - toolId: AiToolId; - plugin: Plugin; -} - -export class MarketplaceRemoveUseCase { - constructor( - private readonly fs: FileWriter, - private readonly manifestRepo: ManifestRepository, - private readonly registry: MarketplaceRegistry, - private readonly prompter: Prompter - ) {} - - async execute(options: MarketplaceRemoveOptions): Promise { - const marketplace = await this.findOrThrow(options.projectRoot, options.name); - const manifest = await this.manifestRepo.load(); - const orphans = manifest ? this.collectOrphans(manifest, options.name) : []; - const cleanup = await this.shouldCleanup(orphans.length, options.autoConfirm); - let removed = 0; - if (cleanup && manifest) { - removed = await this.removeOrphans(manifest, orphans, options.projectRoot); - } - await this.registry.delete(options.projectRoot, marketplace.name, marketplace.scope); - return { marketplace, removedPluginCount: removed, orphanCount: orphans.length }; - } - - private async findOrThrow(projectRoot: string, name: string): Promise { - const list = await this.registry.list(projectRoot); - const found = list.find((m) => m.name === name); - if (!found) throw new MarketplaceNotFoundError(name); - return found; - } - - private collectOrphans(manifest: Manifest, marketplaceName: string): OrphanRef[] { - const orphans: OrphanRef[] = []; - for (const toolId of AI_TOOL_IDS) { - for (const plugin of manifest.getPlugins(toolId)) { - if (plugin.marketplace === marketplaceName) orphans.push({ toolId, plugin }); - } - } - return orphans; - } - - private async shouldCleanup(count: number, autoConfirm: boolean): Promise { - if (count === 0) return false; - if (autoConfirm) return true; - return this.prompter.confirm(`Remove ${count} plugin(s) installed from this marketplace?`); - } - - private async removeOrphans( - manifest: Manifest, - orphans: readonly OrphanRef[], - projectRoot: string - ): Promise { - for (const { toolId, plugin } of orphans) { - await this.deletePluginFiles(plugin.files, projectRoot); - manifest.removePlugin(toolId, plugin.name); - } - await this.manifestRepo.save(manifest); - return orphans.length; - } - - private async deletePluginFiles( - files: ReadonlyMap, - projectRoot: string - ): Promise { - for (const relativePath of files.keys()) { - const fullPath = join(projectRoot, relativePath); - await this.fs.deleteFile(fullPath); - await this.fs.deleteEmptyDirectories(dirname(fullPath)); - } - } -} diff --git a/cli/src/application/use-cases/marketplace/marketplace-sync-settings-use-case.ts b/cli/src/application/use-cases/marketplace/marketplace-sync-settings-use-case.ts deleted file mode 100644 index 0772bceeb..000000000 --- a/cli/src/application/use-cases/marketplace/marketplace-sync-settings-use-case.ts +++ /dev/null @@ -1,547 +0,0 @@ -import { resolve } from "node:path"; -import type { MarketplaceSettings } from "../../../domain/capabilities/plugins-capability.js"; -import { NativePluginCliError } from "../../../domain/errors.js"; -import type { FrameworkBuildTarget } from "../../../domain/models/framework-build.js"; -import type { Manifest } from "../../../domain/models/manifest.js"; -import type { Marketplace } from "../../../domain/models/marketplace.js"; -import { marketplaceCacheDir } from "../../../domain/models/paths.js"; -import type { PluginSource } from "../../../domain/models/plugin-source.js"; -import type { ToolId } from "../../../domain/models/tool-ids.js"; -import type { FileReader } from "../../../domain/ports/file-reader.js"; -import type { FileWriter } from "../../../domain/ports/file-writer.js"; -import type { Hasher } from "../../../domain/ports/hasher.js"; -import type { Logger } from "../../../domain/ports/logger.js"; -import type { ManifestRepository } from "../../../domain/ports/manifest-repository.js"; -import type { MarketplaceRegistry } from "../../../domain/ports/marketplace-registry.js"; -import type { NativePluginActivator } from "../../../domain/ports/native-plugin-activator.js"; -import type { PluginCatalogRepository } from "../../../domain/ports/plugin-catalog-repository.js"; -import { getToolConfig, isAiTool } from "../../../domain/tools/registry.js"; -import type { EnsureBuiltMarketplace } from "../shared/ensure-built-marketplace-use-case.js"; - -export interface MarketplaceSyncSettingsOptions { - projectRoot: string; -} - -export interface MarketplaceSyncSettingsResult { - updatedTools: string[]; -} - -// Upserts local marketplace entries (absolute path may change); never removes entries; skips non-local if already present. -/** Syncing marketplace settings into the tools that read them, as its callers need it. */ -export interface MarketplaceSyncSettings { - execute(options: MarketplaceSyncSettingsOptions): Promise; -} - -export class MarketplaceSyncSettingsUseCase implements MarketplaceSyncSettings { - constructor( - private readonly fs: FileReader & FileWriter, - private readonly manifestRepo: ManifestRepository, - private readonly marketplaceRegistry: MarketplaceRegistry, - private readonly catalogRepo: PluginCatalogRepository, - private readonly hasher: Hasher, - private readonly logger: Logger, - /** Native plugin CLI activators keyed by `NativeActivation.binary` (e.g. "codex", "copilot"). */ - private readonly activators: ReadonlyMap, - private readonly ensureBuilt: EnsureBuiltMarketplace - ) {} - - async execute(options: MarketplaceSyncSettingsOptions): Promise { - const { projectRoot } = options; - const [manifest, marketplaces] = await Promise.all([ - this.manifestRepo.load().catch(() => null), - this.marketplaceRegistry.list(projectRoot), - ]); - if (manifest === null || marketplaces.length === 0) return { updatedTools: [] }; - const updatedTools: string[] = []; - for (const toolId of manifest.getInstalledToolIds()) { - const updated = await this.syncTool(toolId, projectRoot, manifest, marketplaces); - if (updated) updatedTools.push(toolId); - } - if (updatedTools.length > 0) await this.manifestRepo.save(manifest); - const activated = await this.activateNativeTools(projectRoot, manifest, marketplaces); - if (await this.recordWhatActivationWrote(projectRoot, manifest, activated)) - await this.manifestRepo.save(manifest); - return { updatedTools }; - } - - /** Answers the tools whose own CLI actually ran — never every installed tool. A tool whose - * binary is absent, or that has no native activation at all, wrote nothing, so a settings - * file that differs for it differs because a person changed it. Blessing that as ours is - * the one thing this must not do. */ - private async activateNativeTools( - projectRoot: string, - manifest: Manifest, - marketplaces: readonly Marketplace[] - ): Promise { - const activated: ToolId[] = []; - for (const toolId of manifest.getInstalledToolIds()) { - const binary = this.nativeActivationBinary(toolId); - const activator = binary === undefined ? undefined : this.activators.get(binary); - if (binary === undefined || activator === undefined) continue; - const ran = await this.activateTool( - toolId, - binary, - activator, - projectRoot, - manifest, - marketplaces - ); - if (ran) activated.push(toolId); - } - return activated; - } - - /** - * The host's own CLI writes its registration into the very file `syncTool` had just - * hashed — Claude Code declares no separate `enabledPluginsSettingsPath`, so both halves - * land in `.claude/settings.json`. The tracked hash then described content that no longer - * existed, and nothing re-read it: `status` and `doctor` reported a file the person never - * touched as drifted for as long as the manifest stood, and `restore` would have undone - * the host's own registration to reach a state AIDD held for the length of one function. - * - * Re-read rather than re-derive, and only for a tool whose CLI actually ran: what is - * stored is what is on disk after the write, which is the observation, not a guess at - * what the host would have written. - */ - private async recordWhatActivationWrote( - projectRoot: string, - manifest: Manifest, - activated: readonly ToolId[] - ): Promise { - let changed = false; - for (const toolId of activated) { - const settingsPath = this.marketplaceSettingsOf(toolId)?.settingsPath; - if (settingsPath === undefined) continue; - const tracked = manifest - .getToolFiles(toolId) - .find((file) => file.relativePath === settingsPath); - if (tracked === undefined) continue; - const content = await this.fs.readFile(resolve(projectRoot, settingsPath)).catch(() => null); - if (content === null) continue; - const hash = this.hasher.hash(content); - if (hash.value === tracked.hash.value) continue; - manifest.updateTrackedFileHash(toolId, settingsPath, hash); - changed = true; - } - return changed; - } - - private marketplaceSettingsOf(toolId: ToolId): MarketplaceSettings | undefined { - const toolConfig = getToolConfig(toolId); - if (toolConfig === undefined || !isAiTool(toolConfig)) return undefined; - const caps = toolConfig.capabilities as { - plugins?: { marketplaceSettings: MarketplaceSettings | null }; - }; - return caps.plugins?.marketplaceSettings ?? undefined; - } - - private nativeActivationBinary(toolId: ToolId): string | undefined { - const toolConfig = getToolConfig(toolId); - if (toolConfig === undefined || !isAiTool(toolConfig)) return undefined; - const caps = toolConfig.capabilities as { - plugins?: { nativeActivation?: { binary: string } | null }; - }; - return caps.plugins?.nativeActivation?.binary ?? undefined; - } - - /** True when this tool's own CLI was actually driven — the only case in which the settings - * file may have been written by anything but this code. */ - private async activateTool( - toolId: ToolId, - binary: string, - activator: NativePluginActivator, - projectRoot: string, - manifest: Manifest, - marketplaces: readonly Marketplace[] - ): Promise { - const { refs, marketplaces: used } = this.pluginActivation(toolId, manifest, marketplaces); - if (refs.length === 0) return false; - if (!activator.isAvailable()) { - this.logger.warn(`${binary} CLI not found on PATH — skipping native plugin activation.`); - return false; - } - // Each step is independently best-effort: one failing plugin or marketplace - // must warn and let the others through, never abort the whole activation. - for (const marketplace of used) - await this.registerMarketplace(activator, toolId, marketplace, projectRoot); - this.bestEffort(() => activator.upgradeMarketplaces(), "upgrade marketplaces"); - for (const ref of refs) { - this.bestEffort(() => activator.enablePlugin(ref), `enable plugin '${ref}'`); - } - return true; - } - - private bestEffort(action: () => void, label: string): void { - try { - action(); - } catch (error) { - if (!(error instanceof NativePluginCliError)) throw error; - this.logger.warn(`Native plugin activation — ${label} skipped: ${error.message}`); - } - } - - private pluginActivation( - toolId: ToolId, - manifest: Manifest, - marketplaces: readonly Marketplace[] - ): { refs: string[]; marketplaces: Marketplace[] } { - const byName = new Map(marketplaces.map((m) => [m.name, m])); - const refs: string[] = []; - const used = new Map(); - for (const plugin of manifest.getPlugins(toolId)) { - const marketplace = plugin.marketplace == null ? undefined : byName.get(plugin.marketplace); - if (marketplace === undefined) continue; - refs.push(`${plugin.name}@${marketplace.name}`); - used.set(marketplace.name, marketplace); - } - return { refs, marketplaces: [...used.values()] }; - } - - // Native tools must read the BUILT (transformed) tree, not the raw Claude-format - // source. `add` is idempotent for a fresh or same-source registration; the CLI only - // rejects it when the name is already registered from a DIFFERENT source (e.g. a - // stale raw-source dir left by an older CLI). So add first, and only on that - // conflict remove-then-re-add — never a pre-emptive remove that warns on every - // clean install where there is nothing to unregister. - private async registerMarketplace( - activator: NativePluginActivator, - toolId: ToolId, - marketplace: Marketplace, - projectRoot: string - ): Promise { - const builtDir = await this.buildForTool(toolId, marketplace, projectRoot); - if (builtDir === null) return; - try { - activator.addMarketplace(builtDir); - } catch (error) { - if (!(error instanceof NativePluginCliError)) throw error; - this.reregisterFromDifferentSource(activator, marketplace.name, builtDir); - } - } - - // `add` failed: the name is likely registered from a different source, so swap - // it in place. The remove is speculative — if `add` failed for another reason the - // name may be absent, making a failed remove expected — so trace it at debug, not - // warn. The re-add carries the real signal: it warns with the actual message when - // this was not a recoverable conflict. - private reregisterFromDifferentSource( - activator: NativePluginActivator, - name: string, - builtDir: string - ): void { - try { - activator.removeMarketplace(name); - } catch (error) { - if (!(error instanceof NativePluginCliError)) throw error; - this.logger.debug( - `marketplace '${name}' not unregistered before re-add (likely absent): ${error.message}` - ); - } - this.bestEffort(() => activator.addMarketplace(builtDir), `register marketplace '${name}'`); - } - - private async buildForTool( - toolId: ToolId, - marketplace: Marketplace, - projectRoot: string - ): Promise { - try { - const { builtDir } = await this.ensureBuilt.execute({ - projectRoot, - marketplace, - target: toolId as FrameworkBuildTarget, - mode: "marketplace", - }); - return builtDir; - } catch (error) { - this.logger.warn( - `Native plugin activation — build '${marketplace.name}' for ${toolId} skipped: ${(error as Error).message}` - ); - return null; - } - } - - // Settings entries must reference the BUILT tree (claude reads plugins from it; - // copilot surfaces them as recommendations) so settings match the native CLI install. - private async builtSourcesForTool( - toolId: ToolId, - marketplaces: readonly Marketplace[], - projectRoot: string - ): Promise> { - const result = new Map(); - for (const m of marketplaces) { - const builtDir = await this.buildForTool(toolId, m, projectRoot); - if (builtDir !== null) result.set(m.name, { kind: "local", path: builtDir }); - } - return result; - } - - private async syncTool( - toolId: ToolId, - projectRoot: string, - manifest: Manifest, - marketplaces: readonly Marketplace[] - ): Promise { - const toolConfig = getToolConfig(toolId); - if (toolConfig === undefined || !isAiTool(toolConfig)) return false; - const caps = toolConfig.capabilities as { - plugins?: { marketplaceSettings: MarketplaceSettings | null }; - }; - if (!("plugins" in caps) || caps.plugins?.marketplaceSettings == null) return false; - return this.syncToolSettings( - toolId, - projectRoot, - manifest, - marketplaces, - caps.plugins.marketplaceSettings - ); - } - - private async syncToolSettings( - toolId: ToolId, - projectRoot: string, - manifest: Manifest, - marketplaces: readonly Marketplace[], - settings: MarketplaceSettings - ): Promise { - const versionByName = await this.loadAllVersions(projectRoot, marketplaces); - const marketplaceChanged = await this.syncMarketplacesFile( - toolId, - projectRoot, - manifest, - settings, - marketplaces, - versionByName - ); - const pluginsChanged = - settings.enabledPluginsKey != null - ? await this.syncEnabledPluginsFile( - toolId, - projectRoot, - manifest, - marketplaces, - settings, - versionByName - ) - : false; - return marketplaceChanged || pluginsChanged; - } - - private async syncMarketplacesFile( - toolId: ToolId, - projectRoot: string, - manifest: Manifest, - settings: MarketplaceSettings, - marketplaces: readonly Marketplace[], - versionByName: Map - ): Promise { - const absPath = resolve(projectRoot, settings.settingsPath); - const json = await this.loadSettings(absPath); - const builtSources = await this.builtSourcesForTool(toolId, marketplaces, projectRoot); - if ( - !this.mergeMarketplaces( - json, - settings, - marketplaces, - versionByName, - projectRoot, - builtSources - ) - ) - return false; - const content = JSON.stringify(json, null, 2); - await this.fs.writeFile(absPath, content); - manifest.updateTrackedFileHash(toolId, settings.settingsPath, this.hasher.hash(content)); - return true; - } - - private async syncEnabledPluginsFile( - toolId: ToolId, - projectRoot: string, - manifest: Manifest, - marketplaces: readonly Marketplace[], - settings: MarketplaceSettings, - versionByName: Map - ): Promise { - const pluginsPath = - settings.enabledPluginsSettingsPath ?? resolve(projectRoot, settings.settingsPath); - const json = await this.loadSettings(pluginsPath); - if (!this.mergeEnabledPlugins(json, settings, toolId, manifest, marketplaces, versionByName)) - return false; - const content = JSON.stringify(json, null, 2); - await this.fs.writeFile(pluginsPath, content); - if (settings.enabledPluginsSettingsPath == null) { - manifest.updateTrackedFileHash(toolId, settings.settingsPath, this.hasher.hash(content)); - } - return true; - } - - private mergeMarketplaces( - json: Record, - settings: MarketplaceSettings, - marketplaces: readonly Marketplace[], - versionByName: Map, - projectRoot: string, - builtSources: ReadonlyMap - ): boolean { - if (settings.valueShape === "array") { - return this.mergeMarketplacesArray( - json, - settings, - marketplaces, - versionByName, - projectRoot, - builtSources - ); - } - return this.mergeMarketplacesMap( - json, - settings, - marketplaces, - versionByName, - projectRoot, - builtSources - ); - } - - private mergeMarketplacesArray( - json: Record, - settings: MarketplaceSettings, - marketplaces: readonly Marketplace[], - versionByName: Map, - projectRoot: string, - builtSources: ReadonlyMap - ): boolean { - const existing = this.existingArray(json, settings.settingsKey); - const toAdd: string[] = []; - for (const m of marketplaces) { - const source = this.resolveSourceForSettings( - builtSources.get(m.name) ?? m.source, - projectRoot - ); - const entry = settings.toEntry({ name: m.name, source, version: versionByName.get(m.name) }); - if (entry === null || entry.valueShape !== "array") continue; - if (!existing.includes(entry.value) && !toAdd.includes(entry.value)) { - toAdd.push(entry.value); - } - } - if (toAdd.length === 0) return false; - json[settings.settingsKey] = [...existing, ...toAdd]; - return true; - } - - private mergeMarketplacesMap( - json: Record, - settings: MarketplaceSettings, - marketplaces: readonly Marketplace[], - versionByName: Map, - projectRoot: string, - builtSources: ReadonlyMap - ): boolean { - const existing = this.existingRecord(json, settings.settingsKey); - const toMerge: Record> = {}; - for (const m of marketplaces) { - const source = this.resolveSourceForSettings( - builtSources.get(m.name) ?? m.source, - projectRoot - ); - const entry = settings.toEntry({ name: m.name, source, version: versionByName.get(m.name) }); - if (entry === null || entry.valueShape !== "map" || entry.key in toMerge) continue; - if ( - entry.key in existing && - JSON.stringify(existing[entry.key]) === JSON.stringify(entry.value) - ) { - continue; - } - toMerge[entry.key] = entry.value; - } - if (Object.keys(toMerge).length === 0) return false; - json[settings.settingsKey] = { ...existing, ...toMerge }; - return true; - } - - private mergeEnabledPlugins( - json: Record, - settings: MarketplaceSettings, - toolId: ToolId, - manifest: Manifest, - marketplaces: readonly Marketplace[], - versionByName: Map - ): boolean { - const pluginsKey = settings.enabledPluginsKey; - if (pluginsKey == null) return false; - const existing = this.existingRecord(json, pluginsKey); - const toAdd: Record = {}; - const marketplaceByName = new Map(marketplaces.map((m) => [m.name, m])); - for (const plugin of manifest.getPlugins(toolId)) { - if (plugin.marketplace == null) continue; - const marketplace = marketplaceByName.get(plugin.marketplace); - if (marketplace == null) continue; - const entry = settings.toEntry({ - name: marketplace.name, - source: marketplace.source, - version: versionByName.get(marketplace.name), - }); - if (entry == null || entry.valueShape !== "map") continue; - const key = `${plugin.name}@${entry.key}`; - if (!(key in existing)) toAdd[key] = true; - } - if (Object.keys(toAdd).length === 0) return false; - json[pluginsKey] = { ...existing, ...toAdd }; - return true; - } - - private async loadAllVersions( - projectRoot: string, - marketplaces: readonly Marketplace[] - ): Promise> { - const entries = await Promise.all( - marketplaces.map(async (m) => { - const version = await this.loadCatalogVersion(projectRoot, m.name); - return [m.name, version] as const; - }) - ); - return new Map(entries); - } - - private async loadCatalogVersion( - projectRoot: string, - marketplaceName: string - ): Promise { - const cacheDir = marketplaceCacheDir(projectRoot, marketplaceName); - const catalog = await this.catalogRepo.load(cacheDir).catch(() => null); - return catalog?.version; - } - - private existingRecord( - json: Record, - settingsKey: string - ): Record { - const raw = json[settingsKey]; - if (raw !== null && typeof raw === "object" && !Array.isArray(raw)) { - return raw as Record; - } - return {}; - } - - private existingArray(json: Record, settingsKey: string): string[] { - const raw = json[settingsKey]; - if (Array.isArray(raw)) return raw.filter((v): v is string => typeof v === "string"); - return []; - } - - private resolveSourceForSettings(source: PluginSource, projectRoot: string): PluginSource { - if (source.kind !== "local") return source; - return { kind: "local", path: resolve(projectRoot, source.path).replace(/\\/g, "/") }; - } - - private async loadSettings(absPath: string): Promise> { - if (!(await this.fs.fileExists(absPath))) return {}; - const content = await this.fs.readFile(absPath); - const parsed = JSON.parse(content) as unknown; - if (parsed !== null && typeof parsed === "object" && !Array.isArray(parsed)) { - return parsed as Record; - } - return {}; - } -} diff --git a/cli/src/application/use-cases/menu-use-case.ts b/cli/src/application/use-cases/menu-use-case.ts deleted file mode 100644 index 743e711ee..000000000 --- a/cli/src/application/use-cases/menu-use-case.ts +++ /dev/null @@ -1,366 +0,0 @@ -import type { ManifestRepository } from "../../domain/ports/manifest-repository.js"; -import type { Prompter } from "../../domain/ports/prompter.js"; - -interface MenuLeaf { - name: string; - value: string; - description?: string; - command: string[]; - inputPrompt?: string; - commandSuffix?: string[]; -} - -interface MenuBranch { - name: string; - value: string; - description?: string; - children: MenuNode[]; -} - -type MenuNode = MenuLeaf | MenuBranch; - -function isBranch(node: MenuNode): node is MenuBranch { - return "children" in node; -} - -function toChoice(node: MenuNode): { name: string; value: string; description?: string } { - return { name: node.name, value: node.value, description: node.description }; -} - -const INSTALLED_NODES: MenuNode[] = [ - { - name: "Inspect", - value: "inspect", - description: "Check status, health and installed items", - children: [ - { - name: "Status", - value: "status", - description: "Show installed files and detect drift", - command: ["status"], - }, - { - name: "Doctor", - value: "doctor", - description: "Run a structural health check", - command: ["doctor"], - }, - { - name: "List installed", - value: "list-installed", - description: "List installed tools and plugins", - children: [ - { - name: "AI tools", - value: "ai-list", - description: "Show installed AI tools", - command: ["ai", "list"], - }, - { - name: "IDE tools", - value: "ide-list", - description: "Show installed IDE tools", - command: ["ide", "list"], - }, - { - name: "Plugins", - value: "plugin-list", - description: "Show installed plugins per tool", - command: ["plugin", "list"], - }, - ], - }, - ], - }, - { - name: "Manage AI tools", - value: "manage-ai", - description: "Install, remove and sync AI tools", - children: [ - { - name: "Install", - value: "ai-install", - description: "Add an AI tool to this project", - command: ["ai", "install"], - inputPrompt: "AI tool (e.g. claude, cursor, copilot, codex)", - }, - { - name: "Uninstall", - value: "ai-uninstall", - description: "Remove an installed AI tool", - command: ["ai", "uninstall"], - inputPrompt: "AI tool to remove", - }, - { - name: "Update", - value: "ai-update", - description: "Re-install AI tool configs from bundled assets", - command: ["ai", "update"], - }, - { - name: "Sync", - value: "ai-sync", - description: "Propagate changes across installed AI tools", - command: ["ai", "sync"], - }, - { - name: "Restore", - value: "ai-restore", - description: "Restore AI tool tracked files", - command: ["ai", "restore"], - }, - { - name: "Doctor", - value: "ai-doctor", - description: "Check AI tool installation health", - command: ["ai", "doctor"], - }, - ], - }, - { - name: "Manage IDE tools", - value: "manage-ide", - description: "Install, remove and maintain IDE tools", - children: [ - { - name: "Install", - value: "ide-install", - description: "Add an IDE tool to this project", - command: ["ide", "install"], - inputPrompt: "IDE tool (e.g. vscode)", - }, - { - name: "Uninstall", - value: "ide-uninstall", - description: "Remove an installed IDE tool", - command: ["ide", "uninstall"], - inputPrompt: "IDE tool to remove", - }, - { - name: "Update", - value: "ide-update", - description: "Re-install IDE tool configs from bundled assets", - command: ["ide", "update"], - }, - { - name: "Doctor", - value: "ide-doctor", - description: "Check IDE tool installation health", - command: ["ide", "doctor"], - }, - ], - }, - { - name: "Manage plugins", - value: "manage-plugins", - description: "Browse, install and manage AI tool plugins", - children: [ - { - name: "Install plugin", - value: "plugin-install", - description: "Install a plugin by name, local path, or interactive pick", - command: ["plugin", "install"], - inputPrompt: "Plugin name, path, or leave empty for interactive pick", - }, - { - name: "Search", - value: "plugin-search", - description: "Search plugins across all registered marketplaces", - command: ["plugin", "search"], - inputPrompt: "Search query", - }, - { - name: "Update", - value: "plugin-update", - description: "Update all installed plugins to latest version", - command: ["plugin", "update"], - }, - { - name: "Remove", - value: "plugin-remove", - description: "Remove an installed plugin", - command: ["plugin", "remove"], - inputPrompt: "Plugin name to remove", - }, - { - name: "List", - value: "plugin-list", - description: "Show all installed plugins per tool", - command: ["plugin", "list"], - }, - { - name: "Doctor", - value: "plugin-doctor", - description: "Check plugin installation health", - command: ["plugin", "doctor"], - }, - ], - }, - { - name: "Marketplaces", - value: "marketplaces", - description: "Manage plugin marketplace registrations", - children: [ - { - name: "List", - value: "marketplace-list", - description: "Show all registered marketplaces", - command: ["marketplace", "list"], - }, - { - name: "Add", - value: "marketplace-add", - description: "Register a new plugin marketplace", - command: ["marketplace", "add"], - }, - { - name: "Refresh", - value: "marketplace-refresh", - description: "Refresh all registered marketplaces", - command: ["marketplace", "refresh"], - }, - { - name: "Remove", - value: "marketplace-remove", - description: "Unregister a marketplace", - command: ["marketplace", "remove"], - inputPrompt: "Marketplace name to remove", - }, - { - name: "Check freshness", - value: "marketplace-check", - description: "Report stale marketplaces", - command: ["marketplace", "check"], - }, - ], - }, - { - name: "Maintain & repair", - value: "maintain", - description: "Update, sync, restore and clean everything", - children: [ - { - name: "Update everything", - value: "update-all", - description: "Update all installed tools and plugins", - command: ["update"], - }, - { - name: "Sync everything", - value: "sync-all", - description: "Sync configs and plugins across all installed tools", - command: ["sync"], - }, - { - name: "Restore everything", - value: "restore-all", - description: "Restore all modified or deleted tracked files", - command: ["restore"], - }, - { - name: "Clean (nuke .aidd)", - value: "clean", - description: "Remove all AIDD-managed files from this project", - command: ["clean"], - }, - ], - }, - { - name: "System", - value: "system", - description: "CLI self-update and authentication", - children: [ - { - name: "Self-update CLI", - value: "self-update", - description: "Update the AIDD CLI binary", - command: ["self-update"], - }, - { - name: "Authentication", - value: "auth", - description: "Manage authentication credentials", - children: [ - { - name: "Status", - value: "auth-status", - description: "Show current authentication status", - command: ["auth", "status"], - }, - { - name: "Login", - value: "auth-login", - description: "Authenticate with your credentials", - command: ["auth", "login"], - }, - { - name: "Logout", - value: "auth-logout", - description: "Remove stored credentials", - command: ["auth", "logout"], - }, - ], - }, - ], - }, -]; - -const BACK = { name: "← Back", value: "back" } as const; -const EXIT = { name: "Exit", value: "exit" } as const; - -type NavResult = { type: "command"; command: string[] } | { type: "back" } | { type: "exit" }; - -export type InteractiveMenuOptions = Record; - -export interface InteractiveMenuResult { - command: string[]; -} - -export class InteractiveMenuUseCase { - constructor( - private readonly manifestRepo: ManifestRepository, - private readonly prompter: Prompter - ) {} - - async execute(_options?: InteractiveMenuOptions): Promise { - const manifest = await this.manifestRepo.load(); - if (manifest === null) return this.handleFreshInstall(); - const result = await this.showMenu(INSTALLED_NODES, "What would you like to do?", []); - if (result.type !== "command") return { command: ["exit"] }; - return { command: result.command }; - } - - private async handleFreshInstall(): Promise { - const confirmed = await this.prompter.confirm("AIDD not initialized. Run setup now?", true); - return { command: confirmed ? ["setup"] : ["exit"] }; - } - - private async showMenu( - nodes: MenuNode[], - label: string, - breadcrumb: string[] - ): Promise { - const nav = breadcrumb.length > 0 ? [BACK, EXIT] : [EXIT]; - const picked = await this.prompter.select(label, [...nodes.map(toChoice), ...nav]); - if (picked === "exit") return { type: "exit" }; - if (picked === "back") return { type: "back" }; - - const node = nodes.find((n) => n.value === picked); - if (!node) return { type: "exit" }; - if (isBranch(node)) { - const result = await this.showMenu(node.children, node.name, [...breadcrumb, node.value]); - if (result.type === "back") return this.showMenu(nodes, label, breadcrumb); - return result; - } - - return { type: "command", command: await this.resolveCommand(node) }; - } - - private async resolveCommand(node: MenuLeaf): Promise { - if (node.inputPrompt !== undefined) { - const input = await this.prompter.input(node.inputPrompt); - return [...node.command, input, ...(node.commandSuffix ?? [])]; - } - return node.command; - } -} diff --git a/cli/src/application/use-cases/plugin/plugin-create-use-case.ts b/cli/src/application/use-cases/plugin/plugin-create-use-case.ts deleted file mode 100644 index 5e9d2e8f4..000000000 --- a/cli/src/application/use-cases/plugin/plugin-create-use-case.ts +++ /dev/null @@ -1,133 +0,0 @@ -import { dirname, join, relative } from "node:path"; -import { InvalidPluginNameError, PluginTargetExistsError } from "../../../domain/errors.js"; -import { appendPluginToMarketplace } from "../../../domain/formats/marketplace-json.js"; -import { PLUGIN_NAME_REGEX } from "../../../domain/models/plugin.js"; -import type { PluginComponentKind } from "../../../domain/models/plugin-component-kind.js"; -import { buildScaffold } from "../../../domain/models/plugin-scaffold.js"; -import type { AssetProvider } from "../../../domain/ports/asset-provider.js"; -import type { FileReader } from "../../../domain/ports/file-reader.js"; -import type { FileWriter } from "../../../domain/ports/file-writer.js"; -import type { JsonSchemaValidator } from "../../../domain/ports/json-schema-validator.js"; -import type { Logger } from "../../../domain/ports/logger.js"; -import type { Prompter } from "../../../domain/ports/prompter.js"; - -export interface PluginCreateInput { - name: string; - kind: PluginComponentKind | undefined; - outputDir: string; - force: boolean; - yes: boolean; - interactive: boolean; - projectRoot: string; -} - -export interface PluginCreateResult { - pluginDir: string; - filesWritten: number; - marketplaceUpdated: boolean; -} - -const PLUGIN_VERSION = "0.1.0"; - -export class PluginCreateUseCase { - constructor( - private readonly fs: FileReader & FileWriter, - private readonly prompter: Prompter, - private readonly jsonSchemaValidator: JsonSchemaValidator, - private readonly assetProvider: AssetProvider, - private readonly logger: Logger - ) {} - - async execute(input: PluginCreateInput): Promise { - if (!PLUGIN_NAME_REGEX.test(input.name)) throw new InvalidPluginNameError(input.name); - const kind = await this.resolveKind(input); - const description = `${input.name} plugin scaffold`; - const pluginDir = join(input.outputDir, input.name); - const scaffold = await this.buildAndValidateScaffold(input.name, kind, description); - await this.ensureWritableTarget(pluginDir, input.force); - const filesWritten = await this.writeScaffoldFiles(scaffold, pluginDir); - const marketplaceUpdated = await this.maybeAppendMarketplaceEntry( - input, - pluginDir, - description - ); - return { pluginDir, filesWritten, marketplaceUpdated }; - } - - private async resolveKind(input: PluginCreateInput): Promise { - if (input.kind !== undefined) return input.kind; - if (!input.interactive || input.yes) return "full"; - return this.prompter.select("Plugin type:", [ - { name: "full", value: "full" as PluginComponentKind }, - { name: "skills", value: "skills" as PluginComponentKind }, - { name: "agents", value: "agents" as PluginComponentKind }, - { name: "hooks", value: "hooks" as PluginComponentKind }, - { name: "mcp", value: "mcp" as PluginComponentKind }, - ]); - } - - private async buildAndValidateScaffold( - name: string, - kind: PluginComponentKind, - description: string - ): Promise> { - const scaffold = buildScaffold({ name, kind, version: PLUGIN_VERSION, description }); - const manifestStr = scaffold.get(".claude-plugin/plugin.json"); - const schema = this.assetProvider.loadSchema("plugin-manifest"); - this.jsonSchemaValidator.validate(schema, JSON.parse(manifestStr ?? "{}")); - return scaffold; - } - - private async ensureWritableTarget(pluginDir: string, force: boolean): Promise { - const exists = await this.fs.fileExists(pluginDir); - if (!exists) return; - if (!force) throw new PluginTargetExistsError(pluginDir); - this.logger.info(`Overwriting existing directory ${pluginDir}.`); - await this.fs.deleteDirectory(pluginDir); - } - - private async writeScaffoldFiles( - scaffold: ReadonlyMap, - pluginDir: string - ): Promise { - for (const [relPath, content] of scaffold) { - await this.fs.writeFile(join(pluginDir, relPath), content); - } - return scaffold.size; - } - - private async maybeAppendMarketplaceEntry( - input: PluginCreateInput, - pluginDir: string, - description: string - ): Promise { - const marketplacePath = join(input.projectRoot, ".claude-plugin", "marketplace.json"); - if (!(await this.fs.fileExists(marketplacePath))) return false; - if (!input.interactive || input.yes) return false; - const confirmed = await this.prompter.confirm("Add to local marketplace.json?", true); - if (!confirmed) return false; - return this.appendToMarketplace(marketplacePath, input.name, pluginDir, description); - } - - private async appendToMarketplace( - marketplacePath: string, - name: string, - pluginDir: string, - description: string - ): Promise { - const content = await this.fs.readFile(marketplacePath); - const rel = relative(dirname(marketplacePath), pluginDir); - const source = rel.startsWith(".") ? rel : `./${rel}`; - const entry = { - name, - version: PLUGIN_VERSION, - source, - description, - recommended: false, - strict: true, - }; - const updated = appendPluginToMarketplace(content, entry); - await this.fs.writeFile(marketplacePath, updated); - return true; - } -} diff --git a/cli/src/application/use-cases/plugin/plugin-file-sync.ts b/cli/src/application/use-cases/plugin/plugin-file-sync.ts deleted file mode 100644 index 90d00e170..000000000 --- a/cli/src/application/use-cases/plugin/plugin-file-sync.ts +++ /dev/null @@ -1,87 +0,0 @@ -import { join } from "node:path"; -import type { InstallationFile } from "../../../domain/models/file.js"; -import type { Manifest } from "../../../domain/models/manifest.js"; -import type { Plugin } from "../../../domain/models/plugin.js"; -import type { PluginDistribution } from "../../../domain/models/plugin-distribution.js"; -import type { AiToolId } from "../../../domain/models/tool-ids.js"; -import type { FileReader } from "../../../domain/ports/file-reader.js"; -import type { FileWriter } from "../../../domain/ports/file-writer.js"; -import type { Hasher } from "../../../domain/ports/hasher.js"; -import type { ManifestRepository } from "../../../domain/ports/manifest-repository.js"; -import { NoManifestError } from "../../errors.js"; -import type { PluginTranslator } from "./translator/plugin-translator.js"; - -export async function loadPluginManifest(manifestRepo: ManifestRepository): Promise { - const manifest = await manifestRepo.load(); - if (manifest === null) throw new NoManifestError(); - return manifest; -} - -export async function writePluginFiles( - files: InstallationFile[], - baseDir: string, - fs: FileWriter -): Promise { - await Promise.all(files.map((f) => fs.writeFile(join(baseDir, f.relativePath), f.content))); -} - -/** Deletes exactly the paths a plugin's own manifest entry lists, joined to its base dir. - * Never enumerates the directory or deletes by pattern — only manifest-tracked keys. */ -export async function deleteOldFiles( - files: ReadonlyMap, - baseDir: string, - fs: FileWriter -): Promise { - for (const relativePath of files.keys()) { - await fs.deleteFile(join(baseDir, relativePath)); - } -} - -/** Whether the file already on disk matches the content we would write, so a - * caller can skip the write and, more importantly, not count it as restored. */ -export async function isPluginFileAtDesiredState( - fs: FileReader, - hasher: Hasher, - outputPath: string, - expectedHashValue: string -): Promise { - if (!(await fs.fileExists(outputPath))) return false; - const content = await fs.readFile(outputPath); - return hasher.hash(content).value === expectedHashValue; -} - -/** - * Re-registers a marketplace-sourced plugin through its resolved translator: drops the - * existing manifest entry and lets the translator re-add it, so update and restore both - * end up with the same single entry an install would have produced. Works for either - * translation strategy — materializing tools (cursor/opencode) re-copy the BUILT tree; - * Mode A marketplace tools (claude/codex/copilot) register the plugin reference without - * writing any files, matching what install does for them. - * - * Returns how many files the translator actually (re)wrote — not the plugin's total - * file count — so a no-op restore reports zero instead of claiming everything changed. - * `written` is undefined for translators that don't track counts (Mode A never writes - * files; the rare built-tree fallback where the marketplace can't be resolved); that - * is reported as 0 rather than guessed. - */ -export async function materializeViaTranslator( - translator: PluginTranslator, - dist: PluginDistribution, - toolId: AiToolId, - plugin: Plugin, - projectRoot: string, - manifest: Manifest, - docsDir: string -): Promise { - manifest.removePlugin(toolId, plugin.name); - const { written } = await translator.addPlugin( - dist, - toolId, - plugin.source, - projectRoot, - manifest, - plugin.marketplace, - docsDir - ); - return written ?? 0; -} diff --git a/cli/src/application/use-cases/plugin/plugin-list-use-case.ts b/cli/src/application/use-cases/plugin/plugin-list-use-case.ts deleted file mode 100644 index 0ef2d38de..000000000 --- a/cli/src/application/use-cases/plugin/plugin-list-use-case.ts +++ /dev/null @@ -1,30 +0,0 @@ -import type { Manifest } from "../../../domain/models/manifest.js"; -import type { Plugin } from "../../../domain/models/plugin.js"; -import type { AiToolId } from "../../../domain/models/tool-ids.js"; -import type { ManifestRepository } from "../../../domain/ports/manifest-repository.js"; -import { loadPluginManifest } from "./plugin-file-sync.js"; -import { resolvePluginToolIds } from "./plugin-target-resolution.js"; - -export interface PluginListOptions { - toolIds: AiToolId[] | "all"; -} - -export type PluginListResult = Map; - -export class PluginListUseCase { - constructor(private readonly manifestRepo: ManifestRepository) {} - - async execute(options: PluginListOptions): Promise { - const manifest = await loadPluginManifest(this.manifestRepo); - const resolvedToolIds = resolvePluginToolIds(options.toolIds, manifest); - return this.buildResult(resolvedToolIds, manifest); - } - - private buildResult(toolIds: AiToolId[], manifest: Manifest): PluginListResult { - const result: PluginListResult = new Map(); - for (const toolId of toolIds) { - result.set(toolId, manifest.getPlugins(toolId)); - } - return result; - } -} diff --git a/cli/src/application/use-cases/plugin/plugin-remove-use-case.ts b/cli/src/application/use-cases/plugin/plugin-remove-use-case.ts deleted file mode 100644 index 4d12be52e..000000000 --- a/cli/src/application/use-cases/plugin/plugin-remove-use-case.ts +++ /dev/null @@ -1,174 +0,0 @@ -import { homedir as nodeHomedir } from "node:os"; -import { dirname, join } from "node:path"; -import type { McpCapability } from "../../../domain/capabilities/mcp-capability.js"; -import { NativePluginCliError, PluginNotFoundError } from "../../../domain/errors.js"; -import { - cursorProjectHooksScriptDir, - unmergeCursorProjectHooksJson, -} from "../../../domain/formats/cursor-hooks-project-merge.js"; -import { unmergeOpencodeMcp } from "../../../domain/formats/opencode-mcp-merge.js"; -import type { Manifest } from "../../../domain/models/manifest.js"; -import type { Plugin } from "../../../domain/models/plugin.js"; -import type { AiToolId } from "../../../domain/models/tool-ids.js"; -import type { FileReader } from "../../../domain/ports/file-reader.js"; -import type { FileWriter } from "../../../domain/ports/file-writer.js"; -import type { Logger } from "../../../domain/ports/logger.js"; -import type { ManifestRepository } from "../../../domain/ports/manifest-repository.js"; -import type { NativePluginActivator } from "../../../domain/ports/native-plugin-activator.js"; -import { - getToolConfig, - isAiTool, - resolvePluginsCapability, -} from "../../../domain/tools/registry.js"; -import { loadPluginManifest } from "./plugin-file-sync.js"; -import { - isFrameworkPrimeFlatMcp, - resolvePluginBaseDir, - resolvePluginToolIds, -} from "./plugin-target-resolution.js"; - -export interface PluginRemoveOptions { - pluginName: string; - toolIds: AiToolId[] | "all"; - projectRoot: string; -} - -export class PluginRemoveUseCase { - constructor( - private readonly fs: FileWriter & FileReader, - private readonly manifestRepo: ManifestRepository, - private readonly logger: Logger, - /** Native plugin CLI activators keyed by `NativeActivation.binary`, mirroring the map - * `MarketplaceSyncSettingsUseCase` installs through (see deps.ts). */ - private readonly activators: ReadonlyMap - ) {} - - async execute(options: PluginRemoveOptions): Promise { - const { pluginName, toolIds, projectRoot } = options; - const manifest = await loadPluginManifest(this.manifestRepo); - const resolvedToolIds = resolvePluginToolIds(toolIds, manifest); - const removed = await this.removeFromTools(pluginName, resolvedToolIds, projectRoot, manifest); - if (!removed) throw new PluginNotFoundError(pluginName); - await this.manifestRepo.save(manifest); - } - - private async removeFromTools( - pluginName: string, - toolIds: AiToolId[], - projectRoot: string, - manifest: Manifest - ): Promise { - let removed = false; - for (const toolId of toolIds) { - const plugins = manifest.getPlugins(toolId); - const plugin = plugins.find((p) => p.name === pluginName); - if (plugin === undefined) continue; - const baseDir = resolvePluginBaseDir(toolId, projectRoot, nodeHomedir); - this.removeNativeActivation(plugin, toolId); - await this.deletePluginFiles(plugin.files, baseDir); - await this.removeMcpEntries(plugin, toolId, projectRoot); - await this.removeProjectHooks(pluginName, toolId, projectRoot); - manifest.removePlugin(toolId, pluginName); - removed = true; - } - return removed; - } - - // The removal counterpart of MarketplaceSyncSettingsUseCase.activateTool: a tool declared - // `nativeActivation` (Claude, Codex, Copilot) only loads a plugin once its own CLI registers - // it in a user-global registry that install never wrote to directly — so removal must drive - // the same CLI, not edit that registry file itself (see deps.ts and the adapters under - // infrastructure/adapters/*-cli-adapter.ts). A plugin without a recorded marketplace was - // never activated this way at install time either (mirrors - // MarketplaceSyncSettingsUseCase.pluginActivation's `marketplace == null` skip), so there is - // nothing to undo. Best-effort: a host that can't be reached must warn by name with what is - // left behind, never fail the whole removal silently. - private removeNativeActivation(plugin: Plugin, toolId: AiToolId): void { - const nativeActivation = resolvePluginsCapability(toolId)?.nativeActivation; - if (nativeActivation == null || plugin.marketplace === undefined) return; - const activator = this.activators.get(nativeActivation.binary); - if (activator === undefined) return; - const ref = `${plugin.name}@${plugin.marketplace}`; - this.uninstallViaActivator(activator, nativeActivation.binary, ref); - } - - private uninstallViaActivator( - activator: NativePluginActivator, - binary: string, - ref: string - ): void { - if (!activator.isAvailable()) { - this.logger.warn( - `${binary} CLI not found on PATH — '${ref}' was not uninstalled from ${binary}'s own plugin registry and may still be enabled there.` - ); - return; - } - try { - activator.uninstallPlugin(ref); - } catch (error) { - if (!(error instanceof NativePluginCliError)) throw error; - this.logger.warn( - `${binary} plugin uninstall '${ref}' failed: ${error.message} — an entry for it may remain in ${binary}'s own plugin registry.` - ); - } - } - - // The install-time counterpart of ProjectHooksMaterializer: a plugin whose hooks - // were merged into the project's own .cursor/hooks.json (never tracked in - // Plugin.files — see mode-b-flat-materialization-translator.ts) needs its own - // unmerge, not a baseDir-relative file delete. Both destinations are recomputed - // from pluginName alone, exactly as install computed them — no extra state to keep - // in sync. - private async removeProjectHooks( - pluginName: string, - toolId: AiToolId, - projectRoot: string - ): Promise { - if (resolvePluginsCapability(toolId)?.hooksDestination !== "project") return; - const hooksPath = join(projectRoot, ".cursor", "hooks.json"); - const existing = await this.readExistingJson(hooksPath); - if (existing !== null) { - await this.fs.writeFile(hooksPath, unmergeCursorProjectHooksJson(existing, pluginName)); - } - await this.fs.deleteDirectory(join(projectRoot, cursorProjectHooksScriptDir(pluginName))); - } - - private async removeMcpEntries( - plugin: Plugin, - toolId: AiToolId, - projectRoot: string - ): Promise { - if (plugin.mcpEntries.size === 0) return; - const toolConfig = getToolConfig(toolId); - if (!isAiTool(toolConfig)) return; - const caps = toolConfig.capabilities as Record; - if (!isFrameworkPrimeFlatMcp(caps)) return; - const mcpCap = caps.mcp as McpCapability; - const outputRelPath = await mcpCap.resolveOutput(projectRoot, this.fs); - const outputPath = join(projectRoot, outputRelPath); - const existing = await this.readExistingJson(outputPath); - if (existing === null) return; - const updated = unmergeOpencodeMcp(existing, plugin.mcpEntries); - await this.fs.writeFile(outputPath, updated); - } - - private async readExistingJson(path: string): Promise { - try { - return await this.fs.readFile(path); - } catch (err) { - if ((err as NodeJS.ErrnoException).code === "ENOENT") return null; - throw err; - } - } - - private async deletePluginFiles( - files: ReadonlyMap, - baseDir: string - ): Promise { - for (const relativePath of files.keys()) { - const fullPath = join(baseDir, relativePath); - await this.fs.deleteFile(fullPath); - await this.fs.deleteEmptyDirectories(dirname(fullPath)); - } - } -} diff --git a/cli/src/application/use-cases/plugin/plugin-target-resolution.ts b/cli/src/application/use-cases/plugin/plugin-target-resolution.ts deleted file mode 100644 index 59b9821c8..000000000 --- a/cli/src/application/use-cases/plugin/plugin-target-resolution.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { McpCapability } from "../../../domain/capabilities/mcp-capability.js"; -import type { PluginsCapability } from "../../../domain/capabilities/plugins-capability.js"; -import type { Manifest } from "../../../domain/models/manifest.js"; -import type { AiToolId } from "../../../domain/models/tool-ids.js"; -import { AI_TOOL_IDS } from "../../../domain/models/tool-ids.js"; -import { getToolConfig, isAiTool } from "../../../domain/tools/registry.js"; - -export function resolvePluginToolIds(toolIds: AiToolId[] | "all", manifest: Manifest): AiToolId[] { - if (toolIds !== "all") return toolIds; - return AI_TOOL_IDS.filter((id) => manifest.hasTool(id)) as AiToolId[]; -} - -/** The base directory a plugin's files live under: `projectRoot` for project-scope - * plugins, the home-relative dir `PluginsCapability` resolves for user-scope ones. */ -export function resolvePluginBaseDirForCapability( - plugins: PluginsCapability, - projectRoot: string, - homedir: () => string -): string { - return plugins.resolvePluginsBaseDir(projectRoot, homedir()); -} - -export function resolvePluginBaseDir( - toolId: AiToolId, - projectRoot: string, - homedir: () => string -): string { - const toolConfig = getToolConfig(toolId); - if (!isAiTool(toolConfig)) return projectRoot; - const caps = toolConfig.capabilities as Record; - if (!("plugins" in caps)) return projectRoot; - return resolvePluginBaseDirForCapability(caps.plugins as PluginsCapability, projectRoot, homedir); -} - -export function isFrameworkPrimeFlatMcp(caps: Record): boolean { - if (!("mcp" in caps)) return false; - const mcp = caps.mcp; - if (!(mcp instanceof McpCapability)) return false; - if (mcp.params.mergeStrategy !== "framework-prime") return false; - const plugins = caps.plugins as PluginsCapability; - return plugins.mode === "flat"; -} diff --git a/cli/src/application/use-cases/plugin/plugin-update-use-case.ts b/cli/src/application/use-cases/plugin/plugin-update-use-case.ts deleted file mode 100644 index 86b935286..000000000 --- a/cli/src/application/use-cases/plugin/plugin-update-use-case.ts +++ /dev/null @@ -1,157 +0,0 @@ -import { homedir as nodeHomedir } from "node:os"; -import { join } from "node:path"; -import type { Manifest } from "../../../domain/models/manifest.js"; -import { DOCS_DIR, PLUGIN_CACHE_SUBDIR } from "../../../domain/models/paths.js"; -import { Plugin } from "../../../domain/models/plugin.js"; -import { PluginContentTranslator } from "../../../domain/models/plugin-content-translator.js"; -import type { PluginDistribution } from "../../../domain/models/plugin-distribution.js"; -import { compareSemver } from "../../../domain/models/semver.js"; -import type { AiToolId } from "../../../domain/models/tool-ids.js"; -import type { FileReader } from "../../../domain/ports/file-reader.js"; -import type { FileWriter } from "../../../domain/ports/file-writer.js"; -import type { Hasher } from "../../../domain/ports/hasher.js"; -import type { ManifestRepository } from "../../../domain/ports/manifest-repository.js"; -import type { PluginDistributionReader } from "../../../domain/ports/plugin-distribution-reader.js"; -import type { PluginFetcher } from "../../../domain/ports/plugin-fetcher.js"; -import { getToolConfig, type ToolConfig } from "../../../domain/tools/registry.js"; -import type { BuiltMaterializationDeps } from "../shared/apply-plugin-files-use-case.js"; -import { - deleteOldFiles, - loadPluginManifest, - materializeViaTranslator, - writePluginFiles, -} from "./plugin-file-sync.js"; -import { resolvePluginBaseDir, resolvePluginToolIds } from "./plugin-target-resolution.js"; -import type { PluginTranslator } from "./translator/plugin-translator.js"; -import { resolvePluginTranslator } from "./translator/resolve-plugin-translator.js"; - -export interface PluginUpdateOptions { - pluginNames?: string[]; - toolIds: AiToolId[] | "all"; - projectRoot: string; -} - -export class PluginUpdateUseCase { - constructor( - private readonly fs: FileReader & FileWriter, - private readonly manifestRepo: ManifestRepository, - private readonly pluginFetcher: PluginFetcher, - private readonly pluginDistributionReader: PluginDistributionReader, - private readonly hasher: Hasher, - private readonly builtDeps?: BuiltMaterializationDeps - ) {} - - async execute(options: PluginUpdateOptions): Promise { - const { pluginNames, toolIds, projectRoot } = options; - const manifest = await loadPluginManifest(this.manifestRepo); - const resolvedToolIds = resolvePluginToolIds(toolIds, manifest); - const cacheDir = join(projectRoot, PLUGIN_CACHE_SUBDIR); - const docsDir = DOCS_DIR; - const updated: string[] = []; - for (const toolId of resolvedToolIds) { - const names = await this.updatePluginsForTool( - toolId, - pluginNames, - projectRoot, - cacheDir, - manifest, - docsDir - ); - updated.push(...names); - } - await this.manifestRepo.save(manifest); - return updated; - } - - private async updatePluginsForTool( - toolId: AiToolId, - pluginNames: string[] | undefined, - projectRoot: string, - cacheDir: string, - manifest: Manifest, - docsDir: string - ): Promise { - const plugins = manifest.getPlugins(toolId); - const targets = pluginNames - ? plugins.filter((p) => pluginNames.includes(p.name)) - : [...plugins]; - const updated: string[] = []; - for (const plugin of targets) { - const didUpdate = await this.updateOnePlugin( - plugin, - toolId, - projectRoot, - cacheDir, - manifest, - docsDir - ); - if (didUpdate) updated.push(plugin.name); - } - return updated; - } - - private async updateOnePlugin( - plugin: Plugin, - toolId: AiToolId, - projectRoot: string, - cacheDir: string, - manifest: Manifest, - docsDir: string - ): Promise { - const localPath = await this.pluginFetcher.fetch(plugin.source, cacheDir, { - forceRefresh: true, - }); - const dist = await this.pluginDistributionReader.read(localPath); - if (compareSemver(dist.manifest.version, plugin.version) <= 0) return false; - await this.replacePluginFiles(plugin, dist, toolId, projectRoot, manifest, docsDir); - return true; - } - - private async replacePluginFiles( - plugin: Plugin, - dist: PluginDistribution, - toolId: AiToolId, - projectRoot: string, - manifest: Manifest, - docsDir: string - ): Promise { - const baseDir = resolvePluginBaseDir(toolId, projectRoot, nodeHomedir); - await deleteOldFiles(plugin.files, baseDir, this.fs); - const toolConfig = getToolConfig(toolId); - const translator = this.resolveTranslator(toolConfig); - if (translator !== null && plugin.marketplace !== undefined) { - await materializeViaTranslator( - translator, - dist, - toolId, - plugin, - projectRoot, - manifest, - docsDir - ); - return; - } - const { files: newFiles, componentPaths } = new PluginContentTranslator( - this.hasher - ).translateWithComponentPaths(dist, toolConfig, docsDir); - await writePluginFiles(newFiles, baseDir, this.fs); - manifest.updatePlugin( - toolId, - Plugin.fromDistribution(dist, plugin.source, newFiles, componentPaths) - ); - } - - // Materializing tools (cursor/opencode) re-materialize from the BUILT tree, and Mode A - // marketplace tools (claude/codex/copilot) re-register without writing files, so an - // update matches whatever install would have done for that tool. - private resolveTranslator(toolConfig: ToolConfig): PluginTranslator | null { - if (this.builtDeps === undefined) return null; - return resolvePluginTranslator(toolConfig, { - fs: this.fs, - hasher: this.hasher, - homedir: this.builtDeps.homedir, - ensureBuilt: this.builtDeps.ensureBuilt, - marketplaceRegistry: this.builtDeps.marketplaceRegistry, - }); - } -} diff --git a/cli/src/application/use-cases/plugin/translator/built-tree-materialization-translator.ts b/cli/src/application/use-cases/plugin/translator/built-tree-materialization-translator.ts deleted file mode 100644 index 2d0e788f3..000000000 --- a/cli/src/application/use-cases/plugin/translator/built-tree-materialization-translator.ts +++ /dev/null @@ -1,203 +0,0 @@ -import { join, posix } from "node:path"; -import { flatHooksSharedDirPath } from "../../../../domain/formats/flat-paths.js"; -import { InstallationFile } from "../../../../domain/models/file.js"; -import type { Manifest } from "../../../../domain/models/manifest.js"; -import { Plugin } from "../../../../domain/models/plugin.js"; -import type { PluginDistribution } from "../../../../domain/models/plugin-distribution.js"; -import type { PluginSource } from "../../../../domain/models/plugin-source.js"; -import type { ReadonlySkipList } from "../../../../domain/models/plugin-translation-skip.js"; -import type { AiToolId } from "../../../../domain/models/tool-ids.js"; -import type { FileReader } from "../../../../domain/ports/file-reader.js"; -import type { FileWriter } from "../../../../domain/ports/file-writer.js"; -import type { Hasher } from "../../../../domain/ports/hasher.js"; -import type { MarketplaceRegistry } from "../../../../domain/ports/marketplace-registry.js"; -import { resolvePluginsCapability } from "../../../../domain/tools/registry.js"; -import type { EnsureBuiltMarketplace } from "../../shared/ensure-built-marketplace-use-case.js"; -import { isPluginFileAtDesiredState } from "../plugin-file-sync.js"; -import { resolvePluginBaseDir } from "../plugin-target-resolution.js"; -import { ModeBFlatMaterializationTranslator } from "./mode-b-flat-materialization-translator.js"; -import type { PluginTranslator } from "./plugin-translator.js"; -import { ProjectHooksMaterializer } from "./project-hooks-materializer.js"; - -/** - * Materializes plugin content by copying the per-target BUILT tree verbatim into the - * tool's plugin directory — so installed bytes equal `framework build` output. Bypasses - * the per-file content transform (build already did it). For marketplace-sourced installs - * only; raw local-path installs fall back to flat materialization. - * - * componentPaths is left empty (sync does not propagate built plugins), matching the - * existing local-marketplace behavior in PluginUpdateUseCase. - */ -export class BuiltTreeMaterializationTranslator implements PluginTranslator { - readonly mode = "flat" as const; - private readonly projectHooks: ProjectHooksMaterializer; - - constructor( - private readonly fs: FileWriter & FileReader, - private readonly hasher: Hasher, - private readonly homedir: () => string, - private readonly ensureBuilt: EnsureBuiltMarketplace, - private readonly marketplaceRegistry: MarketplaceRegistry - ) { - this.projectHooks = new ProjectHooksMaterializer(fs); - } - - async addPlugin( - dist: PluginDistribution, - toolId: AiToolId, - source: PluginSource, - projectRoot: string, - manifest: Manifest, - marketplace: string | undefined, - docsDir: string, - previousMcpEntries: ReadonlyMap = new Map() - ): Promise<{ skipped: ReadonlySkipList; written?: number }> { - const resolved = - marketplace === undefined ? null : await this.findMarketplace(marketplace, projectRoot); - if (marketplace === undefined || resolved === null) { - return this.fallback().addPlugin( - dist, - toolId, - source, - projectRoot, - manifest, - marketplace, - docsDir, - previousMcpEntries - ); - } - const mode = toolId === "opencode" ? "flat" : "marketplace"; - const { builtDir } = await this.ensureBuilt.execute({ - projectRoot, - marketplace: resolved, - target: toolId, - mode, - }); - const builtFiles = - mode === "flat" - ? await this.readFlatFiles(builtDir, dist, toolId) - : await this.readBuiltFiles( - join(builtDir, "plugins", dist.manifest.name), - dist.manifest.name - ); - // The built tree still carries a plugin-scoped hooks/hooks.json for a capability - // declaring hooksDestination "project" (the marketplace build never learned that - // route exists) — dropped here, and materialized through the same project-hooks - // side channel the local-source route uses, so both land in the one place the - // tool's own declaration names, not wherever this particular build happened to put it. - const deliversHooksToProject = resolvePluginsCapability(toolId)?.hooksDestination === "project"; - const hooksSkips = deliversHooksToProject - ? await this.projectHooks.materialize(dist, toolId, projectRoot) - : []; - const files = deliversHooksToProject - ? withoutHooksPrefix(builtFiles, dist.manifest.name) - : builtFiles; - const baseDir = - mode === "flat" ? projectRoot : resolvePluginBaseDir(toolId, projectRoot, this.homedir); - const written = await this.writeChangedFiles(files, baseDir); - manifest.addPlugin( - toolId, - Plugin.fromDistribution(dist, source, files, new Map(), marketplace) - ); - return { skipped: hooksSkips, written }; - } - - // Verbatim-copies the built subtree, but skips files already matching the built - // content on disk so a no-op restore reports (and performs) zero writes. - private async writeChangedFiles(files: InstallationFile[], baseDir: string): Promise { - let written = 0; - for (const f of files) { - const outputPath = join(baseDir, f.relativePath); - if (await isPluginFileAtDesiredState(this.fs, this.hasher, outputPath, f.hash.value)) { - continue; - } - await this.fs.writeFile(outputPath, f.content); - written++; - } - return written; - } - - // Marketplace build emits plugins//; user-scope tools install at - // //, so the manifest relativePath keeps the / prefix. - private async readBuiltFiles(pluginSrc: string, name: string): Promise { - const absPaths = await this.fs.listFilesRecursive(pluginSrc); - return Promise.all( - absPaths.map(async (abs) => { - const rel = abs.slice(pluginSrc.length + 1); - const content = await this.fs.readFile(abs); - return new InstallationFile({ - // relativePath is always "/"-separated (see withoutHooksPrefix and - // belongsToPlugin below, both string-matching on "/") - node:path's platform - // `join` would answer with "\" on win32, breaking both. - relativePath: posix.join(name, rel), - content, - hash: this.hasher.hash(content), - }); - }) - ); - } - - // Flat build emits the whole marketplace into one workspace. Agents are namespaced - // by .opencode/agents/-...; skills instead nest the whole subtree - // under .opencode/skills//... (genericFlatSkillTreePath — a skill's own - // script can require() a sibling by relative path, which only keeps resolving - // when nothing under the plugin's skills/ subtree gets renamed). Install copies - // only this plugin's files by whichever convention its section uses. Hooks are not - // namespaced — flatHooksDir is one directory the tool's loader scans flat (see - // flatHooksSharedDirPath) — so this plugin's own hook filenames are matched by name - // instead, from its own distribution. - private async readFlatFiles( - builtDir: string, - dist: PluginDistribution, - toolId: AiToolId - ): Promise { - const name = dist.manifest.name; - const hookPaths = this.flatHookOutputPaths(dist, toolId); - const absPaths = await this.fs.listFilesRecursive(builtDir); - const files: InstallationFile[] = []; - for (const abs of absPaths) { - const rel = abs.slice(builtDir.length + 1); - if (!this.belongsToPlugin(rel, name) && !hookPaths.has(rel)) continue; - const content = await this.fs.readFile(abs); - files.push( - new InstallationFile({ relativePath: rel, content, hash: this.hasher.hash(content) }) - ); - } - return files; - } - - private belongsToPlugin(rel: string, name: string): boolean { - const segments = rel.split("/"); - if (segments[0] !== ".opencode" || segments.length < 3) return false; - // skills/ nests the whole plugin under one exactly-named segment (see the comment - // above); every other flat section still hyphen-prefixes the leaf segment. - if (segments[1] === "skills") return segments[2] === name; - return segments[2].startsWith(`${name}-`); - } - - private flatHookOutputPaths(dist: PluginDistribution, toolId: AiToolId): ReadonlySet { - const flatHooksDir = resolvePluginsCapability(toolId)?.flatHooksDir; - if (flatHooksDir === null || flatHooksDir === undefined) return new Set(); - return new Set( - dist.components.hooks - .filter((f) => f.relativePath !== "hooks/hooks.json") - .map((f) => flatHooksSharedDirPath(flatHooksDir, f.relativePath)) - ); - } - - private async findMarketplace(name: string, projectRoot: string) { - const all = await this.marketplaceRegistry.list(projectRoot); - return all.find((m) => m.name === name) ?? null; - } - - private fallback(): ModeBFlatMaterializationTranslator { - return new ModeBFlatMaterializationTranslator(this.fs, this.hasher, this.homedir); - } -} - -// readBuiltFiles prefixes every path with "/" (see its own comment above) — a -// built-tree hooks file therefore always reads "/hooks/". -function withoutHooksPrefix(files: InstallationFile[], pluginName: string): InstallationFile[] { - const hooksPrefix = `${pluginName}/hooks/`; - return files.filter((f) => !f.relativePath.startsWith(hooksPrefix)); -} diff --git a/cli/src/application/use-cases/plugin/translator/mode-a-marketplace-translator.ts b/cli/src/application/use-cases/plugin/translator/mode-a-marketplace-translator.ts deleted file mode 100644 index e60f2c942..000000000 --- a/cli/src/application/use-cases/plugin/translator/mode-a-marketplace-translator.ts +++ /dev/null @@ -1,35 +0,0 @@ -import type { Manifest } from "../../../../domain/models/manifest.js"; -import { Plugin } from "../../../../domain/models/plugin.js"; -import type { PluginDistribution } from "../../../../domain/models/plugin-distribution.js"; -import type { PluginSource } from "../../../../domain/models/plugin-source.js"; -import type { ReadonlySkipList } from "../../../../domain/models/plugin-translation-skip.js"; -import type { AiToolId } from "../../../../domain/models/tool-ids.js"; -import type { PluginTranslator } from "./plugin-translator.js"; - -/** - * Mode A — Marketplace + plugins. - * - * This class is a translator adapter (not a hexagonal port adapter). - * Registers the framework marketplace in the tool's native config file - * (extraKnownMarketplaces / enabledPlugins) using MarketplaceSettings. - * Used by tools with native marketplace support: Claude, Copilot VSCode, Codex, Cursor. - * - * Plugin files are NOT materialized on disk. Instead, a plugin reference is added to - * the manifest with an empty files set — the marketplace sync handles the rest. - */ -export class ModeAMarketplaceTranslator implements PluginTranslator { - readonly mode = "marketplace" as const; - - async addPlugin( - dist: PluginDistribution, - toolId: AiToolId, - source: PluginSource, - _projectRoot: string, - manifest: Manifest, - marketplace: string | undefined, - _docsDir: string - ): Promise<{ skipped: ReadonlySkipList }> { - manifest.addPlugin(toolId, Plugin.fromDistribution(dist, source, [], new Map(), marketplace)); - return { skipped: [] }; - } -} diff --git a/cli/src/application/use-cases/plugin/translator/mode-b-flat-materialization-translator.ts b/cli/src/application/use-cases/plugin/translator/mode-b-flat-materialization-translator.ts deleted file mode 100644 index c3a9ca991..000000000 --- a/cli/src/application/use-cases/plugin/translator/mode-b-flat-materialization-translator.ts +++ /dev/null @@ -1,192 +0,0 @@ -import { join } from "node:path"; -import type { McpCapability } from "../../../../domain/capabilities/mcp-capability.js"; -import type { PluginsCapability } from "../../../../domain/capabilities/plugins-capability.js"; -import { CursorProjectScopeUnsupportedError } from "../../../../domain/errors.js"; -import { mergeOpencodeMcp } from "../../../../domain/formats/opencode-mcp-merge.js"; -import type { InstallationFile } from "../../../../domain/models/file.js"; -import type { Manifest } from "../../../../domain/models/manifest.js"; -import { Plugin } from "../../../../domain/models/plugin.js"; -import { PluginContentTranslator } from "../../../../domain/models/plugin-content-translator.js"; -import type { PluginDistribution } from "../../../../domain/models/plugin-distribution.js"; -import type { PluginSource } from "../../../../domain/models/plugin-source.js"; -import type { - PluginTranslationSkip, - ReadonlySkipList, -} from "../../../../domain/models/plugin-translation-skip.js"; -import type { AiToolId } from "../../../../domain/models/tool-ids.js"; -import type { FileReader } from "../../../../domain/ports/file-reader.js"; -import type { FileWriter } from "../../../../domain/ports/file-writer.js"; -import type { Hasher } from "../../../../domain/ports/hasher.js"; -import { getToolConfig, isAiTool } from "../../../../domain/tools/registry.js"; -import { writePluginFiles } from "../plugin-file-sync.js"; -import { - isFrameworkPrimeFlatMcp, - resolvePluginBaseDirForCapability, -} from "../plugin-target-resolution.js"; -import type { PluginTranslator } from "./plugin-translator.js"; -import { ProjectHooksMaterializer, withoutHooks } from "./project-hooks-materializer.js"; - -/** - * Mode B — Flat materialization. - * - * This class is a translator adapter (not a hexagonal port adapter). - * Materializes plugin content directly into the tool's plugin directory as files on disk. - * Used by tools without native marketplace support: OpenCode, Cursor (user-scope). - */ -export class ModeBFlatMaterializationTranslator implements PluginTranslator { - readonly mode = "flat" as const; - private readonly projectHooks: ProjectHooksMaterializer; - - constructor( - private readonly fs: FileWriter & FileReader, - private readonly hasher: Hasher, - private readonly homedir: () => string - ) { - this.projectHooks = new ProjectHooksMaterializer(fs); - } - - async addPlugin( - dist: PluginDistribution, - toolId: AiToolId, - source: PluginSource, - projectRoot: string, - manifest: Manifest, - marketplace: string | undefined, - docsDir: string, - previousMcpEntries: ReadonlyMap = new Map() - ): Promise<{ skipped: ReadonlySkipList }> { - const ctx = this.resolveFlatToolContext(toolId, dist, docsDir, projectRoot); - if (ctx === null) return { skipped: [] }; - const mcp = await this.resolveMcp(dist, toolId, projectRoot, previousMcpEntries); - const hooksSkips = await this.projectHooks.materialize(dist, toolId, projectRoot); - const allSkipped: ReadonlySkipList = [...ctx.skipped, ...mcp.mcpSkips, ...hooksSkips]; - if (ctx.files.length === 0 && mcp.mcpEntries.size === 0) return { skipped: allSkipped }; - await this.writeAndRegisterPlugin( - dist, - toolId, - source, - ctx.files, - mcp.mcpEntries, - ctx.componentPaths, - marketplace, - ctx.baseDir, - manifest - ); - return { skipped: allSkipped }; - } - - private resolveFlatToolContext( - toolId: AiToolId, - dist: PluginDistribution, - docsDir: string, - projectRoot: string - ): { - caps: Record; - files: InstallationFile[]; - componentPaths: ReadonlyMap; - skipped: ReadonlySkipList; - baseDir: string; - } | null { - const toolConfig = getToolConfig(toolId); - if (!isAiTool(toolConfig)) return null; - const caps = toolConfig.capabilities as Record; - const pluginsCap = caps.plugins as PluginsCapability; - if (pluginsCap.mode === "native" && pluginsCap.installScope !== "user") { - throw new CursorProjectScopeUnsupportedError(); - } - const distForNative = pluginsCap.hooksDestination === "project" ? withoutHooks(dist) : dist; - const { files, componentPaths, skipped } = new PluginContentTranslator( - this.hasher - ).translateWithComponentPaths(distForNative, toolConfig, docsDir); - const baseDir = resolvePluginBaseDirForCapability(pluginsCap, projectRoot, this.homedir); - return { caps, files, componentPaths, skipped, baseDir }; - } - - private async resolveMcp( - dist: PluginDistribution, - toolId: AiToolId, - projectRoot: string, - previousMcpEntries: ReadonlyMap - ): Promise<{ mcpEntries: ReadonlyMap; mcpSkips: ReadonlySkipList }> { - const toolConfig = getToolConfig(toolId); - if (!isAiTool(toolConfig)) return { mcpEntries: new Map(), mcpSkips: [] }; - const caps = toolConfig.capabilities as Record; - if (!isFrameworkPrimeFlatMcp(caps) || dist.components.mcp.length === 0) { - return { mcpEntries: new Map(), mcpSkips: [] }; - } - return this.mergeOpencodeMcpEntries(dist, caps, projectRoot, previousMcpEntries, toolId); - } - - private async writeAndRegisterPlugin( - dist: PluginDistribution, - toolId: AiToolId, - source: PluginSource, - files: InstallationFile[], - mcpEntries: ReadonlyMap, - componentPaths: ReadonlyMap, - marketplace: string | undefined, - baseDir: string, - manifest: Manifest - ): Promise { - if (files.length > 0) await writePluginFiles(files, baseDir, this.fs); - const plugin = Plugin.fromDistributionWithMcp( - dist, - source, - files, - mcpEntries, - componentPaths, - marketplace - ); - manifest.addPlugin(toolId, plugin); - } - - private async mergeOpencodeMcpEntries( - dist: PluginDistribution, - caps: Record, - projectRoot: string, - previousMcpEntries: ReadonlyMap, - toolId: AiToolId - ): Promise<{ mcpEntries: ReadonlyMap; mcpSkips: ReadonlySkipList }> { - const mcpCap = caps.mcp as McpCapability; - const outputRelPath = await mcpCap.resolveOutput(projectRoot, this.fs); - const outputPath = join(projectRoot, outputRelPath); - const existingContent = await this.readExistingJson(outputPath); - const rawMcp = dist.components.mcp[0].content; - const transformed = mcpCap.transform(rawMcp); - const { mergedContent, contributedEntries, collisions } = mergeOpencodeMcp( - existingContent, - transformed, - previousMcpEntries, - this.hasher - ); - if (contributedEntries.size > 0 || previousMcpEntries.size > 0) { - await this.fs.writeFile(outputPath, mergedContent); - } - const mcpSkips = this.collisionsToSkips(collisions, dist.manifest.name, toolId); - return { mcpEntries: contributedEntries, mcpSkips }; - } - - private collisionsToSkips( - collisions: ReadonlyArray, - pluginName: string, - toolId: AiToolId - ): ReadonlySkipList { - return collisions.map( - (reason): PluginTranslationSkip => ({ - pluginName, - component: "mcp", - toolId, - reason, - }) - ); - } - - private async readExistingJson(path: string): Promise { - try { - return await this.fs.readFile(path); - } catch (err) { - if ((err as NodeJS.ErrnoException).code === "ENOENT") return null; - throw err; - } - } -} diff --git a/cli/src/application/use-cases/plugin/translator/plugin-translator-factory.ts b/cli/src/application/use-cases/plugin/translator/plugin-translator-factory.ts deleted file mode 100644 index 5db677697..000000000 --- a/cli/src/application/use-cases/plugin/translator/plugin-translator-factory.ts +++ /dev/null @@ -1,48 +0,0 @@ -import type { PluginsCapability } from "../../../../domain/capabilities/plugins-capability.js"; -import type { FileReader } from "../../../../domain/ports/file-reader.js"; -import type { FileWriter } from "../../../../domain/ports/file-writer.js"; -import type { Hasher } from "../../../../domain/ports/hasher.js"; -import type { MarketplaceRegistry } from "../../../../domain/ports/marketplace-registry.js"; -import type { EnsureBuiltMarketplace } from "../../shared/ensure-built-marketplace-use-case.js"; -import { BuiltTreeMaterializationTranslator } from "./built-tree-materialization-translator.js"; -import { ModeAMarketplaceTranslator } from "./mode-a-marketplace-translator.js"; -import type { PluginTranslator } from "./plugin-translator.js"; - -export interface TranslatorDeps { - fs: FileWriter & FileReader; - hasher: Hasher; - homedir: () => string; - ensureBuilt: EnsureBuiltMarketplace; - marketplaceRegistry: MarketplaceRegistry; -} - -/** - * Resolves the appropriate translation adapter for a given PluginsCapability. - * - * Routing priority: - * 1. `installScope === "user"` or `translationMode === "flat"` → BuiltTreeMaterializationTranslator - * (user-scope tools like Cursor; project-scope flat tools like OpenCode) - * 2. `translationMode === "marketplace"` → ModeAMarketplaceTranslator (Mode A: register in native config) - * 3. otherwise → null (neutral native or unsupported; no translation strategy applies) - * - * Materializing tools copy the per-target BUILT tree verbatim so installed bytes match - * `framework build` output; raw local-path installs fall back to flat materialization. - */ -export function resolveTranslator( - plugins: PluginsCapability, - deps: TranslatorDeps -): PluginTranslator | null { - if (plugins.installScope === "user" || plugins.translationMode === "flat") { - return new BuiltTreeMaterializationTranslator( - deps.fs, - deps.hasher, - deps.homedir, - deps.ensureBuilt, - deps.marketplaceRegistry - ); - } - if (plugins.translationMode === "marketplace") { - return new ModeAMarketplaceTranslator(); - } - return null; -} diff --git a/cli/src/application/use-cases/plugin/translator/plugin-translator.ts b/cli/src/application/use-cases/plugin/translator/plugin-translator.ts deleted file mode 100644 index c53364b79..000000000 --- a/cli/src/application/use-cases/plugin/translator/plugin-translator.ts +++ /dev/null @@ -1,39 +0,0 @@ -import type { Manifest } from "../../../../domain/models/manifest.js"; -import type { PluginDistribution } from "../../../../domain/models/plugin-distribution.js"; -import type { PluginSource } from "../../../../domain/models/plugin-source.js"; -import type { PluginTranslationMode } from "../../../../domain/models/plugin-translation-mode.js"; -import type { ReadonlySkipList } from "../../../../domain/models/plugin-translation-skip.js"; -import type { AiToolId } from "../../../../domain/models/tool-ids.js"; - -/** - * Contract implemented by both translation strategy adapters. - * - * This interface is a translator strategy contract (not a hexagonal port adapter). - * It lives in `application/use-cases/plugin/translator/` following the capability - * sub-use-case subdir pattern (see `.claude/skills/use-case/references/capability-sub-use-cases.md`). - */ -export interface PluginTranslator { - /** Discriminant identifying which translation strategy this adapter implements. */ - readonly mode: PluginTranslationMode; - - /** - * Add a plugin for a specific tool, writing files and/or registering the plugin reference - * in the manifest according to this adapter's strategy. - * - * Returns a skip list — non-empty when the plugin contains components the tool cannot consume — - * and, for strategies that track it, how many files were actually (re)written to disk. - * - * `previousMcpEntries` — pass the plugin's previous mcpEntries when replacing an existing - * plugin install (--replace path). Used for idempotent re-merge of OpenCode MCP servers. - */ - addPlugin( - dist: PluginDistribution, - toolId: AiToolId, - source: PluginSource, - projectRoot: string, - manifest: Manifest, - marketplace: string | undefined, - docsDir: string, - previousMcpEntries?: ReadonlyMap - ): Promise<{ skipped: ReadonlySkipList; written?: number }>; -} diff --git a/cli/src/application/use-cases/plugin/translator/project-hooks-materializer.ts b/cli/src/application/use-cases/plugin/translator/project-hooks-materializer.ts deleted file mode 100644 index 480b9c1ea..000000000 --- a/cli/src/application/use-cases/plugin/translator/project-hooks-materializer.ts +++ /dev/null @@ -1,101 +0,0 @@ -import { join } from "node:path"; -import { - cursorProjectHooksScriptPath, - mergeCursorProjectHooksJson, -} from "../../../../domain/formats/cursor-hooks-project-merge.js"; -import { - type PluginComponentFile, - PluginDistribution, -} from "../../../../domain/models/plugin-distribution.js"; -import type { - PluginTranslationSkip, - ReadonlySkipList, -} from "../../../../domain/models/plugin-translation-skip.js"; -import type { AiToolId } from "../../../../domain/models/tool-ids.js"; -import type { FileReader } from "../../../../domain/ports/file-reader.js"; -import type { FileWriter } from "../../../../domain/ports/file-writer.js"; -import { resolvePluginsCapability } from "../../../../domain/tools/registry.js"; - -const HOOKS_MANIFEST_PATH = "hooks/hooks.json"; - -/** - * Delivers a plugin's hooks to the destination a `hooksDestination: "project"` - * capability names — merged into the project's own hooks file, scripts copied - * beside it — rather than into the plugin's own directory. The single place both - * materialization routes (Mode B flat, and the marketplace-sourced built-tree copy) - * call, so where a tool's hooks land is decided by its own declaration, never by - * which translator happened to run — see measurements.md, Phase 7, Task 2. - */ -export class ProjectHooksMaterializer { - constructor(private readonly fs: FileWriter & FileReader) {} - - async materialize( - dist: PluginDistribution, - toolId: AiToolId, - projectRoot: string - ): Promise { - const pluginsCap = resolvePluginsCapability(toolId); - if (pluginsCap === null || pluginsCap.hooksDestination !== "project") return []; - const manifestFile = dist.components.hooks.find((f) => f.relativePath === HOOKS_MANIFEST_PATH); - if (manifestFile === undefined) return []; - const warnings = await this.mergeProjectHooksJson(dist, manifestFile, projectRoot); - await this.writeProjectHooksScripts(dist, projectRoot); - return warnings.map( - (reason): PluginTranslationSkip => ({ - pluginName: dist.manifest.name, - component: "hooks", - toolId, - reason, - }) - ); - } - - private async mergeProjectHooksJson( - dist: PluginDistribution, - manifestFile: PluginComponentFile, - projectRoot: string - ): Promise { - const destPath = join(projectRoot, ".cursor", "hooks.json"); - const existing = await this.readExistingJson(destPath); - const { content, warnings } = mergeCursorProjectHooksJson( - existing, - manifestFile.content, - dist.manifest.name - ); - await this.fs.writeFile(destPath, content); - return warnings; - } - - private async writeProjectHooksScripts( - dist: PluginDistribution, - projectRoot: string - ): Promise { - for (const file of dist.components.hooks) { - if (file.relativePath === HOOKS_MANIFEST_PATH) continue; - const dest = cursorProjectHooksScriptPath(dist.manifest.name, file.relativePath); - await this.fs.writeFile(join(projectRoot, dest), file.content); - } - } - - private async readExistingJson(path: string): Promise { - try { - return await this.fs.readFile(path); - } catch (err) { - if ((err as NodeJS.ErrnoException).code === "ENOENT") return null; - throw err; - } - } -} - -/** A copy of `dist` with every `hooks/` file dropped, both from `files` (what the - * generic native translator walks) and from `components.hooks` (what a hooks-trust - * notice reads) — for a capability declaring `hooksDestination: "project"`, so none - * of its hooks are written under the plugin's own directory, only via `materialize`. */ -export function withoutHooks(dist: PluginDistribution): PluginDistribution { - return new PluginDistribution({ - manifest: dist.manifest, - format: dist.format, - files: dist.files.filter((f) => f.relativePath.split("/")[0] !== "hooks"), - components: { ...dist.components, hooks: [] }, - }); -} diff --git a/cli/src/application/use-cases/plugin/translator/resolve-plugin-translator.ts b/cli/src/application/use-cases/plugin/translator/resolve-plugin-translator.ts deleted file mode 100644 index 446808056..000000000 --- a/cli/src/application/use-cases/plugin/translator/resolve-plugin-translator.ts +++ /dev/null @@ -1,19 +0,0 @@ -import type { PluginsCapability } from "../../../../domain/capabilities/plugins-capability.js"; -import { isAiTool, type ToolConfig } from "../../../../domain/tools/registry.js"; -import type { PluginTranslator } from "./plugin-translator.js"; -import { resolveTranslator, type TranslatorDeps } from "./plugin-translator-factory.js"; - -/** - * Resolves the plugin translator for a tool, or null when the tool is not an AI - * tool or has no plugins capability. Shared guard used by every call site that - * needs a translator before materializing plugin files. - */ -export function resolvePluginTranslator( - toolConfig: ToolConfig, - deps: TranslatorDeps -): PluginTranslator | null { - if (!isAiTool(toolConfig)) return null; - const caps = toolConfig.capabilities as Record; - if (!("plugins" in caps)) return null; - return resolveTranslator(caps.plugins as PluginsCapability, deps); -} diff --git a/cli/src/application/use-cases/restore/restore-all-plugins-use-case.ts b/cli/src/application/use-cases/restore/restore-all-plugins-use-case.ts deleted file mode 100644 index 7a9e54d94..000000000 --- a/cli/src/application/use-cases/restore/restore-all-plugins-use-case.ts +++ /dev/null @@ -1,109 +0,0 @@ -import { join } from "node:path"; -import type { Manifest } from "../../../domain/models/manifest.js"; -import { PLUGIN_CACHE_SUBDIR } from "../../../domain/models/paths.js"; -import { AI_TOOL_IDS } from "../../../domain/models/tool-ids.js"; -import type { FileReader } from "../../../domain/ports/file-reader.js"; -import type { FileWriter } from "../../../domain/ports/file-writer.js"; -import type { Hasher } from "../../../domain/ports/hasher.js"; -import type { PluginDistributionReader } from "../../../domain/ports/plugin-distribution-reader.js"; -import type { PluginFetcher } from "../../../domain/ports/plugin-fetcher.js"; -import { - getToolConfig, - isAiTool, - type ToolConfig, - type ToolId, -} from "../../../domain/tools/registry.js"; -import { - ApplyPluginFilesUseCase, - type BuiltMaterializationDeps, -} from "../shared/apply-plugin-files-use-case.js"; - -interface RestoreAllPluginsOptions { - projectRoot: string; - manifest: Manifest; - docsDir: string; - fileFilter: ((p: string) => boolean) | null; - pluginName?: string; - /** Restrict which AI tools' plugins get touched. Undefined means every installed AI tool (unscoped). */ - toolIds?: readonly ToolId[]; -} - -export interface RestoreAllPluginsResult { - totalFiles: number; - /** Names of plugins that had >=1 file actually restored, deduped across tools. */ - pluginNames: string[]; -} - -export class RestoreAllPluginsUseCase { - constructor( - private readonly fs: FileReader & FileWriter, - private readonly hasher: Hasher, - private readonly pluginFetcher: PluginFetcher, - private readonly pluginDistributionReader: PluginDistributionReader, - private readonly builtDeps?: BuiltMaterializationDeps - ) {} - - async execute(options: RestoreAllPluginsOptions): Promise { - const { projectRoot, manifest, docsDir, fileFilter, pluginName, toolIds } = options; - const cacheDir = join(projectRoot, PLUGIN_CACHE_SUBDIR); - let totalFiles = 0; - const restoredNames = new Set(); - for (const toolId of AI_TOOL_IDS) { - if (!manifest.hasTool(toolId)) continue; - if (toolIds !== undefined && !toolIds.includes(toolId)) continue; - const toolConfig = getToolConfig(toolId); - if (!isAiTool(toolConfig)) continue; - const result = await this.restoreToolPlugins( - toolId, - manifest, - toolConfig, - projectRoot, - cacheDir, - docsDir, - fileFilter, - pluginName - ); - totalFiles += result.totalFiles; - for (const name of result.pluginNames) restoredNames.add(name); - } - return { totalFiles, pluginNames: [...restoredNames] }; - } - - private async restoreToolPlugins( - toolId: (typeof AI_TOOL_IDS)[number], - manifest: Manifest, - toolConfig: ToolConfig, - projectRoot: string, - cacheDir: string, - docsDir: string, - fileFilter: ((p: string) => boolean) | null, - pluginName: string | undefined - ): Promise { - let totalFiles = 0; - const pluginNames: string[] = []; - const plugins = manifest.getPlugins(toolId); - const targets = - pluginName !== undefined ? plugins.filter((p) => p.name === pluginName) : plugins; - for (const plugin of targets) { - const filesWritten = await new ApplyPluginFilesUseCase( - this.fs, - this.hasher, - this.pluginFetcher, - this.pluginDistributionReader, - this.builtDeps - ).execute({ - toolId, - plugin, - toolConfig, - projectRoot, - cacheDir, - manifest, - docsDir, - fileFilter, - }); - totalFiles += filesWritten; - if (filesWritten > 0) pluginNames.push(plugin.name); - } - return { totalFiles, pluginNames }; - } -} diff --git a/cli/src/application/use-cases/restore/restore-use-case.ts b/cli/src/application/use-cases/restore/restore-use-case.ts deleted file mode 100644 index c4ae313cd..000000000 --- a/cli/src/application/use-cases/restore/restore-use-case.ts +++ /dev/null @@ -1,218 +0,0 @@ -import { join } from "node:path"; -import { - type ConfigRef, - FRAMEWORK_CONFIG_PREFIX, - FrameworkDescriptor, -} from "../../../domain/models/framework.js"; -import type { Manifest } from "../../../domain/models/manifest.js"; -import type { AssetProvider } from "../../../domain/ports/asset-provider.js"; -import type { FileMerger } from "../../../domain/ports/file-merger.js"; -import type { FileReader } from "../../../domain/ports/file-reader.js"; -import type { FileWriter } from "../../../domain/ports/file-writer.js"; -import type { Hasher } from "../../../domain/ports/hasher.js"; -import type { Logger } from "../../../domain/ports/logger.js"; -import type { ManifestRepository } from "../../../domain/ports/manifest-repository.js"; -import type { Platform } from "../../../domain/ports/platform.js"; -import type { PluginDistributionReader } from "../../../domain/ports/plugin-distribution-reader.js"; -import type { PluginFetcher } from "../../../domain/ports/plugin-fetcher.js"; -import type { Prompter } from "../../../domain/ports/prompter.js"; -import type { ToolId } from "../../../domain/tools/registry.js"; -import { NoManifestError } from "../../errors.js"; -import type { BuiltMaterializationDeps } from "../shared/apply-plugin-files-use-case.js"; -import { - type RestoreAllPluginsResult, - RestoreAllPluginsUseCase, -} from "./restore-all-plugins-use-case.js"; -import { - type RestoreToolFilesResult, - RestoreToolFilesUseCase, -} from "./restore-tool-files-use-case.js"; - -const CONFIG_REFS: readonly ConfigRef[] = [ - { name: "mcp", path: `${FRAMEWORK_CONFIG_PREFIX}mcp.json` }, - { name: "vscodeExtensions", path: `${FRAMEWORK_CONFIG_PREFIX}vscode/extensions.json` }, - { name: "vscodeKeybindings", path: `${FRAMEWORK_CONFIG_PREFIX}vscode/keybindings.json` }, - { name: "vscodeSettings", path: `${FRAMEWORK_CONFIG_PREFIX}vscode/settings.json` }, - { name: "opencode", path: `${FRAMEWORK_CONFIG_PREFIX}.opencode/opencode.json` }, -]; - -interface RestoreOptions { - frameworkPath?: string; - version?: string; - docsDir?: string; - projectRoot: string; - toolIds?: ToolId[]; - files?: string[]; - force?: boolean; - interactive?: boolean; - manifest?: Manifest; - pluginName?: string; -} - -interface RestoreCtx { - manifest: Manifest; - descriptor: FrameworkDescriptor; - contentFiles: Map; - docsDir: string; - projectRoot: string; - version: string; - force: boolean; - interactive: boolean; - fileFilter: ((p: string) => boolean) | null; - toolIds: ToolId[]; - pluginName?: string; -} - -interface RestoreResult { - tools: RestoreToolFilesResult[]; - totalRestored: number; - totalKept: number; - totalPluginFilesRestored: number; - restoredPluginNames: string[]; - unrestorable: string[]; -} - -export class RestoreUseCase { - constructor( - private readonly fs: FileReader & FileWriter & FileMerger, - private readonly manifestRepo: ManifestRepository, - private readonly hasher: Hasher, - private readonly logger: Logger, - private readonly platform: Platform, - private readonly prompter: Prompter, - private readonly pluginFetcher?: PluginFetcher, - private readonly pluginDistributionReader?: PluginDistributionReader, - private readonly assetProvider?: AssetProvider, - private readonly builtDeps?: BuiltMaterializationDeps - ) {} - - async execute(options: RestoreOptions): Promise { - const manifest = options.manifest ?? (await this.manifestRepo.load()); - if (manifest === null) throw new NoManifestError(); - const ctx = await this.buildRestoreContext(options, manifest); - return this.executeRestore(ctx); - } - - private async buildRestoreContext( - options: RestoreOptions, - manifest: Manifest - ): Promise { - const resolvedVersion = options.version ?? "unknown"; - return { - manifest, - descriptor: this.buildStaticDescriptor(resolvedVersion), - contentFiles: options.frameworkPath - ? await this.buildContentFiles(options.frameworkPath) - : new Map(), - docsDir: options.docsDir ?? "", - projectRoot: options.projectRoot, - version: resolvedVersion, - force: options.force ?? false, - interactive: options.interactive ?? false, - fileFilter: buildFileFilter(options.files), - toolIds: options.toolIds?.length ? options.toolIds : manifest.getInstalledToolIds(), - pluginName: options.pluginName, - }; - } - - private async executeRestore(ctx: RestoreCtx): Promise { - const toolResults = await this.runToolRestores(ctx); - const pluginResult = await this.runPluginRestore(ctx); - await this.saveIfChanged(toolResults, pluginResult.totalFiles, ctx.manifest); - return this.buildTotals(toolResults, pluginResult); - } - - private async runToolRestores(ctx: RestoreCtx): Promise { - const toolUseCase = new RestoreToolFilesUseCase( - this.fs, - this.hasher, - this.logger, - this.platform, - this.prompter, - this.assetProvider - ); - const results: RestoreToolFilesResult[] = []; - for (const toolId of ctx.toolIds) { - results.push(await toolUseCase.execute({ toolId, ...ctx })); - } - return results; - } - - private async runPluginRestore(ctx: RestoreCtx): Promise { - if (this.pluginFetcher === undefined || this.pluginDistributionReader === undefined) { - return { totalFiles: 0, pluginNames: [] }; - } - return new RestoreAllPluginsUseCase( - this.fs, - this.hasher, - this.pluginFetcher, - this.pluginDistributionReader, - this.builtDeps - ).execute({ - projectRoot: ctx.projectRoot, - manifest: ctx.manifest, - docsDir: ctx.docsDir, - fileFilter: ctx.fileFilter, - pluginName: ctx.pluginName, - toolIds: ctx.toolIds, - }); - } - - private async saveIfChanged( - toolResults: RestoreToolFilesResult[], - totalPluginFilesRestored: number, - manifest: Manifest - ): Promise { - const hasChanges = - toolResults.some((t) => t.restored.length > 0) || totalPluginFilesRestored > 0; - if (hasChanges) await this.manifestRepo.save(manifest); - } - - private buildTotals( - toolResults: RestoreToolFilesResult[], - pluginResult: RestoreAllPluginsResult - ): RestoreResult { - return { - tools: toolResults, - totalRestored: toolResults.reduce((s, t) => s + t.restored.length, 0), - totalKept: toolResults.reduce((s, t) => s + t.kept.length, 0), - totalPluginFilesRestored: pluginResult.totalFiles, - restoredPluginNames: pluginResult.pluginNames, - unrestorable: toolResults.flatMap((t) => t.unrestorable), - }; - } - - private buildStaticDescriptor(version: string): FrameworkDescriptor { - return new FrameworkDescriptor({ - version, - contentSections: [], - templateRefs: [], - configRefs: [...CONFIG_REFS], - }); - } - - private async buildContentFiles(frameworkPath: string): Promise> { - const contentFiles = new Map(); - for (const ref of CONFIG_REFS) { - const absPath = join(frameworkPath, ref.path); - if (await this.fs.fileExists(absPath)) { - contentFiles.set(ref.path, await this.fs.readFile(absPath)); - } - } - return contentFiles; - } -} - -function buildFileFilter(files: string[] | undefined): ((p: string) => boolean) | null { - if (!files || files.length === 0) return null; - return (relativePath: string) => - files.some((entry) => { - const basename = entry.split("/").at(-1) ?? entry; - const isDirectoryPrefix = entry.endsWith("/") || !basename.includes("."); - if (isDirectoryPrefix) { - const prefix = entry.endsWith("/") ? entry : `${entry}/`; - return relativePath.startsWith(prefix); - } - return relativePath === entry; - }); -} diff --git a/cli/src/application/use-cases/setup-use-case.ts b/cli/src/application/use-cases/setup-use-case.ts deleted file mode 100644 index 2bcd386ea..000000000 --- a/cli/src/application/use-cases/setup-use-case.ts +++ /dev/null @@ -1,178 +0,0 @@ -import { CatalogFetchAuthError } from "../../domain/errors.js"; -import type { MarketplaceSourceMode } from "../../domain/models/marketplace-source-mode.js"; -import type { PluginSource } from "../../domain/models/plugin-source.js"; -import type { ProjectContext } from "../../domain/models/project-context.js"; -import type { SetupFlow } from "../../domain/models/setup-flow.js"; -import type { AiToolId, IdeToolId } from "../../domain/models/tool-ids.js"; -import type { FileReader } from "../../domain/ports/file-reader.js"; -import type { FileWriter } from "../../domain/ports/file-writer.js"; -import type { LatestReleaseResolver } from "../../domain/ports/latest-release-resolver.js"; -import type { ManifestRepository } from "../../domain/ports/manifest-repository.js"; -import type { TokenProvider } from "../../domain/ports/token-provider.js"; -import type { VersionReader } from "../../domain/ports/version-reader.js"; -import { InitUseCase } from "./init-use-case.js"; -import type { MarketplaceRefresh } from "./marketplace/marketplace-refresh-use-case.js"; -import type { - MarketplaceRegisterFramework, - MarketplaceRegisterFrameworkOptions, -} from "./marketplace/marketplace-register-framework-use-case.js"; -import type { MarketplaceSyncSettings } from "./marketplace/marketplace-sync-settings-use-case.js"; -import type { ProjectContextDetectorUseCase } from "./setup/project-context-detector-use-case.js"; -import type { SetupMarketplaceSourceUseCase } from "./setup/setup-marketplace-source-use-case.js"; -import type { SetupPluginsPromptUseCase } from "./setup/setup-plugins-prompt-use-case.js"; -import type { SetupToolsPromptUseCase } from "./setup/setup-tools-prompt-use-case.js"; -import type { SetupToolsResult, SetupToolsUseCase } from "./setup/setup-tools-use-case.js"; - -export type { ToolInstallResult } from "./setup/setup-tools-use-case.js"; -export type { SetupToolsResult }; - -export type SetupResult = - | { kind: "initialized"; install: SetupToolsResult; context?: ProjectContext } - | { kind: "up-to-date"; install: SetupToolsResult; context?: ProjectContext }; - -export class SetupUseCase { - constructor( - private readonly fs: FileReader & FileWriter, - private readonly manifestRepo: ManifestRepository, - private readonly setupMarketplaceSourceUseCase: SetupMarketplaceSourceUseCase, - private readonly marketplaceRegisterFrameworkUseCase: MarketplaceRegisterFramework, - private readonly marketplaceRefreshUseCase: MarketplaceRefresh, - private readonly marketplaceSyncSettingsUseCase: MarketplaceSyncSettings, - private readonly setupToolsUseCase: SetupToolsUseCase, - private readonly setupPluginsPromptUseCase: SetupPluginsPromptUseCase, - private readonly currentVersionProvider: VersionReader, - private readonly tokenProvider?: TokenProvider, - private readonly setupToolsPromptUseCase?: SetupToolsPromptUseCase, - private readonly projectContextDetector?: ProjectContextDetectorUseCase, - private readonly releaseResolver?: LatestReleaseResolver - ) {} - - async execute(flow: SetupFlow): Promise { - const context = await this.detectContext(flow); - const isNew = await this.initManifest(flow); - if (flow.registerDefaultMarketplace) { - const source = await this.resolveSource(flow); - await this.guardRemoteAuth(source); - await this.registerMarketplace(flow, source); - await this.refreshCatalog(flow); - } - const install = await this.installTools(flow, context); - if (flow.registerDefaultMarketplace) await this.promptPlugins(flow); - await this.syncSettings(flow); - return this.buildResult(isNew, install, context); - } - - private async detectContext(flow: SetupFlow): Promise { - if (this.projectContextDetector === undefined) return undefined; - return this.projectContextDetector.execute({ projectRoot: flow.projectRoot }); - } - - private async syncSettings(flow: SetupFlow): Promise { - await this.marketplaceSyncSettingsUseCase.execute({ projectRoot: flow.projectRoot }); - } - - private async resolveSource(flow: SetupFlow): Promise { - return this.setupMarketplaceSourceUseCase.execute({ - projectRoot: flow.projectRoot, - sourceFromCli: flow.source, - interactive: flow.interactive, - }); - } - - private async initManifest(flow: SetupFlow): Promise { - const existing = await this.manifestRepo.load(); - if (existing !== null) return false; - await new InitUseCase(this.fs, this.manifestRepo).execute({ - projectRoot: flow.projectRoot, - force: false, - }); - return true; - } - - // Auth is only required to fetch a PRIVATE framework. A token can reach either; - // without one, allow public repos through and gate only private/unreachable ones. - private async guardRemoteAuth(source: MarketplaceSourceMode): Promise { - if (source.kind !== "remote") return; - if (this.tokenProvider === undefined) return; - const token = await this.tokenProvider.resolve(); - if (token !== null) return; - if ( - this.releaseResolver !== undefined && - (await this.releaseResolver.isRepoPublic(source.repo)) - ) { - return; - } - throw new CatalogFetchAuthError(`https://github.com/${source.repo}`); - } - - private async registerMarketplace(flow: SetupFlow, source: MarketplaceSourceMode): Promise { - const opts = this.buildRegisterOptions(flow, source); - await this.marketplaceRegisterFrameworkUseCase.execute(opts); - } - - private buildRegisterOptions( - flow: SetupFlow, - source: MarketplaceSourceMode - ): MarketplaceRegisterFrameworkOptions { - const pluginSource = this.toPluginSource(source); - return { projectRoot: flow.projectRoot, pluginSource, force: true }; - } - - private toPluginSource(source: MarketplaceSourceMode): PluginSource { - if (source.kind === "local") return { kind: "local", path: source.path }; - return { kind: "github", repo: source.repo, ref: source.ref }; - } - - private async refreshCatalog(flow: SetupFlow): Promise { - if (process.env.AIDD_SKIP_MARKETPLACE_REFRESH === "1") return; - await this.marketplaceRefreshUseCase.execute({ projectRoot: flow.projectRoot }); - } - - private async installTools( - flow: SetupFlow, - context: ProjectContext | undefined - ): Promise { - const { aiTools, ideTools } = await this.resolveTools(flow, context); - const version = this.currentVersionProvider.get(); - return this.setupToolsUseCase.execute({ - projectRoot: flow.projectRoot, - aiTools, - ideTools, - force: flow.force, - version, - }); - } - - private async resolveTools( - flow: SetupFlow, - context: ProjectContext | undefined - ): Promise<{ aiTools: readonly AiToolId[]; ideTools: readonly IdeToolId[] }> { - if (this.setupToolsPromptUseCase === undefined) { - return { aiTools: flow.aiTools as AiToolId[], ideTools: flow.ideTools as IdeToolId[] }; - } - return this.setupToolsPromptUseCase.execute({ - interactive: flow.interactive, - aiTools: flow.aiTools as AiToolId[], - ideTools: flow.ideTools as IdeToolId[], - context, - }); - } - - private async promptPlugins(flow: SetupFlow): Promise { - await this.setupPluginsPromptUseCase.execute({ - projectRoot: flow.projectRoot, - mode: flow.pluginMode, - pluginNames: [...flow.pluginNames], - interactive: flow.interactive, - }); - } - - private buildResult( - isNew: boolean, - install: SetupToolsResult, - context: ProjectContext | undefined - ): SetupResult { - if (isNew) return { kind: "initialized", install, context }; - return { kind: "up-to-date", install, context }; - } -} diff --git a/cli/src/application/use-cases/shared/apply-plugin-files-use-case.ts b/cli/src/application/use-cases/shared/apply-plugin-files-use-case.ts deleted file mode 100644 index 801d7e7bd..000000000 --- a/cli/src/application/use-cases/shared/apply-plugin-files-use-case.ts +++ /dev/null @@ -1,122 +0,0 @@ -import { join } from "node:path"; -import type { Manifest } from "../../../domain/models/manifest.js"; -import type { Plugin } from "../../../domain/models/plugin.js"; -import { PluginContentTranslator } from "../../../domain/models/plugin-content-translator.js"; -import type { PluginDistribution } from "../../../domain/models/plugin-distribution.js"; -import type { AiToolId } from "../../../domain/models/tool-ids.js"; -import type { FileReader } from "../../../domain/ports/file-reader.js"; -import type { FileWriter } from "../../../domain/ports/file-writer.js"; -import type { Hasher } from "../../../domain/ports/hasher.js"; -import type { MarketplaceRegistry } from "../../../domain/ports/marketplace-registry.js"; -import type { PluginDistributionReader } from "../../../domain/ports/plugin-distribution-reader.js"; -import type { PluginFetcher } from "../../../domain/ports/plugin-fetcher.js"; -import type { ToolConfig } from "../../../domain/tools/registry.js"; -import { - deleteOldFiles, - isPluginFileAtDesiredState, - materializeViaTranslator, -} from "../plugin/plugin-file-sync.js"; -import { resolvePluginBaseDir } from "../plugin/plugin-target-resolution.js"; -import type { PluginTranslator } from "../plugin/translator/plugin-translator.js"; -import { resolvePluginTranslator } from "../plugin/translator/resolve-plugin-translator.js"; -import type { EnsureBuiltMarketplace } from "./ensure-built-marketplace-use-case.js"; - -interface ApplyPluginFilesOptions { - toolId: AiToolId; - plugin: Plugin; - toolConfig: ToolConfig; - projectRoot: string; - cacheDir: string; - manifest: Manifest; - docsDir: string; - fileFilter?: ((relativePath: string) => boolean) | null; -} - -/** Optional deps that let restore re-materialize via the build pipeline (parity with install). */ -export interface BuiltMaterializationDeps { - ensureBuilt: EnsureBuiltMarketplace; - marketplaceRegistry: MarketplaceRegistry; - homedir: () => string; -} - -export class ApplyPluginFilesUseCase { - constructor( - private readonly fs: FileReader & FileWriter, - private readonly hasher: Hasher, - private readonly pluginFetcher: PluginFetcher, - private readonly pluginDistributionReader: PluginDistributionReader, - private readonly builtDeps?: BuiltMaterializationDeps - ) {} - - async execute(options: ApplyPluginFilesOptions): Promise { - const localPath = await this.pluginFetcher.fetch(options.plugin.source, options.cacheDir); - const dist = await this.pluginDistributionReader.read(localPath); - const translator = this.resolveTranslator(options.toolConfig); - if (translator !== null && options.plugin.marketplace !== undefined) { - return this.restoreViaTranslator(translator, dist, options); - } - return this.restoreViaTranslate(dist, options); - } - - // Materializing tools (cursor/opencode) must re-materialize from the BUILT tree so - // restored content + hashes match what install wrote, and Mode A marketplace tools - // (claude/codex/copilot) must re-register without writing files — not the raw source - // transform in either case. - private resolveTranslator(toolConfig: ToolConfig): PluginTranslator | null { - if (this.builtDeps === undefined) return null; - return resolvePluginTranslator(toolConfig, { - fs: this.fs, - hasher: this.hasher, - homedir: this.builtDeps.homedir, - ensureBuilt: this.builtDeps.ensureBuilt, - marketplaceRegistry: this.builtDeps.marketplaceRegistry, - }); - } - - private async restoreViaTranslator( - translator: PluginTranslator, - dist: PluginDistribution, - options: ApplyPluginFilesOptions - ): Promise { - const { toolId, plugin, projectRoot, manifest, docsDir } = options; - // Mode A never materializes files, so any manifest-tracked path here is a leftover - // from a run before that was true (see plugin-update-use-case.ts's unconditional - // equivalent). Scoped to the manifest's own keys under the plugin's base dir — never - // a directory scan — so it cannot touch files the plugin never wrote. - if (translator.mode === "marketplace" && this.builtDeps !== undefined) { - const baseDir = resolvePluginBaseDir(toolId, projectRoot, this.builtDeps.homedir); - await deleteOldFiles(plugin.files, baseDir, this.fs); - } - return materializeViaTranslator( - translator, - dist, - toolId, - plugin, - projectRoot, - manifest, - docsDir - ); - } - - private async restoreViaTranslate( - dist: PluginDistribution, - options: ApplyPluginFilesOptions - ): Promise { - const { toolId, plugin, toolConfig, projectRoot, manifest, docsDir, fileFilter } = options; - const files = new PluginContentTranslator(this.hasher).translate(dist, toolConfig, docsDir); - let restored = 0; - for (const f of files) { - if (fileFilter !== null && fileFilter !== undefined && !fileFilter(f.relativePath)) continue; - const outputPath = join(projectRoot, f.relativePath); - if (!(await isPluginFileAtDesiredState(this.fs, this.hasher, outputPath, f.hash.value))) { - await this.fs.writeFile(outputPath, f.content); - restored++; - } - } - manifest.updatePlugin( - toolId, - plugin.withFiles(new Map(files.map((f) => [f.relativePath, f.hash.value]))) - ); - return restored; - } -} diff --git a/cli/src/application/use-cases/shared/detect-plugin-drift-use-case.ts b/cli/src/application/use-cases/shared/detect-plugin-drift-use-case.ts deleted file mode 100644 index 5693131c4..000000000 --- a/cli/src/application/use-cases/shared/detect-plugin-drift-use-case.ts +++ /dev/null @@ -1,70 +0,0 @@ -import { homedir } from "node:os"; -import { join } from "node:path"; -import type { Manifest } from "../../../domain/models/manifest.js"; -import type { AiToolId } from "../../../domain/models/tool-ids.js"; -import type { FileReader } from "../../../domain/ports/file-reader.js"; -import type { ToolId } from "../../../domain/tools/registry.js"; -import { resolvePluginBaseDir } from "../plugin/plugin-target-resolution.js"; - -export type PluginFileDriftKind = "missing" | "hash-mismatch"; - -export interface PluginFileDrift { - relativePath: string; - kind: PluginFileDriftKind; -} - -export interface PluginDrift { - toolId: AiToolId; - pluginName: string; - files: PluginFileDrift[]; -} - -export interface DetectPluginDriftOptions { - manifest: Manifest; - projectRoot: string; - toolIds: Iterable; - pluginName?: string; -} - -/** - * Single source of truth for "which of a plugin's installed files no longer match - * the manifest". `status` and `doctor` both project this into their own shapes. - */ -export class DetectPluginDriftUseCase { - constructor(private readonly fs: FileReader) {} - - async execute(options: DetectPluginDriftOptions): Promise { - const { manifest, projectRoot, toolIds, pluginName } = options; - const drifts: PluginDrift[] = []; - for (const id of toolIds) { - const toolId = id as AiToolId; - const plugins = manifest.getPlugins(toolId); - const targets = pluginName ? plugins.filter((p) => p.name === pluginName) : plugins; - const baseDir = resolvePluginBaseDir(toolId, projectRoot, homedir); - for (const plugin of targets) { - const files = await this.driftedFiles(plugin.files, baseDir); - if (files.length > 0) drifts.push({ toolId, pluginName: plugin.name, files }); - } - } - return drifts; - } - - private async driftedFiles( - files: ReadonlyMap, - baseDir: string - ): Promise { - const drifted: PluginFileDrift[] = []; - for (const [relativePath, expectedHash] of files.entries()) { - const fullPath = join(baseDir, relativePath); - if (!(await this.fs.fileExists(fullPath))) { - drifted.push({ relativePath, kind: "missing" }); - continue; - } - const diskHash = await this.fs.readFileHash(fullPath); - if (diskHash.value !== expectedHash) { - drifted.push({ relativePath, kind: "hash-mismatch" }); - } - } - return drifted; - } -} diff --git a/cli/src/application/use-cases/shared/ensure-built-marketplace-use-case.ts b/cli/src/application/use-cases/shared/ensure-built-marketplace-use-case.ts deleted file mode 100644 index 3b4633a5a..000000000 --- a/cli/src/application/use-cases/shared/ensure-built-marketplace-use-case.ts +++ /dev/null @@ -1,166 +0,0 @@ -import { tmpdir } from "node:os"; -import { join, resolve } from "node:path"; -import type { - FrameworkBuildMode, - FrameworkBuildTarget, -} from "../../../domain/models/framework-build.js"; -import type { Marketplace } from "../../../domain/models/marketplace.js"; -import { builtMarketplaceDir, pathsOverlap } from "../../../domain/models/paths.js"; -import type { FileReader } from "../../../domain/ports/file-reader.js"; -import type { FileWriter } from "../../../domain/ports/file-writer.js"; -import type { VersionReader } from "../../../domain/ports/version-reader.js"; -import type { FrameworkBuild } from "../framework/framework-build-use-case.js"; -import type { ResolveMarketplaceUseCase } from "./resolve-marketplace-use-case.js"; - -/** Builds a FrameworkBuildUseCase for a target/mode writing to outDir, or undefined when unsupported. */ -export type FrameworkBuildFor = ( - target: FrameworkBuildTarget, - mode: FrameworkBuildMode, - outDir: string -) => FrameworkBuild | undefined; - -export interface EnsureBuiltMarketplaceOptions { - readonly projectRoot: string; - readonly marketplace: Marketplace; - readonly target: FrameworkBuildTarget; - readonly mode: FrameworkBuildMode; - readonly forceRefresh?: boolean; -} - -export interface EnsureBuiltMarketplaceResult { - readonly builtDir: string; - readonly version: string | undefined; - readonly rebuilt: boolean; -} - -const SENTINEL_FILE = ".build-version"; -const UNVERSIONED = "unversioned"; - -/** - * Guarantees a per-target built tree exists in cache for a marketplace, so install - * consumers read the SAME transformed content `framework build` produces. Build is - * the single source of truth; this owns source resolution, staleness, and the - * guard-safe outDir (build to temp then copy when the cache nests under the source). - */ -/** Getting a built tree for a target, as its callers need it. */ -export interface EnsureBuiltMarketplace { - execute(options: EnsureBuiltMarketplaceOptions): Promise; -} - -export class EnsureBuiltMarketplaceUseCase implements EnsureBuiltMarketplace { - private readonly memo = new Map(); - - constructor( - private readonly fs: FileReader & FileWriter, - private readonly resolveMarketplace: ResolveMarketplaceUseCase, - private readonly buildFor: FrameworkBuildFor, - private readonly version: VersionReader - ) {} - - async execute(options: EnsureBuiltMarketplaceOptions): Promise { - // resolve(), matching sourceDir below: builtMarketplaceDir() joins with the platform - // separator, and on Windows a drive-less projectRoot yields a drive-less builtDir here - // while FrameworkBuildUseCase.execute() resolves its own outDir copy for validation only - // - leaving FlatBuildStrategy's write target (captured unresolved at construction) to - // diverge from the path that gets checked. - const builtDir = resolve( - builtMarketplaceDir(options.projectRoot, options.marketplace.name, options.target) - ); - const resolved = await this.resolveMarketplace.execute({ - marketplace: options.marketplace, - projectRoot: options.projectRoot, - forceRefresh: options.forceRefresh, - }); - const sentinel = this.sentinelValue(resolved.catalog?.version); - const memoKey = `${options.marketplace.name}:${options.target}:${sentinel}`; - const memoized = this.memo.get(memoKey); - if (memoized !== undefined) return memoized; - const result = await this.ensure(options, builtDir, resolve(resolved.localPath), sentinel); - this.memo.set(memoKey, result); - return result; - } - - private sentinelValue(catalogVersion: string | undefined): string { - return `${this.version.get()}:${catalogVersion ?? UNVERSIONED}`; - } - - private async ensure( - options: EnsureBuiltMarketplaceOptions, - builtDir: string, - sourceDir: string, - sentinel: string - ): Promise { - const version = sentinel.split(":")[1]; - if (await this.isFresh(builtDir, sentinel)) { - return { builtDir, version, rebuilt: false }; - } - await this.build(options.target, options.mode, sourceDir, builtDir); - await this.fs.writeFile(join(builtDir, SENTINEL_FILE), sentinel); - return { builtDir, version, rebuilt: true }; - } - - private async isFresh(builtDir: string, sentinel: string): Promise { - if (sentinel.endsWith(`:${UNVERSIONED}`)) return false; - const path = join(builtDir, SENTINEL_FILE); - if (!(await this.fs.fileExists(path))) return false; - const current = await this.fs.readFile(path).catch(() => ""); - return current === sentinel; - } - - private async build( - target: FrameworkBuildTarget, - mode: FrameworkBuildMode, - sourceDir: string, - builtDir: string - ): Promise { - if (this.nested(sourceDir, builtDir)) { - await this.buildViaTemp(target, mode, sourceDir, builtDir); - return; - } - await this.runBuild(target, mode, sourceDir, builtDir); - } - - private nested(sourceDir: string, builtDir: string): boolean { - return pathsOverlap(sourceDir, builtDir); - } - - private async buildViaTemp( - target: FrameworkBuildTarget, - mode: FrameworkBuildMode, - sourceDir: string, - builtDir: string - ): Promise { - const temp = join(tmpdir(), `aidd-built-${target}-${mode}`); - await this.fs.deleteDirectory(temp); - await this.runBuild(target, mode, sourceDir, temp); - await this.fs.deleteDirectory(builtDir); - await this.copyDir(temp, builtDir); - await this.fs.deleteDirectory(temp); - } - - // Every outDir reaching this method (see build() and buildViaTemp() above) is either - // builtMarketplaceDir() or a temp dir this class just deleteDirectory'd — an aidd-owned - // cache, never a user directory — so a collision here is stale-cache reuse, not data loss. - private async runBuild( - target: FrameworkBuildTarget, - mode: FrameworkBuildMode, - sourceDir: string, - outDir: string - ): Promise { - await this.fs.createDirectory(outDir); - const build = this.buildFor(target, mode, outDir); - if (build === undefined) { - throw new Error(`No framework build for target '${target}' mode '${mode}'.`); - } - await build.execute({ sourceDir, outDir, target, mode }); - } - - private async copyDir(from: string, to: string): Promise { - const files = await this.fs.listFilesRecursive(from); - for (const abs of files) { - const rel = abs.slice(from.length + 1); - const content = await this.fs.readFile(abs); - await this.fs.writeFile(join(to, rel), content); - } - } -} diff --git a/cli/src/application/use-cases/shared/post-install-pipeline-use-case.ts b/cli/src/application/use-cases/shared/post-install-pipeline-use-case.ts deleted file mode 100644 index c954e15fd..000000000 --- a/cli/src/application/use-cases/shared/post-install-pipeline-use-case.ts +++ /dev/null @@ -1,25 +0,0 @@ -import type { Manifest } from "../../../domain/models/manifest.js"; -import { AIDD_DIR, DOCS_DIR } from "../../../domain/models/paths.js"; -import type { ManifestRepository } from "../../../domain/ports/manifest-repository.js"; -import type { GitignoreUseCase } from "./gitignore-use-case.js"; - -interface PostInstallPipelineOptions { - projectRoot: string; - manifest: Manifest; -} - -export class PostInstallPipelineUseCase { - constructor( - private readonly manifestRepo: ManifestRepository, - private readonly gitignoreUseCase: GitignoreUseCase - ) {} - - async execute(options: PostInstallPipelineOptions): Promise { - const { projectRoot, manifest } = options; - - await this.manifestRepo.save(manifest); - // The run journal: who worked on what, for how long, and every file a session wrote. - // It belongs to the repository it describes, so it must never be offered to a commit. - await this.gitignoreUseCase.execute(projectRoot, [`${AIDD_DIR}/cache/`, `${DOCS_DIR}/runs/`]); - } -} diff --git a/cli/src/application/use-cases/shared/resolve-update-decision-use-case.ts b/cli/src/application/use-cases/shared/resolve-update-decision-use-case.ts deleted file mode 100644 index 456f29925..000000000 --- a/cli/src/application/use-cases/shared/resolve-update-decision-use-case.ts +++ /dev/null @@ -1,69 +0,0 @@ -import type { Prompter } from "../../../domain/ports/prompter.js"; -import { InputRequiredError } from "../../errors.js"; - -type BulkDecision = "overwrite-all" | "skip-all"; - -/** - * Shared mutable state for bulk conflict resolution within a single update run. - * Created once per invocation in the fan-out use-case; the same reference is passed - * to every UpdateOneToolUseCase call so that "overwrite all" / "skip all" persists - * across tools and files. - */ -export class BulkConflictState { - private decision: BulkDecision | null = null; - - isSet(): boolean { - return this.decision !== null; - } - - get(): BulkDecision | null { - return this.decision; - } - - record(choice: BulkDecision): void { - this.decision = choice; - } -} - -export interface ResolveUpdateDecisionOptions { - relativePath: string; - userForce: boolean; - interactive: boolean; - bulkState: BulkConflictState; -} - -/** - * Decides whether to overwrite a user-modified file during an update. - * Returns true when the file should be written (overwrite), false when it should be kept. - * Throws InputRequiredError when force=false and interactive=false (non-TTY, no --force). - * - * Unmodified files are handled by the caller — this use-case is only consulted for modified files. - */ -export class ResolveUpdateDecisionUseCase { - constructor(private readonly prompter: Prompter) {} - - async execute(options: ResolveUpdateDecisionOptions): Promise { - const { relativePath, userForce, interactive, bulkState } = options; - if (!userForce && !interactive) { - throw new InputRequiredError( - `Use --force to overwrite modified files in non-interactive mode.` - ); - } - if (userForce) return true; - return this.resolveInteractive(relativePath, bulkState); - } - - private async resolveInteractive( - relativePath: string, - bulkState: BulkConflictState - ): Promise { - const existing = bulkState.get(); - if (existing === "overwrite-all") return true; - if (existing === "skip-all") return false; - const decision = await this.prompter.resolveConflictBulk(relativePath, "modified"); - if (decision === "overwrite-all" || decision === "skip-all") { - bulkState.record(decision); - } - return decision === "overwrite" || decision === "overwrite-all"; - } -} diff --git a/cli/src/application/use-cases/shared/restore-drift-entries-use-case.ts b/cli/src/application/use-cases/shared/restore-drift-entries-use-case.ts deleted file mode 100644 index ed675323f..000000000 --- a/cli/src/application/use-cases/shared/restore-drift-entries-use-case.ts +++ /dev/null @@ -1,76 +0,0 @@ -import type { Prompter } from "../../../domain/ports/prompter.js"; -import { ResolveRestoreDecisionUseCase } from "./resolve-restore-decision.js"; - -export interface DriftDescriptor { - relativePath: string; - reason: "deleted" | "modified"; -} - -/** - * What a leaf's drift scan found: entries that can actually be restored (`drift`), - * and entries the manifest still tracks as drifted but the current distribution no - * longer provides anything to restore them from (`unrestorable`) — e.g. a file - * dropped in a newer framework version, or a tool whose content set changed. - */ -export interface DriftCollection { - drift: TDrift[]; - unrestorable: DriftDescriptor[]; -} - -/** - * The I/O leaf: everything that differs between restoring a whole file and - * merging drifted keys back into one. The skeleton below never branches on - * which leaf it is running — it only calls these three methods. - */ -export interface RestoreDriftLeaf { - collectDrift(): Promise>; - restore(entry: TDrift): Promise; - buildResult(restored: string[], kept: string[], unrestorable: string[]): TResult; -} - -/** - * Shared skeleton for both restore flows: collect drift, delegate the - * keep/overwrite decision to ResolveRestoreDecisionUseCase, then partition - * into restored/kept. This is the single place that decision logic lives — - * both restore use-cases inject their own leaf instead of duplicating the loop. - */ -export class RestoreDriftEntriesUseCase { - private readonly resolveDecision: ResolveRestoreDecisionUseCase; - - constructor(prompter: Prompter) { - this.resolveDecision = new ResolveRestoreDecisionUseCase(prompter); - } - - async execute( - leaf: RestoreDriftLeaf, - force: boolean, - interactive: boolean - ): Promise { - const { drift, unrestorable } = await leaf.collectDrift(); - if (drift.length === 0 && unrestorable.length === 0) return null; - - const restored: string[] = []; - const kept: string[] = []; - - for (const entry of drift) { - const skip = await this.resolveDecision.execute({ - relativePath: entry.relativePath, - reason: entry.reason, - force, - interactive, - }); - if (skip) { - kept.push(entry.relativePath); - continue; - } - await leaf.restore(entry); - restored.push(entry.relativePath); - } - - return leaf.buildResult( - restored, - kept, - unrestorable.map((entry) => entry.relativePath) - ); - } -} diff --git a/cli/src/application/use-cases/sync/sync-conflict-resolver-use-case.ts b/cli/src/application/use-cases/sync/sync-conflict-resolver-use-case.ts deleted file mode 100644 index f1cdc0c92..000000000 --- a/cli/src/application/use-cases/sync/sync-conflict-resolver-use-case.ts +++ /dev/null @@ -1,79 +0,0 @@ -import type { FileReader } from "../../../domain/ports/file-reader.js"; - -/** - * Determines whether a target file is in conflict (modified since last sync). - * A conflict occurs when the disk file differs from its manifest hash. - * Does NOT prompt the user — conflict recording is handled by the caller. - */ -export class SyncConflictResolverUseCase { - constructor(private readonly fs: FileReader) {} - - /** Returns true when the target file exists and its disk hash differs from its manifest hash. */ - async isConflict( - diskTargetPath: string, - diskTargetExists: boolean, - targetRelativePath: string, - targetManifestMap: Map - ): Promise { - if (!diskTargetExists) return false; - const diskTargetHash = await this.fs.readFileHash(diskTargetPath); - const targetManifestHash = targetManifestMap.get(targetRelativePath); - return targetManifestHash !== undefined && diskTargetHash.value !== targetManifestHash.value; - } - - /** - * Resolves the write outcome for a target file given transformed content. - * Returns the outcome ("skipped" | "conflict" | "write") and whether a conflict was detected. - * The conflict flag is true even for "write" when force overrides a detected conflict. - */ - async resolveWriteOutcome(opts: { - diskTargetPath: string; - diskTargetExists: boolean; - targetRelativePath: string; - targetManifestMap: Map; - targetContent: string; - force: boolean; - }): Promise<{ outcome: "skipped" | "conflict" | "write"; conflict: boolean }> { - const { - diskTargetPath, - diskTargetExists, - targetRelativePath, - targetManifestMap, - targetContent, - force, - } = opts; - - if (diskTargetExists && (await this.fs.readFile(diskTargetPath)) === targetContent) { - return { outcome: "skipped", conflict: false }; - } - - const conflict = await this.isConflict( - diskTargetPath, - diskTargetExists, - targetRelativePath, - targetManifestMap - ); - - if (conflict && !force) return { outcome: "conflict", conflict: true }; - return { outcome: "write", conflict }; - } - - /** - * Simplified conflict check for plugin-file propagation (no manifest map lookup needed). - * Returns true when the target file exists and the new content differs from disk content. - * In plugin propagation, any existing target file is considered a potential overwrite; - * conflict is detected when force is false and the file exists. - */ - async resolvePluginWriteOutcome(opts: { - diskTargetPath: string; - targetContent: string; - force: boolean; - }): Promise<"skipped" | "conflict" | "write"> { - const { diskTargetPath, targetContent, force } = opts; - const exists = await this.fs.fileExists(diskTargetPath); - - if (exists && (await this.fs.readFile(diskTargetPath)) === targetContent) return "skipped"; - if (!force && exists) return "conflict"; - return "write"; - } -} diff --git a/cli/src/application/use-cases/telemetry/diagnose-telemetry-use-case.ts b/cli/src/application/use-cases/telemetry/diagnose-telemetry-use-case.ts deleted file mode 100644 index a066c6021..000000000 --- a/cli/src/application/use-cases/telemetry/diagnose-telemetry-use-case.ts +++ /dev/null @@ -1,344 +0,0 @@ -import { describeError } from "../../../domain/describe-error.js"; -import { - SESSION_TRAILER_DELEGATE_FILE, - SESSION_TRAILER_TOKEN, -} from "../../../domain/formats/commit-session-trailer.js"; -import { resolveSessionAnchor } from "../../../domain/models/session-anchor.js"; -import { - attributeMoment, - buildStepIntervals, - type StepAttributionSource, -} from "../../../domain/models/step-attribution.js"; -import { - diagnoseTelemetryClaims, - type TelemetryClaim, - type TelemetryClaimJournal, - type TelemetryClaimToolRead, - type TelemetryCodexHookTrust, - type TelemetryEvidence, -} from "../../../domain/models/telemetry-claim.js"; -import type { TelemetryExportLeftover } from "../../../domain/models/telemetry-export-leftover.js"; -import { - buildHostRegistration, - buildTelemetryAllowedSetup, - type TelemetryHostRegistrationSetup, - type TelemetryIdentitySetup, - type TelemetryPluginVersionSetup, - type TelemetryRecorderDeclarationSetup, - type TelemetrySetup, -} from "../../../domain/models/telemetry-setup.js"; -import { AI_TOOL_IDS, type AiToolId } from "../../../domain/models/tool-ids.js"; -import type { HookTrustReader } from "../../../domain/ports/hook-trust-reader.js"; -import type { HostPluginRegistryReader } from "../../../domain/ports/host-plugin-registry-reader.js"; -import type { ManifestRepository } from "../../../domain/ports/manifest-repository.js"; -import type { PersonIdentityStore } from "../../../domain/ports/person-identity-store.js"; -import type { RunJournal, RunJournalReader } from "../../../domain/ports/run-journal-reader.js"; -import type { SessionCostReader } from "../../../domain/ports/session-cost-reader.js"; -import type { TelemetryEvidenceReader } from "../../../domain/ports/telemetry-evidence-reader.js"; -import type { TelemetrySink } from "../../../domain/ports/telemetry-sink.js"; -import type { VersionControl } from "../../../domain/ports/version-control.js"; -import type { VersionReader } from "../../../domain/ports/version-reader.js"; -import { getAiToolConfig, resolvePluginsCapability } from "../../../domain/tools/registry.js"; - -const DEFAULT_RUNS_DIR_LABEL = "aidd_docs/runs"; - -export interface DiagnoseTelemetryUncoveredTool { - readonly tool: AiToolId; - readonly reason: string; -} - -/** What `aidd telemetry check` answers with. `gate`, when present, is a reason the run - * stopped before judging any claim at all — measurement off, or no repository — and is - * mutually exclusive with `claims`: a gated run judges nothing, the same rule that keeps - * absent evidence from ever producing an `ok`. `leftoverExportConfig` is neither a claim - * nor gated by one: a stale export lives in a tool's own settings file, independent of - * whether the local switch is on, so it is gathered and reported either way. `setup` is - * gathered the same way, on both sides of the gate: what is in place is exactly what a - * person switched off still needs to see, never reduced to the one-line gate message - * alone. */ -export type DiagnoseTelemetryResult = - | { - readonly gate: string; - readonly setup: TelemetrySetup; - readonly leftoverExportConfig: readonly TelemetryExportLeftover[]; - } - | { - readonly gate?: undefined; - readonly setup: TelemetrySetup; - readonly claims: readonly TelemetryClaim[]; - readonly uncovered: readonly DiagnoseTelemetryUncoveredTool[]; - readonly leftoverExportConfig: readonly TelemetryExportLeftover[]; - }; - -/** How far back the trailer count looks. Twenty rather than a date: the cost is the same on - * any repository, and it is enough that a person measuring for a week sees whether their - * commits are being stamped without the answer drowning in history from before they were. */ -const COMMITS_EXAMINED_FOR_TRAILER = 20; - -export interface DiagnoseTelemetryOptions { - readonly projectRoot: string; - readonly env: NodeJS.ProcessEnv; -} - -function toClaimJournal(journal: RunJournal): TelemetryClaimJournal { - return { - vendorId: journal.session?.vendor_id, - sessionStartAt: journal.session?.at, - turnClosed: journal.boundaries.length > 0, - }; -} - -function isCovered(tool: AiToolId): boolean { - return getAiToolConfig(tool).telemetryLocalRead.kind === "declared"; -} - -function coveredTools(): readonly AiToolId[] { - return AI_TOOL_IDS.filter(isCovered); -} - -function uncoveredTools(): readonly DiagnoseTelemetryUncoveredTool[] { - return AI_TOOL_IDS.filter((tool) => !isCovered(tool)).map((tool) => { - const localRead = getAiToolConfig(tool).telemetryLocalRead; - return { - tool, - reason: localRead.kind === "unsupported" ? localRead.reason : "no reader wired yet", - }; - }); -} - -/** - * Gathers every claim's evidence from the one route this system reads, then hands it to - * the pure judge in `domain/models/telemetry-claim.ts`. Never writes anywhere — unlike - * `ReadLocalCostUseCase`, this never stores a record, since the question is only ever - * "would a read of this session's figures work", not "read them". - */ -/** The plugin version the hook itself stamped, taken from the most recently opened session - * that carries one. - * - * The most recent, not the first: a plugin upgraded mid-period leaves older lines naming - * the older build, and what a person asking "which version is running" wants is the one - * running now. Sessions that carry none are skipped rather than counted as an absence — one - * line written before the field existed must not hide a later line that has it. - */ -function pluginVersionFrom(journals: readonly RunJournal[]): TelemetryPluginVersionSetup { - const sessions = journals.map((journal) => journal.session).filter(isPresent); - if (sessions.length === 0) return { kind: "nothing-journalled" }; - const withVersion = sessions - .filter((session) => session.plugin_version !== undefined) - .sort((a, b) => Date.parse(b.at) - Date.parse(a.at)); - const newest = withVersion[0]; - return newest?.plugin_version === undefined - ? { kind: "unrecorded" } - : { kind: "recorded", version: newest.plugin_version }; -} - -function isPresent(value: T | undefined): value is T { - return value !== undefined; -} - -export class DiagnoseTelemetryUseCase { - constructor( - private readonly evidence: TelemetryEvidenceReader, - private readonly git: VersionControl, - private readonly runJournalReader: RunJournalReader, - private readonly readers: ReadonlyMap, - private readonly hookTrustReader: HookTrustReader, - private readonly personIdentityStore: PersonIdentityStore, - private readonly telemetrySink: TelemetrySink, - private readonly currentVersion: VersionReader, - private readonly manifestRepo: ManifestRepository, - private readonly hostRegistries: ReadonlyMap - ) {} - - async execute(options: DiagnoseTelemetryOptions): Promise { - // Gathered before the gate, and regardless of it: a stale export in a tool's own - // settings file exports whether or not this project's own switch is on, so a person - // whose switch is off must still be told about it. `setup` follows the same rule — - // what is in place is exactly what a person switched off still needs to see. - const leftoverExportConfig = await this.evidence.findLeftoverExportConfig(options.projectRoot); - const setup = await this.gatherSetup(options); - const gate = await this.gateReason(options); - if (gate !== null) return { gate, setup, leftoverExportConfig }; - const evidence = await this.gatherEvidence(options, setup.recorderDeclaration); - const claims = diagnoseTelemetryClaims(evidence); - return { setup, claims, uncovered: uncoveredTools(), leftoverExportConfig }; - } - - private async gatherSetup(options: DiagnoseTelemetryOptions): Promise { - const [switchSetup, recorderDeclaration] = await Promise.all([ - this.evidence.readSwitchSetup(options.projectRoot), - this.evidence.readRecorderDeclaration(options.projectRoot), - ]); - return { - allowed: buildTelemetryAllowedSetup(switchSetup, options.env), - identity: await this.readIdentitySetup(), - recordsLocation: { path: this.telemetrySink.rootDir }, - recorderDeclaration, - hostRegistration: await this.readHostRegistration(options.projectRoot), - commitTrailer: await this.git.readCommitTrailerSetup( - options.projectRoot, - SESSION_TRAILER_DELEGATE_FILE, - SESSION_TRAILER_TOKEN, - COMMITS_EXAMINED_FOR_TRAILER - ), - versions: { - cli: this.currentVersion.get(), - plugin: pluginVersionFrom(await this.runJournalReader.list()), - }, - }; - } - - /** Every plugin AIDD's own manifest records, against what each host's registry says. - * - * Driven from the manifest and never from a settings file: `mergeEnabledPlugins` skips a - * plugin silently when it records no marketplace or when that marketplace does not - * resolve, so a settings-first comparison would find both sides absent and read it as - * agreement while the plugin never loads. - * - * A tool with no reader in the map contributes its plugins with no reading at all, which - * `buildHostRegistration` turns into `unanswerable` — never into agreement. A manifest - * that cannot be loaded contributes nothing, the same normal state as a project with no - * plugins installed. */ - private async readHostRegistration(projectRoot: string): Promise { - let manifest: Awaited>; - try { - manifest = await this.manifestRepo.load(); - } catch (error) { - // `Manifest`'s parser maps over fields it does not guard, so a damaged manifest throws - // rather than returning null. Reported, never swallowed and never fatal: this is the - // command a person runs precisely when something is wrong. - // Names the file, because the row directly above says `recorder declared: yes` about - // the same one: that row scans the raw JSON for a declaration while this goes through - // the manifest's own validation, so a file that parses but fails validation makes the - // two rows disagree. Naming it is what tells a person they are one file, read twice. - return { - ...buildHostRegistration([]), - manifestUnreadable: `${this.manifestRepo.path} — ${describeError(error)}`, - }; - } - if (manifest === null) return buildHostRegistration([]); - const evidence = await Promise.all( - // Filtered before the read, never after: a tool with no plugin recorded contributes no - // entry, so opening its registry would be a home-directory read whose result is thrown - // away on every `check`. - AI_TOOL_IDS.filter((tool) => manifest.getPlugins(tool).length > 0).map(async (tool) => ({ - tool, - plugins: manifest.getPlugins(tool).map((plugin) => ({ - name: plugin.name, - marketplace: plugin.marketplace, - })), - reading: await this.hostRegistries.get(tool)?.read(projectRoot), - declaresNativeActivation: resolvePluginsCapability(tool)?.nativeActivation != null, - })) - ); - return buildHostRegistration(evidence); - } - - // `PersonIdentityStore.readStrict()` promises to throw on a damaged file, unlike the - // plain `read()` every other identity consumer uses — this is the one caller that must - // tell "nobody chose" apart from "could not be read", so it catches rather than letting - // one damaged file cost every other stated fact its own answer. - private async readIdentitySetup(): Promise { - const path = this.personIdentityStore.filePath; - try { - const identity = await this.personIdentityStore.readStrict(); - return { attached: identity !== null, path, readable: true }; - } catch { - return { attached: false, path, readable: false }; - } - } - - private async gatherEvidence( - options: DiagnoseTelemetryOptions, - recorderDeclaration: TelemetryRecorderDeclarationSetup - ): Promise { - const journals = await this.runJournalReader.list(); - const currentSessionId = resolveSessionAnchor(options.env); - const unrecognisedPayload = await this.evidence.readUnrecognisedPayload(options.projectRoot); - const hookTrust = await this.resolveHookTrust(options.env, currentSessionId); - const toolReads = await this.gatherToolReads(journals); - return { - journals: journals.map(toClaimJournal), - toolReads, - runsDirLabel: DEFAULT_RUNS_DIR_LABEL, - currentSessionId, - unrecognisedPayloadAt: unrecognisedPayload?.at, - hookTrust, - recorderDeclared: recorderDeclaration.declared, - recorderDeclarationReadable: recorderDeclaration.unreadable.length === 0, - foreignSchemaVersions: await this.runJournalReader.listForeignSchemas(), - }; - } - - // Stops the run before any claim is evaluated: neither fact is evidence about the hook, - // both are facts about whether there is anything here for it to have written. - private async gateReason(options: DiagnoseTelemetryOptions): Promise { - if (!(await this.evidence.isTelemetryEnabled(options.projectRoot, options.env))) { - return "measurement is off — nothing to check until it is turned on"; - } - if (!(await this.git.isRepository(options.projectRoot))) { - return "not a git repository — the hook has nowhere to write here, not a hook that failed to fire"; - } - return null; - } - - // Only Codex gates a hook behind a trust grant it can decline in silence: a session - // running under any other tool has nothing to read here, and asks nothing of it. - private async resolveHookTrust( - env: NodeJS.ProcessEnv, - currentSessionId: string | undefined - ): Promise { - if (env.CODEX_THREAD_ID === undefined || currentSessionId === undefined) return undefined; - return this.hookTrustReader.read(); - } - - private async gatherToolReads( - journals: readonly RunJournal[] - ): Promise { - const covered = coveredTools(); - const reads: TelemetryClaimToolRead[] = []; - for (const journal of journals) { - const sessionId = journal.session?.vendor_id; - if (sessionId === undefined) continue; - const intervals = buildStepIntervals(journal); - for (const tool of covered) { - reads.push(await this.readOneTool(tool, sessionId, intervals)); - } - } - return reads; - } - - // Mirrors telemetry-check.cjs's own `readTool`: a reader's own contract promises never - // to throw, and this catches anyway — a diagnostic that crashed on the one tool whose - // file is unreadable would answer nothing about every other claim it could still judge. - private async readOneTool( - tool: AiToolId, - sessionId: string, - intervals: ReturnType - ): Promise { - const reader = this.readers.get(tool); - const hasIntervals = intervals.length > 0; - if (!reader) return { tool, sessionFound: false, hasIntervals, records: [] }; - try { - const result = await reader.read(sessionId); - const records = result.records.map((record) => stampAttribution(record, intervals)); - return { tool, sessionFound: result.sessionFound, hasIntervals, records }; - } catch (error) { - return { - tool, - sessionFound: false, - hasIntervals, - records: [], - error: error instanceof Error ? error.message : String(error), - }; - } - } -} - -function stampAttribution( - record: { readonly step?: string; readonly event_timestamp?: string }, - intervals: ReturnType -): { readonly stepAttribution: StepAttributionSource } { - if (record.step !== undefined) return { stepAttribution: "tool-stated" }; - return { stepAttribution: attributeMoment(intervals, record.event_timestamp).source }; -} diff --git a/cli/src/application/use-cases/telemetry/forget-telemetry-use-case.ts b/cli/src/application/use-cases/telemetry/forget-telemetry-use-case.ts deleted file mode 100644 index 0f79f274e..000000000 --- a/cli/src/application/use-cases/telemetry/forget-telemetry-use-case.ts +++ /dev/null @@ -1,175 +0,0 @@ -import { errorMessage } from "../../../domain/describe-error.js"; -import { RUNS_ENTRY } from "../../../domain/models/paths.js"; -import type { - TelemetryHistoryReading, - TelemetryMachineIdentityRemoval, - TelemetryMachineSinkRemoval, - TelemetryProjectJournalRemoval, - TelemetryRemovalPreview, -} from "../../../domain/models/telemetry-removal.js"; -import type { PersonIdentityStore } from "../../../domain/ports/person-identity-store.js"; -import type { RunJournalStore } from "../../../domain/ports/run-journal-reader.js"; -import type { TelemetrySink } from "../../../domain/ports/telemetry-sink.js"; -import type { VersionControl } from "../../../domain/ports/version-control.js"; - -export interface ForgetTelemetryOptions { - readonly projectRoot: string; -} - -export interface TelemetryRemovalFailure { - readonly path: string; - readonly reason: string; -} - -export interface TelemetryRemovalOutcome { - readonly removed: number; - readonly failed: readonly TelemetryRemovalFailure[]; -} - -export interface TelemetryRemovalResult { - readonly journal: TelemetryRemovalOutcome; - readonly sink: TelemetryRemovalOutcome; - readonly identity: TelemetryRemovalOutcome; - /** Repeated from the preview, unchanged by removing everything else — history does not - * become reachable by having removed the rest, so this is the exact same reading, not a - * fresh one. */ - readonly history: TelemetryHistoryReading; -} - -/** - * Shows, then removes, what this tool measured about one person — never both in the same - * call, and never from the same resolution twice. - * - * `preview()` alone resolves every location; `remove()` takes exactly the value `preview()` - * produced and never resolves a location of its own — it calls no path resolver, and it - * lists no directory. Every name it deletes came from the preview a person already saw. - * Two computations that happen to agree today (this machine's sink directory, a relocated - * `AIDD_USER_CONFIG_DIR`, a file that appears between the two calls) can disagree - * tomorrow, and the failure that produces is deleting something nobody was shown. Passing - * the preview through, rather than re-deriving inside `remove()`, is what makes that - * failure inexpressible rather than merely untested — see `telemetry-removal.ts`'s module - * doc for the same guarantee stated from the value's side. - * - * Confirmation is not this use case's concern: whether to call `remove()` at all is the - * command layer's decision, from `--yes`. A refusal is simply never calling it — never a - * throw, since a person who looked and decided not to is not an error. - * - * The telemetry switch (`.aidd/config.json`) is never touched here — this use case holds - * no dependency capable of writing it, so that is true by construction, not by care. - */ -export class ForgetTelemetryUseCase { - constructor( - private readonly sink: TelemetrySink, - private readonly runJournalReader: RunJournalStore, - private readonly identity: PersonIdentityStore, - private readonly git: VersionControl - ) {} - - /** Resolves every location once, and touches nothing — a person sees exactly this value - * before anything is asked to go. */ - async preview(options: ForgetTelemetryOptions): Promise { - const [dayFileNames, runFileNames, isRepo, tracked, hasHistory, identityState] = - await Promise.all([ - this.sink.listDayFiles(), - this.runJournalReader.listRunFiles(), - this.git.isRepository(options.projectRoot), - this.git.listTrackedFiles(options.projectRoot, RUNS_ENTRY), - this.git.hasHistoryFor(options.projectRoot, RUNS_ENTRY), - this.identityState(), - ]); - return { - journal: { scope: "project", path: this.runJournalReader.runsDir, runFileNames }, - sink: { scope: "machine", path: this.sink.rootDir, dayFileNames }, - identity: { scope: "machine", path: this.identity.filePath, ...identityState }, - history: this.historyReading(isRepo, tracked, hasHistory), - }; - } - - private historyReading( - isRepo: boolean, - tracked: readonly string[], - hasHistory: boolean - ): TelemetryHistoryReading { - if (!isRepo) return { certainty: "none" }; - if (tracked.length === 0) return { certainty: "possible" }; - return hasHistory - ? { certainty: "committed", files: tracked } - : { certainty: "staged", files: tracked }; - } - - /** Removes exactly what `preview` resolved — see the class doc for why this must never - * resolve a location of its own. Every location is attempted, whatever the others did: - * one failure never spares or stops the rest. */ - async remove(preview: TelemetryRemovalPreview): Promise { - const [journal, sink, identity] = await Promise.all([ - this.removeJournal(preview.journal), - this.removeSink(preview.sink), - this.removeIdentity(preview.identity), - ]); - return { journal, sink, identity, history: preview.history }; - } - - // `readStrict()` throwing is the file existing but being unreadable - exactly the file a - // person most needs named as present. `null` is the ordinary "nobody opted in" case, and - // an identity object is presence with nothing wrong. - private async identityState(): Promise<{ present: boolean; unreadable: boolean }> { - try { - return { present: (await this.identity.readStrict()) !== null, unreadable: false }; - } catch { - return { present: true, unreadable: true }; - } - } - - // `journal.path` — never `this.runJournalReader.runsDir` re-read here — is what makes - // this act on the same value a person was shown; see the class doc and - // `telemetry-removal.ts`'s own doc for why that must hold by construction. - private async removeJournal( - journal: TelemetryProjectJournalRemoval - ): Promise { - const failed: TelemetryRemovalFailure[] = []; - let removed = 0; - for (const fileName of journal.runFileNames) { - try { - await this.runJournalReader.deleteRunFile(journal.path, fileName); - removed++; - } catch (error) { - failed.push({ path: fileName, reason: errorMessage(error) }); - } - } - return { removed, failed }; - } - - // `sink.path`, for the same reason `removeJournal` uses `journal.path` rather than - // `this.sink.rootDir` — the sink already froze `rootDir` at construction, so the two - // happen to agree today, but this removes the second computation rather than trusting - // that agreement to hold. - private async removeSink(sink: TelemetryMachineSinkRemoval): Promise { - const failed: TelemetryRemovalFailure[] = []; - let removed = 0; - for (const fileName of sink.dayFileNames) { - try { - await this.sink.deleteDayFile(sink.path, fileName); - removed++; - } catch (error) { - failed.push({ path: fileName, reason: errorMessage(error) }); - } - } - return { removed, failed }; - } - - // Gated on `identity.present`, the preview's own answer — never the filesystem's answer - // at removal time. Without this gate, a file that appeared *after* a preview said - // "nothing to remove" would still be deleted and counted, which is exactly the removal - // reaching past what was shown that this whole design exists to make impossible. - private async removeIdentity( - identity: TelemetryMachineIdentityRemoval - ): Promise { - if (!identity.present) return { removed: 0, failed: [] }; - try { - const wasThere = await this.identity.forget(identity.path); - return { removed: wasThere ? 1 : 0, failed: [] }; - } catch (error) { - return { removed: 0, failed: [{ path: identity.path, reason: errorMessage(error) }] }; - } - } -} diff --git a/cli/src/application/use-cases/telemetry/person-identity-use-case.ts b/cli/src/application/use-cases/telemetry/person-identity-use-case.ts deleted file mode 100644 index 5974f4707..000000000 --- a/cli/src/application/use-cases/telemetry/person-identity-use-case.ts +++ /dev/null @@ -1,204 +0,0 @@ -import { UnreadableIdentityFileError } from "../../../domain/errors.js"; -import type { PersonIdentity } from "../../../domain/ports/person-identity-reader.js"; -import type { PersonIdentityStore } from "../../../domain/ports/person-identity-store.js"; -import { - EmptyDisplayNameError, - EmptyIdentifierError, - IdentityRequiredToLinkError, -} from "../../errors.js"; - -export interface PersonIdentityStatusResult { - readonly filePath: string; - readonly identity: PersonIdentity | null; -} - -export interface PersonIdentityUseResult { - readonly filePath: string; - readonly identity: PersonIdentity; - /** How this machine came to carry the identifier it now carries. - * - * Three values rather than two booleans: `on` used to answer `minted: false` and `use` - * `alreadyInEffect: true` for the same situation, in two shapes, because they were two - * commands. One door needs one word, and the word has to keep `origin`'s own distinction - * visible — an identifier this machine created is not the same fact as one a person - * carried here from another machine, and no reader of this result may have to guess - * which. */ - readonly outcome: "minted" | "adopted" | "unchanged"; - /** The identifier this replaced — present only when a different one was in effect - * before, absent both when nothing was declared yet and when the same identifier was - * already in effect. Records already written keep the identifier they were written - * with; taking a different one never rewrites them. */ - readonly replacedPersonId?: string; - /** The display name this call attached, when one was asked for. Absent when none was — - * never `""`, which would read as a name someone chose to be empty. */ - readonly displayNameSet?: string; -} - -export interface PersonIdentityOffResult { - readonly filePath: string; - /** `false` when there was nothing to withdraw. */ - readonly removed: boolean; - /** `true` when the file existed but could not be read back, and was removed anyway — - * `off` is a privacy control, and it must work exactly when a damaged file would - * otherwise leave a person unable to withdraw. */ - readonly discardedDamaged: boolean; - /** How many identifiers `alsoMe` carried at the moment of withdrawal — `off` removes the - * whole declaration now, this one file included, so every one of them goes with it. `0` - * both when none were added and when a damaged file meant this call never learned how - * many there were. */ - readonly addedIdentifiersRemoved: number; -} - -export interface PersonIdentityLinkResult { - readonly filePath: string; - readonly personId: string; - readonly identity: string; - /** `true` when `identity` already resolved to this same person before this call - a - * caller that always calls `link` first, then reports, must be able to tell a no-op - * apart from a fresh write. */ - readonly alreadyListed: boolean; -} - -export interface PersonIdentityUnlinkResult { - readonly filePath: string; - readonly identity: string; - /** `false` when `identity` was never listed at all - reported as nothing to remove, - * never as a failure. */ - readonly removed: boolean; -} - -/** - * What `aidd telemetry identity`'s verbs promise, all against the one file that is the - * whole declaration of who this machine's user is. - * - * `status` never changes anything. `use` settles which identifier this machine carries: - * without one it mints, reporting the same identifier on every call after; with one it - * takes an identifier minted elsewhere, so the same person reads as one across machines - * without a second identity ever being created for them. A display name goes on in the - * same call, because it is a property of the identifier and not a separate act. `link` and - * `unlink` add or withdraw an identifier this person did not choose here - a tool's own - * pseudonymous identifier, or one kept from before a withdrawal - onto `alsoMe`. `off` - * withdraws the whole file, added identifiers included. - * - * Taking or adding an identifier (`use`, `link`) is a declaration this tool cannot check - - * it never verifies that the person running it is who they claim. - */ -export class PersonIdentityUseCase { - constructor(private readonly store: PersonIdentityStore) {} - - async status(): Promise { - const filePath = this.store.filePath; - const identity = await this.store.readStrict(); - return { - filePath, - identity, - }; - } - - /** - * The one door to "which identifier am I": mint one, take one minted elsewhere, or attach - * a name to whichever stands — asked once, in the terms a person actually holds them. - * - * `identifier` absent mints; present, adopts. That is not a convenience over two verbs, it - * is the same question with and without an answer already in hand, and `origin` keeps the - * two apart on disk exactly as before. - */ - async use(options: { - identifier?: string; - displayName?: string; - }): Promise { - if (options.identifier !== undefined && options.identifier.trim() === "") { - throw new EmptyIdentifierError("use"); - } - if (options.displayName !== undefined && options.displayName.trim() === "") { - throw new EmptyDisplayNameError(); - } - const settled = await this.settleIdentifier(options.identifier); - const identity = - options.displayName === undefined - ? settled.identity - : await this.store.setDisplayName(settled.identity, options.displayName); - return { - filePath: this.store.filePath, - identity, - outcome: settled.outcome, - ...(settled.replacedPersonId === undefined - ? {} - : { replacedPersonId: settled.replacedPersonId }), - ...(options.displayName === undefined ? {} : { displayNameSet: options.displayName }), - }; - } - - /** Which identifier stands after this call, and how it got there. Split out because the - * display name is a second, independent decision — folding both into one body would make - * a rename look like a change of identity. */ - private async settleIdentifier(identifier?: string): Promise<{ - identity: PersonIdentity; - outcome: PersonIdentityUseResult["outcome"]; - replacedPersonId?: string; - }> { - const current = await this.store.readStrict(); - if (identifier === undefined) { - if (current !== null) return { identity: current, outcome: "unchanged" }; - return { identity: await this.store.mint(), outcome: "minted" }; - } - if (current !== null && current.personId === identifier) { - return { identity: current, outcome: "unchanged" }; - } - const identity = await this.store.adopt(identifier); - return { - identity, - outcome: "adopted", - ...(current === null ? {} : { replacedPersonId: current.personId }), - }; - } - - async link(identity: string): Promise { - if (identity.trim() === "") throw new EmptyIdentifierError("link"); - const person = await this.store.readStrict(); - if (person === null) throw new IdentityRequiredToLinkError(); - const alreadyListed = identity === person.personId || person.alsoMe.includes(identity); - if (!alreadyListed) await this.store.addAlsoMe(identity); - return { filePath: this.store.filePath, personId: person.personId, identity, alreadyListed }; - } - - async unlink(identity: string): Promise { - const person = await this.store.readStrict(); - const removed = person?.alsoMe.includes(identity) ?? false; - if (removed) await this.store.removeAlsoMe(identity); - return { filePath: this.store.filePath, identity, removed }; - } - - /** - * The one verb allowed to swallow `readStrict()`'s throw. `status`, `use` and `link` are - * right to error on a damaged file — the contract's own "the identity - * file is unreadable" edge case. `off` is different: it is how a person gets out, and a - * file too damaged to parse is exactly the moment withdrawing must still work. A damaged - * file is discarded the same as a readable one, and the result says so rather than - * staying silent about it. - */ - async off(): Promise { - const filePath = this.store.filePath; - const { existing, discardedDamaged } = await this.readForWithdrawal(); - const addedIdentifiersRemoved = existing?.alsoMe.length ?? 0; - // Always asks the store, never decides from the read above: a file holding an empty - // `person_id` reads as "nobody chose" and would have been left on disk by a caller - // that skipped the removal whenever the read came back empty. - const removed = await this.store.forget(filePath); - return { filePath, removed, discardedDamaged, addedIdentifiersRemoved }; - } - - private async readForWithdrawal(): Promise<{ - existing: PersonIdentity | null; - discardedDamaged: boolean; - }> { - try { - return { existing: await this.store.readStrict(), discardedDamaged: false }; - } catch (error) { - if (error instanceof UnreadableIdentityFileError) { - return { existing: null, discardedDamaged: true }; - } - throw error; - } - } -} diff --git a/cli/src/application/use-cases/telemetry/read-local-cost-use-case.ts b/cli/src/application/use-cases/telemetry/read-local-cost-use-case.ts deleted file mode 100644 index 8e316dbed..000000000 --- a/cli/src/application/use-cases/telemetry/read-local-cost-use-case.ts +++ /dev/null @@ -1,622 +0,0 @@ -import type { - TelemetryLocalRead, - TelemetryLocalReadDeclared, -} from "../../../domain/capabilities/telemetry-capability.js"; -import { errorMessage } from "../../../domain/describe-error.js"; -import { - resolveSessionProject, - type SessionProject, -} from "../../../domain/models/session-project.js"; -import { - attributeMoment, - buildStepIntervals, - type StepInterval, -} from "../../../domain/models/step-attribution.js"; -import { - SINK_SCHEMA_VERSION, - type TelemetrySinkRecord, -} from "../../../domain/models/telemetry-sink-record.js"; -import { - DEFAULT_TELEMETRY_SINK_RETENTION_DAYS, - decideTelemetrySinkRetention, -} from "../../../domain/models/telemetry-sink-retention.js"; -import { AI_TOOL_IDS, type AiToolId } from "../../../domain/models/tool-ids.js"; -import type { Logger } from "../../../domain/ports/logger.js"; -import type { - PersonIdentity, - PersonIdentityReader, -} from "../../../domain/ports/person-identity-reader.js"; -import type { RunJournalReader } from "../../../domain/ports/run-journal-reader.js"; -import type { - LocalCostCandidateRecord, - LocalCostReadResult, - SessionCostReader, -} from "../../../domain/ports/session-cost-reader.js"; -import type { TelemetryEvidenceReader } from "../../../domain/ports/telemetry-evidence-reader.js"; -import type { TelemetrySink } from "../../../domain/ports/telemetry-sink.js"; -import type { VersionReader } from "../../../domain/ports/version-reader.js"; -import { getAiToolConfig, journalHostToAiToolId } from "../../../domain/tools/registry.js"; - -/** Six answers, and only one of them may ever be printed as a zero. - * - * - `found` — this tool held the session and billed for it. - * - `empty` — it held the session and billed nothing. The zero is the measurement. - * - `not-found` — it has no trace of the session at all. Nothing is known about it. - * - `unreadable` — its reader failed. Nothing is known about it, and something is wrong. - * - `not-covered` — nothing here can read this tool, and its declaration says why. - * - `not-asked` — the journal named another tool for this session, so this reader was - * never run. Deliberately not `not-found`: that one is an observation, this one is a - * decision not to look, and only the first is evidence about the tool. - * - * The last five look alike in a total and mean five different things. Collapsing any of - * them into `empty` is exactly how a session that was never measured reads as free. */ -export type LocalCostToolStatus = - | "found" - | "empty" - | "not-found" - | "unreadable" - | "not-covered" - | "not-asked"; - -export interface LocalCostToolReport { - readonly tool: AiToolId; - readonly status: LocalCostToolStatus; - /** Records the reader returned, before dedup — this is what makes "found" and "empty" - * distinguishable from each other, independent of how many were new. */ - readonly recordsFound: number; - /** Records newly appended to the sink; a re-read of an already-stored session can be - * `status: "found"` with `recordsStored: 0`. */ - readonly recordsStored: number; - /** Why this tool is not covered, or — for a covered one — what its figures cannot yet be - * used for; both come from the declaration. On `unreadable` it is what the reader itself - * said, since only the reader knows why it could not answer. */ - readonly reason?: string; - /** Sessions this tool's reader threw on. Carried separately from `status` because a - * sweep can read nineteen sessions and fail the twentieth: the figures are real, so the - * status is `found`, and a failure that only showed up in the status would vanish - * exactly when there is most to lose. Zero on a single-session read that succeeded. */ - readonly sessionsFailed: number; - /** What the last failed session's reader said, when any failed. */ - readonly failureReason?: string; -} - -export interface ReadLocalCostOptions { - /** One session by name. Absent reads every session the run journal knows about — the - * only route a person has, since nothing tells them a session identifier. */ - readonly sessionId?: string; - readonly at?: Date; - /** Where to look for `.aidd/config.json` when asking whether the project switch is on — - * the same question `ReportCostUseCase` and `DiagnoseTelemetryUseCase` already ask - * before doing anything with what they read. Required, not defaulted: this is the one - * route left that writes the sink, and a refusal that does not hold on it is cosmetic. */ - readonly projectRoot: string; - /** Passed through to the same refusal check the switch itself honours - * (`AIDD_TELEMETRY=0`), rather than read from `process.env` here. */ - readonly env: NodeJS.ProcessEnv; -} - -/** What every candidate from one session gets stamped with, gathered once and carried as - * one value rather than three parameters — `intervals` and `project` are per-session, - * `person` is per-sweep, but all three are facts about where a record came from, never - * about the record itself. */ -interface LocalReadAttribution { - readonly intervals: readonly StepInterval[]; - readonly project: SessionProject | null; - readonly person: PersonIdentity | null; -} - -/** What one session's read produced. `sessionId` is on the report because a sweep answers - * about several and a caller has to be able to tell them apart. */ -export interface LocalCostSessionReport { - readonly sessionId: string; - readonly toolReports: readonly LocalCostToolReport[]; -} - -export interface ReadLocalCostResult { - readonly sessions: readonly LocalCostSessionReport[]; - /** Every tool's answer across every session read, so a caller sees one line per tool - * rather than one per tool per session. */ - readonly toolReports: readonly LocalCostToolReport[]; - /** Present only when the sweep refused to run at all — the project switch is off, or the - * person refused in their own environment. `sessions` and `toolReports` are both empty in - * this case, and never for any other reason a caller could confuse with this one: an - * empty sweep with no journal reads differently (see `printLocalCostReadReport`), and - * must not be told apart from a refusal by inference. */ - readonly refusedReason?: string; -} - -/** Reads what every locally-readable tool's own files hold for one session, normalises it - * into the stored shape, and appends what is not already there. Which tools are readable - * is a declaration in `domain/tools/ai/*.ts`, read through the registry — this class names - * no tool. Which adapter serves a declared tool is decided once, at the composition root, - * and handed in as `readers`. */ -function isPresent(value: string | undefined): value is string { - return value !== undefined; -} - -/** Every already-stored record for this session, keyed on its own `turn_id` — a record - * with none is not indexed, the same as it is never matched by a re-read. - * - * Mutable on purpose: `storeNewCandidates` adds each record it appends, so a second - * candidate for the same turn in the same batch is matched against the first. Read once - * and left frozen, it could only ever answer for what an earlier invocation stored. */ -function groupByTurnId( - records: readonly TelemetrySinkRecord[] -): Map { - const groups = new Map(); - for (const record of records) { - if (record.turn_id === undefined) continue; - const bucket = groups.get(record.turn_id); - if (bucket) bucket.push(record); - else groups.set(record.turn_id, [record]); - } - return groups; -} - -function indexStoredRecord( - groups: Map, - record: TelemetrySinkRecord -): void { - if (record.turn_id === undefined) return; - const bucket = groups.get(record.turn_id); - if (bucket) bucket.push(record); - else groups.set(record.turn_id, [record]); -} - -const LOCAL_READ_TURN_COUNTER_KEYS = [ - "input_tokens", - "output_tokens", - "cache_read_tokens", - "cache_creation_tokens", -] as const; - -/** How much of a turn a record accounts for — used only to find the largest of several - * still-open readings of it, never stored, never itself summed into a total. */ -function counterWeight(record: TelemetrySinkRecord): number { - return LOCAL_READ_TURN_COUNTER_KEYS.reduce((sum, key) => sum + (record[key] ?? 0), 0); -} - -/** Whether `candidate` genuinely improves on `stored` — every counter at least as large, - * and at least one strictly larger. A candidate that would drop a counter `stored` already - * carried, or read smaller on any one, is never an improvement: the sink keeps the larger - * reading rather than letting a figure fall back silently (metrics-contract.md, "The other - * way to double count"). */ -function strictlyImprovesOn( - stored: TelemetrySinkRecord, - candidate: LocalCostCandidateRecord -): boolean { - let improved = false; - for (const key of LOCAL_READ_TURN_COUNTER_KEYS) { - const before = stored[key]; - const after = candidate[key]; - if (before === undefined) { - if (after !== undefined) improved = true; - continue; - } - if (after === undefined || after < before) return false; - if (after > before) improved = true; - } - return improved; -} - -/** The strongest answer a tool gave anywhere in the sweep. - * - * A tool that read one session and could not read another reports as `found`: the figures - * it produced are real, and calling the whole tool broken would discard them. The failure - * does not disappear with the status — `sessionsFailed` counts it separately, precisely so - * that a status which is honest about the figures cannot also be a silence about the - * failures. `unreadable` outranks the two silences for the mirror reason. */ -const STATUS_RANK: readonly LocalCostToolStatus[] = [ - "found", - "unreadable", - "empty", - "not-found", - "not-covered", - // Weakest on purpose: one session where this tool was never asked must never outrank - // another where it actually answered. - "not-asked", -]; - -function strongestOf(tool: AiToolId, reports: readonly LocalCostToolReport[]): LocalCostToolReport { - // The seed for a tool with no report at all. Reached only when `reports` is empty, which - // `printLocalCostReadReport` never renders — it returns on an empty sweep before printing - // any tool line. So this is the honest value for an unreachable-today case, not a - // behaviour change: `not-asked` is what "nothing looked at it" means, where `not-found` - // would report an observation never made. - const nothingKnown: LocalCostToolReport = { - tool, - status: "not-asked", - recordsFound: 0, - recordsStored: 0, - sessionsFailed: 0, - }; - return reports.reduce( - (strongest, report) => - STATUS_RANK.indexOf(report.status) < STATUS_RANK.indexOf(strongest.status) - ? report - : strongest, - reports[0] ?? nothingKnown - ); -} - -function mergeOneTool( - tool: AiToolId, - sessions: readonly LocalCostSessionReport[] -): LocalCostToolReport { - const reports = sessions.flatMap((session) => - session.toolReports.filter((report) => report.tool === tool) - ); - const failures = reports - .map((report) => report.failureReason) - .filter((reason): reason is string => reason !== undefined); - return { - ...strongestOf(tool, reports), - recordsFound: reports.reduce((sum, report) => sum + report.recordsFound, 0), - recordsStored: reports.reduce((sum, report) => sum + report.recordsStored, 0), - sessionsFailed: failures.length, - ...(failures.length === 0 ? {} : { failureReason: failures[failures.length - 1] }), - }; -} - -/** Nothing here can read this tool at all, with the reason its declaration gives. */ -function notCovered(tool: AiToolId, localRead: TelemetryLocalRead): LocalCostToolReport { - return { - tool, - status: "not-covered", - recordsFound: 0, - recordsStored: 0, - sessionsFailed: 0, - ...(localRead.kind === "unsupported" ? { reason: localRead.reason } : {}), - }; -} - -/** This tool's reader was never run, because the journal named another tool for the - * session. Carries no `reason`: there is nothing wrong here and nothing was measured — the - * status is the whole fact. */ -function notAsked(tool: AiToolId): LocalCostToolReport { - return { tool, status: "not-asked", recordsFound: 0, recordsStored: 0, sessionsFailed: 0 }; -} - -/** Its reader failed, so nothing is known about it and something is wrong — distinct from - * `not-found`, where nothing is known and nothing is wrong. */ -function unreadable(tool: AiToolId, failure: string): LocalCostToolReport { - return { - tool, - status: "unreadable", - recordsFound: 0, - recordsStored: 0, - sessionsFailed: 1, - reason: failure, - failureReason: failure, - }; -} - -function mergeToolReports( - sessions: readonly LocalCostSessionReport[] -): readonly LocalCostToolReport[] { - return AI_TOOL_IDS.map((tool) => mergeOneTool(tool, sessions)); -} - -const REFUSED_REASON = - "measurement is refused — AIDD_TELEMETRY=0 or the project switch is off; nothing read, nothing stored"; - -export class ReadLocalCostUseCase { - constructor( - private readonly sink: TelemetrySink, - private readonly readers: ReadonlyMap, - private readonly runJournalReader: RunJournalReader, - private readonly personIdentityReader: PersonIdentityReader, - private readonly telemetryEvidenceReader: TelemetryEvidenceReader, - /** The CLI's own version, stamped on every record this sweep stores - * (`stampProvenanceAndTool`) - read through the same port `current-version-adapter.ts` - * already resolves it through, never a second way. Production wiring (`deps.ts`) - * always supplies the real adapter; that guarantee is not just asserted here, it is - * enforced - `telemetry-multi-tool.e2e.test.ts` runs the real built binary through - * `deps.ts` and fails if a stored record ever lacks `cli_version`. Optional on this - * constructor only so a caller exercising a concern this field is not about (most unit - * tests) does not have to invent a version to reach it - absent, this simply omits the - * field from what gets stored, never guesses at a value. */ - private readonly versionReader?: VersionReader, - /** Only the retention prune below writes here, and only to warn. Optional because a - * caller that does not care about housekeeping warnings should not have to invent a - * logger to read its own figures. */ - private readonly logger: Logger = { debug() {}, info() {}, warn() {} }, - private readonly retentionDays: number = DEFAULT_TELEMETRY_SINK_RETENTION_DAYS - ) {} - - async execute(options: ReadLocalCostOptions): Promise { - // The same refusal `ReportCostUseCase` and `DiagnoseTelemetryUseCase` already resolve, - // checked here too since this is the sink's one remaining writer: a refusal enforced - // only upstream, by the hook never writing a journal, does not hold against - // `--session `, which never reads the journal at all. Checked first, before a - // single reader runs, so a refused sweep touches neither the sink nor a tool's files. - if ( - !(await this.telemetryEvidenceReader.isTelemetryEnabled(options.projectRoot, options.env)) - ) { - // Told once, by the caller's own display layer (printLocalCostReadReport), not here - // too: this.logger exists for housekeeping the figures themselves never surface, the - // same reason its own doc restricts it to the retention prune below. - return { sessions: [], toolReports: mergeToolReports([]), refusedReason: REFUSED_REASON }; - } - const at = options.at ?? new Date(); - const sessionIds = - options.sessionId === undefined ? await this.journalledSessionIds() : [options.sessionId]; - // Resolved once for the whole sweep, not per session: this is a fact about the machine - // this process is running on, not about any one session it reads. - const person = await this.personIdentityReader.read(); - const sessions: LocalCostSessionReport[] = []; - for (const sessionId of sessionIds) { - sessions.push({ sessionId, toolReports: await this.readOneSession(sessionId, at, person) }); - } - // A sweep prunes; a single named session does not. That was already this function's - // own stated reason — "once per sweep rather than per new day file, because a sweep is - // already the unit a person invokes" — but it ran on every call, which was harmless - // while `aidd telemetry read` was the only caller. It stopped being harmless the moment - // `report` began catching sessions up one at a time: a command that had never destroyed - // anything started deleting stored day files past the retention window, silently, as a - // side effect of being asked a question. Housekeeping belongs to the command a person - // runs to do housekeeping. - if (options.sessionId === undefined) await this.pruneOldDayFiles(); - return { sessions, toolReports: mergeToolReports(sessions) }; - } - - /** - * Keeps the sink inside its retention window, once per sweep. - * - * This ran on the export receiver until that route was deleted, and it was the sink's - * only pruning: `read` is now the one thing that writes a day file, so it is the one - * thing that can bound how many there are. Once per sweep rather than per new day file, - * because a sweep is already the unit a person invokes. - * - * Every failure is a warning and never a throw, per file: housekeeping must not cost the - * figures this sweep just stored, and one undeletable file must not spare every older - * one behind it. - */ - private async pruneOldDayFiles(): Promise { - let prune: readonly string[]; - try { - prune = decideTelemetrySinkRetention( - await this.sink.listDayFiles(), - this.retentionDays - ).prune; - } catch (error) { - this.logger.warn(`telemetry read: retention prune failed - ${errorMessage(error)}`); - return; - } - for (const fileName of prune) { - try { - await this.sink.deleteDayFile(this.sink.rootDir, fileName); - } catch (error) { - this.logger.warn(`telemetry read: could not delete ${fileName} - ${errorMessage(error)}`); - } - } - } - - /** Every session the journal names, oldest file first. A person has no other way to - * learn a session identifier, and the journal has recorded every one of them. */ - private async journalledSessionIds(): Promise { - const journals = await this.runJournalReader.list(); - const ids = journals.map((journal) => journal.session?.vendor_id).filter(isPresent); - return [...new Set(ids)]; - } - - private async readOneSession( - sessionId: string, - at: Date, - person: PersonIdentity | null - ): Promise { - // Read once per session, never per tool: every reader's candidates for one session are - // joined against the same journal. A session with no journal at all — the reader's - // contract promises never to throw for that — yields an empty interval list, so every - // candidate falls through to unattributed rather than the read failing; the project is - // `null` for the same reason, never re-derived from wherever this process runs. - const journal = await this.runJournalReader.read(sessionId); - const attribution: LocalReadAttribution = { - intervals: journal ? buildStepIntervals(journal) : [], - project: resolveSessionProject(journal), - person, - }; - // Only the tool whose session this is. The journal's `session_start` names the host - // that wrote it, and `journalHostToAiToolId` is the one place those names and this - // codebase's tool ids are related — so asking the other four is guaranteed-useless - // work, and one of them pays for it in process spawns: the OpenCode reader shells out - // to its binary and waits, measured at 1.15s for a session it does not have. On a - // machine with that binary installed, a sweep over 200 journalled sessions spent about - // four minutes proving four times over what the journal already said. - // - // Fan out only when the journal cannot name a tool — `--session ` given by hand - // (no journal at all), or a host no registered tool claims. There the tool is genuinely - // unknown, and asking every reader is the only way to find out. - const host = journal?.session?.tool; - const namedTool = host === undefined ? null : journalHostToAiToolId(host); - const toolReports: LocalCostToolReport[] = []; - for (const tool of AI_TOOL_IDS) { - toolReports.push(await this.answerFor(tool, namedTool, sessionId, at, attribution)); - } - return toolReports; - } - - /** What this tool has to say about this session, and in which order the three reasons it - * might say nothing are considered. - * - * Coverage first, always: "nothing here can read this tool" is a fact about the tool, - * true of every session, and it carries the declaration's own reason. Answering - * `not-asked` there would trade that reason for a session-shaped one that says less. */ - private async answerFor( - tool: AiToolId, - namedTool: AiToolId | null, - sessionId: string, - at: Date, - attribution: LocalReadAttribution - ): Promise { - const localRead = getAiToolConfig(tool).telemetryLocalRead; - if (localRead.kind !== "declared") return notCovered(tool, localRead); - if (namedTool !== null && tool !== namedTool) return notAsked(tool); - return this.readOneTool(tool, localRead, sessionId, at, attribution); - } - - private async readOneTool( - tool: AiToolId, - localRead: TelemetryLocalReadDeclared, - sessionId: string, - at: Date, - attribution: LocalReadAttribution - ): Promise { - const attempt = await this.attemptRead(tool, sessionId); - if ("failure" in attempt) return unreadable(tool, attempt.failure); - const candidates = attempt.records; - const recordsStored = await this.storeNewCandidates( - tool, - sessionId, - candidates, - at, - attribution - ); - return { - tool, - status: candidates.length > 0 ? "found" : attempt.sessionFound ? "empty" : "not-found", - recordsFound: candidates.length, - recordsStored, - sessionsFailed: 0, - ...(localRead.limitation !== undefined ? { reason: localRead.limitation } : {}), - }; - } - - /** The one place this use case catches, and it catches for a reason the architecture's - * "use-cases throw, never catch" rule does not cover: this is a fan-out over independent - * sources, so a reader failing is not one operation that failed but one of several. A - * throw here would cost every other tool's figures for a session none of them had any - * trouble with — and, once a sweep reads every journalled session, every other session's - * too. See https://github.com/ai-driven-dev/framework/issues/689. */ - private async attemptRead( - tool: AiToolId, - sessionId: string - ): Promise { - const reader = this.readers.get(tool); - if (!reader) return { records: [], sessionFound: false }; - try { - return await reader.read(sessionId); - } catch (error) { - return { failure: error instanceof Error ? error.message : String(error) }; - } - } - - /** Matches each candidate against what the sink already holds for this session, on - * `turn_id` alone — never a hash of the line, since the tool's own file keeps growing - * as the same record is read again. A candidate with no `turn_id` cannot be matched and - * is always appended: the reader's contract forbids inventing a key for it. - * - * The index grows as this appends, so two candidates for one turn in a single batch match - * each other and not only what an earlier invocation left behind. Measured on a live sink: - * 339 groups of byte-identical records, 474 extra lines, every one a subagent record — - * of that project's 29,741 distinct request ids, 350 appear in more than one transcript - * file, and one read hands both copies over as two candidates of the same batch. No - * reported figure ever moved, since `collapseBilledRequests` already merges them at - * report time; what this stops is the sink storing an observation it already holds. - * - * A candidate whose `turn_id` *is* already stored is dropped — unless it is a - * `kind: "request"` local-read record correcting an earlier, still-open reading of the - * same turn (`isLocalReadTurnCorrection`), the one case this match used to refuse - * outright. The correction lands as a second line, never an edit: the sink is - * append-only, and `cost-report.ts`'s `collapseSupersededTurns` is what later reconciles - * the two readings into one. - * - * **A correction is a larger counter, never a field the stored record lacks — so a - * record's field set is fixed the first time its turn is seen.** A reader that later - * learns to resolve something the earlier one could not names nothing already stored: the - * turn matches, the counters have not grown, and the candidate is dropped. Measured - * 2026-09-05 on a live sink: 844 records carry no `prompt_id` for exactly this reason, - * 2.75% of 30,714, and `CostReportPromptRow` states it where the figure is read. - * - * Left as it is, deliberately. Enriching would mean appending a line whose counters equal - * one already stored, which `collapseSupersededTurns` picks between by counter weight and - * then by serialized content — so the merge would have to learn a preference it does not - * have, to close a gap re-reading could barely close anyway: of the 811 measured, 720 - * name a request no transcript on disk still holds. Roughly 90 records in 30,714 is the - * whole prize. Revisit this the day a reader learns a field that matters more than a - * prompt id, and that arithmetic changes. */ - private async storeNewCandidates( - tool: AiToolId, - sessionId: string, - candidates: readonly LocalCostCandidateRecord[], - at: Date, - attribution: LocalReadAttribution - ): Promise { - if (candidates.length === 0) return 0; - const byTurnId = groupByTurnId(await this.sink.readRecordsForVendor(sessionId)); - let stored = 0; - for (const candidate of candidates) { - const prior = candidate.turn_id === undefined ? undefined : byTurnId.get(candidate.turn_id); - if (prior && !this.isLocalReadTurnCorrection(candidate, prior)) continue; - const record = this.stampProvenanceAndTool(tool, candidate, attribution); - await this.sink.appendRecord(record, at); - indexStoredRecord(byTurnId, record); - stored++; - } - return stored; - } - - /** Whether `candidate` should land as a correction to `prior` — every record already - * stored under this `turn_id`. Never for a `kind: "session"` record: Copilot's shutdown - * total shares this match (it is keyed on the shutdown event's own id), but it is a - * one-shot cumulative figure with no provisional reading to correct, and matching it here - * would let a re-read start doubling it. Otherwise, only when the candidate strictly - * improves on the largest already stored — never gated on whether the run journal's own - * `turn_end` has been seen: a strictly larger candidate is itself proof the stored reading - * was not final, whatever the journal says about the clock, and a re-read that brings - * nothing larger is already a no-op without needing to ask the journal anything. */ - private isLocalReadTurnCorrection( - candidate: LocalCostCandidateRecord, - prior: readonly TelemetrySinkRecord[] - ): boolean { - if (candidate.kind !== "request") return false; - const priorReads = prior.filter((r) => r.kind === "request" && r.provenance === "local-read"); - if (priorReads.length === 0) return false; - const largest = priorReads.reduce((best, r) => - counterWeight(r) > counterWeight(best) ? r : best - ); - return strictlyImprovesOn(largest, candidate); - } - - // The caller asked this tool's reader by name — that is the fact this stamps, never - // inferred from the candidate itself, which the reader's contract forbids it naming. - private stampProvenanceAndTool( - tool: AiToolId, - candidate: LocalCostCandidateRecord, - { intervals, project, person }: LocalReadAttribution - ): TelemetrySinkRecord { - return { - ...candidate, - sink_schema_version: SINK_SCHEMA_VERSION, - provenance: "local-read", - tool, - ...this.resolveStepAttribution(candidate, intervals), - ...(project === null - ? {} - : { project_id: project.projectId, project_field: project.projectField }), - ...(person === null ? {} : { person_id: person.personId }), - ...(person?.displayName === undefined ? {} : { person_display_name: person.displayName }), - ...(this.versionReader === undefined ? {} : { cli_version: this.versionReader.get() }), - }; - } - - // Where the candidate itself carries `step`, the tool stated it directly (see - // claude-code-transcript.ts) — exact, and never second-guessed by an interval, which is - // only ever an inference. Everything else falls back to the journal, joined on the - // candidate's own moment; a candidate with no moment, or one earlier than every - // interval, comes back unattributed rather than folded into the nearest step. - private resolveStepAttribution( - candidate: LocalCostCandidateRecord, - intervals: readonly StepInterval[] - ): Pick { - if (candidate.step !== undefined) { - return { - step_attribution: "tool-stated", - step: candidate.step, - step_plugin: candidate.step_plugin, - }; - } - const attribution = attributeMoment(intervals, candidate.event_timestamp); - return { step_attribution: attribution.source, step: attribution.step, step_plugin: undefined }; - } -} diff --git a/cli/src/application/use-cases/telemetry/report-cost-use-case.ts b/cli/src/application/use-cases/telemetry/report-cost-use-case.ts deleted file mode 100644 index 630d29cf4..000000000 --- a/cli/src/application/use-cases/telemetry/report-cost-use-case.ts +++ /dev/null @@ -1,552 +0,0 @@ -import { UnreadableIdentityFileError } from "../../../domain/errors.js"; -import { - buildCostReport, - type CostReport, - type CostReportFilters, - type CostReportInput, - type CostReportSessionJournal, - type CostReportToolCapability, - type CostReportToolDeclaration, - type PersonIdentityUnusableCause, -} from "../../../domain/models/cost-report.js"; -import { buildFlowIntervals } from "../../../domain/models/flow-attribution.js"; -import type { ResolvedReportPeriod } from "../../../domain/models/report-period.js"; -import { - attributeMoment, - buildStepIntervals, - type StepInterval, -} from "../../../domain/models/step-attribution.js"; -import { buildTaskIntervals } from "../../../domain/models/task-attribution.js"; -import { - type TaskBacklogDeclaration, - taskFolderPathFromIdentity, -} from "../../../domain/models/task-backlog-link.js"; -import { - type TaskIdentity, - taskIdentityFromWrittenPath, -} from "../../../domain/models/task-identity.js"; -import type { TelemetrySinkRecord } from "../../../domain/models/telemetry-sink-record.js"; -import { AI_TOOL_IDS } from "../../../domain/models/tool-ids.js"; -import type { Logger } from "../../../domain/ports/logger.js"; -import type { PersonIdentity } from "../../../domain/ports/person-identity-reader.js"; -import type { PersonIdentityStore } from "../../../domain/ports/person-identity-store.js"; -import type { RunJournal, RunJournalReader } from "../../../domain/ports/run-journal-reader.js"; -import type { TaskBacklogReader } from "../../../domain/ports/task-backlog-reader.js"; -import type { TelemetryEvidenceReader } from "../../../domain/ports/telemetry-evidence-reader.js"; -import type { - TelemetrySink, - TelemetrySinkPeriodRead, -} from "../../../domain/ports/telemetry-sink.js"; -import { getAiToolConfig } from "../../../domain/tools/registry.js"; -import type { ReadLocalCostResult, ReadLocalCostUseCase } from "./read-local-cost-use-case.js"; - -export interface ReportCostOptions { - /** Already two absolute days. Resolving what a caller asked for is - * `domain/models/report-period.ts`'s job and happens once, at the edge — so nothing from - * here down reads a clock, and the same options answer the same twice. */ - readonly period: ResolvedReportPeriod; - /** Restrict to the sessions that wrote into this task. Absent reports the whole period. */ - readonly task?: TaskIdentity; - /** Any of `project`, `step`, `model` and `tool` - each optional, composing with `task` - * and each other by `and`. */ - readonly filters?: CostReportFilters; - /** Where to look for `.aidd/config.json` when asking whether the project switch is on. */ - readonly projectRoot: string; - /** Passed through to the same refusal check the switch itself honours - * (`AIDD_TELEMETRY=0`), rather than read from `process.env` down in an adapter a report - * cannot otherwise reach the caller's environment through. */ - readonly env: NodeJS.ProcessEnv; -} - -/** What each tool declares about being read at all, as data the pure report consumes. A - * tool whose own files cannot be read is `not-covered` with the reason its declaration - * gives, so a report prints why rather than a zero; a readable tool carries its - * `limitation` forward for the same reason, since a caveat that stays in a source comment - * reaches nobody downstream. */ -function declaredTools(): readonly CostReportToolDeclaration[] { - return AI_TOOL_IDS.map((tool) => { - const config = getAiToolConfig(tool); - const localRead = config.telemetryLocalRead; - const capability: CostReportToolCapability = { - localRead: localRead.kind === "declared" ? localRead.supplies : null, - // No tool declares an export route any more — "one route, and every sentence about - // it true" deleted the OTLP receiver, so nothing configures one and nothing could - // ever supply this. Always `null`, the same value a tool with no declaration at all - // already carried, rather than a type change that would ripple through the `--json` - // contract for a capability that can no longer exist either way. - export: null, - journalAttributable: config.telemetryJournalHost !== undefined, - taskAttributable: config.telemetryTaskAttributable, - }; - if (localRead.kind === "declared") { - return { - tool, - coverage: "covered" as const, - ...(localRead.limitation === undefined ? {} : { reason: localRead.limitation }), - capability, - }; - } - return { - tool, - coverage: "not-covered" as const, - ...(localRead.kind === "unsupported" ? { reason: localRead.reason } : {}), - capability, - }; - }); -} - -/** The first and last moment a journal's own lines carry, or nothing when not one of them - * carries a moment this reader can parse. Every line kind counts, not only the kinds an - * interval opens or closes on: the question this answers is "was this journal open then", - * and a written file witnesses that as surely as a boundary does. - * - * Not capped at the period's end, unlike an unclosed interval. This span is only ever asked - * whether it contains a record's moment, and the sink never returns a record past the - * period end, so a clock-skewed line can widen the span past a moment no record can reach - - * it cannot pull one in. */ -const LAST_MILLISECOND_OF_A_SECOND = 999; - -function witnessedSpan(journal: RunJournal): { fromMs: number; toMs: number } | undefined { - const moments = [ - ...journal.boundaries, - ...journal.taskDeclarations, - ...journal.filesWritten, - ...(journal.session ? [journal.session] : []), - ] - .map((line) => Date.parse(line.at)) - .filter((atMs) => !Number.isNaN(atMs)); - if (moments.length === 0) return undefined; - // The end is the end of the second the last line names, not that second's first instant. - // A journal moment IS a second - `nowIso()` in the writing hook strips the milliseconds - // - while a record carries them, so comparing the two as instants refuses a record that - // landed inside the very second the journal last wrote. Measured, that rounding cost one - // record of 1073 on a real session. The start needs no such widening: a truncated moment - // already sits at the first instant of its own second. - return { - fromMs: Math.min(...moments), - toMs: Math.max(...moments) + LAST_MILLISECOND_OF_A_SECOND, - }; -} - -function toSessionJournal( - journal: RunJournal, - periodEndMs: number -): CostReportSessionJournal | null { - if (!journal.session) return null; - const span = witnessedSpan(journal); - return { - vendorId: journal.session.vendor_id, - tool: journal.session.tool, - ...(journal.session.project_id === undefined ? {} : { projectId: journal.session.project_id }), - writtenPaths: journal.filesWritten.map((written) => written.path), - taskIntervals: buildTaskIntervals(journal, periodEndMs), - flowIntervals: buildFlowIntervals(journal, periodEndMs), - ...(span === undefined ? {} : { witnessed: span }), - }; -} - -/** Every distinct task identity this period's journals could ever key `by_task` on - the - * same declared intervals `declaredTaskKeyOf` reads from, built once here from the raw - * `RunJournal`s rather than the already-mapped session journals, so it needs no restructure - * of `toReportInput`'s own mapping. Each identity is resolved to its folder's declaration - * exactly once, never once per record. Order is incidental; the report only ever looks this - * map up by key. */ -function distinctTaskIdentities( - journals: readonly RunJournal[], - periodEndMs: number -): readonly TaskIdentity[] { - const seen = new Set(); - const identities: TaskIdentity[] = []; - const remember = (identity: TaskIdentity | null): void => { - if (identity === null || seen.has(identity)) return; - seen.add(identity); - identities.push(identity); - }; - for (const journal of journals) { - for (const interval of buildTaskIntervals(journal, periodEndMs)) { - remember(taskIdentityFromWrittenPath(interval.path)); - } - // Written paths too, not declared intervals alone: a task the written-file route names - // has a folder like any other, and that folder can declare a backlog item. Resolving - // from declarations alone would send every inferred record to "this task declares no - // backlog item" - a claim about the task, produced by a lookup that never ran. - for (const written of journal.filesWritten) { - remember(taskIdentityFromWrittenPath(written.path)); - } - } - return identities; -} - -/** One read per distinct task identity, through the port - never the filesystem directly, - * and never re-read per record. A reader that throws is not this function's to catch: - * `TaskBacklogReader.read` promises it never does. */ -async function taskBacklogDeclarationsOf( - reader: TaskBacklogReader, - journals: readonly RunJournal[], - periodEndMs: number -): Promise> { - const declarations = new Map(); - for (const identity of distinctTaskIdentities(journals, periodEndMs)) { - declarations.set(identity, await reader.read(taskFolderPathFromIdentity(identity))); - } - return declarations; -} - -const MILLISECONDS_PER_DAY = 24 * 60 * 60 * 1000; - -/** The first moment no record `readRecordsInPeriod` could ever return can fall on or after - - * `toDay` itself runs through 23:59:59.999 UTC, so this is the *start* of the day after. - * `buildTaskIntervals` clamps an unclosed interval's end here rather than at `toDay`'s own - * start, which would wrongly cut off a record legitimately timestamped later on `toDay`. */ -function periodEndMsOf(toDay: string): number { - return Date.parse(`${toDay}T00:00:00Z`) + MILLISECONDS_PER_DAY; -} - -interface PersonIdentityFields { - readonly identity: PersonIdentity | null; - readonly identityUnusableCause?: PersonIdentityUnusableCause; -} - -/** - * Answers what a report's own person-resolution inputs should be, without ever aborting - * the report over it — the same fan-out reasoning `ReadLocalCostUseCase.attemptRead` - * documents for a local-cost reader failing on one session: a damaged identity file is one - * dependency's own trouble, never the report's, and the figures must still come back - * whole. - * - * Names which of the two possible causes actually fired, rather than folding both into one - * boolean: `readStrict()` answers "no identity at all" with `null`, never a throw, so that - * cause is read off the return value directly - it is not reachable from a `catch`. - * `readStrict()` throws `UnreadableIdentityFileError` for a declared file that could not be - * read back, which is the one thrown cause this recognises. Anything else thrown is not - * this function's to explain and is re-thrown rather than mislabelled as either named - * cause - a report that hides an unexpected failure behind a familiar-looking caveat would - * be worse than one that surfaces it. - */ -async function personIdentityFields(store: PersonIdentityStore): Promise { - try { - const identity = await store.readStrict(); - return identity === null ? { identity: null, identityUnusableCause: "absent" } : { identity }; - } catch (error) { - if (error instanceof UnreadableIdentityFileError) { - return { identity: null, identityUnusableCause: "unreadable" }; - } - throw error; - } -} - -/** `identity` and `identityUnusableCause` together, as `buildCostReport` wants them - pulled - * out on its own so `execute` reads as one shape assembled from its own reads, not a wall of - * field-by-field assignments (the same reason `cost-report.ts`'s own `readFields` exists). */ -function identityInputFields( - fields: PersonIdentityFields -): Pick { - return { - identity: fields.identity, - ...(fields.identityUnusableCause === undefined - ? {} - : { identityUnusableCause: fields.identityUnusableCause }), - }; -} - -/** Which skill each prompt opened, from the journal's own `step_start` lines. - * - * **First wins.** Three steps can open under one prompt — measured on a live session, where - * `aidd-orchestrator:01-sdlc`, `aidd-pm:04-spec` and `aidd-dev:01-plan` all carried - * `839ab4a8-…`. A prompt therefore names the step its work *began* in, and a later opener - * never rewrites it: taking the last would answer "plan" for the reasoning that produced the - * spec, which is a different claim and a wrong one. */ -function promptToSkill(journal: RunJournal): ReadonlyMap { - const byPrompt = new Map(); - for (const boundary of journal.boundaries) { - if (boundary.type !== "step_start" || boundary.turn_id === undefined) continue; - if (!byPrompt.has(boundary.turn_id)) byPrompt.set(boundary.turn_id, boundary.skill); - } - return byPrompt; -} - -/** The step a record's own prompt opened, where both sides name the same one. - * - * Outranks the interval, and says so: `prompt-matched` is an identifier two sources agree - * on, where `journal-interval` is an inference from moments. It is the only reading that - * stays true when two tasks advance at once — two prompts remain two prompts however their - * moments overlap. Two tasks inside *one* prompt stay indivisible: a billed amount cannot be - * split without inventing a ratio, and this returns the one step that prompt opened. */ -function matchOnPrompt( - record: TelemetrySinkRecord, - byPrompt: ReadonlyMap | undefined -): { readonly source: "prompt-matched"; readonly step: string } | null { - const step = journalNamedStep(record, byPrompt) ?? record.prompt_skill; - return step === undefined ? null : { source: "prompt-matched", step }; -} - -/** What the run journal says the record's own prompt opened, asked first. - * - * Both sides name the same fact from the same identifier, so they can only disagree if one - * of them is wrong — and the journal was written by a hook the host itself fired, while - * `prompt_skill` is read back off a transcript afterwards. The reading with a witness wins. - * - * A session the journal never saw at all has no answer here and falls through to the - * record's own. Measured on the real sink: 28 prompts across 22 days ran before the hook - * was installed, and 318 records are named by that route and by nothing else. */ -function journalNamedStep( - record: TelemetrySinkRecord, - byPrompt: ReadonlyMap | undefined -): string | undefined { - if (record.prompt_id === undefined || byPrompt === undefined) return undefined; - return byPrompt.get(record.prompt_id); -} - -/** Every record's step, taken from the journal rather than from the record. - * - * **A judgement is derived; only an observation is trusted from disk.** `step_attribution` - * is written into the record when it is read, so a record stored before a rule was - * corrected keeps whatever that rule answered — for good. Measured on a live sink: it - * reported 91% `unattributed` while a fresh read of the very same session, under the same - * build, reported 0%. Nothing in the store was wrong when it was written; it was simply - * frozen at the moment the least was known about it. - * - * `tool-stated` is left alone. The tool naming a skill on the line carrying the counters is - * something it witnessed, not something anyone inferred, and no journal can improve on it. - * - * A session the period's journals say nothing about is left alone too — there is no - * interval to judge it against, and overwriting a stored answer with a blanker one would - * trade a stale reading for no reading at all. */ -function withDerivedStep( - records: readonly TelemetrySinkRecord[], - journals: readonly RunJournal[] -): readonly TelemetrySinkRecord[] { - const bySession = new Map(); - const skillByPrompt = new Map>(); - for (const journal of journals) { - if (!journal.session) continue; - bySession.set(journal.session.vendor_id, buildStepIntervals(journal)); - skillByPrompt.set(journal.session.vendor_id, promptToSkill(journal)); - } - - return records.map((record) => { - if (record.step_attribution === "tool-stated") return record; - const intervals = bySession.get(record.vendor_id); - if (intervals === undefined) return record; - - const matched = matchOnPrompt(record, skillByPrompt.get(record.vendor_id)); - const derived = matched ?? attributeMoment(intervals, record.event_timestamp); - // Rebuilt rather than spread over: a record that carried a step from an earlier reading - // must lose it when the journal no longer names one, and a spread would keep it. - const { step: _step, step_plugin: _plugin, ...rest } = record; - return { - ...rest, - step_attribution: derived.source, - ...(derived.step === undefined ? {} : { step: derived.step }), - }; - }); -} - -/** Every gathered read, folded into the one shape `buildCostReport` wants - kept on its own - * so `execute` reads as "gather, then assemble," not a wall of field assignments. */ -function toReportInput( - options: ReportCostOptions, - read: Awaited>, - journals: readonly RunJournal[], - identity: PersonIdentityFields, - measurementEnabled: boolean, - taskBacklogDeclarations: ReadonlyMap -): CostReportInput { - const { fromDay, toDay } = options.period; - const periodEndMs = periodEndMsOf(toDay); - return { - fromDay, - toDay, - records: withDerivedStep(read.records, journals), - journals: journals - .map((journal) => toSessionJournal(journal, periodEndMs)) - .filter((journal) => journal !== null), - declaredTools: declaredTools(), - undatedRecords: read.undated.length, - unreadableLines: read.skippedLines, - ...(options.task === undefined ? {} : { task: options.task }), - ...(options.filters === undefined ? {} : { filters: options.filters }), - knownValues: read.knownValues, - measurementEnabled, - taskBacklogDeclarations, - ...identityInputFields(identity), - }; -} - -/** - * Answers what a period, or one task inside it, cost. - * - * Orchestration only: the two reads belong to their ports, the rules belong to - * `domain/models/cost-report.ts`, and what is left is asking for one period's records and - * one period's journals and handing both over. It names no tool and computes no figure - - * in particular no amount, since the rates live outside this repository and an amount is - * only ever reported where a tool's own files already carried one. - */ -/** Sessions holding at least one stored record a re-read could never be matched against. - * - * A re-read is reconciled with what is stored on `turn_id`, and `groupByTurnId` indexes - * nothing without one — so re-reading such a session appends its records a second time. - * That was a documented edge while only unseen sessions were read; once every session in - * the period is, it would double a figure on every report. - * - * Found by the reference week going from 7 requests to 10, not by reasoning: its transcripts - * are hand-written and carry no `requestId`. Claude Code writes one on every line — 0 of 810 - * records without one on a live sink — but a host that does not must not be silently - * doubled, and "in general it has one" is not a guard. */ -function sessionsWithAnUnmatchableRecord( - stored: readonly TelemetrySinkRecord[] -): ReadonlySet { - const sessions = new Set(); - for (const record of stored) { - if (record.turn_id === undefined) sessions.add(record.vendor_id); - } - return sessions; -} - -/** Every session the journal names whose own `session_start` falls inside the period. - * - * **Not "the ones the sink has never seen".** That was the rule until 2026-09-04, keyed on - * whether a session appeared in the stored records at all, and it froze a session the - * moment its first turn was stored: a session still running was declared read and never - * looked at again. Measured live — the sink held 285 records while the transcript had 541, - * and `report` caught up none of them. It answered with a plausible wrong figure, which is - * the one thing every other rule in this layer refuses. - * - * Re-reading costs little and is safe: `read-local-cost-use-case.ts` dedupes per `turn_id` - * and appends only what is missing, so the session-level gate was a second filter at the - * wrong granularity. - * - * The period bound is what keeps the cost from growing with the age of the project: without - * it a report over one week would re-read every session a repository has ever journalled. */ -function sessionsToCatchUp( - stored: readonly TelemetrySinkRecord[], - journals: readonly RunJournal[], - fromMs: number, - periodEndMs: number -): readonly string[] { - const unmatchable = sessionsWithAnUnmatchableRecord(stored); - const missing: string[] = []; - for (const journal of journals) { - const session = journal.session; - if (session === undefined || unmatchable.has(session.vendor_id)) continue; - const atMs = Date.parse(session.at); - // `periodEndMs` is the first instant *after* the period, which is why this is `>=` and - // not `>`. Computing an end here rather than taking the one `periodEndMsOf` already - // gives is how the first version of this excluded the whole of `toDay`: a report over - // the single day work happened on answered "nothing in this period", and `--days N` - // sets `toDay` to today, so the default report never caught up anything journalled - // today — the one case this exists for. - if (Number.isNaN(atMs) || atMs < fromMs || atMs >= periodEndMs) continue; - missing.push(session.vendor_id); - } - return missing; -} - -export class ReportCostUseCase { - constructor( - private readonly sink: TelemetrySink, - private readonly runJournalReader: RunJournalReader, - private readonly personIdentityStore: PersonIdentityStore, - private readonly telemetryEvidenceReader: TelemetryEvidenceReader, - private readonly taskBacklogReader: TaskBacklogReader, - /** Reads the sessions the sink has not caught up with yet, before the report is built. - * Optional so a caller exercising the report's own rules need not wire a reader it is - * not asking about; absent, this reports exactly what the sink already holds. Production - * wiring always supplies it — that is what lets `report` be the only command a person - * runs, with `read` kept for asking on purpose rather than as a step to remember. */ - private readonly readLocalCost?: ReadLocalCostUseCase, - /** Only `warnAboutFailures` writes here. Optional for the same reason `readLocalCost` - * is: a caller asking about the report's own rules wires neither. */ - private readonly logger?: Logger - ) {} - - /** Whether the project switch is on right now - independent of the sink and the journal, - * so gathered on its own rather than folded into either of their reads. */ - private async measurementEnabled(options: ReportCostOptions): Promise { - return this.telemetryEvidenceReader.isTelemetryEnabled(options.projectRoot, options.env); - } - - async execute(options: ReportCostOptions): Promise { - const { fromDay, toDay } = options.period; - const from = new Date(`${fromDay}T00:00:00Z`); - const to = new Date(`${toDay}T00:00:00Z`); - const periodEndMs = periodEndMsOf(toDay); - // Every journal, not only the period's: a journal carries no date in its file name, and - // the records it is joined to were already selected by their own moments. Filtering the - // journals as well would only be a second, weaker selection over the same thing. - const journals = await this.runJournalReader.list(); - const read = await this.catchUp( - await this.sink.readRecordsInPeriod(from, to), - journals, - options, - { from, to, periodEndMs } - ); - const identity = await personIdentityFields(this.personIdentityStore); - const measurementEnabled = await this.measurementEnabled(options); - const taskBacklogDeclarations = await taskBacklogDeclarationsOf( - this.taskBacklogReader, - journals, - periodEndMsOf(toDay) - ); - - return buildCostReport( - toReportInput(options, read, journals, identity, measurementEnabled, taskBacklogDeclarations) - ); - } - - /** Reads whatever the sink has not caught up with, then asks it again. - * - * `ReadLocalCostUseCase` refuses on its own when the project switch is off or the person - * refused, so this needs no second gate: a refusal simply stores nothing and the report - * describes what was already there. Silent on success by design — the figures are the - * announcement, and `read` remains the command for asking what each tool answered. */ - private async catchUp( - read: TelemetrySinkPeriodRead, - journals: readonly RunJournal[], - options: ReportCostOptions, - period: { from: Date; to: Date; periodEndMs: number } - ): Promise { - if (this.readLocalCost === undefined) return read; - const missing = sessionsToCatchUp( - read.records, - journals, - period.from.getTime(), - period.periodEndMs - ); - if (missing.length === 0) return read; - for (const sessionId of missing) { - this.warnAboutFailures( - sessionId, - await this.readLocalCost.execute({ - sessionId, - projectRoot: options.projectRoot, - env: options.env, - }) - ); - } - return this.sink.readRecordsInPeriod(period.from, period.to); - } - - /** Says what a reader could not answer, since behind a report nobody sees the read's own - * output any more. - * - * Silence on success is a choice; silence on failure is the failure this whole layer - * exists to refuse. A period where every reader threw would otherwise print exactly what - * a period with no spend prints. Warnings go to stderr, so a `--json` caller's stdout - * stays one parseable object. */ - private warnAboutFailures(sessionId: string, result: ReadLocalCostResult): void { - // A missing logger cannot be allowed to swallow the failures this exists to surface — - // that is the rule this method is named for, applied to itself. Production always wires - // one (`deps.ts`); a caller that does not gets the same sentences on stderr rather than - // silence, because a report that quietly drops unreadable sessions reads as low spend. - const say = - this.logger?.warn.bind(this.logger) ?? ((line: string) => process.stderr.write(`${line}\n`)); - for (const report of result.toolReports) { - if (report.status !== "unreadable") continue; - say( - `telemetry report: ${report.tool} could not be read for session ${sessionId}` + - `${report.failureReason === undefined ? "" : ` - ${report.failureReason}`}` - ); - } - } -} diff --git a/cli/src/application/use-cases/telemetry/telemetry-off-use-case.ts b/cli/src/application/use-cases/telemetry/telemetry-off-use-case.ts deleted file mode 100644 index 4dd27ada6..000000000 --- a/cli/src/application/use-cases/telemetry/telemetry-off-use-case.ts +++ /dev/null @@ -1,98 +0,0 @@ -import { - SESSION_TRAILER_DELEGATE_FILE, - SESSION_TRAILER_TOKEN, -} from "../../../domain/formats/commit-session-trailer.js"; -import { - buildTelemetrySwitchFile, - parseTelemetrySwitchFile, - telemetryConfigPath, -} from "../../../domain/models/telemetry-switch.js"; -import type { FileReader } from "../../../domain/ports/file-reader.js"; -import type { FileWriter } from "../../../domain/ports/file-writer.js"; -import type { Logger } from "../../../domain/ports/logger.js"; -import type { TelemetryEvidenceReader } from "../../../domain/ports/telemetry-evidence-reader.js"; -import type { VersionControl } from "../../../domain/ports/version-control.js"; - -export interface TelemetryOffOptions { - readonly projectRoot: string; -} - -export interface TelemetryOffResult { - readonly switchPath: string; - readonly switchChanged: boolean; -} - -/** Sets the switch off, preserving whatever `endpoint` the file already carries — see its - * declaration in `telemetry-switch.ts` for why. Never edits a tool's own settings file: - * no command left in this system writes one, so there is none this could safely undo - * either — an `off` that started editing a file nobody here wrote could erase somebody's - * real setup the moment they turned off the local journal. It only warns when one still - * carries a stale export configuration; see `warnLeftoverExportConfig` below. */ -export class TelemetryOffUseCase { - constructor( - private readonly fs: FileReader & FileWriter, - private readonly logger: Logger, - private readonly telemetryEvidenceReader: TelemetryEvidenceReader, - private readonly git: VersionControl - ) {} - - async execute(options: TelemetryOffOptions): Promise { - const switchPath = telemetryConfigPath(options.projectRoot); - this.logger.info(`AIDD telemetry switch -> ${switchPath}`); - const switchChanged = await this.turnSwitchOff(switchPath); - await this.stopTrailingCommits(options.projectRoot); - await this.warnLeftoverExportConfig(options.projectRoot); - return { switchPath, switchChanged }; - } - - /** Unlike a tool's own settings file, this one *is* ours to undo: `on` wrote the hook - * line and the delegate beside it, so `off` takes both back. Runs whatever the switch's - * previous state was — a switch already off with the hook still installed is exactly the - * state a person running `off` a second time is trying to get out of. - * - * Commits already written keep the trailer they were written with. Nothing here rewrites - * history, the same rule `identity off` follows for records already stored. */ - private async stopTrailingCommits(projectRoot: string): Promise { - const removed = await this.git.removeCommitMessageDelegate( - projectRoot, - SESSION_TRAILER_DELEGATE_FILE - ); - if (!removed) return; - this.logger.info( - `New commits will carry no ${SESSION_TRAILER_TOKEN} trailer. Commits already made ` + - "keep theirs — nothing here rewrites history." - ); - } - - /** Names what `off` cannot touch: a tool's own settings file, still carrying a key - * `aidd telemetry endpoint` wrote before that command was deleted. Silence here is - * exactly the failure this exists to close — a person who ran that command has no other - * way left to learn their machine is still exporting. */ - private async warnLeftoverExportConfig(projectRoot: string): Promise { - const leftovers = await this.telemetryEvidenceReader.findLeftoverExportConfig(projectRoot); - for (const leftover of leftovers) { - this.logger.warn( - `${leftover.path} still sets ${leftover.keys.join(", ")} — this switch cannot ` + - "touch a tool's own settings file. Delete these keys from its `env` block by " + - "hand to stop that export." - ); - } - } - - private async turnSwitchOff(switchPath: string): Promise { - if (!(await this.fs.fileExists(switchPath))) { - this.logger.info("AIDD telemetry: already off, unchanged."); - return false; - } - const raw = await this.fs.readFile(switchPath); - const current = parseTelemetrySwitchFile(raw); - if (current?.enabled !== true) { - this.logger.info("AIDD telemetry: already off, unchanged."); - return false; - } - const next = buildTelemetrySwitchFile(raw, { enabled: false, endpoint: current.endpoint }); - await this.fs.writeFile(switchPath, next); - this.logger.info("AIDD telemetry: off."); - return true; - } -} diff --git a/cli/src/application/use-cases/telemetry/telemetry-on-use-case.ts b/cli/src/application/use-cases/telemetry/telemetry-on-use-case.ts deleted file mode 100644 index 688962d83..000000000 --- a/cli/src/application/use-cases/telemetry/telemetry-on-use-case.ts +++ /dev/null @@ -1,134 +0,0 @@ -import { - SESSION_TRAILER_DELEGATE_FILE, - SESSION_TRAILER_TOKEN, - sessionTrailerDelegateScript, -} from "../../../domain/formats/commit-session-trailer.js"; -import { RUNS_ENTRY } from "../../../domain/models/paths.js"; -import { - buildTelemetrySwitchFile, - parseTelemetrySwitchFile, - type TelemetrySwitch, - telemetryConfigPath, -} from "../../../domain/models/telemetry-switch.js"; -import type { FileReader } from "../../../domain/ports/file-reader.js"; -import type { FileWriter } from "../../../domain/ports/file-writer.js"; -import type { Logger } from "../../../domain/ports/logger.js"; -import type { VersionControl } from "../../../domain/ports/version-control.js"; -import { TelemetryProjectScopeRequiresYesError } from "../../errors.js"; -import type { GitignoreUseCase } from "../shared/gitignore-use-case.js"; - -export interface TelemetryOnOptions { - readonly projectRoot: string; - /** Same consequence as `endpoint --scope project`: `.aidd/config.json` is a git-tracked - * file, deliberately un-ignored so a fresh clone inherits the project's decision. Refusing - * without this is the whole reason a person is ever asked at all. */ - readonly confirmed: boolean; -} - -export interface TelemetryOnResult { - readonly switchPath: string; - readonly switchChanged: boolean; -} - -/** Owns the AIDD telemetry switch alone: flips `.aidd/config.json`'s `telemetry.enabled` - * and git-ignores the run journal. Never touches a tool's own settings file — arming a - * tool to export and recording locally are two different promises, and no command in this - * system writes the former any more. Any `endpoint` already recorded in the switch file is - * preserved untouched — see its declaration in `telemetry-switch.ts` for why. */ -export class TelemetryOnUseCase { - constructor( - private readonly fs: FileReader & FileWriter, - private readonly logger: Logger, - private readonly gitignoreUseCase: GitignoreUseCase, - private readonly git: VersionControl - ) {} - - async execute(options: TelemetryOnOptions): Promise { - const switchPath = telemetryConfigPath(options.projectRoot); - this.guardConfirmed(options); - this.logger.info(`AIDD telemetry switch -> ${switchPath}`); - const switchChanged = await this.writeSwitch(switchPath); - await this.protectRunsDir(options.projectRoot); - await this.makeCommitsJoinable(options.projectRoot); - return { switchPath, switchChanged }; - } - - // `.aidd/config.json` is deliberately git-tracked — un-ignored so a fresh clone inherits - // the project's decision — which is exactly the consequence `endpoint --scope project` - // already refuses without `--yes`. Same consequence, same sentence, same error: fires - // unconditionally, whatever the switch's current state, the same way that guard does. - private guardConfirmed(options: TelemetryOnOptions): void { - if (options.confirmed) return; - throw new TelemetryProjectScopeRequiresYesError( - "aidd telemetry on", - telemetryConfigPath(options.projectRoot) - ); - } - - // Every successful `on` re-checks this, switch newly written or not — the rule the - // plugin's own switch script followed before the CLI took it over, and the reason: a project - // turned on before this existed must still get caught up on ignoring the journal and on - // naming anything git already tracks, without a person having to turn it off and on again. - private async protectRunsDir(projectRoot: string): Promise { - const added = await this.gitignoreUseCase.execute(projectRoot, [RUNS_ENTRY]); - if (added) { - this.logger.info( - `Added ${RUNS_ENTRY} to .gitignore — the journal names no person, only the ` + - "repository, the task folders written into, the skills run, and their timings. " + - "Delete that line to commit it instead." - ); - } - const tracked = await this.git.listTrackedFiles(projectRoot, RUNS_ENTRY); - if (tracked.length === 0) return; - this.logger.warn( - "Already tracked by git — the repository, the task folders written into, the skills " + - `run, and their timings:\n${tracked.map((file) => ` ${file}`).join("\n")}\n` + - "Nothing removed or rewritten — your call." - ); - } - - /** Re-run on every successful `on`, switch newly written or not, for the same reason - * `protectRunsDir` is: a project turned on before this existed has to be caught up - * without anyone turning it off and on again. - * - * What it buys is the one link the chain was missing. A record already names its turn, - * its session and the task folder that session declared; nothing named the commit, so - * "what did this backlog item cost" could be answered and "what did this commit cost" - * could not. Announced rather than done quietly: this writes into commit messages a team - * will read, and a person who did not expect it must be able to find the sentence that - * told them, and the command that undoes it. */ - private async makeCommitsJoinable(projectRoot: string): Promise { - const installed = await this.git.installCommitMessageDelegate( - projectRoot, - SESSION_TRAILER_DELEGATE_FILE, - sessionTrailerDelegateScript() - ); - if (!installed) return; - this.logger.info( - `Commits made by an AI session will carry an ${SESSION_TRAILER_TOKEN} trailer, so what ` + - "a session cost can be read per commit. A commit no session made carries nothing. " + - "`aidd telemetry off` removes it." - ); - } - - private async readIfExists(path: string): Promise { - return (await this.fs.fileExists(path)) ? await this.fs.readFile(path) : null; - } - - private async writeSwitch(switchPath: string): Promise { - const existingRaw = await this.readIfExists(switchPath); - const existing: TelemetrySwitch | null = - existingRaw !== null ? parseTelemetrySwitchFile(existingRaw) : null; - if (existing?.enabled === true) { - this.logger.info("AIDD telemetry: already on, unchanged."); - return false; - } - const next = buildTelemetrySwitchFile(existingRaw, { - enabled: true, - endpoint: existing?.endpoint, - }); - await this.fs.writeFile(switchPath, next); - this.logger.info("AIDD telemetry: on."); - return true; - } -} diff --git a/cli/src/application/use-cases/uninstall/uninstall-plugin-use-case.ts b/cli/src/application/use-cases/uninstall/uninstall-plugin-use-case.ts deleted file mode 100644 index 83e909327..000000000 --- a/cli/src/application/use-cases/uninstall/uninstall-plugin-use-case.ts +++ /dev/null @@ -1,75 +0,0 @@ -import { dirname, join } from "node:path"; -import { PluginNotFoundError } from "../../../domain/errors.js"; -import type { Manifest } from "../../../domain/models/manifest.js"; -import type { AiToolId } from "../../../domain/models/tool-ids.js"; -import { AI_TOOL_IDS } from "../../../domain/models/tool-ids.js"; -import type { FileWriter } from "../../../domain/ports/file-writer.js"; -import type { ManifestRepository } from "../../../domain/ports/manifest-repository.js"; -import type { ToolId } from "../../../domain/tools/registry.js"; -import { NoManifestError } from "../../errors.js"; - -export interface UninstallPluginOptions { - pluginName: string; - toolIds: ToolId[]; - projectRoot: string; -} - -export interface UninstallPluginResult { - toolId: ToolId; - fileCount: number; - deletedFiles: string[]; -} - -export class UninstallPluginUseCase { - constructor( - private readonly fs: FileWriter, - private readonly manifestRepo: ManifestRepository - ) {} - - async execute(options: UninstallPluginOptions): Promise { - const { pluginName, toolIds, projectRoot } = options; - const manifest = await this.manifestRepo.load(); - if (manifest === null) throw new NoManifestError(); - const scope = this.resolveToolScope(toolIds, manifest); - const results = await this.removeFromTools(pluginName, scope, projectRoot, manifest); - if (results.length === 0) throw new PluginNotFoundError(pluginName); - await this.manifestRepo.save(manifest); - return results; - } - - private resolveToolScope(toolIds: ToolId[], manifest: Manifest): AiToolId[] { - if (toolIds.length > 0) return toolIds.filter((id) => manifest.hasTool(id)) as AiToolId[]; - return AI_TOOL_IDS.filter((id) => manifest.hasTool(id)) as AiToolId[]; - } - - private async removeFromTools( - pluginName: string, - toolIds: AiToolId[], - projectRoot: string, - manifest: Manifest - ): Promise { - const results: UninstallPluginResult[] = []; - for (const toolId of toolIds) { - const plugin = manifest.getPlugins(toolId).find((p) => p.name === pluginName); - if (plugin === undefined) continue; - const deletedFiles = await this.deleteFiles(plugin.files, projectRoot); - manifest.removePlugin(toolId, pluginName); - results.push({ toolId, fileCount: deletedFiles.length, deletedFiles }); - } - return results; - } - - private async deleteFiles( - files: ReadonlyMap, - projectRoot: string - ): Promise { - const deleted: string[] = []; - for (const relativePath of files.keys()) { - const fullPath = join(projectRoot, relativePath); - await this.fs.deleteFile(fullPath); - await this.fs.deleteEmptyDirectories(dirname(fullPath)); - deleted.push(relativePath); - } - return deleted; - } -} diff --git a/cli/src/cli.ts b/cli/src/cli.ts index d38e0a620..bcebebfc4 100644 --- a/cli/src/cli.ts +++ b/cli/src/cli.ts @@ -1,24 +1,20 @@ import { platform } from "node:os"; import { Command } from "commander"; -import { registerAiCommand } from "./application/commands/ai.js"; -import { registerAuthCommand } from "./application/commands/auth.js"; -import { registerCleanCommand } from "./application/commands/clean.js"; -import { registerDoctorCommand } from "./application/commands/doctor.js"; -import { registerFrameworkCommand } from "./application/commands/framework.js"; -import { registerIdeCommand } from "./application/commands/ide.js"; -import { registerKanbanCommand } from "./application/commands/kanban.js"; -import { registerMarketplaceCommand } from "./application/commands/marketplace.js"; -import { runMenuLoop } from "./application/commands/menu.js"; -import { registerPluginCommand } from "./application/commands/plugin.js"; -import { registerRestoreCommand } from "./application/commands/restore.js"; -import { registerSelfUpdateCommand } from "./application/commands/self-update.js"; -import { registerSetupCommand } from "./application/commands/setup.js"; -import { registerStatusCommand } from "./application/commands/status.js"; -import { registerTelemetryCommand } from "./application/commands/telemetry.js"; -import { registerUpdateCommand } from "./application/commands/update.js"; -import { CLIOutput } from "./application/output.js"; -import { CurrentVersionAdapter } from "./infrastructure/adapters/current-version-adapter.js"; -import { createDeps } from "./infrastructure/deps.js"; +import { registerAuthCommand } from "./presentation/commands/auth.js"; +import { registerCleanCommand } from "./presentation/commands/clean.js"; +import { registerDoctorCommand } from "./presentation/commands/doctor.js"; +import { registerFrameworkCommand } from "./presentation/commands/framework.js"; +import { registerMarketplaceCommand } from "./presentation/commands/marketplace.js"; +import { runMenuLoop } from "./presentation/commands/menu.js"; +import { registerPluginCommand } from "./presentation/commands/plugin.js"; +import { registerSetupCommand } from "./presentation/commands/setup.js"; +import { registerSyncCommand } from "./presentation/commands/sync.js"; +import { registerTelemetryCommand } from "./presentation/commands/telemetry.js"; +import { registerTranslateCommand } from "./presentation/commands/translate.js"; +import { registerUpdateCommand } from "./presentation/commands/update.js"; +import { CLIOutput } from "./presentation/output.js"; +import { CurrentVersionAdapter } from "./runtime/self-update/current-version-adapter.js"; +import { createDeps } from "./runtime/wiring/framework.js"; function formatVersion(version: string): string { return `aidd/${version} node/${process.versions.node} ${platform()}-${process.arch}`; @@ -36,38 +32,37 @@ program registerSetupCommand(program); registerFrameworkCommand(program); -registerAiCommand(program); -registerIdeCommand(program); +registerTranslateCommand(program); registerPluginCommand(program); registerMarketplaceCommand(program); registerAuthCommand(program); -registerStatusCommand(program); -registerKanbanCommand(program); -registerRestoreCommand(program); +registerSyncCommand(program); registerUpdateCommand(program); registerDoctorCommand(program); registerCleanCommand(program); registerTelemetryCommand(program); -registerSelfUpdateCommand(program); -// Commands already paying for network I/O: piggyback the update-check refresh on them. -// Subcommand-path-granular — `marketplace remove` (offline) and `self-update` are deliberately absent. +// Commands already paying for network I/O, so the update-check refresh rides one of them. +// `marketplace remove` is offline and `update` already resolves the latest version itself. const ONLINE_COMMAND_PATHS = new Set([ - "update", "marketplace refresh", "marketplace check", "marketplace list", "marketplace add", + "sync", ]); program.hook("preAction", async (_thisCommand, actionCommand) => { + if (process.env.AIDD_SKIP_UPDATE_CHECK === "1") return; const opts = program.opts<{ verbose?: boolean }>(); const output = new CLIOutput(opts.verbose ?? false); const deps = await createDeps(process.cwd(), { verbose: opts.verbose ?? false }, output).catch( () => null ); if (!deps) return; - if (actionCommand.name() === "self-update") return; + // A bare verb with no subject means "the CLI itself" (Claude Code/Codex convention): + // `update` resolves the latest version on its own, so the generic check is redundant. + if (actionCommand.name() === "update") return; await deps.checkUpdateUseCase.printFromCacheOnly().catch((err: unknown) => { deps.logger.debug( `CLI update check failed: ${err instanceof Error ? err.message : String(err)}` @@ -76,6 +71,11 @@ program.hook("preAction", async (_thisCommand, actionCommand) => { }); program.hook("postAction", async (_thisCommand, actionCommand) => { + // The refresh asks GitHub what the latest release is and caches the answer, so any + // run that performs it produces output depending on what has been published since. + // A test suite that captures output cannot afford that: every release would rewrite + // its expectations. Same switch shape as AIDD_SKIP_MARKETPLACE_REFRESH, same reason. + if (process.env.AIDD_SKIP_UPDATE_CHECK === "1") return; if (!ONLINE_COMMAND_PATHS.has(resolveCommandPath(actionCommand))) return; const opts = program.opts<{ verbose?: boolean }>(); const output = new CLIOutput(opts.verbose ?? false); diff --git a/cli/src/application/use-cases/shared/fetch-marketplace-source-use-case.ts b/cli/src/contexts/distribution/application/fetch-marketplace-source-use-case.ts similarity index 81% rename from cli/src/application/use-cases/shared/fetch-marketplace-source-use-case.ts rename to cli/src/contexts/distribution/application/fetch-marketplace-source-use-case.ts index 3485442b3..c37b3f29d 100644 --- a/cli/src/application/use-cases/shared/fetch-marketplace-source-use-case.ts +++ b/cli/src/contexts/distribution/application/fetch-marketplace-source-use-case.ts @@ -1,16 +1,16 @@ import { join } from "node:path"; -import type { Marketplace } from "../../../domain/models/marketplace.js"; +import type { FileReader } from "../../../kernel/ports/file-reader.js"; +import type { FileWriter } from "../../../kernel/ports/file-writer.js"; +import type { Logger } from "../../../kernel/ports/logger.js"; +import type { PluginSourceGitHub } from "../../../kernel/source.js"; import { hasRelativePluginSources, type PluginCatalog, parsePluginCatalog, -} from "../../../domain/models/plugin-catalog.js"; -import type { PluginSourceGitHub } from "../../../domain/models/plugin-source.js"; -import type { FileReader } from "../../../domain/ports/file-reader.js"; -import type { FileWriter } from "../../../domain/ports/file-writer.js"; -import type { Logger } from "../../../domain/ports/logger.js"; -import type { PluginFetcher, PluginFetchOptions } from "../../../domain/ports/plugin-fetcher.js"; -import type { RawCatalogFetcher } from "../../../domain/ports/raw-catalog-fetcher.js"; +} from "../domain/catalog.js"; +import type { Marketplace } from "../domain/marketplace.js"; +import type { PluginFetcher, PluginFetchOptions } from "../domain/ports/plugin-fetcher.js"; +import type { RawCatalogFetcher } from "../domain/ports/raw-catalog-fetcher.js"; const CLAUDE_CATALOG_PATH = ".claude-plugin/marketplace.json"; diff --git a/cli/src/application/use-cases/marketplace/marketplace-add-use-case.ts b/cli/src/contexts/distribution/application/marketplace-add-use-case.ts similarity index 79% rename from cli/src/application/use-cases/marketplace/marketplace-add-use-case.ts rename to cli/src/contexts/distribution/application/marketplace-add-use-case.ts index af8986c9d..456ca7e4e 100644 --- a/cli/src/application/use-cases/marketplace/marketplace-add-use-case.ts +++ b/cli/src/contexts/distribution/application/marketplace-add-use-case.ts @@ -3,18 +3,15 @@ import { InvalidPluginManifestError, MarketplaceAlreadyRegisteredError, TrustDeniedError, -} from "../../../domain/errors.js"; -import { - FRAMEWORK_MARKETPLACE_NAME, - Marketplace, - type MarketplaceScope, -} from "../../../domain/models/marketplace.js"; -import type { PluginSource } from "../../../domain/models/plugin-source.js"; -import type { MarketplaceRegistry } from "../../../domain/ports/marketplace-registry.js"; -import type { MarketplaceTrustStore } from "../../../domain/ports/marketplace-trust-store.js"; -import type { Prompter } from "../../../domain/ports/prompter.js"; -import type { ResolveMarketplaceUseCase } from "../shared/resolve-marketplace-use-case.js"; -import type { MarketplaceRemoveUseCase } from "./marketplace-remove-use-case.js"; +} from "../../../kernel/errors.js"; +import type { Prompter } from "../../../kernel/ports/prompter.js"; +import type { MarketplaceScope } from "../../../kernel/scope.js"; +import type { PluginSource } from "../../../kernel/source.js"; +import type { MarketplaceRemoveUseCase } from "../../framework/application/flows/marketplace-remove-use-case.js"; +import { FRAMEWORK_MARKETPLACE_NAME, Marketplace } from "../domain/marketplace.js"; +import type { MarketplaceRegistry } from "../domain/ports/marketplace-registry.js"; +import type { MarketplaceTrustStore } from "../domain/ports/marketplace-trust-store.js"; +import type { ResolveMarketplaceUseCase } from "./resolve-marketplace-use-case.js"; export interface MarketplaceAddOptions { source: PluginSource; diff --git a/cli/src/application/use-cases/marketplace/marketplace-list-use-case.ts b/cli/src/contexts/distribution/application/marketplace-list-use-case.ts similarity index 80% rename from cli/src/application/use-cases/marketplace/marketplace-list-use-case.ts rename to cli/src/contexts/distribution/application/marketplace-list-use-case.ts index 1eb606c07..e8407378d 100644 --- a/cli/src/application/use-cases/marketplace/marketplace-list-use-case.ts +++ b/cli/src/contexts/distribution/application/marketplace-list-use-case.ts @@ -1,8 +1,8 @@ -import type { Marketplace } from "../../../domain/models/marketplace.js"; -import type { PluginCatalog } from "../../../domain/models/plugin-catalog.js"; -import type { Logger } from "../../../domain/ports/logger.js"; -import type { MarketplaceRegistry } from "../../../domain/ports/marketplace-registry.js"; -import type { ResolveMarketplaceUseCase } from "../shared/resolve-marketplace-use-case.js"; +import type { Logger } from "../../../kernel/ports/logger.js"; +import type { PluginCatalog } from "../domain/catalog.js"; +import type { Marketplace } from "../domain/marketplace.js"; +import type { MarketplaceRegistry } from "../domain/ports/marketplace-registry.js"; +import type { ResolveMarketplaceUseCase } from "./resolve-marketplace-use-case.js"; export interface MarketplaceListOptions { projectRoot: string; diff --git a/cli/src/application/use-cases/marketplace/marketplace-refresh-use-case.ts b/cli/src/contexts/distribution/application/marketplace-refresh-use-case.ts similarity index 82% rename from cli/src/application/use-cases/marketplace/marketplace-refresh-use-case.ts rename to cli/src/contexts/distribution/application/marketplace-refresh-use-case.ts index 10349d87d..e3c4f4ae2 100644 --- a/cli/src/application/use-cases/marketplace/marketplace-refresh-use-case.ts +++ b/cli/src/contexts/distribution/application/marketplace-refresh-use-case.ts @@ -1,16 +1,16 @@ import { join, resolve } from "node:path"; -import type { Marketplace } from "../../../domain/models/marketplace.js"; -import { marketplaceCacheDir } from "../../../domain/models/paths.js"; +import { marketplaceCacheDir } from "../../../kernel/paths.js"; +import type { FileReader } from "../../../kernel/ports/file-reader.js"; +import type { Logger } from "../../../kernel/ports/logger.js"; import { hasRelativePluginSources, type PluginCatalog, parsePluginCatalog, -} from "../../../domain/models/plugin-catalog.js"; -import type { FileReader } from "../../../domain/ports/file-reader.js"; -import type { Logger } from "../../../domain/ports/logger.js"; -import type { MarketplaceCachePort } from "../../../domain/ports/marketplace-cache.js"; -import type { MarketplaceRegistry } from "../../../domain/ports/marketplace-registry.js"; -import type { ResolveMarketplaceUseCase } from "../shared/resolve-marketplace-use-case.js"; +} from "../domain/catalog.js"; +import type { Marketplace } from "../domain/marketplace.js"; +import type { MarketplaceCachePort } from "../domain/ports/marketplace-cache.js"; +import type { MarketplaceRegistry } from "../domain/ports/marketplace-registry.js"; +import type { ResolveMarketplaceUseCase } from "./resolve-marketplace-use-case.js"; export interface MarketplaceRefreshOptions { projectRoot: string; @@ -57,8 +57,8 @@ export class MarketplaceRefreshUseCase implements MarketplaceRefresh { return { results, failedCount }; } - // @policy report-and-continue: a single failed marketplace must not abort the - // batch refresh. Each failure is reported via the result, never thrown. + // A single failed marketplace must not abort the batch refresh: each failure is reported + // through the result, never thrown. private async refreshOne(projectRoot: string, m: Marketplace): Promise { try { const cacheDir = marketplaceCacheDir(projectRoot, m.name); diff --git a/cli/src/contexts/distribution/application/marketplace-register-framework-use-case.ts b/cli/src/contexts/distribution/application/marketplace-register-framework-use-case.ts new file mode 100644 index 000000000..c87a6de6a --- /dev/null +++ b/cli/src/contexts/distribution/application/marketplace-register-framework-use-case.ts @@ -0,0 +1,68 @@ +import type { MarketplaceScope } from "../../../kernel/scope.js"; +import type { PluginSource } from "../../../kernel/source.js"; +import { FRAMEWORK_MARKETPLACE_NAME, Marketplace } from "../domain/marketplace.js"; +import type { MarketplaceRegistry } from "../domain/ports/marketplace-registry.js"; + +export interface MarketplaceRegisterFrameworkOptions { + projectRoot: string; + force?: boolean; + frameworkPath?: string; + /** Explicit plugin source — when provided, deriveSource() is skipped. */ + pluginSource?: PluginSource; +} + +export interface MarketplaceRegisterFrameworkResult { + registered: boolean; + /** The scope this registration actually lives at once `execute` returns — always `"user"` + * today, since a pre-existing project-scope entry is migrated unconditionally. Carried back + * rather than hardcoded again by a caller that needs to know. */ + scope: MarketplaceScope; +} + +/** Registering the bundled framework marketplace, as its callers need it. */ +export interface MarketplaceRegisterFramework { + execute( + options: MarketplaceRegisterFrameworkOptions + ): Promise; +} + +export class MarketplaceRegisterFrameworkUseCase implements MarketplaceRegisterFramework { + constructor(private readonly registry: MarketplaceRegistry) {} + + async execute( + options: MarketplaceRegisterFrameworkOptions + ): Promise { + const list = await this.registry.list(options.projectRoot); + const found = list.find((m) => m.name === FRAMEWORK_MARKETPLACE_NAME); + if (found?.scope === "project") { + // A project-scope entry from before the machine-scope move is retired unconditionally, + // `--force` or not: this is the migration itself completing, never an option a caller + // opts out of. `list()` puts a project entry first and filters a same-named user one out, + // so leaving this one in place would make the user-scope entry written below invisible to + // every future `list()` call. + await this.registry.delete(options.projectRoot, FRAMEWORK_MARKETPLACE_NAME, "project"); + } else if (found !== undefined) { + // Already migrated (scope "user"): idempotent unless a caller asks to rewrite it. + if (!options.force) return { registered: false, scope: found.scope }; + await this.registry.delete(options.projectRoot, FRAMEWORK_MARKETPLACE_NAME, "user"); + } + const source = options.pluginSource ?? this.deriveSource(options.frameworkPath); + // Machine scope, not project: every project on this machine registers the same framework + // marketplace, so a second project must find the first project's entry rather than write + // its own — codex and copilot refuse a second source under the same name outright, and + // claude would otherwise silently repoint the whole machine at whichever project last ran + // `setup`. + const marketplace = Marketplace.create({ + name: FRAMEWORK_MARKETPLACE_NAME, + source, + scope: "user", + addedAt: new Date().toISOString(), + }); + await this.registry.save(options.projectRoot, marketplace); + return { registered: true, scope: marketplace.scope }; + } + + private deriveSource(frameworkPath?: string): PluginSource { + return { kind: "local", path: frameworkPath ?? "." }; + } +} diff --git a/cli/src/application/use-cases/shared/resolve-marketplace-use-case.ts b/cli/src/contexts/distribution/application/resolve-marketplace-use-case.ts similarity index 80% rename from cli/src/application/use-cases/shared/resolve-marketplace-use-case.ts rename to cli/src/contexts/distribution/application/resolve-marketplace-use-case.ts index d93cfe4f1..9089e0c0d 100644 --- a/cli/src/application/use-cases/shared/resolve-marketplace-use-case.ts +++ b/cli/src/contexts/distribution/application/resolve-marketplace-use-case.ts @@ -1,7 +1,7 @@ -import type { Marketplace } from "../../../domain/models/marketplace.js"; -import { marketplaceCacheDir } from "../../../domain/models/paths.js"; -import type { PluginCatalog } from "../../../domain/models/plugin-catalog.js"; -import type { PluginCatalogRepository } from "../../../domain/ports/plugin-catalog-repository.js"; +import { marketplaceCacheDir } from "../../../kernel/paths.js"; +import type { PluginCatalog } from "../domain/catalog.js"; +import type { Marketplace } from "../domain/marketplace.js"; +import type { PluginCatalogRepository } from "../domain/ports/plugin-catalog-repository.js"; import type { FetchMarketplaceSourceUseCase } from "./fetch-marketplace-source-use-case.js"; export interface ResolveMarketplaceOptions { diff --git a/cli/src/domain/formats/copilot-marketplace-catalog.ts b/cli/src/contexts/distribution/domain/catalog-parsers/copilot-marketplace-catalog.ts similarity index 78% rename from cli/src/domain/formats/copilot-marketplace-catalog.ts rename to cli/src/contexts/distribution/domain/catalog-parsers/copilot-marketplace-catalog.ts index 96697f80f..8f743a7e6 100644 --- a/cli/src/domain/formats/copilot-marketplace-catalog.ts +++ b/cli/src/contexts/distribution/domain/catalog-parsers/copilot-marketplace-catalog.ts @@ -1,18 +1,11 @@ /** - * Copilot-native multi-plugin marketplace catalog parser — pure, no I/O. - * - * Parses the `.github/plugin/marketplace.json` format produced by - * `aidd framework build --target copilot`. Schema mirrors `github/awesome-copilot`: - * - Top-level: `name`, `metadata: { pluginRoot, ... }`, `owner`, `plugins[]` - * - Each plugin entry: `name`, `source` (bare subdirectory name), `description`, `version` - * - * Returns a `PluginCatalog` (same type as Claude marketplace). The bare `source` string - * is combined with `metadata.pluginRoot` into a relative `{ kind: "local" }` source so - * the adapter's existing `resolveLocalPaths` lifts it to an absolute path. + * Copilot-native multi-plugin marketplace catalog parser — pure, no I/O. An entry's `source` is + * a bare subdirectory name, combined with `metadata.pluginRoot` into a relative local source so + * the adapter's own `resolveLocalPaths` lifts it to an absolute path. */ -import { InvalidPluginManifestError } from "../errors.js"; -import type { PluginCatalog, PluginCatalogEntry } from "../models/plugin-catalog.js"; +import { InvalidPluginManifestError } from "../../../../kernel/errors.js"; +import type { PluginCatalog, PluginCatalogEntry } from "../catalog.js"; const COPILOT_SOURCE = "copilot-catalog"; diff --git a/cli/src/contexts/distribution/domain/catalog.ts b/cli/src/contexts/distribution/domain/catalog.ts new file mode 100644 index 000000000..cf4dcc3fa --- /dev/null +++ b/cli/src/contexts/distribution/domain/catalog.ts @@ -0,0 +1,70 @@ +import { isAbsolute } from "node:path"; +import { InvalidPluginManifestError } from "../../../kernel/errors.js"; +import { type PluginSource, parsePluginSource } from "../../../kernel/source.js"; + +export interface PluginCatalogEntry { + name: string; + source: PluginSource; + description?: string; + version?: string; + recommended: boolean; + strict: boolean; +} + +export interface PluginCatalog { + name?: string; + version?: string; + plugins: readonly PluginCatalogEntry[]; +} + +function parseEntry(raw: unknown, index: number): PluginCatalogEntry { + if (raw === null || typeof raw !== "object" || Array.isArray(raw)) { + throw new InvalidPluginManifestError(`plugins[${index}] must be an object`); + } + const obj = raw as Record; + + if (typeof obj.name !== "string" || obj.name.length === 0) { + throw new InvalidPluginManifestError(`plugins[${index}].name must be a non-empty string`); + } + + if (obj.source === undefined) { + throw new InvalidPluginManifestError(`plugins[${index}].source is required`); + } + + const source = parsePluginSource(obj.source); + + const entry: PluginCatalogEntry = { + name: obj.name, + source, + recommended: typeof obj.recommended === "boolean" ? obj.recommended : false, + strict: typeof obj.strict === "boolean" ? obj.strict : false, + }; + + if (typeof obj.description === "string") entry.description = obj.description; + if (typeof obj.version === "string") entry.version = obj.version; + + return entry; +} + +export function hasRelativePluginSources(catalog: PluginCatalog): boolean { + return catalog.plugins.some( + (entry) => entry.source.kind === "local" && !isAbsolute(entry.source.path) + ); +} + +export function parsePluginCatalog(raw: unknown): PluginCatalog { + if (raw === null || typeof raw !== "object" || Array.isArray(raw)) { + throw new InvalidPluginManifestError("marketplace.json must be a JSON object"); + } + const obj = raw as Record; + + if (!Array.isArray(obj.plugins)) { + throw new InvalidPluginManifestError('"plugins" must be an array'); + } + + const plugins = obj.plugins.map((entry, i) => parseEntry(entry, i)); + const catalog: PluginCatalog = { plugins }; + if (typeof obj.name === "string" && obj.name.length > 0) catalog.name = obj.name; + if (typeof obj.version === "string" && obj.version.length > 0) catalog.version = obj.version; + return catalog; +} diff --git a/cli/src/domain/models/marketplace-source-mode.ts b/cli/src/contexts/distribution/domain/marketplace-source-mode.ts similarity index 97% rename from cli/src/domain/models/marketplace-source-mode.ts rename to cli/src/contexts/distribution/domain/marketplace-source-mode.ts index 8a6d9baaa..33a71b779 100644 --- a/cli/src/domain/models/marketplace-source-mode.ts +++ b/cli/src/contexts/distribution/domain/marketplace-source-mode.ts @@ -1,4 +1,4 @@ -import { EmptyLocalSourcePathError, MarketplaceSourceKindError } from "../errors.js"; +import { EmptyLocalSourcePathError, MarketplaceSourceKindError } from "../../../kernel/errors.js"; export const DEFAULT_FRAMEWORK_REPO = "ai-driven-dev/framework"; diff --git a/cli/src/contexts/distribution/domain/marketplace.ts b/cli/src/contexts/distribution/domain/marketplace.ts new file mode 100644 index 000000000..009c3ccce --- /dev/null +++ b/cli/src/contexts/distribution/domain/marketplace.ts @@ -0,0 +1,139 @@ +import { + InvalidMarketplaceNameError, + InvalidMarketplaceScopeError, +} from "../../../kernel/errors.js"; +import type { MarketplaceScope } from "../../../kernel/scope.js"; +import { + type PluginSource, + parsePluginSource, + serializePluginSource, +} from "../../../kernel/source.js"; + +export const MARKETPLACE_NAME_REGEX = /^[a-z0-9]+(-[a-z0-9]+)*$/; +export const FRAMEWORK_MARKETPLACE_NAME = "aidd-framework"; +export const STALE_MAX_DAYS_DEFAULT = 7; +const MS_PER_DAY = 24 * 60 * 60 * 1000; + +export interface MarketplaceData { + name: string; + source: Record; + scope: MarketplaceScope; + addedAt: string; + lastFetched?: string; + version?: string; +} + +/** A marketplace record as it comes off disk: `scope` is whatever the file said, which is + * exactly why `fromJSON` checks it. Saying so lets a caller hand over an unchecked value + * without pretending it is already one of the two the domain accepts. */ +export type StoredMarketplaceData = Omit & { scope: string }; + +function isMarketplaceScope(value: string): value is MarketplaceScope { + return value === "project" || value === "user"; +} + +export class Marketplace { + readonly name: string; + readonly source: PluginSource; + readonly scope: MarketplaceScope; + readonly addedAt: string; + readonly lastFetched?: string; + readonly version?: string; + + private constructor(params: { + name: string; + source: PluginSource; + scope: MarketplaceScope; + addedAt: string; + lastFetched?: string; + version?: string; + }) { + this.name = params.name; + this.source = params.source; + this.scope = params.scope; + this.addedAt = params.addedAt; + this.lastFetched = params.lastFetched; + this.version = params.version; + } + + static create(params: { + name: string; + source: PluginSource; + scope: MarketplaceScope; + addedAt: string; + }): Marketplace { + return Marketplace.fromJSON({ + name: params.name, + source: serializePluginSource(params.source), + scope: params.scope, + addedAt: params.addedAt, + }); + } + + static fromJSON(data: StoredMarketplaceData): Marketplace { + if (!MARKETPLACE_NAME_REGEX.test(data.name)) { + throw new InvalidMarketplaceNameError(data.name); + } + if (!isMarketplaceScope(data.scope)) { + throw new InvalidMarketplaceScopeError(String(data.scope)); + } + const source = parsePluginSource(data.source); + return new Marketplace({ + name: data.name, + source, + scope: data.scope, + addedAt: data.addedAt, + lastFetched: data.lastFetched, + version: data.version, + }); + } + + toJSON(): MarketplaceData { + const data: MarketplaceData = { + name: this.name, + source: serializePluginSource(this.source), + scope: this.scope, + addedAt: this.addedAt, + }; + if (this.lastFetched !== undefined) data.lastFetched = this.lastFetched; + if (this.version !== undefined) data.version = this.version; + return data; + } + + withLastFetched(when: string): Marketplace { + return new Marketplace({ + name: this.name, + source: this.source, + scope: this.scope, + addedAt: this.addedAt, + lastFetched: when, + version: this.version, + }); + } + + withVersion(version: string): Marketplace { + return new Marketplace({ + name: this.name, + source: this.source, + scope: this.scope, + addedAt: this.addedAt, + lastFetched: this.lastFetched, + version, + }); + } + + isFramework(): boolean { + return this.name === FRAMEWORK_MARKETPLACE_NAME; + } +} + +export function isMarketplaceStale( + marketplace: Marketplace, + now: number, + maxDays: number +): boolean { + if (!marketplace.lastFetched) return true; + const lastMs = Date.parse(marketplace.lastFetched); + if (Number.isNaN(lastMs)) return true; + return now - lastMs > maxDays * MS_PER_DAY; +} diff --git a/cli/src/contexts/distribution/domain/ports/marketplace-cache.ts b/cli/src/contexts/distribution/domain/ports/marketplace-cache.ts new file mode 100644 index 000000000..b8eb3c7c7 --- /dev/null +++ b/cli/src/contexts/distribution/domain/ports/marketplace-cache.ts @@ -0,0 +1,3 @@ +export interface MarketplaceCachePort { + clear(name?: string): Promise; +} diff --git a/cli/src/domain/ports/marketplace-registry.ts b/cli/src/contexts/distribution/domain/ports/marketplace-registry.ts similarity index 80% rename from cli/src/domain/ports/marketplace-registry.ts rename to cli/src/contexts/distribution/domain/ports/marketplace-registry.ts index 5dc7405af..a79106f85 100644 --- a/cli/src/domain/ports/marketplace-registry.ts +++ b/cli/src/contexts/distribution/domain/ports/marketplace-registry.ts @@ -1,4 +1,5 @@ -import type { Marketplace, MarketplaceScope } from "../models/marketplace.js"; +import type { MarketplaceScope } from "../../../../kernel/scope.js"; +import type { Marketplace } from "../marketplace.js"; export interface MarketplaceRegistry { list(projectRoot: string): Promise; diff --git a/cli/src/contexts/distribution/domain/ports/marketplace-trust-store.ts b/cli/src/contexts/distribution/domain/ports/marketplace-trust-store.ts new file mode 100644 index 000000000..50037d6f1 --- /dev/null +++ b/cli/src/contexts/distribution/domain/ports/marketplace-trust-store.ts @@ -0,0 +1,6 @@ +import type { PluginSource } from "../../../../kernel/source.js"; + +export interface MarketplaceTrustStore { + isTrusted(projectRoot: string, source: PluginSource): Promise; + trust(projectRoot: string, source: PluginSource): Promise; +} diff --git a/cli/src/contexts/distribution/domain/ports/plugin-catalog-repository.ts b/cli/src/contexts/distribution/domain/ports/plugin-catalog-repository.ts new file mode 100644 index 000000000..9c63f07c5 --- /dev/null +++ b/cli/src/contexts/distribution/domain/ports/plugin-catalog-repository.ts @@ -0,0 +1,5 @@ +import type { PluginCatalog } from "../catalog.js"; + +export interface PluginCatalogRepository { + load(frameworkPath: string): Promise; +} diff --git a/cli/src/domain/ports/plugin-fetcher.ts b/cli/src/contexts/distribution/domain/ports/plugin-fetcher.ts similarity index 75% rename from cli/src/domain/ports/plugin-fetcher.ts rename to cli/src/contexts/distribution/domain/ports/plugin-fetcher.ts index dfd4fd7f6..8a87e6f0d 100644 --- a/cli/src/domain/ports/plugin-fetcher.ts +++ b/cli/src/contexts/distribution/domain/ports/plugin-fetcher.ts @@ -1,4 +1,4 @@ -import type { PluginSource } from "../models/plugin-source.js"; +import type { PluginSource } from "../../../../kernel/source.js"; export interface PluginFetchOptions { forceRefresh?: boolean; diff --git a/cli/src/contexts/distribution/domain/ports/raw-catalog-fetcher.ts b/cli/src/contexts/distribution/domain/ports/raw-catalog-fetcher.ts new file mode 100644 index 000000000..629085b53 --- /dev/null +++ b/cli/src/contexts/distribution/domain/ports/raw-catalog-fetcher.ts @@ -0,0 +1,5 @@ +import type { PluginSourceGitHub } from "../../../../kernel/source.js"; + +export interface RawCatalogFetcher { + fetchCatalog(source: PluginSourceGitHub, catalogPath: string, cacheDir: string): Promise; +} diff --git a/cli/src/infrastructure/adapters/github-raw-fetcher-adapter.ts b/cli/src/contexts/distribution/infrastructure/github-raw-fetcher-adapter.ts similarity index 86% rename from cli/src/infrastructure/adapters/github-raw-fetcher-adapter.ts rename to cli/src/contexts/distribution/infrastructure/github-raw-fetcher-adapter.ts index 8da5c3828..06da03b92 100644 --- a/cli/src/infrastructure/adapters/github-raw-fetcher-adapter.ts +++ b/cli/src/contexts/distribution/infrastructure/github-raw-fetcher-adapter.ts @@ -5,12 +5,12 @@ import { CatalogFetchAuthError, CatalogFetchError, CatalogFetchNotFoundError, -} from "../../domain/errors.js"; -import type { PluginSourceGitHub } from "../../domain/models/plugin-source.js"; -import type { RawCatalogFetcher } from "../../domain/ports/raw-catalog-fetcher.js"; -import type { TokenProvider } from "../../domain/ports/token-provider.js"; -import { HttpNotFoundError } from "../errors.js"; -import type { HttpGet } from "../http/http-client.js"; + HttpNotFoundError, +} from "../../../kernel/errors.js"; +import type { PluginSourceGitHub } from "../../../kernel/source.js"; +import type { TokenProvider } from "../../../runtime/auth/ports/token-provider.js"; +import type { HttpGet } from "../../../runtime/http/http-client.js"; +import type { RawCatalogFetcher } from "../domain/ports/raw-catalog-fetcher.js"; const GITHUB_API_BASE = "https://api.github.com"; const RAW_ACCEPT = "application/vnd.github.raw"; diff --git a/cli/src/contexts/distribution/infrastructure/marketplace-cache-adapter.ts b/cli/src/contexts/distribution/infrastructure/marketplace-cache-adapter.ts new file mode 100644 index 000000000..e7bacff49 --- /dev/null +++ b/cli/src/contexts/distribution/infrastructure/marketplace-cache-adapter.ts @@ -0,0 +1,25 @@ +import { readdir, rm } from "node:fs/promises"; +import { join } from "node:path"; +import { MARKETPLACE_CACHE_SUBDIR } from "../../../kernel/paths.js"; +import type { MarketplaceCachePort } from "../domain/ports/marketplace-cache.js"; + +export class MarketplaceCacheAdapter implements MarketplaceCachePort { + constructor(private readonly projectRoot: string) {} + + async clear(name?: string): Promise { + const cacheRoot = join(this.projectRoot, MARKETPLACE_CACHE_SUBDIR); + if (name !== undefined) { + await rm(join(cacheRoot, name), { recursive: true, force: true }); + return; + } + let entries: string[]; + try { + entries = await readdir(cacheRoot); + } catch { + return; + } + for (const entry of entries) { + await rm(join(cacheRoot, entry), { recursive: true, force: true }); + } + } +} diff --git a/cli/src/infrastructure/adapters/marketplace-registry-adapter.ts b/cli/src/contexts/distribution/infrastructure/marketplace-registry-adapter.ts similarity index 77% rename from cli/src/infrastructure/adapters/marketplace-registry-adapter.ts rename to cli/src/contexts/distribution/infrastructure/marketplace-registry-adapter.ts index 16ebf8725..1b86e94f6 100644 --- a/cli/src/infrastructure/adapters/marketplace-registry-adapter.ts +++ b/cli/src/contexts/distribution/infrastructure/marketplace-registry-adapter.ts @@ -1,13 +1,12 @@ -import { mkdir, readFile, writeFile } from "node:fs/promises"; -import { homedir } from "node:os"; +import { mkdir, readFile } from "node:fs/promises"; import { dirname, join } from "node:path"; -import { - Marketplace, - type MarketplaceData, - type MarketplaceScope, -} from "../../domain/models/marketplace.js"; -import { AIDD_DIR, AIDD_MARKETPLACES_FILENAME } from "../../domain/models/paths.js"; -import type { MarketplaceRegistry } from "../../domain/ports/marketplace-registry.js"; +import { UnreadableMarketplaceRegistryError } from "../../../kernel/errors.js"; +import { AIDD_DIR, AIDD_MARKETPLACES_FILENAME } from "../../../kernel/paths.js"; +import type { MarketplaceScope } from "../../../kernel/scope.js"; +import { atomicWriteFile } from "../../../runtime/filesystem/atomic-write.js"; +import { userConfigDir } from "../../../runtime/user-config-dir.js"; +import { Marketplace, type MarketplaceData } from "../domain/marketplace.js"; +import type { MarketplaceRegistry } from "../domain/ports/marketplace-registry.js"; const SCHEMA_VERSION = 1; @@ -21,11 +20,8 @@ interface RegistryFile { * a silent empty read would not merely hide the marketplaces a person registered - it would * delete them on the very next write. A file that is simply absent is a different answer and * keeps its own: no file, no marketplaces, nothing to lose. */ -function unreadable(path: string, reason: string): Error { - return new Error( - `Cannot read the marketplace registry at ${path}: ${reason}. Repair the file, or ` + - `delete it to start from an empty registry.` - ); +function unreadable(path: string, reason: string): never { + throw new UnreadableMarketplaceRegistryError(path, reason); } /** The registry's own list, or the reason the file cannot supply one. */ @@ -34,10 +30,10 @@ function registryEntries(raw: string, path: string): MarketplaceData[] { try { parsed = JSON.parse(raw); } catch (error) { - throw unreadable(path, error instanceof Error ? error.message : "it is not valid JSON"); + unreadable(path, error instanceof Error ? error.message : "it is not valid JSON"); } const entries = (parsed as Partial | null)?.marketplaces; - if (!Array.isArray(entries)) throw unreadable(path, "it carries no \`marketplaces\` list"); + if (!Array.isArray(entries)) unreadable(path, "it carries no `marketplaces` list"); return entries; } @@ -98,9 +94,7 @@ export class MarketplaceRegistryAdapter implements MarketplaceRegistry { } private userPath(): string { - const override = process.env.AIDD_USER_CONFIG_DIR; - const dir = override ?? join(homedir(), ".config", "aidd"); - return join(dir, AIDD_MARKETPLACES_FILENAME); + return join(userConfigDir(), AIDD_MARKETPLACES_FILENAME); } private async read(path: string, scope: MarketplaceScope): Promise { @@ -119,6 +113,6 @@ export class MarketplaceRegistryAdapter implements MarketplaceRegistry { version: SCHEMA_VERSION, marketplaces: entries.map((m) => m.toJSON()), }; - await writeFile(path, JSON.stringify(file, null, 2), "utf-8"); + await atomicWriteFile(path, JSON.stringify(file, null, 2)); } } diff --git a/cli/src/infrastructure/adapters/marketplace-trust-store-adapter.ts b/cli/src/contexts/distribution/infrastructure/marketplace-trust-store-adapter.ts similarity index 89% rename from cli/src/infrastructure/adapters/marketplace-trust-store-adapter.ts rename to cli/src/contexts/distribution/infrastructure/marketplace-trust-store-adapter.ts index 959a0a1f9..4cfb03a47 100644 --- a/cli/src/infrastructure/adapters/marketplace-trust-store-adapter.ts +++ b/cli/src/contexts/distribution/infrastructure/marketplace-trust-store-adapter.ts @@ -1,9 +1,9 @@ import { chmod, mkdir, readFile, writeFile } from "node:fs/promises"; import { dirname, join } from "node:path"; -import { AIDD_DIR } from "../../domain/models/paths.js"; -import { type PluginSource, serializePluginSource } from "../../domain/models/plugin-source.js"; -import type { Hasher } from "../../domain/ports/hasher.js"; -import type { MarketplaceTrustStore } from "../../domain/ports/marketplace-trust-store.js"; +import { AIDD_DIR } from "../../../kernel/paths.js"; +import type { Hasher } from "../../../kernel/ports/hasher.js"; +import { type PluginSource, serializePluginSource } from "../../../kernel/source.js"; +import type { MarketplaceTrustStore } from "../domain/ports/marketplace-trust-store.js"; const TRUST_STORE_FILENAME = "trusted-marketplaces.json"; const SCHEMA_VERSION = 1; diff --git a/cli/src/contexts/distribution/infrastructure/plugin-catalog-repository-adapter.ts b/cli/src/contexts/distribution/infrastructure/plugin-catalog-repository-adapter.ts new file mode 100644 index 000000000..5fe57d532 --- /dev/null +++ b/cli/src/contexts/distribution/infrastructure/plugin-catalog-repository-adapter.ts @@ -0,0 +1,83 @@ +import { isAbsolute, join, resolve } from "node:path"; +import { MalformedMarketplaceCatalogError } from "../../../kernel/errors.js"; +import { MARKETPLACE_CACHE_SUBDIR } from "../../../kernel/paths.js"; +import type { FileReader } from "../../../kernel/ports/file-reader.js"; +import type { PluginSource } from "../../../kernel/source.js"; +import { type PluginCatalog, parsePluginCatalog } from "../domain/catalog.js"; +import { parseCopilotMarketplaceCatalog } from "../domain/catalog-parsers/copilot-marketplace-catalog.js"; +import type { PluginCatalogRepository } from "../domain/ports/plugin-catalog-repository.js"; + +const COPILOT_MARKETPLACE_PATH = ".plugin/marketplace.json"; +const CLAUDE_MARKETPLACE_PATH = ".claude-plugin/marketplace.json"; + +export class PluginCatalogRepositoryAdapter implements PluginCatalogRepository { + constructor(private readonly fs: FileReader) {} + + async load(frameworkPath: string): Promise { + const copilotPath = join(frameworkPath, COPILOT_MARKETPLACE_PATH); + if (await this.fs.fileExists(copilotPath)) { + const catalog = await this.readCopilotNativeCatalog(copilotPath); + return this.resolveLocalPaths(catalog, frameworkPath); + } + const claudePath = join(frameworkPath, CLAUDE_MARKETPLACE_PATH); + if (!(await this.fs.fileExists(claudePath))) { + return null; + } + const catalog = await this.readClaudeCatalog(claudePath); + return this.resolveLocalPaths(catalog, frameworkPath); + } + + private isCachePath(fullPath: string): boolean { + return fullPath.includes(MARKETPLACE_CACHE_SUBDIR); + } + + private parseDetail(err: unknown): string { + const message = err instanceof Error ? err.message : String(err); + return message.replace(/^Invalid plugin manifest:\s*/, ""); + } + + private async readCopilotNativeCatalog(fullPath: string): Promise { + const raw = await this.fs.readFile(fullPath); + try { + return parseCopilotMarketplaceCatalog(raw); + } catch (err) { + throw new MalformedMarketplaceCatalogError( + fullPath, + this.parseDetail(err), + this.isCachePath(fullPath) + ); + } + } + + private async readClaudeCatalog(fullPath: string): Promise { + const cached = this.isCachePath(fullPath); + let raw: unknown; + try { + raw = JSON.parse(await this.fs.readFile(fullPath)); + } catch { + throw new MalformedMarketplaceCatalogError(fullPath, "not valid JSON", cached); + } + try { + return parsePluginCatalog(raw); + } catch (err) { + throw new MalformedMarketplaceCatalogError(fullPath, this.parseDetail(err), cached); + } + } + + private resolveLocalPaths(catalog: PluginCatalog, frameworkPath: string): PluginCatalog { + const plugins = catalog.plugins.map((entry) => ({ + ...entry, + source: this.resolveSource(entry.source, frameworkPath), + })); + const resolved: PluginCatalog = { plugins }; + if (catalog.name !== undefined) resolved.name = catalog.name; + if (catalog.version !== undefined) resolved.version = catalog.version; + return resolved; + } + + private resolveSource(source: PluginSource, frameworkPath: string): PluginSource { + if (source.kind !== "local") return source; + if (isAbsolute(source.path)) return source; + return { kind: "local", path: resolve(frameworkPath, source.path) }; + } +} diff --git a/cli/src/infrastructure/adapters/plugin-fetcher-adapter.ts b/cli/src/contexts/distribution/infrastructure/plugin-fetcher-adapter.ts similarity index 85% rename from cli/src/infrastructure/adapters/plugin-fetcher-adapter.ts rename to cli/src/contexts/distribution/infrastructure/plugin-fetcher-adapter.ts index f12eda9ef..e0098d8f1 100644 --- a/cli/src/infrastructure/adapters/plugin-fetcher-adapter.ts +++ b/cli/src/contexts/distribution/infrastructure/plugin-fetcher-adapter.ts @@ -2,19 +2,19 @@ import { execFile as execFileCb } from "node:child_process"; import { join, resolve } from "node:path"; import { promisify } from "node:util"; import { simpleGit } from "simple-git"; -import { PluginFetchError } from "../../domain/errors.js"; +import { PluginFetchError } from "../../../kernel/errors.js"; +import type { FileReader } from "../../../kernel/ports/file-reader.js"; +import type { FileWriter } from "../../../kernel/ports/file-writer.js"; import type { PluginSource, PluginSourceGitHub, PluginSourceGitSubdir, PluginSourceNpm, PluginSourceUrl, -} from "../../domain/models/plugin-source.js"; -import type { FileReader } from "../../domain/ports/file-reader.js"; -import type { FileWriter } from "../../domain/ports/file-writer.js"; -import type { PluginFetcher, PluginFetchOptions } from "../../domain/ports/plugin-fetcher.js"; -import type { TokenProvider } from "../../domain/ports/token-provider.js"; -import { injectTokenIntoUrl } from "../git/inject-token.js"; +} from "../../../kernel/source.js"; +import type { TokenProvider } from "../../../runtime/auth/ports/token-provider.js"; +import { injectTokenIntoUrl, withoutCredentials } from "../../../runtime/git/inject-token.js"; +import type { PluginFetcher, PluginFetchOptions } from "../domain/ports/plugin-fetcher.js"; const execFile = promisify(execFileCb); @@ -76,7 +76,7 @@ export class PluginFetcherAdapter implements PluginFetcher { cacheDir: string, forceRefresh: boolean ): Promise { - const key = `${encodeKey(source.url)}${source.ref ? `-${source.ref}` : "-HEAD"}`; + const key = `${encodeKey(withoutCredentials(source.url))}${source.ref ? `-${source.ref}` : "-HEAD"}`; const targetDir = join(cacheDir, key); await this.bustCacheIfNeeded(targetDir, forceRefresh); if (!(await this.fs.fileExists(targetDir))) { @@ -95,7 +95,7 @@ export class PluginFetcherAdapter implements PluginFetcher { forceRefresh: boolean ): Promise { const { url, path: subpath, ref } = source; - const key = `${encodeKey(url)}-subdir-${subpath.replace(/\//g, "_")}-${ref ?? "HEAD"}`; + const key = `${encodeKey(withoutCredentials(url))}-subdir-${subpath.replace(/\//g, "_")}-${ref ?? "HEAD"}`; const targetDir = join(cacheDir, key); await this.bustCacheIfNeeded(targetDir, forceRefresh); if (!(await this.fs.fileExists(targetDir))) { @@ -171,12 +171,14 @@ export class PluginFetcherAdapter implements PluginFetcher { private classifyAndThrow(err: unknown, displayUrl: string): never { const msg = err instanceof Error ? err.message : String(err); + // The URL the user typed may carry their own credential; the message must not. + const safeUrl = withoutCredentials(displayUrl); if (AUTH_ERROR_PATTERN.test(msg)) { throw new PluginFetchError( - `Authentication failed for "${displayUrl}". ${this.authHint(displayUrl)}` + `Authentication failed for "${safeUrl}". ${this.authHint(safeUrl)}` ); } - throw new PluginFetchError(`git clone failed for "${displayUrl}": ${scrubCredentials(msg)}`); + throw new PluginFetchError(`git clone failed for "${safeUrl}": ${scrubCredentials(msg)}`); } private authHint(url: string): string { diff --git a/cli/src/contexts/framework/application/clean-use-case.ts b/cli/src/contexts/framework/application/clean-use-case.ts new file mode 100644 index 000000000..0e59ccdb6 --- /dev/null +++ b/cli/src/contexts/framework/application/clean-use-case.ts @@ -0,0 +1,696 @@ +import { dirname, join } from "node:path"; +import { NativePluginCliError } from "../../../kernel/errors.js"; +import { + isMergeContentEmpty, + type MergeFileEntry, + removeEntriesFromJson, +} from "../../../kernel/merge.js"; +import { + AIDD_CONFIG_FILENAME, + AIDD_DIR, + AIDD_MARKETPLACES_FILENAME, + PLUGIN_CACHE_SUBDIR, +} from "../../../kernel/paths.js"; +import type { FileReader } from "../../../kernel/ports/file-reader.js"; +import type { FileWriter } from "../../../kernel/ports/file-writer.js"; +import type { Logger } from "../../../kernel/ports/logger.js"; +import type { Prompter } from "../../../kernel/ports/prompter.js"; +import { resolveHomeDir } from "../../../kernel/reading/home-dir.js"; +import type { MarketplaceScope } from "../../../kernel/scope.js"; +import type { AiToolId, ToolId } from "../../../kernel/tool.js"; +import { isAiToolId } from "../../../kernel/tool.js"; +import type { MarketplaceRegistry } from "../../distribution/domain/ports/marketplace-registry.js"; +import type { HostMarketplaceRegistryReader } from "../../tools/domain/ports/host-marketplace-registry-reader.js"; +import type { HostPluginRegistryReader } from "../../tools/domain/ports/host-plugin-registry-reader.js"; +import type { NativePluginActivator } from "../../tools/domain/ports/native-plugin-activator.js"; +import { + machineLocalFilesOf, + nativeActivationOf, + pluginEnablementIsMachineGlobal, + projectHooksFileOf, +} from "../../tools/domain/registry.js"; +import type { NativeRegistrations } from "../domain/manifest/native-registrations.js"; +import type { Manifest } from "../domain/manifest.js"; +import { aiddGitignoreEntries } from "../domain/manifest-gitignore-entries.js"; +import type { InstalledPlugin } from "../domain/plugins/installed-plugin.js"; +import type { ManifestRepository } from "../domain/ports/manifest-repository.js"; +import type { UserSourceReferences } from "../domain/ports/user-source-references.js"; +import type { GitignoreUseCase } from "./gitignore-use-case.js"; +import { deletePluginFilesForTool } from "./plugin/plugin-helpers.js"; +import { bestEffortNativeCall } from "./shared/best-effort-native-call.js"; +import { + purgeAllNativeCaches, + type UndoneToolRegistrations, +} from "./shared/purge-native-marketplace-cache.js"; +import { removeProjectHooks } from "./shared/remove-project-hooks.js"; +import { resolveUninstallScopeOrder } from "./shared/resolve-uninstall-scope.js"; +import { + describeGuardedPluginRefMessage, + frameworkSourceIsShared, + otherProjectsReferencing, + refAnotherProjectStillNeeds, + resolveProjectRootForReferences, + toleratingUnreadableSourceReferences, +} from "./shared/shared-source-reference-support.js"; +import { userScopeFilesSafeToDelete } from "./shared/user-scope-plugin-files.js"; + +/** What dropping this project's own reference to the shared source found — `undefined` only + * when there is nothing to guard on at all: the port is absent, or no shared, machine-scope + * marketplace is registered locally. `otherProjects` is read regardless of whether this + * project's own claim was there to drop, since another project's claim is a fact worth + * guarding on either way. `alias` is this project's own local name for the source, resolved + * per tool into that tool's `hostName` — never the alias, which a host never learns. */ +interface SharedSourceReferenceOutcome { + readonly alias: string; + readonly otherProjects: readonly string[]; +} + +interface CleanOptions { + projectRoot: string; + force: boolean; + interactive?: boolean; +} + +interface CleanPreview { + tools: Array<{ toolId: ToolId; fileCount: number }>; + totalFileCount: number; + /** What a `--force` run will ask each tool's own CLI to undo — that step drives an external + * binary, the one part of `clean` this preview cannot reduce to a file count. */ + nativeRegistrations: Array<{ + toolId: ToolId; + binary: string; + marketplaceCount: number; + pluginRefCount: number; + /** Absolute cache paths a `--force` run will attempt to purge — empty for a tool whose + * profile declares no `NativeActivation.pluginCacheDir`. An announcement, not a guarantee: + * whether a path is still there, and safe to purge, is known only once the host's CLI ran. */ + cachePaths: readonly string[]; + }>; + /** The *other* projects on this machine that reference the shared source, read before + * `--force` would drop this project's own claim, never after. `undefined` when the port was + * never wired in, or no shared, machine-scope marketplace is registered locally. */ + sharedSourceOtherProjects?: readonly string[]; +} + +interface CleanResult { + dryRun: boolean; + manifestFound: boolean; + preview: CleanPreview; + fileCount: number; +} + +type UndoneRegistration = UndoneToolRegistrations; + +export class CleanUseCase { + constructor( + private readonly fs: FileReader & FileWriter, + private readonly manifestRepo: ManifestRepository, + private readonly logger: Logger, + private readonly gitignoreUseCase: GitignoreUseCase, + /** Native plugin CLI activators keyed by `NativeActivation.binary`. */ + private readonly activators: ReadonlyMap = new Map(), + /** Resolves a registered marketplace's own scope, needed to undo a native registration at + * the same scope it was added at. */ + private readonly marketplaceRegistry?: MarketplaceRegistry, + private readonly prompter?: Prompter, + /** Readers of a host's own marketplace registry, keyed by `AiToolId`. Absent for a tool + * whose profile declares no `marketplaceRegistry` (codex): `purgeOneMarketplaceCache` then + * proves its leftover safe to remove by its own emptiness instead of a registry read. */ + private readonly hostMarketplaceRegistries: ReadonlyMap< + AiToolId, + HostMarketplaceRegistryReader + > = new Map(), + /** The one resolver for the OS home directory this use case ever calls. `purgeNativeCaches` + * composes its cache root from it and `filesSafeToDelete` its user-scope containment + * boundary: both must read the same `HOME` the caller's own `hostMarketplaceRegistries` + * readers were built from, or a `HOME` override reaches one half of a post-condition. */ + private readonly homeDir: () => string = resolveHomeDir, + /** The registry of projects referencing the shared machine-scope source. Absent skips the + * decrement entirely. */ + private readonly userSourceReferences?: UserSourceReferences, + /** Host plugin registry readers keyed by `AiToolId`, consulted before uninstalling a ref so + * the scope asked for is the one the host actually registered it at, never a guess. Absent + * falls back to the manifest's own recorded scope. */ + private readonly hostPluginRegistries: ReadonlyMap< + AiToolId, + HostPluginRegistryReader + > = new Map() + ) {} + + async execute(options: CleanOptions): Promise { + const manifest = await this.manifestRepo.load(); + if (manifest === null) { + const emptyPreview: CleanPreview = { tools: [], totalFileCount: 0, nativeRegistrations: [] }; + return { dryRun: false, manifestFound: false, preview: emptyPreview, fileCount: 0 }; + } + const home = this.homeDir(); + const preview = await this.buildPreview(manifest, home, options.projectRoot); + const dryRunResult = await this.confirmOrDryRun(options, preview); + if (dryRunResult !== null) return dryRunResult; + // Decremented exactly once per run, before the per-tool loop: the shared source's reference + // count is a project-level fact, and claude, codex and copilot can each carry their own ref, + // so decrementing inside that loop would drop this project's claim once per tool. + const sharedSourceOutcome = await this.dropSharedSourceReference(options.projectRoot); + // Undoing a host's own registration must happen before any of the rest: the tool's CLI + // resolves the marketplace name against the built tree under `.aidd/cache/`, which + // `removeAiddState` deletes next, and a host may refuse to unregister a source that is gone. + const undone = await this.undoNativeRegistrations( + manifest, + options.projectRoot, + home, + sharedSourceOutcome + ); + // Purging a host's own plugin cache is the next step, never before this: it is only + // Only ever safe once `undoNativeRegistrations` has actually asked that host to forget the + // name (see `purgeNativeCaches`'s own post-conditions). + await this.purgeNativeCaches(home, undone); + let deleted = await this.deleteAllToolFiles(manifest, options.projectRoot); + deleted += await this.deleteMachineLocalFiles(manifest, options.projectRoot); + await this.removeAiddState(options.projectRoot); + // Exactly what the pipeline added on install, never a subset of it. + await this.gitignoreUseCase.remove(options.projectRoot, aiddGitignoreEntries(manifest)); + return { dryRun: false, manifestFound: true, preview, fileCount: deleted }; + } + + // `config.json` is the committed telemetry switch: a file clean did not write, so clean never + // removes it. Everything AIDD did write must go before the emptiness check, or its own presence + // blocks a removal that should happen — the registry `marketplace add` writes included. + private async removeAiddState(projectRoot: string): Promise { + const aiddDir = join(projectRoot, AIDD_DIR); + const configKept = await this.fs.fileExists(join(aiddDir, AIDD_CONFIG_FILENAME)); + + await this.fs.deleteDirectory(join(aiddDir, "cache")); + await this.fs.deleteDirectory(join(projectRoot, PLUGIN_CACHE_SUBDIR)); + await this.fs.deleteFile(join(aiddDir, AIDD_MARKETPLACES_FILENAME)); + await this.manifestRepo.delete(); + + if (!(await this.fs.fileExists(aiddDir))) return; + const remaining = await this.fs.listDirectory(aiddDir); + if (remaining.length === 0) { + await this.fs.deleteDirectory(aiddDir); + return; + } + if (configKept) this.logger.info(`Kept ${AIDD_DIR}/${AIDD_CONFIG_FILENAME}`); + } + + /** Drives each tool's own CLI to undo what it was asked to register. Never a direct edit of + * the host's own registry file: that file is the host's to write. + * + * Returns, per tool the activator actually ran for, both its full registrations and the + * `hostName`s `removeMarketplace` itself confirmed removed — a marketplace the host refused to + * drop stays in `registrations.marketplaces` but never in `removedHostNames`, which is what + * `purgeNativeCaches`'s codex branch gates its own purge on. */ + private async undoNativeRegistrations( + manifest: Manifest, + projectRoot: string, + home: string, + sharedSourceOutcome: SharedSourceReferenceOutcome | undefined + ): Promise> { + const undone = new Map(); + for (const toolId of manifest.getInstalledToolIds()) { + const registrations = manifest.getNativeRegistrations(toolId); + if (registrations === undefined) continue; + const removedHostNames = await this.undoToolNativeRegistrations( + manifest, + toolId, + registrations, + projectRoot, + home, + sharedSourceOutcome + ); + if (removedHostNames !== undefined) undone.set(toolId, { registrations, removedHostNames }); + } + return undone; + } + + private async undoToolNativeRegistrations( + manifest: Manifest, + toolId: ToolId, + registrations: NativeRegistrations, + projectRoot: string, + home: string, + sharedSourceOutcome: SharedSourceReferenceOutcome | undefined + ): Promise | undefined> { + const { binary } = registrations; + const activator = this.activators.get(binary); + if (activator === undefined || !activator.isAvailable()) { + this.logger.warn( + `${binary}: registration left in place, the ${binary} CLI is not on the PATH.` + + this.describeSurvivingCachePaths(toolId, registrations, home) + ); + return undefined; + } + // Every plugin ref uninstalled before any marketplace is removed: only Copilot + // declares `forceRemoveArgs`, so Claude and Codex can refuse to remove a + // marketplace that still has plugins installed from it. + for (const ref of registrations.pluginRefs) { + await this.uninstallPluginRef( + activator, + binary, + toolId, + ref, + projectRoot, + manifest, + registrations, + sharedSourceOutcome + ); + } + const removedHostNames = new Set(); + for (const { alias, hostName } of registrations.marketplaces) { + const removed = await this.undoMarketplaceRegistration( + activator, + binary, + alias, + hostName, + projectRoot, + toolId, + home, + sharedSourceOutcome + ); + if (removed) removedHostNames.add(hostName); + } + return removedHostNames; + } + + // `alias` resolves this project's own registry entry, the only place its `scope` is recorded; + // `hostName` is what reaches the host's own CLI, since a host knows a registration only by its + // catalog's own declared name. The two differ whenever a project registers under an alias its + // catalog does not declare, a supported capability — passing `alias` to a host-facing call + // would ask it to remove a name it never held. + private async undoMarketplaceRegistration( + activator: NativePluginActivator, + binary: string, + alias: string, + hostName: string, + projectRoot: string, + toolId: ToolId, + home: string, + sharedSourceOutcome: SharedSourceReferenceOutcome | undefined + ): Promise { + const marketplaces = (await this.marketplaceRegistry?.list(projectRoot)) ?? []; + const marketplace = marketplaces.find((m) => m.name === alias); + if (marketplace === undefined) { + this.logger.warn( + `${binary}: '${alias}' is no longer a registered marketplace here, so its scope cannot be resolved — its ${binary} registration was left in place.` + ); + return false; + } + if (marketplace.scope === "user") { + // Machine-scope: every project on this machine shares this one registration, so a single + // project's `clean` must never unregister it. Three things survive, not one: the host's + // own registration, the `userConfigDir()/marketplaces.json` entry, and this tool's own + // plugin cache, named by its absolute path when the profile declares one. + this.logger.warn( + `${binary}: '${hostName}' is shared by every project on this machine — left registered. ` + + this.describeSharedSourceSurvival(toolId, hostName, home, sharedSourceOutcome) + ); + return false; + } + return bestEffortNativeCall( + this.logger, + () => activator.removeMarketplace(hostName, marketplace.scope), + `${binary} marketplace remove '${hostName}'` + ); + } + + /** Decrements this project's own claim exactly once per `clean` run, independent of how many + * tools' registrations name it: the count in `references.json` is per project, never per tool. + * Never reads a "current" CLI version to decide which key to touch, so a self-update between + * the `sync` that wrote the reference and this `clean` cannot strand it. `undefined` only when + * the port was never wired in, or no shared marketplace is registered locally — never merely + * because this project's own claim was already missing. */ + private async dropSharedSourceReference( + projectRoot: string + ): Promise { + return this.withSharedSourceClaims( + projectRoot, + async (userSourceReferences, alias, resolvedRoot) => { + // A no-op when this project's own registry never held the shared entry, which must never + // collapse into "no other projects": another project's claim is worth guarding on + // regardless, so `otherProjects` is always read in full. + await userSourceReferences.removeReference(resolvedRoot); + const otherProjects = await otherProjectsReferencing(userSourceReferences, resolvedRoot); + return { alias, otherProjects }; + } + ); + } + + /** Shared preamble behind `dropSharedSourceReference` and `previewSharedSourceOtherProjects`: + * `undefined` when the port was never wired in or no shared, machine-scope registration exists + * to act on. `action` alone decides whether the run only reads or also writes. */ + private async withSharedSourceClaims( + projectRoot: string, + action: ( + userSourceReferences: UserSourceReferences, + sharedAlias: string, + resolvedRoot: string + ) => Promise + ): Promise { + if (this.userSourceReferences === undefined) return undefined; + const marketplaces = (await this.marketplaceRegistry?.list(projectRoot)) ?? []; + const shared = marketplaces.find((m) => frameworkSourceIsShared(m.name, m.scope)); + if (shared === undefined) return undefined; + const userSourceReferences = this.userSourceReferences; + return toleratingUnreadableSourceReferences(this.logger, undefined, async () => { + const resolvedRoot = await resolveProjectRootForReferences(this.fs, projectRoot); + return action(userSourceReferences, shared.name, resolvedRoot); + }); + } + + /** What survives a shared registration this run left in place, plus which other projects still + * claim the shared source — reported whether or not this project itself ever held a claim: + * purging the source is a machine-scope decision, not this project's own `clean` to make. */ + private describeSharedSourceSurvival( + toolId: ToolId, + hostName: string, + home: string, + outcome: SharedSourceReferenceOutcome | undefined + ): string { + const base = `Its entry survives at userConfigDir()/marketplaces.json${this.describeSurvivingCachePath(toolId, hostName, home)}.`; + if (outcome === undefined) return base; + const { otherProjects } = outcome; + if (otherProjects.length > 0) { + const plural = otherProjects.length === 1 ? "project" : "projects"; + return `${base} Still referenced by ${otherProjects.length} other ${plural} on this machine.`; + } + return `${base} No project on this machine still references it — \`aidd clean --scope user\` is what purges it for the machine.`; + } + + /** One marketplace's own surviving cache path — empty for a tool whose profile declares no + * `NativeActivation.pluginCacheDir` (copilot). */ + private describeSurvivingCachePath(toolId: ToolId, hostName: string, home: string): string { + if (!isAiToolId(toolId)) return ""; + const cacheRoot = nativeActivationOf(toolId)?.pluginCacheDir?.(home); + if (cacheRoot === undefined) return ""; + return `, and its cache at: ${join(cacheRoot, hostName)}`; + } + + /** Named for the "not on the PATH" warning: `clean` never reaches `purgeNativeCaches` for a + * tool whose binary is absent, so the cache it would have purged survives silently unless this + * names it. Empty for a profile declaring no `NativeActivation.pluginCacheDir`. */ + private describeSurvivingCachePaths( + toolId: ToolId, + registrations: NativeRegistrations, + home: string + ): string { + if (!isAiToolId(toolId)) return ""; + const cacheRoot = nativeActivationOf(toolId)?.pluginCacheDir?.(home); + if (cacheRoot === undefined) return ""; + const paths = registrations.marketplaces.map((m) => join(cacheRoot, m.hostName)); + if (paths.length === 0) return ""; + return ` Its cache survives at: ${paths.join(", ")}.`; + } + + /** + * Uninstalls one plugin ref at the scope it was actually registered at, never a default: + * `resolveUninstallScopeOrder` asks the host's own registry first. A real `claude` binary + * refuses a mismatched-scope uninstall outright, so the resolved list is tried in order until + * one succeeds; best-effort throughout — a ref the host will not forget is named, not thrown. + */ + private async uninstallPluginRef( + activator: NativePluginActivator, + binary: string, + toolId: ToolId, + ref: string, + projectRoot: string, + manifest: Manifest, + registrations: NativeRegistrations, + sharedSourceOutcome: SharedSourceReferenceOutcome | undefined + ): Promise { + const guardMessage = this.describeGuardedPluginRef( + binary, + toolId, + ref, + registrations, + sharedSourceOutcome + ); + if (guardMessage !== undefined) { + this.logger.warn(guardMessage); + return; + } + const reader = isAiToolId(toolId) ? this.hostPluginRegistries.get(toolId) : undefined; + const manifestScope = this.manifestScopeForRef(manifest, toolId, registrations, ref); + const order = await resolveUninstallScopeOrder(reader, ref, projectRoot, manifestScope); + let lastMessage = ""; + for (const scope of order) { + try { + activator.uninstallPlugin(ref, scope); + return; + } catch (error) { + if (!(error instanceof NativePluginCliError)) throw error; + lastMessage = error.message; + } + } + this.logger.warn(`${binary} plugin uninstall '${ref}' failed: ${lastMessage}`); + } + + /** `undefined` when nothing guards `ref` — the ordinary case that still uninstalls it. + * Otherwise the message to warn with instead of ever calling `uninstallPlugin`: this host + * enables a plugin for the whole machine (no `scopeArgs` — codex, copilot), `ref` came from the + * shared source, and another project still references it, so uninstalling here would disable it + * there too. */ + private describeGuardedPluginRef( + binary: string, + toolId: ToolId, + ref: string, + registrations: NativeRegistrations, + sharedSourceOutcome: SharedSourceReferenceOutcome | undefined + ): string | undefined { + const hostName = registrations.marketplaces.find( + (m) => m.alias === sharedSourceOutcome?.alias + )?.hostName; + const otherProjects = sharedSourceOutcome?.otherProjects ?? []; + const guarded = refAnotherProjectStillNeeds({ + ref, + sharedSourceHostName: hostName, + enablementIsMachineGlobal: pluginEnablementIsMachineGlobal(toolId), + otherProjects, + }); + if (!guarded) return undefined; + return describeGuardedPluginRefMessage({ binary, ref, otherProjects }); + } + + /** The scope this project's own manifest recorded for the plugin behind `ref`, `"project"` when + * nothing names it. `ref` is `@`, so matching it back to a manifest entry + * (keyed by this project's own alias) goes through `registrations.marketplaces`, the one place + * both names are recorded together. */ + private manifestScopeForRef( + manifest: Manifest, + toolId: ToolId, + registrations: NativeRegistrations, + ref: string + ): MarketplaceScope { + for (const plugin of manifest.getPlugins(toolId)) { + if (plugin.marketplace == null) continue; + const hostName = registrations.marketplaces.find( + (m) => m.alias === plugin.marketplace + )?.hostName; + if (hostName === undefined) continue; + if (`${plugin.name}@${hostName}` === ref) return plugin.scope; + } + return "project"; + } + + /** Only for a tool `undoNativeRegistrations` actually drove — that map holds none whose binary + * was absent. */ + private async purgeNativeCaches( + home: string, + undone: ReadonlyMap + ): Promise { + await purgeAllNativeCaches(this.fs, this.logger, home, this.hostMarketplaceRegistries, undone); + } + + /** The files a tool writes that `plugins[].files` never tracks: a machine-local settings file + * (`.claude/settings.local.json`) and, for a tool merging a plugin's hooks into its own project + * file (`.cursor/hooks.json`), the same unmerge `plugin remove` drives one plugin at a time. */ + private async deleteMachineLocalFiles(manifest: Manifest, projectRoot: string): Promise { + let count = 0; + for (const toolId of manifest.getInstalledToolIds()) { + count += await this.deleteMachineLocalSettingsFiles(toolId, projectRoot); + count += await this.removeProjectHooksForTool(manifest, toolId, projectRoot); + } + return count; + } + + private async deleteMachineLocalSettingsFiles( + toolId: ToolId, + projectRoot: string + ): Promise { + let count = 0; + for (const relativePath of machineLocalFilesOf(toolId)) { + const fullPath = join(projectRoot, relativePath); + if (!(await this.fs.fileExists(fullPath))) continue; + await this.fs.deleteFile(fullPath); + await this.fs.deleteEmptyDirectories(dirname(fullPath)); + count++; + } + return count; + } + + private async removeProjectHooksForTool( + manifest: Manifest, + toolId: ToolId, + projectRoot: string + ): Promise { + if (projectHooksFileOf(toolId) === undefined || !isAiToolId(toolId)) return 0; + let count = 0; + for (const plugin of manifest.getPlugins(toolId)) { + if (await removeProjectHooks(this.fs, plugin.name, toolId, projectRoot)) count++; + } + return count; + } + + private async buildPreview( + manifest: Manifest, + home: string, + projectRoot: string + ): Promise { + const tools = manifest.getInstalledToolIds().map((toolId) => ({ + toolId, + fileCount: manifest.getToolFiles(toolId).length + manifest.getMergeFiles(toolId).length, + })); + const totalFileCount = tools.reduce((s, t) => s + t.fileCount, 0); + const nativeRegistrations = this.previewNativeRegistrations(manifest, home); + const sharedSourceOtherProjects = await this.previewSharedSourceOtherProjects(projectRoot); + return { tools, totalFileCount, nativeRegistrations, sharedSourceOtherProjects }; + } + + /** Read-only counterpart to `dropSharedSourceReference`: a dry-run must never write. Reads + * across every version key, so two projects synced under two different CLI versions see the + * same "other projects" fact from `aidd clean` as from `aidd clean --force`. */ + private async previewSharedSourceOtherProjects( + projectRoot: string + ): Promise { + return this.withSharedSourceClaims(projectRoot, (userSourceReferences, _alias, resolvedRoot) => + otherProjectsReferencing(userSourceReferences, resolvedRoot) + ); + } + + private previewNativeRegistrations( + manifest: Manifest, + home: string + ): CleanPreview["nativeRegistrations"] { + const preview: CleanPreview["nativeRegistrations"] = []; + for (const toolId of manifest.getInstalledToolIds()) { + const registrations = manifest.getNativeRegistrations(toolId); + if (registrations === undefined) continue; + const cacheRoot = isAiToolId(toolId) + ? nativeActivationOf(toolId)?.pluginCacheDir?.(home) + : undefined; + preview.push({ + toolId, + binary: registrations.binary, + marketplaceCount: registrations.marketplaces.length, + pluginRefCount: registrations.pluginRefs.length, + cachePaths: + cacheRoot === undefined + ? [] + : registrations.marketplaces.map((m) => join(cacheRoot, m.hostName)), + }); + } + return preview; + } + + private async confirmOrDryRun( + options: CleanOptions, + preview: CleanPreview + ): Promise { + if (options.force) return null; + if (options.interactive && this.prompter) { + const confirmed = await this.prompter.confirm("Remove all AIDD files?"); + if (!confirmed) return { dryRun: true, manifestFound: true, preview, fileCount: 0 }; + return null; + } + return { dryRun: true, manifestFound: true, preview, fileCount: 0 }; + } + + private async deleteAllToolFiles(manifest: Manifest, projectRoot: string): Promise { + let deleted = 0; + for (const toolId of manifest.getInstalledToolIds()) { + this.logger.info(`Removing ${toolId} files...`); + deleted += await this.deleteFiles(manifest.getToolFiles(toolId), projectRoot); + deleted += await this.cleanMergeFileKeys(manifest.getMergeFiles(toolId), projectRoot); + if (isAiToolId(toolId)) { + deleted += await this.deleteToolPluginFiles(manifest, toolId, projectRoot); + } + } + return deleted; + } + + private async deleteToolPluginFiles( + manifest: Manifest, + toolId: AiToolId, + projectRoot: string + ): Promise { + let count = 0; + for (const plugin of manifest.getPlugins(toolId)) { + const files = await this.filesSafeToDelete(plugin, toolId); + const deleted = await deletePluginFilesForTool( + files, + plugin.scope, + toolId, + projectRoot, + this.fs + ); + count += deleted.length; + } + return count; + } + + /** For a project-scope plugin every tracked file is safe: it lives under `projectRoot`, which + * `clean` is already trusted with. A user-scope plugin's files go through + * `userScopeFilesSafeToDelete`, where a raw path comparison would miss both a `..` segment and + * a symlink escape. */ + private async filesSafeToDelete( + plugin: InstalledPlugin, + toolId: AiToolId + ): Promise> { + if (plugin.scope !== "user") return plugin.files; + return userScopeFilesSafeToDelete(this.fs, this.logger, plugin, toolId, this.homeDir()); + } + + private async cleanMergeFileKeys( + mergeFiles: readonly MergeFileEntry[], + projectRoot: string + ): Promise { + let count = 0; + for (const mergeFile of mergeFiles) { + const fullPath = join(projectRoot, mergeFile.relativePath); + if (!(await this.fs.fileExists(fullPath))) continue; + await this.applyMergeFileCleaning(fullPath, mergeFile); + count++; + } + return count; + } + + private async applyMergeFileCleaning(fullPath: string, mergeFile: MergeFileEntry): Promise { + const keys = Object.keys(mergeFile.entries); + if (keys.length === 0) { + await this.fs.deleteFile(fullPath); + await this.fs.deleteEmptyDirectories(dirname(fullPath)); + return; + } + const content = await this.fs.readFile(fullPath); + const cleaned = removeEntriesFromJson(content, mergeFile.sectionKey, keys); + if (isMergeContentEmpty(cleaned, mergeFile.sectionKey)) { + await this.fs.deleteFile(fullPath); + await this.fs.deleteEmptyDirectories(dirname(fullPath)); + } else { + await this.fs.writeFile(fullPath, cleaned); + } + } + + private async deleteFiles( + files: ReadonlyArray<{ relativePath: string }>, + projectRoot: string + ): Promise { + let count = 0; + for (const file of files) { + const fullPath = join(projectRoot, file.relativePath); + await this.fs.deleteFile(fullPath); + await this.fs.deleteEmptyDirectories(dirname(fullPath)); + count++; + } + return count; + } +} diff --git a/cli/src/contexts/framework/application/clean/clean-user-scope-use-case.ts b/cli/src/contexts/framework/application/clean/clean-user-scope-use-case.ts new file mode 100644 index 000000000..37f138110 --- /dev/null +++ b/cli/src/contexts/framework/application/clean/clean-user-scope-use-case.ts @@ -0,0 +1,340 @@ +import { join } from "node:path"; +import { USER_SOURCE_REFERENCES_FILENAME, userBuiltCacheRoot } from "../../../../kernel/paths.js"; +import type { FileReader } from "../../../../kernel/ports/file-reader.js"; +import type { FileWriter } from "../../../../kernel/ports/file-writer.js"; +import type { Logger } from "../../../../kernel/ports/logger.js"; +import type { Prompter } from "../../../../kernel/ports/prompter.js"; +import { resolveHomeDir } from "../../../../kernel/reading/home-dir.js"; +import { type AiToolId, isAiToolId, type ToolId } from "../../../../kernel/tool.js"; +import { FRAMEWORK_MARKETPLACE_NAME } from "../../../distribution/domain/marketplace.js"; +import type { MarketplaceRegistry } from "../../../distribution/domain/ports/marketplace-registry.js"; +import type { HostMarketplaceRegistryReader } from "../../../tools/domain/ports/host-marketplace-registry-reader.js"; +import type { NativePluginActivator } from "../../../tools/domain/ports/native-plugin-activator.js"; +import { nativeActivationOf } from "../../../tools/domain/registry.js"; +import type { NativeRegistrations } from "../../domain/manifest/native-registrations.js"; +import type { Manifest } from "../../domain/manifest.js"; +import type { ManifestRepository } from "../../domain/ports/manifest-repository.js"; +import type { UserSourceReferences } from "../../domain/ports/user-source-references.js"; +import { deletePluginFilesForTool } from "../plugin/plugin-helpers.js"; +import { bestEffortNativeCall } from "../shared/best-effort-native-call.js"; +import { resolveCacheCandidate } from "../shared/purge-declared-cache.js"; +import { + purgeAllNativeCaches, + type UndoneToolRegistrations, +} from "../shared/purge-native-marketplace-cache.js"; +import { + describeFullRemovalInstruction, + toleratingUnreadableSourceReferences, +} from "../shared/shared-source-reference-support.js"; +import { userScopeFilesSafeToDelete } from "../shared/user-scope-plugin-files.js"; + +export interface CleanUserScopeOptions { + /** Threaded only to `MarketplaceRegistry.delete`, whose signature takes one for the + * project-scope caller it usually serves — discarded by the adapter for scope `"user"`, since + * this operation has no one project of its own. */ + projectRoot: string; + force: boolean; + interactive?: boolean; +} + +export interface CleanUserScopePreview { + toolIds: readonly ToolId[]; + /** Every version directory found under `userConfigDir()/cache/built/` — read structurally, + * never trusted from any one tool's own manifest entry. */ + builtVersions: readonly string[]; + /** Every project on this machine that `references.json` still names, existing paths only. Empty + * when the port was never wired in, or nothing else references the source. */ + referencingProjects: readonly string[]; +} + +export interface CleanUserScopeResult { + dryRun: boolean; + manifestFound: boolean; + preview: CleanUserScopePreview; +} + +/** + * `aidd clean --scope user`: undoes the machine-scope registration and purges the shared source + * itself, which a project-scope `clean` refuses to do for a registration every project shares. + * + * A user manifest is optional: an absent one skips the host-registration steps rather than guessing, + * while the whitelist purge, reading nothing from the manifest, still runs. Order is a hard + * constraint — every plugin ref uninstalled, then every marketplace unregistered, both through the + * host's own CLI at scope `"user"`, before any cache is purged — and the whitelist resolves each + * entry through `realpath` and containment, never `userConfigDir()` itself. + */ +export class CleanUserScopeUseCase { + constructor( + private readonly fs: FileReader & FileWriter, + private readonly userManifestRepo: ManifestRepository, + private readonly logger: Logger, + private readonly marketplaceRegistry: MarketplaceRegistry, + private readonly userConfigDir: () => string, + /** Native plugin CLI activators keyed by `NativeActivation.binary`. */ + private readonly activators: ReadonlyMap = new Map(), + /** Readers of a host's own marketplace registry, keyed by `AiToolId` — + * `purgeAllNativeCaches`'s own post-condition. */ + private readonly hostMarketplaceRegistries: ReadonlyMap< + AiToolId, + HostMarketplaceRegistryReader + > = new Map(), + /** The one resolver for the OS home directory this use case ever calls. */ + private readonly homeDir: () => string = resolveHomeDir, + /** Absent reports no referencing project at all rather than guessing one. */ + private readonly userSourceReferences?: UserSourceReferences, + private readonly prompter?: Prompter + ) {} + + async execute(options: CleanUserScopeOptions): Promise { + const manifest = await this.userManifestRepo.load(); + const manifestFound = manifest !== null; + const preview = await this.buildPreview(manifest); + if (!manifestFound) this.logger.info(this.describeNoUserRegistration(preview)); + const dryRunResult = await this.confirmOrDryRun(options, preview, manifestFound); + if (dryRunResult !== null) return dryRunResult; + + if (manifest !== null) { + // Undoing a host's own registration must happen before any purge: a host's own CLI resolves + // what it is unregistering against the built tree still on disk, and the purge below removes + // exactly that tree. Absent a manifest there is nothing recorded to undo. + const undone = await this.undoNativeRegistrations(manifest); + await purgeAllNativeCaches( + this.fs, + this.logger, + this.homeDir(), + this.hostMarketplaceRegistries, + undone + ); + await this.purgeCursorUserScopeFiles(manifest, options.projectRoot); + } + await this.purgeWhitelistedMachineState(options.projectRoot); + + return { dryRun: false, manifestFound, preview }; + } + + private async buildPreview(manifest: Manifest | null): Promise { + return { + toolIds: manifest?.getInstalledToolIds() ?? [], + builtVersions: await this.listBuiltVersions(), + referencingProjects: await this.listReferencingProjects(), + }; + } + + /** What a manifest-less run states plainly, in the logged output and in a non-`--force` run's + * confirmation: no host registration exists to undo, and, when `references.json` still lists + * other projects, that each must run its own `aidd clean` first — their hosts still resolve the + * shared source this run is about to purge. */ + private describeNoUserRegistration(preview: CleanUserScopePreview): string { + const base = "No host registration was undone: nothing was registered at user scope."; + if (preview.referencingProjects.length === 0) return base; + const projects = preview.referencingProjects.join(", "); + return `${base} ${projects} still resolve the shared source through their own host; ${describeFullRemovalInstruction()}`; + } + + private async listBuiltVersions(): Promise { + const root = userBuiltCacheRoot(this.userConfigDir()); + let entries: string[]; + try { + entries = await this.fs.listDirectory(root); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return []; + throw error; + } + const versions = new Set(); + for (const entry of entries) { + const version = entry.split("/")[0]; + if (version) versions.add(version); + } + return [...versions].sort(); + } + + private async listReferencingProjects(): Promise { + if (this.userSourceReferences === undefined) return []; + const userSourceReferences = this.userSourceReferences; + return toleratingUnreadableSourceReferences(this.logger, [], () => + userSourceReferences.listAllReferencingProjects() + ); + } + + private async confirmOrDryRun( + options: CleanUserScopeOptions, + preview: CleanUserScopePreview, + manifestFound: boolean + ): Promise { + if (options.force) return null; + if (options.interactive && this.prompter) { + const confirmed = await this.prompter.confirm(this.confirmationMessage(preview)); + if (!confirmed) return { dryRun: true, manifestFound, preview }; + return null; + } + return { dryRun: true, manifestFound, preview }; + } + + private confirmationMessage(preview: CleanUserScopePreview): string { + const versions = + preview.builtVersions.length > 0 ? preview.builtVersions.join(", ") : "none built yet"; + const projects = + preview.referencingProjects.length > 0 + ? preview.referencingProjects.join(", ") + : "no other project"; + return ( + `Remove the shared '${FRAMEWORK_MARKETPLACE_NAME}' source for this machine ` + + `(versions: ${versions})? Still referenced by: ${projects}.` + ); + } + + private async undoNativeRegistrations( + manifest: Manifest + ): Promise> { + const undone = new Map(); + for (const toolId of manifest.getInstalledToolIds()) { + const registrations = manifest.getNativeRegistrations(toolId); + if (registrations === undefined) continue; + const removedHostNames = await this.undoToolNativeRegistrations(toolId, registrations); + if (removedHostNames !== undefined) undone.set(toolId, { registrations, removedHostNames }); + } + return undone; + } + + private async undoToolNativeRegistrations( + toolId: ToolId, + registrations: NativeRegistrations + ): Promise | undefined> { + const { binary } = registrations; + const activator = this.activators.get(binary); + if (activator === undefined || !activator.isAvailable()) { + this.logger.warn(this.describeBinaryAbsent(toolId, registrations)); + return undefined; + } + for (const ref of registrations.pluginRefs) { + bestEffortNativeCall( + this.logger, + () => activator.uninstallPlugin(ref, "user"), + `${binary} plugin uninstall '${ref}'` + ); + } + const removedHostNames = new Set(); + for (const { hostName } of registrations.marketplaces) { + const removed = bestEffortNativeCall( + this.logger, + () => activator.removeMarketplace(hostName, "user"), + `${binary} marketplace remove '${hostName}'` + ); + if (removed) removedHostNames.add(hostName); + } + return removedHostNames; + } + + /** Named so a binary this run cannot reach still tells a person what it left standing — the + * marketplace and plugin-ref counts, plus this tool's own cache path when its profile declares + * one. */ + private describeBinaryAbsent(toolId: ToolId, registrations: NativeRegistrations): string { + const { binary } = registrations; + const base = + `${binary}: registration left in place, the ${binary} CLI is not on the PATH. ` + + `It would have unregistered ${registrations.marketplaces.length} marketplace(s) ` + + `and ${registrations.pluginRefs.length} plugin ref(s).`; + if (!isAiToolId(toolId)) return base; + const cacheRoot = nativeActivationOf(toolId)?.pluginCacheDir?.(this.homeDir()); + if (cacheRoot === undefined) return base; + const paths = registrations.marketplaces.map((m) => join(cacheRoot, m.hostName)); + if (paths.length === 0) return base; + return `${base} Its cache survives at: ${paths.join(", ")}.`; + } + + /** The one tree this whitelist purges outside `userConfigDir()` itself: a user-scope plugin's + * own files, listed by the user manifest and never deleted without `userScopeFilesSafeToDelete`'s + * `realpath` + containment check — a `..` segment a corrupted entry carries, or a plugin + * directory that became a symlink after install, is left in place and named. */ + private async purgeCursorUserScopeFiles(manifest: Manifest, projectRoot: string): Promise { + for (const toolId of manifest.getInstalledToolIds()) { + if (!isAiToolId(toolId)) continue; + for (const plugin of manifest.getPlugins(toolId)) { + if (plugin.scope !== "user") continue; + const files = await userScopeFilesSafeToDelete( + this.fs, + this.logger, + plugin, + toolId, + this.homeDir() + ); + await deletePluginFilesForTool(files, plugin.scope, toolId, projectRoot, this.fs); + } + } + } + + private async purgeWhitelistedMachineState(projectRoot: string): Promise { + await this.purgeWhitelistedPath("cache/built", "cache/built", (candidate) => + this.fs.deleteDirectory(candidate) + ); + // The self-update check cache, written into the same `cache/` directory by any online + // command: left behind it keeps the shell below non-empty, the one occupant that made + // "leaves nothing of aidd's on the machine" false in the ordinary case. + await this.purgeWhitelistedPath( + "cache/update-check.json", + "cache/update-check.json", + (candidate) => this.fs.deleteFile(candidate) + ); + await this.purgeEmptyCacheShell(); + // Where the same cache was written before it moved under `cache/`; the reader still falls + // back to it and an older CLI on this machine may still write it. + await this.purgeWhitelistedPath("update-check.json", "update-check.json", (candidate) => + this.fs.deleteFile(candidate) + ); + await this.purgeWhitelistedPath( + USER_SOURCE_REFERENCES_FILENAME, + USER_SOURCE_REFERENCES_FILENAME, + (candidate) => this.fs.deleteFile(candidate) + ); + // The manifest's own repository is the single writer of `manifest.json` — deleting through + // it, never a second path to the same file, is what keeps that true. + await this.userManifestRepo.delete(); + await this.marketplaceRegistry.delete(projectRoot, FRAMEWORK_MARKETPLACE_NAME, "user"); + } + + /** `cache/built/` and `cache/update-check.json` are this whitelist's only occupants of + * `userConfigDir()/cache/`. The shell around them is removed only once proven empty, under the + * same containment as every other candidate — never assumed from having just purged its only + * children, since a future writer under `cache/` would make that assumption stale silently. */ + private async purgeEmptyCacheShell(): Promise { + const candidate = await resolveCacheCandidate( + this.fs, + this.logger, + this.userConfigDir(), + "cache", + "user scope: cache" + ); + if (candidate === null) return; + let entries: string[]; + try { + entries = await this.fs.listDirectory(candidate); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return; + throw error; + } + if (entries.length > 0) return; + await this.fs.deleteDirectory(candidate); + this.logger.info(`user scope: cache purged: ${candidate}`); + } + + /** Never `userConfigDir()` itself, and never on a manifest's word: every fixed whitelist entry + * is still resolved through `resolveCacheCandidate`'s `realpath` + containment check before it + * is touched — defense in depth against one of those directories having become a symlink since + * it was last written. */ + private async purgeWhitelistedPath( + relativeSegments: string, + label: string, + remove: (candidate: string) => Promise + ): Promise { + const candidate = await resolveCacheCandidate( + this.fs, + this.logger, + this.userConfigDir(), + relativeSegments, + `user scope: ${label}` + ); + if (candidate === null) return; + await remove(candidate); + this.logger.info(`user scope: ${label} purged: ${candidate}`); + } +} diff --git a/cli/src/application/use-cases/doctor/doctor-layout-use-case.ts b/cli/src/contexts/framework/application/doctor/doctor-layout-use-case.ts similarity index 84% rename from cli/src/application/use-cases/doctor/doctor-layout-use-case.ts rename to cli/src/contexts/framework/application/doctor/doctor-layout-use-case.ts index 7cc2d3b1e..386c05c07 100644 --- a/cli/src/application/use-cases/doctor/doctor-layout-use-case.ts +++ b/cli/src/contexts/framework/application/doctor/doctor-layout-use-case.ts @@ -1,8 +1,8 @@ -import type { DoctorIssue } from "../../../domain/models/doctor.js"; -import type { Manifest } from "../../../domain/models/manifest.js"; -import type { FileReader } from "../../../domain/ports/file-reader.js"; -import type { TokenProvider } from "../../../domain/ports/token-provider.js"; -import { getAllRegisteredTools, hasToolSignals } from "../../../domain/tools/registry.js"; +import type { FileReader } from "../../../../kernel/ports/file-reader.js"; +import type { TokenProvider } from "../../../../runtime/auth/ports/token-provider.js"; +import { getAllRegisteredTools, hasToolSignals } from "../../../tools/domain/registry.js"; +import type { DoctorIssue } from "../../domain/doctor.js"; +import type { Manifest } from "../../domain/manifest.js"; export interface DoctorLayoutOptions { manifest: Manifest; diff --git a/cli/src/application/use-cases/doctor/doctor-merge-files-use-case.ts b/cli/src/contexts/framework/application/doctor/doctor-merge-files-use-case.ts similarity index 79% rename from cli/src/application/use-cases/doctor/doctor-merge-files-use-case.ts rename to cli/src/contexts/framework/application/doctor/doctor-merge-files-use-case.ts index 42f442b4e..ddf9362cb 100644 --- a/cli/src/application/use-cases/doctor/doctor-merge-files-use-case.ts +++ b/cli/src/contexts/framework/application/doctor/doctor-merge-files-use-case.ts @@ -1,9 +1,9 @@ import { join } from "node:path"; -import type { DoctorIssue } from "../../../domain/models/doctor.js"; -import type { Manifest } from "../../../domain/models/manifest.js"; -import { extractMergeEntries, type MergeFileEntry } from "../../../domain/models/merge.js"; -import type { FileReader } from "../../../domain/ports/file-reader.js"; -import type { Hasher } from "../../../domain/ports/hasher.js"; +import { extractMergeEntries, type MergeFileEntry } from "../../../../kernel/merge.js"; +import type { FileReader } from "../../../../kernel/ports/file-reader.js"; +import type { Hasher } from "../../../../kernel/ports/hasher.js"; +import type { DoctorIssue } from "../../domain/doctor.js"; +import type { Manifest } from "../../domain/manifest.js"; export interface DoctorMergeFilesOptions { manifest: Manifest; @@ -39,7 +39,7 @@ export class DoctorMergeFilesUseCase { { severity: "error", message: `Missing merge file: ${mergeFile.relativePath}`, - fix: `Run \`aidd restore --force\` to reinstall tracked files.`, + fix: `Run \`aidd sync --force\` to reinstall tracked files.`, }, ]; } @@ -56,13 +56,13 @@ export class DoctorMergeFilesUseCase { issues.push({ severity: "error", message: `Missing key in ${mergeFile.relativePath} > ${key}`, - fix: `Run \`aidd restore --force\` to restore managed keys.`, + fix: `Run \`aidd sync --force\` to restore managed keys.`, }); } else if (!diskHash.equals(manifestHash)) { issues.push({ severity: "warning", message: `Modified key in ${mergeFile.relativePath} > ${key}`, - fix: `Run \`aidd restore --force\` to restore the original value.`, + fix: `Run \`aidd sync --force\` to restore the original value.`, }); } } diff --git a/cli/src/contexts/framework/application/doctor/doctor-plugin-use-case.ts b/cli/src/contexts/framework/application/doctor/doctor-plugin-use-case.ts new file mode 100644 index 000000000..0fb685d1a --- /dev/null +++ b/cli/src/contexts/framework/application/doctor/doctor-plugin-use-case.ts @@ -0,0 +1,40 @@ +import type { PluginIssueEntry } from "../../domain/doctor.js"; +import type { Manifest } from "../../domain/manifest.js"; +import type { DetectPluginDriftUseCase } from "../shared/detect-plugin-drift-use-case.js"; + +export interface DoctorPluginOptions { + manifest: Manifest; + projectRoot: string; + allowedIds: Set | null; + pluginName?: string; +} + +export class DoctorPluginUseCase { + constructor(private readonly detectPluginDrift: DetectPluginDriftUseCase) {} + + async execute(options: DoctorPluginOptions): Promise { + const { manifest, projectRoot, allowedIds, pluginName } = options; + const toolIds = manifest + .getInstalledToolIds() + .filter((toolId) => allowedIds === null || allowedIds.has(toolId)); + const drifts = await this.detectPluginDrift.execute({ + manifest, + projectRoot, + toolIds, + pluginName, + }); + return drifts.flatMap((drift): PluginIssueEntry[] => { + if (drift.notInstalledOnMachine) { + return [ + { toolId: drift.toolId, pluginName: drift.pluginName, issue: "not-installed-on-machine" }, + ]; + } + return drift.files.map((file) => ({ + toolId: drift.toolId, + pluginName: drift.pluginName, + issue: file.kind, + filePath: file.relativePath, + })); + }); + } +} diff --git a/cli/src/application/use-cases/doctor/doctor-references-use-case.ts b/cli/src/contexts/framework/application/doctor/doctor-references-use-case.ts similarity index 90% rename from cli/src/application/use-cases/doctor/doctor-references-use-case.ts rename to cli/src/contexts/framework/application/doctor/doctor-references-use-case.ts index 93628eaa0..2c77c3968 100644 --- a/cli/src/application/use-cases/doctor/doctor-references-use-case.ts +++ b/cli/src/contexts/framework/application/doctor/doctor-references-use-case.ts @@ -1,13 +1,13 @@ import { dirname, join, normalize } from "node:path"; +import type { FileReader } from "../../../../kernel/ports/file-reader.js"; +import type { AiToolId, ToolId } from "../../../../kernel/tool.js"; +import type { DoctorIssue } from "../../domain/doctor.js"; import { extractAtReferences, extractMarkdownLinkTargets, isFileReference, -} from "../../../domain/formats/markdown-references.js"; -import type { DoctorIssue } from "../../../domain/models/doctor.js"; -import type { Manifest } from "../../../domain/models/manifest.js"; -import type { AiToolId, ToolId } from "../../../domain/models/tool-ids.js"; -import type { FileReader } from "../../../domain/ports/file-reader.js"; +} from "../../domain/formats/markdown-references.js"; +import type { Manifest } from "../../domain/manifest.js"; export interface DoctorReferencesOptions { manifest: Manifest; diff --git a/cli/src/contexts/framework/application/doctor/doctor-registration-use-case.ts b/cli/src/contexts/framework/application/doctor/doctor-registration-use-case.ts new file mode 100644 index 000000000..0f5ec75a3 --- /dev/null +++ b/cli/src/contexts/framework/application/doctor/doctor-registration-use-case.ts @@ -0,0 +1,379 @@ +import { join, resolve } from "node:path"; +import { builtMarketplaceDir, userBuiltMarketplaceDir } from "../../../../kernel/paths.js"; +import type { FileReader } from "../../../../kernel/ports/file-reader.js"; +import type { VersionReader } from "../../../../kernel/ports/version-reader.js"; +import { type AiToolId, isAiToolId, type ToolId } from "../../../../kernel/tool.js"; +import { + FRAMEWORK_MARKETPLACE_NAME, + type Marketplace, +} from "../../../distribution/domain/marketplace.js"; +import type { MarketplaceRegistry } from "../../../distribution/domain/ports/marketplace-registry.js"; +import { answeredRegistry } from "../../../tools/domain/host-plugin-registration.js"; +import type { MarketplaceSettings } from "../../../tools/domain/marketplace-settings.js"; +import { + describePluginDiff, + pluginSetDifference, +} from "../../../tools/domain/marketplace-source-conflict.js"; +import type { HostMarketplaceRegistryReader } from "../../../tools/domain/ports/host-marketplace-registry-reader.js"; +import type { + HostPluginRegistryReader, + HostPluginRegistryReading, +} from "../../../tools/domain/ports/host-plugin-registry-reader.js"; +import type { NativePluginActivator } from "../../../tools/domain/ports/native-plugin-activator.js"; +import { getToolConfig, isAiTool, nativeActivationOf } from "../../../tools/domain/registry.js"; +import type { DoctorIssue } from "../../domain/doctor.js"; +import type { Manifest } from "../../domain/manifest.js"; +import { + type HostMarketplaceSourceCheck, + hostMarketplaceSourceConflict, + isDriftFound, + type MarketplaceSourceDriftFound, +} from "../shared/host-marketplace-source-conflict.js"; +import { readMarketplaceCatalogIdentity } from "../shared/read-marketplace-catalog-identity.js"; + +export interface DoctorRegistrationOptions { + manifest: Manifest; + projectRoot: string; + allowedIds: Set | null; +} + +/** One plugin `doctor` expects a native-activation tool's own registry to carry, and the ref to + * look it up by — `ref` absent exactly when nothing here can build one, which is the unanswerable + * case a missing marketplace produces. */ +interface ExpectedNativeRegistration { + readonly plugin: string; + readonly ref?: string; +} + +/** + * Checks the registrations the CLI writes but does not track. + * + * Two unrelated facets share this class because they share a shape: both look past a tracked + * file's own hash, at state a tool's own CLI wrote and this CLI only observes. A tool's + * machine-local settings file is deliberately untracked (absolute paths), so nothing else would + * notice it emptied, edited or deleted; a native-activation tool's own plugin registry answers + * `registered`, `not-registered`, `registered-disabled` or `unanswerable`, and only the first two + * are errors — `unanswerable` is the normal state on a machine that has never run that binary. + */ +export class DoctorRegistrationUseCase { + constructor( + private readonly fs: FileReader, + private readonly registry: MarketplaceRegistry, + /** Native plugin CLI activators keyed by `NativeActivation.binary`. */ + private readonly activators: ReadonlyMap = new Map(), + /** Host plugin registry readers keyed by `AiToolId`, one per tool whose own CLI + * activates plugins. */ + private readonly hostRegistries: ReadonlyMap = new Map(), + /** Host marketplace registry readers keyed by `AiToolId`. Only a tool whose profile declares + * `NativeActivation.marketplaceRegistry` is ever looked up here. */ + private readonly hostMarketplaceRegistries: ReadonlyMap< + AiToolId, + HostMarketplaceRegistryReader + > = new Map(), + /** Root of a user-scope marketplace's built tree, mirroring `EnsureBuiltMarketplaceUseCase`'s + * own `userCacheRoot` — needed to recompute the same path that use case would build, without + * running a build. No default: a caller with nothing real to pass would silently recompute a + * path this run never built. */ + private readonly userCacheRoot: () => string, + /** This run's own CLI version: the shared source is one directory per version, so recomputing + * the expected path needs the same version a build would use. No default — an empty version + * collapses `join(…, "", …)` to the pre-version path shape, a different, wrong path silently + * recomputed rather than a failure. */ + private readonly currentVersion: VersionReader + ) {} + + async execute(options: DoctorRegistrationOptions): Promise { + return [ + ...(await this.checkDeclaredMarketplaces(options)), + ...(await this.checkNativeRegistrations(options)), + ...(await this.checkMarketplaceSources(options)), + ]; + } + + /** + * Whether a host's own marketplace registry already holds this project's marketplace name + * pointed at a source other than the one this project would register — the doctor-side half of + * the sync-time guard, so a conflict is visible between two `sync` runs, not only when one fails. + * + * The expected source is **recomputed**, never stored: `builtMarketplaceDir` / + * `userBuiltMarketplaceDir` are the same pure functions `EnsureBuiltMarketplaceUseCase` calls to + * decide where it builds, so nothing here triggers a build. Silent on an unreadable registry and + * on a source that has never been built — reporting a conflict against a path nothing has ever + * pointed at would invent the exact false positive this guard exists to prevent. + */ + private async checkMarketplaceSources( + options: DoctorRegistrationOptions + ): Promise { + const { projectRoot } = options; + const issues: DoctorIssue[] = []; + for await (const { toolId, expected } of this.retainedToolsWithExpectedMarketplaces(options)) { + if (!isAiToolId(toolId)) continue; + if (nativeActivationOf(toolId)?.marketplaceRegistry === undefined) continue; + const reader = this.hostMarketplaceRegistries.get(toolId); + if (reader === undefined) continue; + for (const marketplace of expected) { + const requestedSource = await this.resolvedBuiltDir(projectRoot, marketplace, toolId); + if (requestedSource === undefined) continue; + const requestedIdentity = await readMarketplaceCatalogIdentity( + this.fs, + toolId, + requestedSource + ); + if (requestedIdentity === undefined) continue; + // Keyed by the catalog's own declared name, never `marketplace.name` (aidd's local + // alias): a host's registry only ever holds an entry under the name its own catalog + // declares. `resolvedBuiltDir` just above stays keyed by alias, since that path is aidd's + // own build location, not a host-facing lookup. + // + // The drift context is handed to every `aidd-framework` entry regardless of its own + // recorded `scope`: an unmigrated project-scope registration is exactly the "still points + // at this project's own pre-migration cache" case this decides. + const check: HostMarketplaceSourceCheck = await hostMarketplaceSourceConflict( + this.fs, + toolId, + reader, + requestedSource, + requestedIdentity, + { + userCacheRoot: this.userCacheRoot(), + projectRoot, + marketplaceName: marketplace.name, + target: toolId, + } + ); + if (check === undefined) continue; + if (isDriftFound(check)) { + issues.push(this.driftIssue(toolId, check)); + continue; + } + const diff = pluginSetDifference(check.registeredIdentity, check.requestedIdentity); + issues.push({ + severity: "error", + message: `${toolId}'s marketplace registry (${check.location}) carries '${check.name}' from a different catalog (${check.registeredSource}) than this project's own (${check.requestedSource}) — plugins ${describePluginDiff(diff)}`, + fix: `Run \`claude plugin marketplace remove ${check.name}\`, then \`aidd sync\` to re-register it for this project — or rename this project's marketplace if it is meant to point elsewhere.`, + }); + } + } + return issues; + } + + /** Both `warning`, never `error`: neither drift is a fault this project caused, and neither + * blocks anything the way a genuine different-catalog conflict does. */ + private driftIssue(toolId: AiToolId, found: MarketplaceSourceDriftFound): DoctorIssue { + const { drift } = found; + if (drift.kind === "version-behind") { + return { + severity: "warning", + message: `${toolId}'s marketplace registry (${found.location}) already carries a newer aidd-framework build, ${drift.registeredVersion}, than this run's own ${drift.requestedVersion}`, + fix: "Run `aidd update` to bring this project's CLI to at least the version the host already follows.", + }; + } + if (drift.kind === "unmigrated-foreign-project-source") { + return { + severity: "warning", + message: `${toolId}'s marketplace registry (${found.location}) still carries '${found.name}' from another project's pre-migration cache (${found.registeredSource})`, + fix: "Run `aidd sync` to move it to the shared, machine-scope source.", + }; + } + return { + severity: "warning", + message: `${toolId}'s marketplace registry (${found.location}) still carries '${found.name}' from this project's own pre-migration cache (${found.registeredSource})`, + fix: "Run `aidd sync` to move it to the shared, machine-scope source.", + }; + } + + /** + * `undefined` when nothing resolves at the computed path, which means either it was never built + * or it no longer exists — neither a fact this check may turn into a conflict. + * + * The reserved framework name always resolves to the shared, machine-scope path, even where the + * registry still records `scope: "project"`: honouring that record would compute the + * pre-migration path as "expected", which the host's own registration already matches, and + * doctor would report nothing wrong in the one state it most needs to name. Every other + * marketplace still resolves by its own recorded `scope`. + */ + private async resolvedBuiltDir( + projectRoot: string, + marketplace: Marketplace, + toolId: AiToolId + ): Promise { + const raw = + marketplace.scope === "user" || marketplace.name === FRAMEWORK_MARKETPLACE_NAME + ? userBuiltMarketplaceDir( + this.userCacheRoot(), + this.currentVersion.get(), + marketplace.name, + toolId + ) + : builtMarketplaceDir(projectRoot, marketplace.name, toolId); + try { + return await this.fs.realpath(resolve(raw)); + } catch { + return undefined; + } + } + + private async checkDeclaredMarketplaces( + options: DoctorRegistrationOptions + ): Promise { + const { projectRoot } = options; + const issues: DoctorIssue[] = []; + for await (const { toolId, expected } of this.retainedToolsWithExpectedMarketplaces(options)) { + const settings = this.untrackedSettingsOf(toolId); + if (settings === undefined) continue; + // A tool that writes its own registration cannot have written one while its binary was out + // of reach: reporting the absence would report that an uninstalled tool is unconfigured. + if (!this.canRegisterItself(toolId)) continue; + const registered = await this.registeredNames(projectRoot, settings); + for (const marketplace of expected) { + if (registered.has(marketplace.name)) continue; + issues.push({ + severity: "warning", + message: `${toolId} no longer declares marketplace '${marketplace.name}'`, + fix: `Run \`aidd marketplace refresh\` to write it back to ${settings.marketplacesSettingsPath}.`, + }); + } + } + return issues; + } + + private async checkNativeRegistrations( + options: DoctorRegistrationOptions + ): Promise { + const { manifest, projectRoot, allowedIds } = options; + const issues: DoctorIssue[] = []; + for (const toolId of this.retainedToolIds(manifest, allowedIds)) { + if (!isAiToolId(toolId)) continue; + if (nativeActivationOf(toolId) === undefined) continue; + const expected = this.expectedNativeRegistrations(manifest, toolId); + if (expected.length === 0) continue; + const reading = await this.hostRegistries.get(toolId)?.read(projectRoot); + issues.push(...this.compareNativeRegistrations(toolId, expected, reading)); + } + return issues; + } + + /** `nativeRegistrations` when the manifest carries one, falling back to the plugins it tracks — + * the state a `sync`-unaware installer produced. `pluginRefs` are already `@` + * strings; the fallback assembles the same shape, `ref` absent exactly when no marketplace was + * recorded for a plugin, the one case nothing here can look up at all. */ + private expectedNativeRegistrations( + manifest: Manifest, + toolId: AiToolId + ): readonly ExpectedNativeRegistration[] { + const recorded = manifest.getNativeRegistrations(toolId); + if (recorded !== undefined) { + return recorded.pluginRefs.map((ref) => ({ plugin: ref, ref })); + } + return manifest.getPlugins(toolId).map((plugin) => ({ + plugin: plugin.name, + ref: plugin.marketplace === undefined ? undefined : `${plugin.name}@${plugin.marketplace}`, + })); + } + + private compareNativeRegistrations( + toolId: AiToolId, + expected: readonly ExpectedNativeRegistration[], + reading: HostPluginRegistryReading | undefined + ): DoctorIssue[] { + const answered = answeredRegistry(toolId, reading, true); + if ("detail" in answered) { + return [ + { + severity: "info", + message: answered.detail, + fix: `The plugin does not load until ${toolId}'s own CLI has run and answered this.`, + }, + ]; + } + const issues: DoctorIssue[] = []; + for (const item of expected) { + if (item.ref === undefined) { + issues.push({ + severity: "info", + message: `AIDD records no marketplace for ${item.plugin} (${toolId}), so its registry cannot be asked`, + fix: `${toolId} will not load it until a marketplace is recorded for it.`, + }); + continue; + } + const entry = answered.refs.get(item.ref); + if (entry === undefined) { + issues.push({ + severity: "error", + message: `${toolId}'s registry (${answered.location}) does not carry ${item.ref}`, + fix: "Run `aidd sync` to re-register it.", + }); + } else if (!entry.enabled) { + issues.push({ + severity: "error", + message: `${toolId}'s registry (${answered.location}) carries ${item.ref} and records it disabled`, + fix: `Run \`aidd framework install --tool ${toolId}\`.`, + }); + } + } + return issues; + } + + private untrackedSettingsOf( + toolId: ToolId + ): (MarketplaceSettings & { marketplacesSettingsPath: string }) | undefined { + const config = getToolConfig(toolId); + if (!isAiTool(config)) return undefined; + const caps = config.capabilities as { + plugins?: { marketplaceSettings?: MarketplaceSettings | null }; + }; + const settings = caps.plugins?.marketplaceSettings; + // `null` means the tool writes no machine-local registration at all, so there is + // nothing here to check — only a declared path leaves a file worth looking at. + if (typeof settings?.marketplacesSettingsPath !== "string") return undefined; + return settings as MarketplaceSettings & { marketplacesSettingsPath: string }; + } + + private async registeredNames( + projectRoot: string, + settings: MarketplaceSettings & { marketplacesSettingsPath: string } + ): Promise> { + const path = join(projectRoot, settings.marketplacesSettingsPath); + if (!(await this.fs.fileExists(path))) return new Set(); + let parsed: unknown; + try { + parsed = JSON.parse(await this.fs.readFile(path)); + } catch { + return new Set(); + } + if (parsed === null || typeof parsed !== "object") return new Set(); + const value = (parsed as Record)[settings.settingsKey]; + if (Array.isArray(value)) return new Set(value.map(String)); + if (value !== null && typeof value === "object") return new Set(Object.keys(value)); + return new Set(); + } + + private canRegisterItself(toolId: ToolId): boolean { + const activation = nativeActivationOf(toolId); + if (activation === undefined) return true; + return this.activators.get(activation.binary)?.isAvailable() ?? false; + } + + /** The one preamble all three passes open with, so `--tool ` narrows every one of them the + * same way. */ + private *retainedToolIds(manifest: Manifest, allowedIds: Set | null): Generator { + for (const toolId of manifest.getInstalledToolIds()) { + if (allowedIds && !allowedIds.has(toolId)) continue; + yield toolId; + } + } + + /** `retainedToolIds` paired with the registered marketplaces, since both callers need one tool + * at a time *and* the full marketplace list. Yields nothing when no marketplace is registered, + * which is the "no issues" answer both passes already gave that case. */ + private async *retainedToolsWithExpectedMarketplaces( + options: DoctorRegistrationOptions + ): AsyncGenerator<{ toolId: ToolId; expected: readonly Marketplace[] }> { + const { manifest, projectRoot, allowedIds } = options; + const expected = await this.registry.list(projectRoot); + if (expected.length === 0) return; + for (const toolId of this.retainedToolIds(manifest, allowedIds)) { + yield { toolId, expected }; + } + } +} diff --git a/cli/src/application/use-cases/doctor/doctor-tracked-files-use-case.ts b/cli/src/contexts/framework/application/doctor/doctor-tracked-files-use-case.ts similarity index 84% rename from cli/src/application/use-cases/doctor/doctor-tracked-files-use-case.ts rename to cli/src/contexts/framework/application/doctor/doctor-tracked-files-use-case.ts index 69e3a142b..09ea9209f 100644 --- a/cli/src/application/use-cases/doctor/doctor-tracked-files-use-case.ts +++ b/cli/src/contexts/framework/application/doctor/doctor-tracked-files-use-case.ts @@ -1,8 +1,8 @@ import { join } from "node:path"; -import type { DoctorIssue } from "../../../domain/models/doctor.js"; -import type { Manifest } from "../../../domain/models/manifest.js"; -import type { ToolId } from "../../../domain/models/tool-ids.js"; -import type { FileReader } from "../../../domain/ports/file-reader.js"; +import type { FileReader } from "../../../../kernel/ports/file-reader.js"; +import type { ToolId } from "../../../../kernel/tool.js"; +import type { DoctorIssue } from "../../domain/doctor.js"; +import type { Manifest } from "../../domain/manifest.js"; export interface DoctorTrackedFilesOptions { manifest: Manifest; @@ -45,7 +45,7 @@ export class DoctorTrackedFilesUseCase { issues.push({ severity: "error", message: `Missing tracked file: ${file.relativePath}`, - fix: `Restore the file or run \`aidd restore\` to reinstall tracked files.`, + fix: `Restore the file or run \`aidd sync\` to reinstall tracked files.`, }); } } @@ -68,7 +68,7 @@ export class DoctorTrackedFilesUseCase { issues.push({ severity: "warning", message: `Modified tracked file: ${file.relativePath}`, - fix: `Run \`aidd restore --force\` to revert to the framework version.`, + fix: `Run \`aidd sync --force\` to revert to the framework version.`, }); } } diff --git a/cli/src/application/use-cases/doctor/doctor-use-case.ts b/cli/src/contexts/framework/application/doctor/doctor-use-case.ts similarity index 83% rename from cli/src/application/use-cases/doctor/doctor-use-case.ts rename to cli/src/contexts/framework/application/doctor/doctor-use-case.ts index 965fb3cd2..a90bea3c8 100644 --- a/cli/src/application/use-cases/doctor/doctor-use-case.ts +++ b/cli/src/contexts/framework/application/doctor/doctor-use-case.ts @@ -1,26 +1,21 @@ -import { ManifestValidationError } from "../../../domain/errors.js"; +import { ManifestValidationError, NoManifestError } from "../../../../kernel/errors.js"; +import type { ToolCategory } from "../../../../kernel/tool.js"; +import { toolIdsForCategory } from "../../../tools/domain/registry.js"; import type { DoctorIssue, DoctorReport, PluginIssueEntry, ToolHealth, -} from "../../../domain/models/doctor.js"; -import type { Manifest } from "../../../domain/models/manifest.js"; -import type { ToolCategory } from "../../../domain/models/tool-ids.js"; -import type { ManifestRepository } from "../../../domain/ports/manifest-repository.js"; -import { toolIdsForCategory } from "../../../domain/tools/registry.js"; -import { NoManifestError } from "../../errors.js"; +} from "../../domain/doctor.js"; +import type { Manifest } from "../../domain/manifest.js"; +import type { ManifestRepository } from "../../domain/ports/manifest-repository.js"; import type { DoctorLayoutUseCase } from "./doctor-layout-use-case.js"; import type { DoctorMergeFilesUseCase } from "./doctor-merge-files-use-case.js"; import type { DoctorPluginUseCase } from "./doctor-plugin-use-case.js"; import type { DoctorReferencesUseCase } from "./doctor-references-use-case.js"; +import type { DoctorRegistrationUseCase } from "./doctor-registration-use-case.js"; import type { DoctorTrackedFilesUseCase } from "./doctor-tracked-files-use-case.js"; -export { - extractAtReferences, - extractMarkdownLinkTargets, -} from "../../../domain/formats/markdown-references.js"; - export interface DoctorOptions { projectRoot: string; category?: ToolCategory; @@ -34,7 +29,8 @@ export class DoctorUseCase { private readonly mergeFiles: DoctorMergeFilesUseCase, private readonly plugin: DoctorPluginUseCase, private readonly references: DoctorReferencesUseCase, - private readonly layout: DoctorLayoutUseCase + private readonly layout: DoctorLayoutUseCase, + private readonly registration: DoctorRegistrationUseCase ) {} async execute(options: DoctorOptions): Promise { @@ -92,6 +88,7 @@ export class DoctorUseCase { allowedIds, trackedFiles: trackedFileList, })), + ...(await this.registration.execute({ manifest, projectRoot, allowedIds })), ]; if (!category) issues.push(...(await this.layout.execute({ manifest, projectRoot }))); return issues; diff --git a/cli/src/application/use-cases/marketplace/marketplace-check-use-case.ts b/cli/src/contexts/framework/application/flows/marketplace-check-use-case.ts similarity index 86% rename from cli/src/application/use-cases/marketplace/marketplace-check-use-case.ts rename to cli/src/contexts/framework/application/flows/marketplace-check-use-case.ts index be0d91888..52ea655fd 100644 --- a/cli/src/application/use-cases/marketplace/marketplace-check-use-case.ts +++ b/cli/src/contexts/framework/application/flows/marketplace-check-use-case.ts @@ -1,13 +1,13 @@ -import type { Manifest } from "../../../domain/models/manifest.js"; +import { AI_TOOL_IDS, type AiToolId } from "../../../../kernel/tool.js"; +import type { ResolveMarketplaceUseCase } from "../../../distribution/application/resolve-marketplace-use-case.js"; import { isMarketplaceStale, type Marketplace, STALE_MAX_DAYS_DEFAULT, -} from "../../../domain/models/marketplace.js"; -import { AI_TOOL_IDS, type AiToolId } from "../../../domain/models/tool-ids.js"; -import type { ManifestRepository } from "../../../domain/ports/manifest-repository.js"; -import type { MarketplaceRegistry } from "../../../domain/ports/marketplace-registry.js"; -import type { ResolveMarketplaceUseCase } from "../shared/resolve-marketplace-use-case.js"; +} from "../../../distribution/domain/marketplace.js"; +import type { MarketplaceRegistry } from "../../../distribution/domain/ports/marketplace-registry.js"; +import type { Manifest } from "../../domain/manifest.js"; +import type { ManifestRepository } from "../../domain/ports/manifest-repository.js"; export interface MarketplaceCheckOptions { projectRoot: string; diff --git a/cli/src/contexts/framework/application/flows/marketplace-remove-use-case.ts b/cli/src/contexts/framework/application/flows/marketplace-remove-use-case.ts new file mode 100644 index 000000000..5f5f0fb22 --- /dev/null +++ b/cli/src/contexts/framework/application/flows/marketplace-remove-use-case.ts @@ -0,0 +1,99 @@ +import { + InvalidMarketplaceNameError, + MarketplaceNotFoundError, +} from "../../../../kernel/errors.js"; +import type { FileWriter } from "../../../../kernel/ports/file-writer.js"; +import type { Prompter } from "../../../../kernel/ports/prompter.js"; +import { AI_TOOL_IDS, type AiToolId } from "../../../../kernel/tool.js"; +import { + FRAMEWORK_MARKETPLACE_NAME, + type Marketplace, +} from "../../../distribution/domain/marketplace.js"; +import type { MarketplaceRegistry } from "../../../distribution/domain/ports/marketplace-registry.js"; +import type { Manifest } from "../../domain/manifest.js"; +import type { InstalledPlugin } from "../../domain/plugins/installed-plugin.js"; +import type { ManifestRepository } from "../../domain/ports/manifest-repository.js"; +import { deletePluginFilesForTool } from "../plugin/plugin-helpers.js"; + +export interface MarketplaceRemoveOptions { + name: string; + projectRoot: string; + autoConfirm: boolean; +} + +export interface MarketplaceRemoveResult { + marketplace: Marketplace; + removedPluginCount: number; + orphanCount: number; +} + +interface OrphanRef { + toolId: AiToolId; + plugin: InstalledPlugin; +} + +export class MarketplaceRemoveUseCase { + constructor( + private readonly fs: FileWriter, + private readonly manifestRepo: ManifestRepository, + private readonly registry: MarketplaceRegistry, + private readonly prompter: Prompter + ) {} + + async execute(options: MarketplaceRemoveOptions): Promise { + // Machine-scope, shared by every project on this machine: removing it here would orphan the + // host's own registration for every other project, with no confirmation and no way back short + // of `aidd setup` running again. It is removed with the framework itself, by `aidd clean`. + if (options.name === FRAMEWORK_MARKETPLACE_NAME) { + throw new InvalidMarketplaceNameError( + `"${FRAMEWORK_MARKETPLACE_NAME}" is shared by every project on this machine and is not removed with \`aidd marketplace remove\` — it is removed with the framework itself, by \`aidd clean\`, once machine scope lands there.` + ); + } + const marketplace = await this.findOrThrow(options.projectRoot, options.name); + const manifest = await this.manifestRepo.load(); + const orphans = manifest ? this.collectOrphans(manifest, options.name) : []; + const cleanup = await this.shouldCleanup(orphans.length, options.autoConfirm); + let removed = 0; + if (cleanup && manifest) { + removed = await this.removeOrphans(manifest, orphans, options.projectRoot); + } + await this.registry.delete(options.projectRoot, marketplace.name, marketplace.scope); + return { marketplace, removedPluginCount: removed, orphanCount: orphans.length }; + } + + private async findOrThrow(projectRoot: string, name: string): Promise { + const list = await this.registry.list(projectRoot); + const found = list.find((m) => m.name === name); + if (!found) throw new MarketplaceNotFoundError(name); + return found; + } + + private collectOrphans(manifest: Manifest, marketplaceName: string): OrphanRef[] { + const orphans: OrphanRef[] = []; + for (const toolId of AI_TOOL_IDS) { + for (const plugin of manifest.getPlugins(toolId)) { + if (plugin.marketplace === marketplaceName) orphans.push({ toolId, plugin }); + } + } + return orphans; + } + + private async shouldCleanup(count: number, autoConfirm: boolean): Promise { + if (count === 0) return false; + if (autoConfirm) return true; + return this.prompter.confirm(`Remove ${count} plugin(s) installed from this marketplace?`); + } + + private async removeOrphans( + manifest: Manifest, + orphans: readonly OrphanRef[], + projectRoot: string + ): Promise { + for (const { toolId, plugin } of orphans) { + await deletePluginFilesForTool(plugin.files, plugin.scope, toolId, projectRoot, this.fs); + manifest.removePlugin(toolId, plugin.name); + } + await this.manifestRepo.save(manifest); + return orphans.length; + } +} diff --git a/cli/src/contexts/framework/application/flows/marketplace-sync-settings-use-case.ts b/cli/src/contexts/framework/application/flows/marketplace-sync-settings-use-case.ts new file mode 100644 index 000000000..fa9962f09 --- /dev/null +++ b/cli/src/contexts/framework/application/flows/marketplace-sync-settings-use-case.ts @@ -0,0 +1,861 @@ +import { join, resolve } from "node:path"; +import { + MarketplaceSourceConflictError, + NativePluginCliError, + UnreadableBuiltCatalogError, +} from "../../../../kernel/errors.js"; +import { BUILT_CACHE_SUBDIR } from "../../../../kernel/paths.js"; +import type { FileReader } from "../../../../kernel/ports/file-reader.js"; +import type { FileWriter } from "../../../../kernel/ports/file-writer.js"; +import type { Hasher } from "../../../../kernel/ports/hasher.js"; +import type { Logger } from "../../../../kernel/ports/logger.js"; +import type { VersionReader } from "../../../../kernel/ports/version-reader.js"; +import type { MarketplaceScope } from "../../../../kernel/scope.js"; +import { type AiToolId, isAiToolId, type ToolId } from "../../../../kernel/tool.js"; +import type { MarketplaceRegisterFramework } from "../../../distribution/application/marketplace-register-framework-use-case.js"; +import { + FRAMEWORK_MARKETPLACE_NAME, + type Marketplace, +} from "../../../distribution/domain/marketplace.js"; +import type { MarketplaceRegistry } from "../../../distribution/domain/ports/marketplace-registry.js"; +import type { MarketplaceSettings } from "../../../tools/domain/marketplace-settings.js"; +import { + describePluginDiff, + type MarketplaceCatalogIdentity, + pluginSetDifference, +} from "../../../tools/domain/marketplace-source-conflict.js"; +import type { HostMarketplaceRegistryReader } from "../../../tools/domain/ports/host-marketplace-registry-reader.js"; +import type { NativePluginActivator } from "../../../tools/domain/ports/native-plugin-activator.js"; +import { nativeActivationOf, resolvePluginsCapability } from "../../../tools/domain/registry.js"; +import type { FrameworkBuildTarget } from "../../../translate/domain/build-target.js"; +import type { + NativeMarketplaceRegistration, + NativeRegistrations, +} from "../../domain/manifest/native-registrations.js"; +import type { Manifest } from "../../domain/manifest.js"; +import type { ManifestRepository } from "../../domain/ports/manifest-repository.js"; +import type { UserSourceReferences } from "../../domain/ports/user-source-references.js"; +import type { EnsureBuiltMarketplace } from "../shared/ensure-built-marketplace-use-case.js"; +import { + hostMarketplaceSourceConflict, + isDriftFound, + type MarketplaceSourceDriftFound, +} from "../shared/host-marketplace-source-conflict.js"; +import { resolveCacheCandidate } from "../shared/purge-declared-cache.js"; +import { + marketplaceCatalogProbePath, + readMarketplaceCatalogIdentity, +} from "../shared/read-marketplace-catalog-identity.js"; +import { + frameworkSourceIsShared, + resolveProjectRootForReferences, + toleratingUnreadableSourceReferences, +} from "../shared/shared-source-reference-support.js"; + +export interface MarketplaceSyncSettingsOptions { + projectRoot: string; + /** Limits both the settings sync and the native activation to these tools; every + * installed tool when absent. */ + toolIds?: readonly ToolId[]; + /** Re-registers the framework marketplace when this run finds none at all — `sync` alone + * sets it, so every other caller reads an empty registry as found rather than repopulating it. */ + recreateFrameworkIfMissing?: boolean; + /** + * The scope this run enables plugins at, which is a different question than a marketplace's + * own `scope`. `"user"` also writes nothing under `projectRoot`, so `syncTool` is skipped. + */ + scope?: MarketplaceScope; + /** + * Overrides the manifest this run reads and writes — the user-scope manifest under + * `userConfigDir()`, which only `setup --scope user` and `sync --scope user` ever pass. + */ + manifestRepo?: ManifestRepository; + /** + * Narrows the settings sync and native activation to the marketplaces named here; absent + * activates every registered one. A name matching nothing resolves to zero marketplaces, + * never a fallback to every one. + */ + marketplaceNames?: readonly string[]; +} + +interface ActivationOutcome { + marketplaces: readonly NativeMarketplaceRegistration[]; + pluginRefs: readonly string[]; + /** A marketplace whose build failed was warned about and left unregistered this run — the + * host's own registration for it is wherever it was before. */ + buildFailed: boolean; +} + +export interface MarketplaceSyncSettingsResult { + /** Tools whose own CLI actually ran, whether or not every step inside it succeeded. */ + activated: readonly ToolId[]; + /** Tools with a native activation whose binary was not on PATH — nothing of theirs + * ran, so the settings this pass wrote will not load until it has. */ + binaryMissing: readonly { toolId: ToolId; binary: string }[]; + /** What a recoverable, best-effort step logged — the same text `logger.warn` received. */ + warnings: readonly string[]; + /** A hard failure that is not the recoverable `NativePluginCliError` family: a bug in an + * activator, or the source-conflict guard's deliberate refusal. Returned rather than thrown, + * so whether the whole command fails stays the caller's decision. */ + errors: readonly { scope: string; message: string }[]; +} + +const EMPTY_RESULT: MarketplaceSyncSettingsResult = { + activated: [], + binaryMissing: [], + warnings: [], + errors: [], +}; + +export interface MarketplaceSyncSettings { + execute(options: MarketplaceSyncSettingsOptions): Promise; +} + +interface ActivationRun { + outcomes: ReadonlyMap; + binaryMissing: readonly { toolId: ToolId; binary: string }[]; + warnings: readonly string[]; + errors: readonly { scope: string; message: string }[]; +} + +export class MarketplaceSyncSettingsUseCase implements MarketplaceSyncSettings { + constructor( + private readonly fs: FileReader & FileWriter, + private readonly manifestRepo: ManifestRepository, + private readonly marketplaceRegistry: MarketplaceRegistry, + private readonly hasher: Hasher, + private readonly logger: Logger, + /** Native plugin CLI activators, keyed by the `binary` each profile declares. */ + private readonly activators: ReadonlyMap, + private readonly ensureBuilt: EnsureBuiltMarketplace, + /** Readers of a host's own marketplace registry, keyed by `AiToolId` — only a tool whose + * profile declares `NativeActivation.marketplaceRegistry` is ever looked up here, so a tool + * absent from this map still syncs. */ + private readonly hostMarketplaceRegistries: ReadonlyMap< + AiToolId, + HostMarketplaceRegistryReader + > = new Map(), + /** Root of a user-scope marketplace's built tree, mirroring `EnsureBuiltMarketplaceUseCase`'s + * own `userCacheRoot` — the version a drift is decided against comes from `builtDir` itself, + * already built before this runs, never from a path recomputed without one. */ + private readonly userCacheRoot: () => string = () => "", + /** Re-registers the shared machine-scope source when this run finds no marketplace at all. + * Absent keeps the silent no-op instead of guessing what to register. */ + private readonly marketplaceRegisterFrameworkUseCase?: MarketplaceRegisterFramework, + private readonly userSourceReferences?: UserSourceReferences, + private readonly currentVersionProvider?: VersionReader + ) {} + + async execute(options: MarketplaceSyncSettingsOptions): Promise { + const { projectRoot } = options; + const manifestRepo = options.manifestRepo ?? this.manifestRepo; + const scope = options.scope ?? "project"; + const [manifest, initialMarketplaces] = await Promise.all([ + manifestRepo.load().catch(() => null), + this.marketplaceRegistry.list(projectRoot), + ]); + if (manifest === null) return EMPTY_RESULT; + const recreatedMarketplaces = options.recreateFrameworkIfMissing + ? await this.ensureFrameworkRegistered(projectRoot, initialMarketplaces) + : initialMarketplaces; + const marketplaces = + options.marketplaceNames === undefined + ? recreatedMarketplaces + : recreatedMarketplaces.filter((m) => options.marketplaceNames?.includes(m.name)); + if (marketplaces.length === 0) return EMPTY_RESULT; + // A user-scope run has no project-scope manifest for a later `clean` to decrement this + // claim from. + if (scope !== "user") await this.recordSharedSourceReference(projectRoot, marketplaces); + const toolIds = this.selectToolIds(manifest, options.toolIds); + let anyToolUpdated = false; + // A user-scope run lands nothing under `projectRoot`, so no project settings file mirrors it. + if (scope === "project") { + for (const toolId of toolIds) { + if (await this.syncTool(toolId, projectRoot, manifest, marketplaces)) anyToolUpdated = true; + } + } + if (anyToolUpdated) await manifestRepo.save(manifest); + const activation = await this.activateNativeTools( + projectRoot, + manifest, + marketplaces, + toolIds, + scope + ); + const wroteHashes = await this.recordWhatActivationWrote(projectRoot, manifest, [ + ...activation.outcomes.keys(), + ]); + const wroteRegistrations = this.recordNativeRegistrations( + manifest, + activation.outcomes, + options.marketplaceNames !== undefined + ); + if (wroteHashes || wroteRegistrations) await manifestRepo.save(manifest); + if (options.recreateFrameworkIfMissing === true && scope === "project") { + await this.purgeStaleProjectCache(projectRoot, marketplaces, activation); + } + return { + activated: [...activation.outcomes.keys()], + binaryMissing: activation.binaryMissing, + warnings: activation.warnings, + errors: activation.errors, + }; + } + + /** + * Only ever called when `recreateFrameworkIfMissing` is set: every other caller reaching an + * empty registry is reading a deliberate choice to have no marketplace at all. A project-scope + * `aidd-framework` entry is retired to the machine-scope one here, carrying forward its own + * `pluginSource` — the register use case's default would silently replace a project installed + * from GitHub or a custom path with `{ kind: "local", path: "." }`. + */ + private async ensureFrameworkRegistered( + projectRoot: string, + marketplaces: readonly Marketplace[] + ): Promise { + const framework = marketplaces.find((m) => m.name === FRAMEWORK_MARKETPLACE_NAME); + if (marketplaces.length > 0 && framework?.scope !== "project") return marketplaces; + if (this.marketplaceRegisterFrameworkUseCase === undefined) return marketplaces; + await this.marketplaceRegisterFrameworkUseCase.execute({ + projectRoot, + pluginSource: framework?.source, + }); + return this.marketplaceRegistry.list(projectRoot); + } + + /** + * Never run before every tool's own native activation: a host's CLI needs the tree it is + * unregistering from to still exist, and a binary off `PATH` or a failed build can leave a + * registration still naming this project's own pre-migration cache. `resolveCacheCandidate` + * refuses anything that does not `realpath` strictly inside `projectRoot`. + */ + private async purgeStaleProjectCache( + projectRoot: string, + marketplaces: readonly Marketplace[], + activation: ActivationRun + ): Promise { + if (activation.errors.length > 0) return; + const framework = marketplaces.find((m) => frameworkSourceIsShared(m.name, m.scope)); + if (framework === undefined) return; + if (activation.binaryMissing.length > 0) { + this.logger.warn( + "This project's own pre-migration framework cache kept: a requested tool's CLI " + + "was not on PATH this run, so its own registration may still point at it — run " + + "`aidd sync` again once every tool's CLI is on PATH." + ); + return; + } + if ([...activation.outcomes.values()].some((outcome) => outcome.buildFailed)) { + this.logger.warn( + "This project's own pre-migration framework cache kept: a requested tool's build " + + "failed this run, so its own registration may still point at it — fix the build " + + "warning above, then run `aidd sync` again." + ); + return; + } + const candidate = await resolveCacheCandidate( + this.fs, + this.logger, + projectRoot, + join(BUILT_CACHE_SUBDIR, FRAMEWORK_MARKETPLACE_NAME), + "This project's own pre-migration framework cache" + ); + if (candidate === null) return; + await this.fs.deleteDirectory(candidate); + this.logger.info(`This project's own pre-migration framework cache purged: ${candidate}`); + } + + /** + * Refreshed on every run that finds the shared source registered, not only the one that had to + * recreate it: this project's own reference is still missing the first time its `sync` runs here. + */ + private async recordSharedSourceReference( + projectRoot: string, + marketplaces: readonly Marketplace[] + ): Promise { + if (this.userSourceReferences === undefined || this.currentVersionProvider === undefined) { + return; + } + const framework = marketplaces.find((m) => frameworkSourceIsShared(m.name, m.scope)); + if (framework === undefined) return; + await this.recordReferenceForRoot(projectRoot); + } + + private async recordReferenceForRoot(root: string): Promise { + if (this.userSourceReferences === undefined || this.currentVersionProvider === undefined) { + return; + } + const userSourceReferences = this.userSourceReferences; + const currentVersionProvider = this.currentVersionProvider; + await toleratingUnreadableSourceReferences(this.logger, undefined, async () => { + const resolvedRoot = await resolveProjectRootForReferences(this.fs, root); + await userSourceReferences.addReference(currentVersionProvider.get(), resolvedRoot); + }); + } + + private selectToolIds( + manifest: Manifest, + toolIds: readonly ToolId[] | undefined + ): readonly ToolId[] { + const installed = manifest.getInstalledToolIds(); + if (toolIds === undefined) return installed; + const requested = new Set(toolIds); + return installed.filter((toolId) => requested.has(toolId)); + } + + /** Never a tool with no native activation, and never one whose binary is absent: neither wrote + * anything, so a settings file that differs for it differs because a person changed it. */ + private async activateNativeTools( + projectRoot: string, + manifest: Manifest, + marketplaces: readonly Marketplace[], + toolIds: readonly ToolId[], + scope: MarketplaceScope + ): Promise { + const outcomes = new Map(); + const binaryMissing: { toolId: ToolId; binary: string }[] = []; + const warnings: string[] = []; + const errors: { scope: string; message: string }[] = []; + for (const toolId of toolIds) { + const binary = this.nativeActivationBinary(toolId); + const activator = binary === undefined ? undefined : this.activators.get(binary); + if (binary === undefined || activator === undefined) continue; + if (!activator.isAvailable()) { + this.logger.warn(`${binary} CLI not found on PATH — skipping native plugin activation.`); + binaryMissing.push({ toolId, binary }); + continue; + } + try { + const outcome = await this.activateTool( + toolId, + activator, + projectRoot, + manifest, + marketplaces, + warnings, + scope + ); + outcomes.set(toolId, outcome); + } catch (error) { + errors.push({ scope: toolId, message: (error as Error).message }); + } + } + return { outcomes, binaryMissing, warnings, errors }; + } + + /** Only for a tool this run actually activated. A `narrowed` run's `outcome` carries only the + * marketplace it touched, so every other entry must survive the merge; an unnarrowed run replaces + * outright, since anything absent from `outcome` is a dead registration nothing else ever drops. + * `pluginRefs` merge by the `@` suffix only for a hostName no retained marketplace + * still owns — two local aliases may resolve to one hostName. */ + private recordNativeRegistrations( + manifest: Manifest, + activated: ReadonlyMap, + narrowed: boolean + ): boolean { + let changed = false; + for (const [toolId, outcome] of activated) { + const binary = this.nativeActivationBinary(toolId); + if (binary === undefined) continue; + const existing = manifest.getNativeRegistrations(toolId); + const touchedAliases = new Set(outcome.marketplaces.map((m) => m.alias)); + const touchedHostNames = new Set(outcome.marketplaces.map((m) => m.hostName)); + const retainedMarketplaces = narrowed + ? (existing?.marketplaces ?? []).filter((m) => !touchedAliases.has(m.alias)) + : []; + const retainedHostNames = new Set(retainedMarketplaces.map((m) => m.hostName)); + const retainedRefs = narrowed + ? (existing?.pluginRefs ?? []).filter( + (ref) => + ![...touchedHostNames].some( + (hostName) => !retainedHostNames.has(hostName) && ref.endsWith(`@${hostName}`) + ) + ) + : []; + const registrations: NativeRegistrations = { + binary, + marketplaces: [...retainedMarketplaces, ...outcome.marketplaces], + pluginRefs: [...new Set([...retainedRefs, ...outcome.pluginRefs])], + }; + if (nativeRegistrationsEqual(existing, registrations)) continue; + manifest.setNativeRegistrations(toolId, registrations); + changed = true; + } + return changed; + } + + /** + * A host's own CLI writes its registration into the very file `syncTool` had just hashed, so + * the hash is re-read from disk after activation rather than re-derived — what is stored is + * then the observation, not a guess at what the host would have written. + */ + private async recordWhatActivationWrote( + projectRoot: string, + manifest: Manifest, + activated: readonly ToolId[] + ): Promise { + let changed = false; + for (const toolId of activated) { + const settingsPath = this.marketplaceSettingsOf(toolId)?.settingsPath; + if (settingsPath === undefined) continue; + const tracked = manifest + .getToolFiles(toolId) + .find((file) => file.relativePath === settingsPath); + if (tracked === undefined) continue; + const content = await this.fs.readFile(resolve(projectRoot, settingsPath)).catch(() => null); + if (content === null) continue; + const hash = this.hasher.hash(content); + if (hash.value === tracked.hash.value) continue; + manifest.updateTrackedFileHash(toolId, settingsPath, hash); + changed = true; + } + return changed; + } + + private marketplaceSettingsOf(toolId: ToolId): MarketplaceSettings | undefined { + return resolvePluginsCapability(toolId)?.marketplaceSettings ?? undefined; + } + + private nativeActivationBinary(toolId: ToolId): string | undefined { + return nativeActivationOf(toolId)?.binary; + } + + /** The caller has already checked `isAvailable()`, so every failure reaching here is either a + * recoverable `NativePluginCliError` — collected into `warnings`, never thrown — or a genuine + * bug in the activator, which propagates. */ + private async activateTool( + toolId: ToolId, + activator: NativePluginActivator, + projectRoot: string, + manifest: Manifest, + marketplaces: readonly Marketplace[], + warnings: string[], + scope: MarketplaceScope + ): Promise { + // Every known marketplace, never only the ones a plugin points at: declaring a marketplace + // and installing a plugin from it are two acts. Measured against the real `claude` binary — + // a project with two registered marketplaces and no plugin was told about neither. + // + // Each step is independently best-effort: one failing plugin or marketplace must warn and + // let the others through, never abort the whole activation. + const registeredMarketplaces: NativeMarketplaceRegistration[] = []; + let buildFailed = false; + for (const marketplace of marketplaces) { + const registration = await this.registerMarketplace( + activator, + toolId, + marketplace, + projectRoot, + warnings + ); + if (!registration.registered) buildFailed = true; + registeredMarketplaces.push({ alias: marketplace.name, hostName: registration.hostName }); + } + if (!activator.enablesPlugins()) + return { marketplaces: registeredMarketplaces, pluginRefs: [], buildFailed }; + this.bestEffort(() => activator.upgradeMarketplaces(), "upgrade marketplaces", warnings); + const hostNameByAlias = new Map(registeredMarketplaces.map((m) => [m.alias, m.hostName])); + const refs = this.pluginRefsToEnable(toolId, manifest, marketplaces, hostNameByAlias); + for (const ref of refs) { + this.bestEffort(() => activator.enablePlugin(ref, scope), `enable plugin '${ref}'`, warnings); + } + return { marketplaces: registeredMarketplaces, pluginRefs: refs, buildFailed }; + } + + private bestEffort(action: () => void, label: string, warnings: string[]): void { + try { + action(); + } catch (error) { + if (!(error instanceof NativePluginCliError)) throw error; + const message = `Native plugin activation — ${label} skipped: ${error.message}`; + this.logger.warn(message); + warnings.push(message); + } + } + + /** Keyed by `hostName`, not `plugin.marketplace` (aidd's own alias): the host resolves the + * marketplace half of a ref against its own registry, which knows this catalog only by the + * name it declares itself. */ + private pluginRefsToEnable( + toolId: ToolId, + manifest: Manifest, + marketplaces: readonly Marketplace[], + hostNameByAlias: ReadonlyMap + ): string[] { + const byName = new Map(marketplaces.map((m) => [m.name, m])); + const refs: string[] = []; + for (const plugin of manifest.getPlugins(toolId)) { + const marketplace = plugin.marketplace == null ? undefined : byName.get(plugin.marketplace); + if (marketplace === undefined) continue; + const hostName = hostNameByAlias.get(marketplace.name); + if (hostName === undefined) continue; + refs.push(`${plugin.name}@${hostName}`); + } + return refs; + } + + // Native tools must read the BUILT (transformed) tree, not the raw Claude-format source. + // Returns the host's own catalog name, never this project's local alias: a catalog this + // project just built and cannot read back is not registered at all (see the throw below). + private async registerMarketplace( + activator: NativePluginActivator, + toolId: ToolId, + marketplace: Marketplace, + projectRoot: string, + warnings: string[] + ): Promise<{ hostName: string; registered: boolean }> { + const builtDir = await this.buildForTool(toolId, marketplace, projectRoot); + if (builtDir === null) return { hostName: marketplace.name, registered: false }; + const requestedIdentity = await readMarketplaceCatalogIdentity(this.fs, toolId, builtDir); + if (requestedIdentity === undefined) { + throw new UnreadableBuiltCatalogError( + marketplaceCatalogProbePath(toolId, builtDir) ?? builtDir + ); + } + const hostName = requestedIdentity.name; + const decision = await this.guardAgainstConflict( + toolId, + builtDir, + requestedIdentity, + marketplace, + projectRoot, + warnings + ); + // A "skip" is a host already following a newer shared build, never this project's own + // pre-migration cache, so it counts as registered. + if (decision === "skip") return { hostName, registered: true }; + try { + activator.addMarketplace(builtDir, marketplace.scope); + } catch (error) { + if (!(error instanceof NativePluginCliError)) throw error; + this.reclaimOrReport(toolId, activator, marketplace, hostName, builtDir, error, warnings); + } + return { hostName, registered: true }; + } + + /** + * Refuses `addMarketplace` where it would silently replace a *different catalog* the host holds + * under this name — measured: claude derives the registered name from the source's own catalog + * and re-adds over it with no prompt and no error. A local alias differing from the catalog's + * declared name is supported and never compared here, and the same catalog reached through a + * differently resolved path is no conflict. A drift decides first: a host on a newer shared build + * returns `"skip"` and is never written backward, one on a pre-migration cache proceeds. + */ + private async guardAgainstConflict( + toolId: ToolId, + builtDir: string, + requestedIdentity: MarketplaceCatalogIdentity, + marketplace: Marketplace, + projectRoot: string, + warnings: string[] + ): Promise<"proceed" | "skip"> { + if (!isAiToolId(toolId)) return "proceed"; + if (nativeActivationOf(toolId)?.marketplaceRegistry === undefined) return "proceed"; + const reader = this.hostMarketplaceRegistries.get(toolId); + if (reader === undefined) return "proceed"; + const requestedSource = await this.fs.realpath(builtDir).catch(() => builtDir); + const check = await hostMarketplaceSourceConflict( + this.fs, + toolId, + reader, + requestedSource, + requestedIdentity, + { + userCacheRoot: this.userCacheRoot(), + projectRoot, + marketplaceName: marketplace.name, + target: toolId, + } + ); + if (check === undefined) return "proceed"; + if (isDriftFound(check)) return await this.decideOnDrift(toolId, check, warnings); + const diff = pluginSetDifference(check.registeredIdentity, check.requestedIdentity); + throw new MarketplaceSourceConflictError( + `Marketplace '${check.name}' is already registered from a different catalog: ` + + `${check.registeredSource} differs from the one requested, ${check.requestedSource} ` + + `— plugins ${describePluginDiff(diff)}, per ${check.location}. ` + + `Run \`claude plugin marketplace remove ${check.name}\`, then \`aidd sync\` again ` + + `to re-register it for this project.` + ); + } + + /** + * A host already following a newer build is never written backward — warned, not thrown, so a + * caller iterating several tools still proceeds. A host tracking *another* project's + * pre-migration cache is no refusal: the repoint completes that migration and its claim is + * recorded alongside this project's, but only once that root is proven to still exist — + * `resolveProjectRootForReferences` falls back to the path as given on `ENOENT`. + */ + private async decideOnDrift( + toolId: ToolId, + found: MarketplaceSourceDriftFound, + warnings: string[] + ): Promise<"proceed" | "skip"> { + if (found.drift.kind === "unmigrated-foreign-project-source") { + if (await this.fs.fileExists(found.drift.projectRoot)) { + await this.recordReferenceForRoot(found.drift.projectRoot); + } + return "proceed"; + } + if (found.drift.kind !== "version-behind") return "proceed"; + const { registeredVersion, requestedVersion } = found.drift; + const message = + `${toolId}'s marketplace registry (${found.location}) already carries a newer ` + + `aidd-framework build, ${registeredVersion}, than this run's own ${requestedVersion} — ` + + "not registering this run's build over it. Run `aidd update` to bring this project's " + + "CLI to at least the version the host already follows."; + this.logger.warn(message); + warnings.push(message); + return "skip"; + } + + // `add` refused, which for a global registry means the name is already held. A registration that + // still resolves belongs to a live project and taking it would break that project; one whose + // source is gone belongs to nobody. `hostName`, never `marketplace.name`, drives every host-facing + // call below: the alias would answer "dead" for a live registration whenever the two differ. The + // reserved framework name at `"user"` scope is reclaimed on any refusal, not only a proven-dead + // one — codex answers `"unknown"` for every name, copilot `"live"` — safe only because every + // registration under that name is this CLI's own packaged catalog. + private reclaimOrReport( + toolId: ToolId, + activator: NativePluginActivator, + marketplace: Marketplace, + hostName: string, + builtDir: string, + addError: NativePluginCliError, + warnings: string[] + ): void { + const state = activator.registrationState(hostName); + const isUnguardedFrameworkMarketplace = + marketplace.name === FRAMEWORK_MARKETPLACE_NAME && + marketplace.scope === "user" && + (!isAiToolId(toolId) || nativeActivationOf(toolId)?.marketplaceRegistry === undefined); + if (state !== "dead" && !isUnguardedFrameworkMarketplace) { + const message = `Native plugin activation — register marketplace '${hostName}' skipped: ${addError.message}`; + this.logger.warn(message); + warnings.push(message); + return; + } + const reclaimMessage = + state === "dead" + ? `Marketplace '${hostName}' was registered to a directory that no longer exists; re-registering it for this project. Plugins installed from it are removed and the ones this CLI manages are put back.` + : `Marketplace '${hostName}' is registered from a different source and ${toolId} refuses to overwrite it in place; removing and re-registering it from the shared, machine-scope build. Plugins installed from it are removed and the ones this CLI manages are put back.`; + this.logger.warn(reclaimMessage); + warnings.push(reclaimMessage); + this.bestEffort( + () => activator.removeMarketplace(hostName, marketplace.scope, { force: true }), + `unregister stale marketplace '${hostName}'`, + warnings + ); + this.bestEffort( + () => activator.addMarketplace(builtDir, marketplace.scope), + `register marketplace '${hostName}'`, + warnings + ); + } + + private async buildForTool( + toolId: ToolId, + marketplace: Marketplace, + projectRoot: string + ): Promise { + try { + const { builtDir } = await this.ensureBuilt.execute({ + projectRoot, + marketplace, + target: toolId as FrameworkBuildTarget, + mode: "marketplace", + }); + return builtDir; + } catch (error) { + this.logger.warn( + `Native plugin activation — build '${marketplace.name}' for ${toolId} skipped: ${(error as Error).message}` + ); + return null; + } + } + + /** + * The registration pointing at these trees is written by the tool's own CLI, so nothing here + * needs the built paths back — but the build has to happen either way, including on a machine + * where that CLI is absent and activation stops short. + */ + private async buildAllForTool( + toolId: ToolId, + marketplaces: readonly Marketplace[], + projectRoot: string + ): Promise { + for (const m of marketplaces) await this.buildForTool(toolId, m, projectRoot); + } + + private async syncTool( + toolId: ToolId, + projectRoot: string, + manifest: Manifest, + marketplaces: readonly Marketplace[] + ): Promise { + const marketplaceSettings = resolvePluginsCapability(toolId)?.marketplaceSettings; + if (marketplaceSettings == null) return false; + return this.syncToolSettings(toolId, projectRoot, manifest, marketplaces, marketplaceSettings); + } + + private async syncToolSettings( + toolId: ToolId, + projectRoot: string, + manifest: Manifest, + marketplaces: readonly Marketplace[], + settings: MarketplaceSettings + ): Promise { + const marketplaceChanged = await this.syncMarketplacesFile( + toolId, + projectRoot, + manifest, + settings, + marketplaces + ); + const pluginsChanged = + settings.enabledPluginsKey != null + ? await this.syncEnabledPluginsFile(toolId, projectRoot, manifest, marketplaces, settings) + : false; + return marketplaceChanged || pluginsChanged; + } + + // The marketplaces key names built trees by absolute path, so a profile may send it to a file + // of its own. That file is written but never hashed: an absolute path recorded in the manifest + // would read as drift on every other machine. + private async syncMarketplacesFile( + toolId: ToolId, + projectRoot: string, + manifest: Manifest, + settings: MarketplaceSettings, + marketplaces: readonly Marketplace[] + ): Promise { + // Building the tree is this CLI's job whoever registers it: a tool that is not installed + // today may be tomorrow, and the tree is what any registration points at. + await this.buildAllForTool(toolId, marketplaces, projectRoot); + return this.evictMarketplacesFromSharedFile(toolId, projectRoot, manifest, settings); + } + + // The key kept an absolute path in the shared, committed file, wrong for everyone but its + // author. Take it out and re-hash, so the move reaches projects that already exist. + private async evictMarketplacesFromSharedFile( + toolId: ToolId, + projectRoot: string, + manifest: Manifest, + settings: MarketplaceSettings + ): Promise { + const sharedPath = resolve(projectRoot, settings.settingsPath); + const shared = await this.loadSettings(sharedPath); + if (!(settings.settingsKey in shared)) return false; + delete shared[settings.settingsKey]; + const content = JSON.stringify(shared, null, 2); + await this.fs.writeFile(sharedPath, content); + manifest.updateTrackedFileHash(toolId, settings.settingsPath, this.hasher.hash(content)); + return true; + } + + private async syncEnabledPluginsFile( + toolId: ToolId, + projectRoot: string, + manifest: Manifest, + marketplaces: readonly Marketplace[], + settings: MarketplaceSettings + ): Promise { + const pluginsPath = resolve(projectRoot, settings.settingsPath); + const json = await this.loadSettings(pluginsPath); + if (!this.mergeEnabledPlugins(json, settings, toolId, manifest, marketplaces)) return false; + const content = JSON.stringify(json, null, 2); + await this.fs.writeFile(pluginsPath, content); + manifest.updateTrackedFileHash(toolId, settings.settingsPath, this.hasher.hash(content)); + return true; + } + + private mergeEnabledPlugins( + json: Record, + settings: MarketplaceSettings, + toolId: ToolId, + manifest: Manifest, + marketplaces: readonly Marketplace[] + ): boolean { + const pluginsKey = settings.enabledPluginsKey; + if (pluginsKey == null) return false; + const existing = this.existingRecord(json, pluginsKey); + const toAdd: Record = {}; + const marketplaceByName = new Map(marketplaces.map((m) => [m.name, m])); + for (const plugin of manifest.getPlugins(toolId)) { + if (plugin.marketplace == null) continue; + const marketplace = marketplaceByName.get(plugin.marketplace); + if (marketplace == null) continue; + const entryKey = settings.toEntryKey({ + name: marketplace.name, + source: marketplace.source, + }); + if (entryKey == null) continue; + const key = `${plugin.name}@${entryKey}`; + if (!(key in existing)) toAdd[key] = true; + } + if (Object.keys(toAdd).length === 0) return false; + json[pluginsKey] = { ...existing, ...toAdd }; + return true; + } + + private existingRecord( + json: Record, + settingsKey: string + ): Record { + const raw = json[settingsKey]; + if (raw !== null && typeof raw === "object" && !Array.isArray(raw)) { + return raw as Record; + } + return {}; + } + + // These files are co-owned and the machine-local one is untracked and gitignored, which is + // exactly the kind of file people hand-edit: a trailing comma must not take the whole sync + // down with it. + private async loadSettings(absPath: string): Promise> { + if (!(await this.fs.fileExists(absPath))) return {}; + const content = await this.fs.readFile(absPath); + let parsed: unknown; + try { + parsed = JSON.parse(content); + } catch { + this.logger.warn(`Ignoring malformed JSON in ${absPath}; rewriting the keys this CLI owns.`); + return {}; + } + if (parsed !== null && typeof parsed === "object" && !Array.isArray(parsed)) { + return parsed as Record; + } + return {}; + } +} + +/** Order included: both sides are built from the same marketplace/plugin iteration order each + * run, so a difference is real. Keeps a no-op sync from rewriting the manifest. */ +function nativeRegistrationsEqual( + a: NativeRegistrations | undefined, + b: NativeRegistrations +): boolean { + if (a === undefined) return false; + return ( + a.binary === b.binary && + marketplaceRegistrationsEqual(a.marketplaces, b.marketplaces) && + arraysEqual(a.pluginRefs, b.pluginRefs) + ); +} + +function marketplaceRegistrationsEqual( + a: readonly NativeMarketplaceRegistration[], + b: readonly NativeMarketplaceRegistration[] +): boolean { + return ( + a.length === b.length && + a.every( + (value, index) => value.alias === b[index]?.alias && value.hostName === b[index]?.hostName + ) + ); +} + +function arraysEqual(a: readonly string[], b: readonly string[]): boolean { + return a.length === b.length && a.every((value, index) => value === b[index]); +} diff --git a/cli/src/contexts/framework/application/framework/translator/built-tree-materialization-translator.ts b/cli/src/contexts/framework/application/framework/translator/built-tree-materialization-translator.ts new file mode 100644 index 000000000..f55b4eb18 --- /dev/null +++ b/cli/src/contexts/framework/application/framework/translator/built-tree-materialization-translator.ts @@ -0,0 +1,210 @@ +import { join, posix } from "node:path"; +import { InstallationFile } from "../../../../../kernel/file.js"; +/** + * Materializes plugin content by copying the per-target BUILT tree verbatim into the tool's plugin + * directory, bypassing the per-file transform the build already applied. Marketplace-sourced + * installs only; a raw local-path install falls back to flat materialization. + */ +import { flatHooksPathWithLoaderEntry } from "../../../../../kernel/materialization/flat-paths.js"; +import { posixRelative } from "../../../../../kernel/paths.js"; +import type { FileReader } from "../../../../../kernel/ports/file-reader.js"; +import type { FileWriter } from "../../../../../kernel/ports/file-writer.js"; +import type { Hasher } from "../../../../../kernel/ports/hasher.js"; +import type { PluginSource } from "../../../../../kernel/source.js"; +import type { AiToolId } from "../../../../../kernel/tool.js"; +import type { MarketplaceRegistry } from "../../../../distribution/domain/ports/marketplace-registry.js"; +import { + frameworkBuildModeFor, + resolvePluginsCapability, +} from "../../../../tools/domain/registry.js"; +import type { PluginDistribution } from "../../../../translate/domain/plugin-distribution.js"; +import type { ReadonlySkipList } from "../../../../translate/domain/plugin-translation-skip.js"; +import type { Manifest } from "../../../domain/manifest.js"; +import { InstalledPlugin } from "../../../domain/plugins/installed-plugin.js"; +import { isPluginFileAtDesiredState } from "../../plugin/plugin-helpers.js"; +import { + resolveBaseDirFromRecord, + resolveScopeForInstall, +} from "../../plugin/plugin-target-resolution.js"; +import type { EnsureBuiltMarketplace } from "../../shared/ensure-built-marketplace-use-case.js"; +import { ModeBFlatMaterializationTranslator } from "./mode-b-flat-materialization-translator.js"; +import type { PluginTranslator } from "./plugin-translator.js"; +import { ProjectHooksMaterializer } from "./project-hooks-materializer.js"; +export class BuiltTreeMaterializationTranslator implements PluginTranslator { + readonly mode = "flat" as const; + private readonly projectHooks: ProjectHooksMaterializer; + + constructor( + private readonly fs: FileWriter & FileReader, + private readonly hasher: Hasher, + private readonly homedir: () => string, + private readonly ensureBuilt: EnsureBuiltMarketplace, + private readonly marketplaceRegistry: MarketplaceRegistry + ) { + this.projectHooks = new ProjectHooksMaterializer(fs); + } + + async addPlugin( + dist: PluginDistribution, + toolId: AiToolId, + source: PluginSource, + projectRoot: string, + manifest: Manifest, + marketplace: string | undefined, + previousMcpEntries: ReadonlyMap = new Map() + ): Promise<{ skipped: ReadonlySkipList; written?: number }> { + const resolved = + marketplace === undefined ? null : await this.findMarketplace(marketplace, projectRoot); + if (marketplace === undefined || resolved === null) { + return this.fallback().addPlugin( + dist, + toolId, + source, + projectRoot, + manifest, + marketplace, + previousMcpEntries + ); + } + const mode = frameworkBuildModeFor(toolId); + const { builtDir } = await this.ensureBuilt.execute({ + projectRoot, + marketplace: resolved, + target: toolId, + mode, + }); + const builtFiles = + mode === "flat" + ? await this.readFlatFiles(builtDir, dist, toolId) + : await this.readBuiltFiles( + join(builtDir, "plugins", dist.manifest.name), + dist.manifest.name + ); + // The built tree still carries a plugin-scoped `hooks/hooks.json` for a capability declaring + // `hooksDestination: "project"` — dropped here and materialized through the same project-hooks + // side channel the local-source route uses, so both land where the tool's own declaration says. + const deliversHooksToProject = resolvePluginsCapability(toolId)?.hooksDestination === "project"; + const hooksSkips = deliversHooksToProject + ? await this.projectHooks.materialize(dist, toolId, projectRoot) + : []; + const files = deliversHooksToProject + ? withoutHooksPrefix(builtFiles, dist.manifest.name) + : builtFiles; + const scope = resolveScopeForInstall(toolId); + const baseDir = + mode === "flat" + ? projectRoot + : resolveBaseDirFromRecord(scope, toolId, projectRoot, this.homedir); + const written = await this.writeChangedFiles(files, baseDir); + manifest.addPlugin( + toolId, + InstalledPlugin.fromDistribution(dist, source, files, scope, new Map(), marketplace) + ); + return { skipped: hooksSkips, written }; + } + + // Skips a file already matching the built content on disk, so a no-op restore reports (and + // performs) zero writes. + private async writeChangedFiles(files: InstallationFile[], baseDir: string): Promise { + let written = 0; + for (const f of files) { + const outputPath = join(baseDir, f.relativePath); + if (await isPluginFileAtDesiredState(this.fs, this.hasher, outputPath, f.hash.value)) { + continue; + } + await this.fs.writeFile(outputPath, f.content); + written++; + } + return written; + } + + // Marketplace build emits plugins//; user-scope tools install at + // //, so the manifest relativePath keeps the / prefix. + private async readBuiltFiles(pluginSrc: string, name: string): Promise { + const absPaths = await this.fs.listFilesRecursive(pluginSrc); + return Promise.all( + absPaths.map(async (abs) => { + const rel = posixRelative(pluginSrc, abs); + const content = await this.fs.readFile(abs); + return new InstallationFile({ + // relativePath is always "/"-separated (see withoutHooksPrefix and + // belongsToPlugin below, both string-matching on "/") - node:path's platform + // `join` would answer with "\" on win32, breaking both. + relativePath: posix.join(name, rel), + content, + hash: this.hasher.hash(content), + }); + }) + ); + } + + // Flat build emits the whole marketplace into one workspace. Agents are namespaced by + // `-`; skills instead nest the whole subtree under `skills//`, since a + // skill's own script can `require()` a sibling by relative path, which only keeps resolving while + // nothing under that subtree is renamed. Hooks land under `flatHooksDir//`, except the + // one script that is the loader's own runtime module, renamed to the plugin's name in the + // loader's directory — so hook paths are matched by path, never by naming convention. + private async readFlatFiles( + builtDir: string, + dist: PluginDistribution, + toolId: AiToolId + ): Promise { + const name = dist.manifest.name; + const hookPaths = this.flatHookOutputPaths(dist, toolId); + const absPaths = await this.fs.listFilesRecursive(builtDir); + const files: InstallationFile[] = []; + for (const abs of absPaths) { + const rel = posixRelative(builtDir, abs); + if (!this.belongsToPlugin(rel, name) && !hookPaths.has(rel)) continue; + const content = await this.fs.readFile(abs); + files.push( + new InstallationFile({ relativePath: rel, content, hash: this.hasher.hash(content) }) + ); + } + return files; + } + + private belongsToPlugin(rel: string, name: string): boolean { + const segments = rel.split("/"); + if (segments[0] !== ".opencode" || segments.length < 3) return false; + // `skills/` nests the whole plugin under one exactly-named segment; every other flat section + // hyphen-prefixes the leaf segment. + if (segments[1] === "skills") return segments[2] === name; + return segments[2].startsWith(`${name}-`); + } + + private flatHookOutputPaths(dist: PluginDistribution, toolId: AiToolId): ReadonlySet { + const plugins = resolvePluginsCapability(toolId); + const flatHooksDir = plugins?.flatHooksDir; + if (plugins === null || flatHooksDir === null || flatHooksDir === undefined) return new Set(); + const name = dist.manifest.name; + return new Set( + dist.components.hooks + .filter((f) => f.relativePath !== "hooks/hooks.json") + .map((f) => + flatHooksPathWithLoaderEntry( + flatHooksDir, + plugins.flatHooksLoaderEntry, + name, + f.relativePath + ) + ) + ); + } + + private async findMarketplace(name: string, projectRoot: string) { + const all = await this.marketplaceRegistry.list(projectRoot); + return all.find((m) => m.name === name) ?? null; + } + + private fallback(): ModeBFlatMaterializationTranslator { + return new ModeBFlatMaterializationTranslator(this.fs, this.hasher, this.homedir); + } +} + +// `readBuiltFiles` prefixes every path with `/`, so a built-tree hooks file always reads +// `/hooks/`. +function withoutHooksPrefix(files: InstallationFile[], pluginName: string): InstallationFile[] { + const hooksPrefix = `${pluginName}/hooks/`; + return files.filter((f) => !f.relativePath.startsWith(hooksPrefix)); +} diff --git a/cli/src/contexts/framework/application/framework/translator/mode-a-marketplace-translator.ts b/cli/src/contexts/framework/application/framework/translator/mode-a-marketplace-translator.ts new file mode 100644 index 000000000..228021c0d --- /dev/null +++ b/cli/src/contexts/framework/application/framework/translator/mode-a-marketplace-translator.ts @@ -0,0 +1,41 @@ +import type { PluginSource } from "../../../../../kernel/source.js"; +import type { AiToolId } from "../../../../../kernel/tool.js"; +import type { PluginDistribution } from "../../../../translate/domain/plugin-distribution.js"; +import type { ReadonlySkipList } from "../../../../translate/domain/plugin-translation-skip.js"; +import type { Manifest } from "../../../domain/manifest.js"; +import { InstalledPlugin } from "../../../domain/plugins/installed-plugin.js"; +import { resolveScopeForInstall } from "../../plugin/plugin-target-resolution.js"; +import type { PluginTranslator } from "./plugin-translator.js"; + +/** + * Mode A — a marketplace registration, for a tool with native marketplace support. + * + * Files are NOT materialized on disk: a plugin reference is added to the manifest with an empty + * files set, and `MarketplaceSyncSettingsUseCase` does the rest — driving the tool's own CLI where + * `nativeActivation` is declared, writing the enabled-plugins key directly where it is not. + */ +export class ModeAMarketplaceTranslator implements PluginTranslator { + readonly mode = "marketplace" as const; + + async addPlugin( + dist: PluginDistribution, + toolId: AiToolId, + source: PluginSource, + _projectRoot: string, + manifest: Manifest, + marketplace: string | undefined + ): Promise<{ skipped: ReadonlySkipList }> { + manifest.addPlugin( + toolId, + InstalledPlugin.fromDistribution( + dist, + source, + [], + resolveScopeForInstall(toolId), + new Map(), + marketplace + ) + ); + return { skipped: [] }; + } +} diff --git a/cli/src/contexts/framework/application/framework/translator/mode-b-flat-materialization-translator.ts b/cli/src/contexts/framework/application/framework/translator/mode-b-flat-materialization-translator.ts new file mode 100644 index 000000000..c36e99af3 --- /dev/null +++ b/cli/src/contexts/framework/application/framework/translator/mode-b-flat-materialization-translator.ts @@ -0,0 +1,194 @@ +import { join } from "node:path"; +import { CursorProjectScopeUnsupportedError } from "../../../../../kernel/errors.js"; +import type { InstallationFile } from "../../../../../kernel/file.js"; +import type { FileReader } from "../../../../../kernel/ports/file-reader.js"; +import type { FileWriter } from "../../../../../kernel/ports/file-writer.js"; +import type { Hasher } from "../../../../../kernel/ports/hasher.js"; +import type { PluginSource } from "../../../../../kernel/source.js"; +import type { AiToolId } from "../../../../../kernel/tool.js"; +import type { McpCapability } from "../../../../tools/domain/capabilities/mcp-capability.js"; +import type { PluginsCapability } from "../../../../tools/domain/capabilities/plugins-capability.js"; +import { mergeOpencodeMcp } from "../../../../tools/domain/formats/opencode-mcp-merge.js"; +import { getToolConfig, isAiTool } from "../../../../tools/domain/registry.js"; +import { PluginContentTranslator } from "../../../../translate/domain/content-translator.js"; +import type { PluginDistribution } from "../../../../translate/domain/plugin-distribution.js"; +import type { + PluginTranslationSkip, + ReadonlySkipList, +} from "../../../../translate/domain/plugin-translation-skip.js"; +import type { Manifest } from "../../../domain/manifest.js"; +import { InstalledPlugin, type PluginScope } from "../../../domain/plugins/installed-plugin.js"; +import { writePluginFiles } from "../../plugin/plugin-helpers.js"; +import { + isFrameworkPrimeFlatMcp, + resolveBaseDirFromRecord, + resolveScopeForInstall, +} from "../../plugin/plugin-target-resolution.js"; +import type { PluginTranslator } from "./plugin-translator.js"; +import { ProjectHooksMaterializer, withoutHooks } from "./project-hooks-materializer.js"; + +/** + * Mode B — flat materialization: writes a plugin's content directly into the tool's plugin + * directory, for a tool without native marketplace support. A translator adapter, not a hexagonal + * port adapter. + */ +export class ModeBFlatMaterializationTranslator implements PluginTranslator { + readonly mode = "flat" as const; + private readonly projectHooks: ProjectHooksMaterializer; + + constructor( + private readonly fs: FileWriter & FileReader, + private readonly hasher: Hasher, + private readonly homedir: () => string + ) { + this.projectHooks = new ProjectHooksMaterializer(fs); + } + + async addPlugin( + dist: PluginDistribution, + toolId: AiToolId, + source: PluginSource, + projectRoot: string, + manifest: Manifest, + marketplace: string | undefined, + previousMcpEntries: ReadonlyMap = new Map() + ): Promise<{ skipped: ReadonlySkipList }> { + const ctx = this.resolveFlatToolContext(toolId, dist, projectRoot); + if (ctx === null) return { skipped: [] }; + const mcp = await this.resolveMcp(dist, toolId, projectRoot, previousMcpEntries); + const hooksSkips = await this.projectHooks.materialize(dist, toolId, projectRoot); + const allSkipped: ReadonlySkipList = [...ctx.skipped, ...mcp.mcpSkips, ...hooksSkips]; + if (ctx.files.length === 0 && mcp.mcpEntries.size === 0) return { skipped: allSkipped }; + await this.writeAndRegisterPlugin( + dist, + toolId, + source, + ctx.files, + mcp.mcpEntries, + ctx.componentPaths, + marketplace, + ctx.baseDir, + ctx.scope, + manifest + ); + return { skipped: allSkipped }; + } + + private resolveFlatToolContext( + toolId: AiToolId, + dist: PluginDistribution, + projectRoot: string + ): { + caps: Record; + files: InstallationFile[]; + componentPaths: ReadonlyMap; + skipped: ReadonlySkipList; + baseDir: string; + scope: PluginScope; + } | null { + const toolConfig = getToolConfig(toolId); + if (!isAiTool(toolConfig)) return null; + const caps = toolConfig.capabilities as Record; + const pluginsCap = caps.plugins as PluginsCapability; + if (pluginsCap.mode === "native" && pluginsCap.installScope !== "user") { + throw new CursorProjectScopeUnsupportedError(); + } + const distForNative = pluginsCap.hooksDestination === "project" ? withoutHooks(dist) : dist; + const { files, componentPaths, skipped } = new PluginContentTranslator( + this.hasher + ).translateWithComponentPaths(distForNative, toolConfig); + const scope = resolveScopeForInstall(toolId); + const baseDir = resolveBaseDirFromRecord(scope, toolId, projectRoot, this.homedir); + return { caps, files, componentPaths, skipped, baseDir, scope }; + } + + private async resolveMcp( + dist: PluginDistribution, + toolId: AiToolId, + projectRoot: string, + previousMcpEntries: ReadonlyMap + ): Promise<{ mcpEntries: ReadonlyMap; mcpSkips: ReadonlySkipList }> { + const toolConfig = getToolConfig(toolId); + if (!isAiTool(toolConfig)) return { mcpEntries: new Map(), mcpSkips: [] }; + const caps = toolConfig.capabilities as Record; + if (!isFrameworkPrimeFlatMcp(caps) || dist.components.mcp.length === 0) { + return { mcpEntries: new Map(), mcpSkips: [] }; + } + return this.mergeOpencodeMcpEntries(dist, caps, projectRoot, previousMcpEntries, toolId); + } + + private async writeAndRegisterPlugin( + dist: PluginDistribution, + toolId: AiToolId, + source: PluginSource, + files: InstallationFile[], + mcpEntries: ReadonlyMap, + componentPaths: ReadonlyMap, + marketplace: string | undefined, + baseDir: string, + scope: PluginScope, + manifest: Manifest + ): Promise { + if (files.length > 0) await writePluginFiles(files, baseDir, this.fs); + const plugin = InstalledPlugin.fromDistributionWithMcp( + dist, + source, + files, + mcpEntries, + scope, + componentPaths, + marketplace + ); + manifest.addPlugin(toolId, plugin); + } + + private async mergeOpencodeMcpEntries( + dist: PluginDistribution, + caps: Record, + projectRoot: string, + previousMcpEntries: ReadonlyMap, + toolId: AiToolId + ): Promise<{ mcpEntries: ReadonlyMap; mcpSkips: ReadonlySkipList }> { + const mcpCap = caps.mcp as McpCapability; + const outputRelPath = await mcpCap.resolveOutput(projectRoot, this.fs); + const outputPath = join(projectRoot, outputRelPath); + const existingContent = await this.readExistingJson(outputPath); + const rawMcp = dist.components.mcp[0].content; + const transformed = mcpCap.transform(rawMcp); + const { mergedContent, contributedEntries, collisions } = mergeOpencodeMcp( + existingContent, + transformed, + previousMcpEntries, + this.hasher + ); + if (contributedEntries.size > 0 || previousMcpEntries.size > 0) { + await this.fs.writeFile(outputPath, mergedContent); + } + const mcpSkips = this.collisionsToSkips(collisions, dist.manifest.name, toolId); + return { mcpEntries: contributedEntries, mcpSkips }; + } + + private collisionsToSkips( + collisions: ReadonlyArray, + pluginName: string, + toolId: AiToolId + ): ReadonlySkipList { + return collisions.map( + (reason): PluginTranslationSkip => ({ + pluginName, + component: "mcp", + toolId, + reason, + }) + ); + } + + private async readExistingJson(path: string): Promise { + try { + return await this.fs.readFile(path); + } catch (err) { + if ((err as NodeJS.ErrnoException).code === "ENOENT") return null; + throw err; + } + } +} diff --git a/cli/src/contexts/framework/application/framework/translator/plugin-translator-factory.ts b/cli/src/contexts/framework/application/framework/translator/plugin-translator-factory.ts new file mode 100644 index 000000000..b463614d5 --- /dev/null +++ b/cli/src/contexts/framework/application/framework/translator/plugin-translator-factory.ts @@ -0,0 +1,42 @@ +import type { FileReader } from "../../../../../kernel/ports/file-reader.js"; +import type { FileWriter } from "../../../../../kernel/ports/file-writer.js"; +import type { Hasher } from "../../../../../kernel/ports/hasher.js"; +import type { MarketplaceRegistry } from "../../../../distribution/domain/ports/marketplace-registry.js"; +import type { PluginsCapability } from "../../../../tools/domain/capabilities/plugins-capability.js"; +import type { EnsureBuiltMarketplace } from "../../shared/ensure-built-marketplace-use-case.js"; +import { BuiltTreeMaterializationTranslator } from "./built-tree-materialization-translator.js"; +import { ModeAMarketplaceTranslator } from "./mode-a-marketplace-translator.js"; +import type { PluginTranslator } from "./plugin-translator.js"; + +export interface TranslatorDeps { + fs: FileWriter & FileReader; + hasher: Hasher; + homedir: () => string; + ensureBuilt: EnsureBuiltMarketplace; + marketplaceRegistry: MarketplaceRegistry; +} + +/** + * Resolves the translation adapter for a `PluginsCapability`, or `null` when none applies. + * + * A materializing tool copies the per-target BUILT tree verbatim, so installed bytes match the + * build's own output; a raw local-path install falls back to flat materialization. + */ +export function resolveTranslator( + plugins: PluginsCapability, + deps: TranslatorDeps +): PluginTranslator | null { + if (plugins.installScope === "user" || plugins.translationMode === "flat") { + return new BuiltTreeMaterializationTranslator( + deps.fs, + deps.hasher, + deps.homedir, + deps.ensureBuilt, + deps.marketplaceRegistry + ); + } + if (plugins.translationMode === "marketplace") { + return new ModeAMarketplaceTranslator(); + } + return null; +} diff --git a/cli/src/contexts/framework/application/framework/translator/plugin-translator.ts b/cli/src/contexts/framework/application/framework/translator/plugin-translator.ts new file mode 100644 index 000000000..6cb29bfcd --- /dev/null +++ b/cli/src/contexts/framework/application/framework/translator/plugin-translator.ts @@ -0,0 +1,28 @@ +import type { PluginSource } from "../../../../../kernel/source.js"; +import type { AiToolId } from "../../../../../kernel/tool.js"; +import type { PluginTranslationMode } from "../../../../tools/domain/plugin-translation-mode.js"; +import type { PluginDistribution } from "../../../../translate/domain/plugin-distribution.js"; +import type { ReadonlySkipList } from "../../../../translate/domain/plugin-translation-skip.js"; +import type { Manifest } from "../../../domain/manifest.js"; + +/** A translator strategy contract, not a hexagonal port adapter. */ +export interface PluginTranslator { + readonly mode: PluginTranslationMode; + + /** + * Returns a skip list — non-empty when the plugin carries components the tool cannot consume — + * and, for strategies that track it, how many files were actually (re)written to disk. + * + * `previousMcpEntries` carries the plugin's previous entries when replacing an existing install, + * for an idempotent re-merge of OpenCode MCP servers. + */ + addPlugin( + dist: PluginDistribution, + toolId: AiToolId, + source: PluginSource, + projectRoot: string, + manifest: Manifest, + marketplace: string | undefined, + previousMcpEntries?: ReadonlyMap + ): Promise<{ skipped: ReadonlySkipList; written?: number }>; +} diff --git a/cli/src/contexts/framework/application/framework/translator/project-hooks-materializer.ts b/cli/src/contexts/framework/application/framework/translator/project-hooks-materializer.ts new file mode 100644 index 000000000..1a8735048 --- /dev/null +++ b/cli/src/contexts/framework/application/framework/translator/project-hooks-materializer.ts @@ -0,0 +1,106 @@ +import { join } from "node:path"; +import { + cursorProjectHooksScriptPath, + mergeCursorProjectHooksJson, +} from "../../../../../contexts/tools/domain/formats/cursor-hooks-project-merge.js"; +import type { FileReader } from "../../../../../kernel/ports/file-reader.js"; +import type { FileWriter } from "../../../../../kernel/ports/file-writer.js"; +import type { AiToolId } from "../../../../../kernel/tool.js"; +import { resolvePluginsCapability } from "../../../../tools/domain/registry.js"; +import { + type PluginComponentFile, + PluginDistribution, +} from "../../../../translate/domain/plugin-distribution.js"; +import type { + PluginTranslationSkip, + ReadonlySkipList, +} from "../../../../translate/domain/plugin-translation-skip.js"; + +const HOOKS_MANIFEST_PATH = "hooks/hooks.json"; + +/** + * Delivers a plugin's hooks to the destination a `hooksDestination: "project"` capability names — + * merged into the project's own hooks file, scripts copied beside it — rather than into the + * plugin's own directory. Both materialization routes call it, so where a tool's hooks land is + * decided by its own declaration, never by which translator happened to run. + */ +export class ProjectHooksMaterializer { + constructor(private readonly fs: FileWriter & FileReader) {} + + async materialize( + dist: PluginDistribution, + toolId: AiToolId, + projectRoot: string + ): Promise { + const pluginsCap = resolvePluginsCapability(toolId); + if (pluginsCap === null || pluginsCap.hooksDestination !== "project") return []; + const projectHooksRelativePath = pluginsCap.projectHooksRelativePath; + if (projectHooksRelativePath === null) return []; + const manifestFile = dist.components.hooks.find((f) => f.relativePath === HOOKS_MANIFEST_PATH); + if (manifestFile === undefined) return []; + const warnings = await this.mergeProjectHooksJson( + dist, + manifestFile, + projectRoot, + projectHooksRelativePath + ); + await this.writeProjectHooksScripts(dist, projectRoot); + return warnings.map( + (reason): PluginTranslationSkip => ({ + pluginName: dist.manifest.name, + component: "hooks", + toolId, + reason, + }) + ); + } + + private async mergeProjectHooksJson( + dist: PluginDistribution, + manifestFile: PluginComponentFile, + projectRoot: string, + projectHooksRelativePath: string + ): Promise { + const destPath = join(projectRoot, projectHooksRelativePath); + const existing = await this.readExistingJson(destPath); + const { content, warnings } = mergeCursorProjectHooksJson( + existing, + manifestFile.content, + dist.manifest.name + ); + await this.fs.writeFile(destPath, content); + return warnings; + } + + private async writeProjectHooksScripts( + dist: PluginDistribution, + projectRoot: string + ): Promise { + for (const file of dist.components.hooks) { + if (file.relativePath === HOOKS_MANIFEST_PATH) continue; + const dest = cursorProjectHooksScriptPath(dist.manifest.name, file.relativePath); + await this.fs.writeFile(join(projectRoot, dest), file.content); + } + } + + private async readExistingJson(path: string): Promise { + try { + return await this.fs.readFile(path); + } catch (err) { + if ((err as NodeJS.ErrnoException).code === "ENOENT") return null; + throw err; + } + } +} + +/** A copy of `dist` with every `hooks/` file dropped, from `files` and from `components.hooks` + * alike — for a capability declaring `hooksDestination: "project"`, so none of its hooks are + * written under the plugin's own directory, only through `materialize`. */ +export function withoutHooks(dist: PluginDistribution): PluginDistribution { + return new PluginDistribution({ + manifest: dist.manifest, + format: dist.format, + files: dist.files.filter((f) => f.relativePath.split("/")[0] !== "hooks"), + components: { ...dist.components, hooks: [] }, + }); +} diff --git a/cli/src/contexts/framework/application/framework/translator/resolve-plugin-translator.ts b/cli/src/contexts/framework/application/framework/translator/resolve-plugin-translator.ts new file mode 100644 index 000000000..c2ee4fb05 --- /dev/null +++ b/cli/src/contexts/framework/application/framework/translator/resolve-plugin-translator.ts @@ -0,0 +1,15 @@ +import type { PluginsCapability } from "../../../../tools/domain/capabilities/plugins-capability.js"; +import { isAiTool, type ToolConfig } from "../../../../tools/domain/registry.js"; +import type { PluginTranslator } from "./plugin-translator.js"; +import { resolveTranslator, type TranslatorDeps } from "./plugin-translator-factory.js"; + +/** `null` when the tool is not an AI tool, or has no plugins capability. */ +export function resolvePluginTranslator( + toolConfig: ToolConfig, + deps: TranslatorDeps +): PluginTranslator | null { + if (!isAiTool(toolConfig)) return null; + const caps = toolConfig.capabilities as Record; + if (!("plugins" in caps)) return null; + return resolveTranslator(caps.plugins as PluginsCapability, deps); +} diff --git a/cli/src/application/use-cases/shared/gitignore-use-case.ts b/cli/src/contexts/framework/application/gitignore-use-case.ts similarity index 75% rename from cli/src/application/use-cases/shared/gitignore-use-case.ts rename to cli/src/contexts/framework/application/gitignore-use-case.ts index f1859f200..f7c2ff42e 100644 --- a/cli/src/application/use-cases/shared/gitignore-use-case.ts +++ b/cli/src/contexts/framework/application/gitignore-use-case.ts @@ -1,15 +1,14 @@ -import type { FileReader } from "../../../domain/ports/file-reader.js"; -import type { FileWriter } from "../../../domain/ports/file-writer.js"; +import type { FileReader } from "../../../kernel/ports/file-reader.js"; +import type { FileWriter } from "../../../kernel/ports/file-writer.js"; const GITIGNORE_FILENAME = ".gitignore"; export class GitignoreUseCase { constructor(private readonly fs: FileReader & FileWriter) {} - /** `true` when at least one of `entries` was newly appended — `false` when every one - * was already there and nothing was written. Callers that only care about the file - * ending up correct can discard the return value; one does care (`aidd telemetry on` - * only announces the journal being ignored the run it actually adds the line). */ + /** `true` when at least one of `entries` was newly appended, `false` when every one was already + * there. One caller depends on it: `aidd telemetry on` announces the journal being ignored only + * on the run that actually adds the line. */ async execute(projectRoot: string, entries: string[]): Promise { const gitignorePath = `${projectRoot}/${GITIGNORE_FILENAME}`; diff --git a/cli/src/contexts/framework/application/global/doctor-all-use-case.ts b/cli/src/contexts/framework/application/global/doctor-all-use-case.ts new file mode 100644 index 000000000..9fd6c9fef --- /dev/null +++ b/cli/src/contexts/framework/application/global/doctor-all-use-case.ts @@ -0,0 +1,51 @@ +import type { DoctorReport } from "../../domain/doctor.js"; +import type { DoctorUseCase } from "../doctor/doctor-use-case.js"; +import type { GlobalExecutionError } from "./update-one-tool-use-case.js"; + +export interface DoctorAllResult { + ai: DoctorReport | null; + ide: DoctorReport | null; + /** Plugin issues only. Plugins hang off AI tools, so the ai scope already carries them all. */ + pluginIssues: DoctorReport["pluginIssues"]; + healthy: boolean; + errors: GlobalExecutionError[]; +} + +export class DoctorAllUseCase { + constructor(private readonly doctorUseCase: DoctorUseCase) {} + + async execute(projectRoot: string, pluginName?: string): Promise { + const errors: GlobalExecutionError[] = []; + const ai = await this.runScope( + () => this.doctorUseCase.execute({ projectRoot, category: "ai", pluginName }), + "ai", + errors + ); + const ide = await this.runScope( + () => this.doctorUseCase.execute({ projectRoot, category: "ide", pluginName }), + "ide", + errors + ); + const healthy = errors.length === 0 && this.computeHealthy(ai, ide); + return { ai, ide, pluginIssues: ai?.pluginIssues ?? [], healthy, errors }; + } + + private async runScope( + fn: () => Promise, + scope: string, + errors: GlobalExecutionError[] + ): Promise { + try { + return await fn(); + } catch (err) { + errors.push({ scope, message: err instanceof Error ? err.message : String(err) }); + return null; + } + } + + // A scope that errored is reported by `errors`, never here: a null report means "could + // not be checked", not "checked and fine", so healthy must never read the two the same way. + private computeHealthy(ai: DoctorReport | null, ide: DoctorReport | null): boolean { + return (ai === null || ai.healthy) && (ide === null || ide.healthy); + } +} diff --git a/cli/src/contexts/framework/application/global/resolve-update-decision-use-case.ts b/cli/src/contexts/framework/application/global/resolve-update-decision-use-case.ts new file mode 100644 index 000000000..9434c6a24 --- /dev/null +++ b/cli/src/contexts/framework/application/global/resolve-update-decision-use-case.ts @@ -0,0 +1,61 @@ +import { InputRequiredError } from "../../../../kernel/errors.js"; +import type { Prompter } from "../../../../kernel/ports/prompter.js"; + +type BulkDecision = "overwrite-all" | "skip-all"; + +/** + * Created once per update run and passed to every per-tool call, so "overwrite all" / "skip all" + * persists across tools and files. + */ +export class BulkConflictState { + private decision: BulkDecision | null = null; + + get(): BulkDecision | null { + return this.decision; + } + + record(choice: BulkDecision): void { + this.decision = choice; + } +} + +export interface ResolveUpdateDecisionOptions { + relativePath: string; + userForce: boolean; + interactive: boolean; + bulkState: BulkConflictState; +} + +/** + * Returns true when the file should be overwritten, false when it should be kept; throws + * `InputRequiredError` for a non-interactive run without `--force`. Consulted only for a modified + * file — the caller handles an unmodified one. + */ +export class ResolveUpdateDecisionUseCase { + constructor(private readonly prompter: Prompter) {} + + async execute(options: ResolveUpdateDecisionOptions): Promise { + const { relativePath, userForce, interactive, bulkState } = options; + if (!userForce && !interactive) { + throw new InputRequiredError( + `Use --force to overwrite modified files in non-interactive mode.` + ); + } + if (userForce) return true; + return this.resolveInteractive(relativePath, bulkState); + } + + private async resolveInteractive( + relativePath: string, + bulkState: BulkConflictState + ): Promise { + const existing = bulkState.get(); + if (existing === "overwrite-all") return true; + if (existing === "skip-all") return false; + const decision = await this.prompter.resolveConflictBulk(relativePath, "modified"); + if (decision === "overwrite-all" || decision === "skip-all") { + bulkState.record(decision); + } + return decision === "overwrite" || decision === "overwrite-all"; + } +} diff --git a/cli/src/contexts/framework/application/global/restore-all-use-case.ts b/cli/src/contexts/framework/application/global/restore-all-use-case.ts new file mode 100644 index 000000000..b64833e77 --- /dev/null +++ b/cli/src/contexts/framework/application/global/restore-all-use-case.ts @@ -0,0 +1,134 @@ +import { NoManifestError } from "../../../../kernel/errors.js"; +import type { Prompter } from "../../../../kernel/ports/prompter.js"; +import type { AiToolId } from "../../../../kernel/tool.js"; +import type { Manifest } from "../../domain/manifest.js"; +import type { ManifestRepository } from "../../domain/ports/manifest-repository.js"; +import type { RestoreUseCase } from "../restore/restore-use-case.js"; +import type { StatusUseCase } from "../status-use-case.js"; +import type { GlobalExecutionError } from "./update-one-tool-use-case.js"; + +export interface RestoreAllResult { + totalRestored: number; + totalKept: number; + pluginNamesRestored: string[]; + errors: GlobalExecutionError[]; + unrestorable: string[]; + /** AI tools this run could not restore any plugin file for because their + * registration is native — the tool's own CLI owns it, not a file tree. */ + nativeOnlyToolIds: AiToolId[]; +} + +export class RestoreAllUseCase { + constructor( + private readonly manifestRepo: ManifestRepository, + private readonly prompter: Prompter, + private readonly statusUseCase: StatusUseCase, + private readonly restoreUseCase: RestoreUseCase + ) {} + + async execute( + projectRoot: string, + force: boolean, + interactive: boolean + ): Promise { + const errors: GlobalExecutionError[] = []; + const manifest = await this.manifestRepo.load(); + if (manifest === null) throw new NoManifestError(); + + const effectiveFiles = interactive ? await this.promptForFiles(projectRoot) : undefined; + const version = this.resolveVersion(manifest); + const restoreResult = await this.runConfigRestore( + projectRoot, + version, + effectiveFiles, + force, + interactive, + manifest, + errors + ); + + return { + totalRestored: restoreResult.totalRestored, + totalKept: restoreResult.totalKept, + pluginNamesRestored: restoreResult.restoredPluginNames, + errors, + unrestorable: restoreResult.unrestorable, + nativeOnlyToolIds: restoreResult.nativeOnlyToolIds, + }; + } + + private resolveVersion(manifest: Manifest): string { + return ( + manifest + .getInstalledToolIds() + .map((id) => manifest.getToolVersion(id)) + .find((v) => v !== undefined) ?? "unknown" + ); + } + + private async promptForFiles(projectRoot: string): Promise { + const report = await this.statusUseCase.execute({ projectRoot }); + const driftedFiles = report.tools.flatMap((t) => + t.drifted + .filter((d) => d.status === "modified" || d.status === "deleted") + .map((d) => d.relativePath) + ); + if (driftedFiles.length === 0) return []; + const selected = await this.prompter.checkbox( + "Select files to restore:", + driftedFiles.map((f) => ({ name: f, value: f })) + ); + return selected.length === 0 ? [] : selected; + } + + private async runConfigRestore( + projectRoot: string, + version: string, + files: string[] | undefined, + force: boolean, + interactive: boolean, + manifest: Awaited>, + errors: GlobalExecutionError[] + ): Promise<{ + totalRestored: number; + totalKept: number; + restoredPluginNames: string[]; + unrestorable: string[]; + nativeOnlyToolIds: AiToolId[]; + }> { + const empty = { + totalRestored: 0, + totalKept: 0, + restoredPluginNames: [], + unrestorable: [], + nativeOnlyToolIds: [], + }; + try { + if (manifest === null) return empty; + const result = await this.restoreUseCase.execute({ + version, + projectRoot, + files, + // Consent to overwrite a modified file comes from either the checkbox the interactive run + // already made the user answer, or `--force` when there is no TTY to ask. Neither is a reason + // to ask a second time. + force: force || interactive, + interactive, + manifest, + }); + return { + totalRestored: result.totalRestored, + totalKept: result.totalKept, + restoredPluginNames: result.restoredPluginNames, + unrestorable: result.unrestorable, + nativeOnlyToolIds: result.nativeOnlyToolIds, + }; + } catch (err) { + errors.push({ + scope: "config-restore", + message: err instanceof Error ? err.message : String(err), + }); + return empty; + } + } +} diff --git a/cli/src/application/use-cases/global/status-all-use-case.ts b/cli/src/contexts/framework/application/global/status-all-use-case.ts similarity index 95% rename from cli/src/application/use-cases/global/status-all-use-case.ts rename to cli/src/contexts/framework/application/global/status-all-use-case.ts index 116322260..8a2d1b3a8 100644 --- a/cli/src/application/use-cases/global/status-all-use-case.ts +++ b/cli/src/contexts/framework/application/global/status-all-use-case.ts @@ -1,6 +1,5 @@ import type { StatusQuery, StatusReport } from "../status-use-case.js"; -import type { GlobalExecutionError } from "./update-all-use-case.js"; - +import type { GlobalExecutionError } from "./update-one-tool-use-case.js"; export interface StatusAllResult { aiTools: StatusReport; ideTools: StatusReport; diff --git a/cli/src/contexts/framework/application/global/update-ai-tools-use-case.ts b/cli/src/contexts/framework/application/global/update-ai-tools-use-case.ts new file mode 100644 index 000000000..53c9bb276 --- /dev/null +++ b/cli/src/contexts/framework/application/global/update-ai-tools-use-case.ts @@ -0,0 +1,16 @@ +import type { VersionReader } from "../../../../kernel/ports/version-reader.js"; +import type { AiToolId } from "../../../../kernel/tool.js"; +import { isAiToolId } from "../../../../kernel/tool.js"; +import type { ManifestRepository } from "../../domain/ports/manifest-repository.js"; +import type { UpdateOneToolUseCase } from "./update-one-tool-use-case.js"; +import { UpdateToolsUseCase } from "./update-tools-use-case.js"; + +export class UpdateAiToolsUseCase extends UpdateToolsUseCase { + constructor( + manifestRepo: ManifestRepository, + versionReader: VersionReader, + updateOneToolUseCase: UpdateOneToolUseCase + ) { + super(manifestRepo, versionReader, updateOneToolUseCase, isAiToolId); + } +} diff --git a/cli/src/contexts/framework/application/global/update-ide-tools-use-case.ts b/cli/src/contexts/framework/application/global/update-ide-tools-use-case.ts new file mode 100644 index 000000000..b73f8baa0 --- /dev/null +++ b/cli/src/contexts/framework/application/global/update-ide-tools-use-case.ts @@ -0,0 +1,16 @@ +import type { VersionReader } from "../../../../kernel/ports/version-reader.js"; +import type { IdeToolId } from "../../../../kernel/tool.js"; +import { isIdeToolId } from "../../../tools/domain/registry.js"; +import type { ManifestRepository } from "../../domain/ports/manifest-repository.js"; +import type { UpdateOneToolUseCase } from "./update-one-tool-use-case.js"; +import { UpdateToolsUseCase } from "./update-tools-use-case.js"; + +export class UpdateIdeToolsUseCase extends UpdateToolsUseCase { + constructor( + manifestRepo: ManifestRepository, + versionReader: VersionReader, + updateOneToolUseCase: UpdateOneToolUseCase + ) { + super(manifestRepo, versionReader, updateOneToolUseCase, isIdeToolId); + } +} diff --git a/cli/src/application/use-cases/shared/update-one-tool-use-case.ts b/cli/src/contexts/framework/application/global/update-one-tool-use-case.ts similarity index 88% rename from cli/src/application/use-cases/shared/update-one-tool-use-case.ts rename to cli/src/contexts/framework/application/global/update-one-tool-use-case.ts index da583ce15..8c124a0e4 100644 --- a/cli/src/application/use-cases/shared/update-one-tool-use-case.ts +++ b/cli/src/contexts/framework/application/global/update-one-tool-use-case.ts @@ -1,13 +1,13 @@ import { join } from "node:path"; -import type { FileHash } from "../../../domain/models/file.js"; -import type { Manifest } from "../../../domain/models/manifest.js"; -import type { AiToolId, IdeToolId } from "../../../domain/models/tool-ids.js"; -import type { FileReader } from "../../../domain/ports/file-reader.js"; -import { getToolConfig, isAiTool, type ToolId } from "../../../domain/tools/registry.js"; -import { InputRequiredError } from "../../errors.js"; +import { InputRequiredError } from "../../../../kernel/errors.js"; +import type { FileHash } from "../../../../kernel/file.js"; +import type { FileReader } from "../../../../kernel/ports/file-reader.js"; +import type { AiToolId, IdeToolId, ToolId } from "../../../../kernel/tool.js"; +import type { SyncConflictResolverUseCase } from "../../../../presentation/prompts/sync-conflict-resolver-use-case.js"; +import { getToolConfig, isAiTool } from "../../../tools/domain/registry.js"; +import type { Manifest } from "../../domain/manifest.js"; import type { InstallIdeConfigUseCase } from "../install/install-ide-config-use-case.js"; import type { InstallRuntimeConfigUseCase } from "../install/install-runtime-config-use-case.js"; -import type { SyncConflictResolverUseCase } from "../sync/sync-conflict-resolver-use-case.js"; import type { BulkConflictState, ResolveUpdateDecisionUseCase, diff --git a/cli/src/contexts/framework/application/global/update-tools-use-case.ts b/cli/src/contexts/framework/application/global/update-tools-use-case.ts new file mode 100644 index 000000000..3511ff84f --- /dev/null +++ b/cli/src/contexts/framework/application/global/update-tools-use-case.ts @@ -0,0 +1,83 @@ +import type { VersionReader } from "../../../../kernel/ports/version-reader.js"; +import type { ToolId } from "../../../../kernel/tool.js"; +import { Manifest } from "../../domain/manifest.js"; +import type { ManifestRepository } from "../../domain/ports/manifest-repository.js"; +import { BulkConflictState } from "./resolve-update-decision-use-case.js"; +import type { GlobalExecutionError, UpdateOneToolUseCase } from "./update-one-tool-use-case.js"; + +export interface UpdateToolsInput { + toolArg?: T; + projectRoot: string; + userForce: boolean; + interactive: boolean; +} + +export interface UpdateToolsResult { + updatedTools: { toolId: ToolId; fileCount: number }[]; + errors: GlobalExecutionError[]; +} + +/** + * Fans out an update across every installed tool of one category, fixed by the `isTargetToolId` + * predicate injected at construction: the orchestration is identical for both categories. + */ +export class UpdateToolsUseCase { + constructor( + private readonly manifestRepo: ManifestRepository, + private readonly versionReader: VersionReader, + private readonly updateOneToolUseCase: UpdateOneToolUseCase, + private readonly isTargetToolId: (id: string) => id is T + ) {} + + async execute(input: UpdateToolsInput): Promise { + const { toolArg, projectRoot, userForce, interactive } = input; + const manifest = (await this.manifestRepo.load()) ?? Manifest.create(); + const targetIds = this.resolveTargetIds(manifest, toolArg); + const version = this.versionReader.get(); + const errors: GlobalExecutionError[] = []; + // Scoped to this invocation only — a fresh instance per `execute()` call, never a field, so an + // "overwrite all" choice cannot leak into a later, unrelated update run. + const bulkState = new BulkConflictState(); + const updatedTools = await this.updateTargets( + targetIds, + manifest, + projectRoot, + version, + errors, + { + userForce, + interactive, + bulkState, + } + ); + return { updatedTools, errors }; + } + + private resolveTargetIds(manifest: Manifest, toolArg: T | undefined): T[] { + if (toolArg !== undefined) return [toolArg]; + return manifest.getInstalledToolIds().filter(this.isTargetToolId); + } + + private async updateTargets( + targetIds: T[], + manifest: Manifest, + projectRoot: string, + version: string, + errors: GlobalExecutionError[], + options: { userForce: boolean; interactive: boolean; bulkState: BulkConflictState } + ): Promise<{ toolId: ToolId; fileCount: number }[]> { + const updated: { toolId: ToolId; fileCount: number }[] = []; + for (const toolId of targetIds) { + const entry = await this.updateOneToolUseCase.execute( + toolId, + manifest, + projectRoot, + version, + errors, + options + ); + if (entry) updated.push(entry); + } + return updated; + } +} diff --git a/cli/src/contexts/framework/application/init-use-case.ts b/cli/src/contexts/framework/application/init-use-case.ts new file mode 100644 index 000000000..dc166541d --- /dev/null +++ b/cli/src/contexts/framework/application/init-use-case.ts @@ -0,0 +1,79 @@ +import { + AiddFilesDetectedError, + AlreadyInitializedError, + NoManifestError, +} from "../../../kernel/errors.js"; +import { AIDD_DIR } from "../../../kernel/paths.js"; +import type { FileReader } from "../../../kernel/ports/file-reader.js"; +import type { FileWriter } from "../../../kernel/ports/file-writer.js"; +import { getAllRegisteredTools, hasToolSignals } from "../../tools/domain/registry.js"; +import { Manifest } from "../domain/manifest.js"; +import type { ManifestRepository } from "../domain/ports/manifest-repository.js"; +import { GitignoreUseCase } from "./gitignore-use-case.js"; + +interface InitOptions { + projectRoot: string; + force?: boolean; +} + +interface InitResult { + manifest: Manifest; +} + +export class InitUseCase { + constructor( + private readonly fs: FileReader & FileWriter, + private readonly manifestRepo: ManifestRepository + ) {} + + async checkPreconditions(options: Pick): Promise { + const { projectRoot, force = false } = options; + const existing = await this.manifestRepo.load(); + + if (force) { + if (existing === null) { + throw new NoManifestError(); + } + return; + } + + if (existing !== null) { + throw new AlreadyInitializedError( + `Already initialized. Run \`aidd clean --force\` then \`aidd setup\` to reset completely.` + ); + } + + if (await this.hasAiddSignals(projectRoot)) { + throw new AiddFilesDetectedError(); + } + } + + private async hasAiddSignals(projectRoot: string): Promise { + for (const tool of getAllRegisteredTools().values()) { + if ((await hasToolSignals(this.fs, tool, projectRoot)).length > 0) return true; + } + return false; + } + + async execute(options: InitOptions): Promise { + const { projectRoot, force = false } = options; + + const existing = await this.manifestRepo.load(); + await this.checkPreconditions({ projectRoot, force }); + + const manifest = force && existing !== null ? existing : Manifest.create(); + await this.persistInit(manifest, projectRoot, force); + return { manifest }; + } + + private async persistInit( + manifest: Manifest, + projectRoot: string, + force: boolean + ): Promise { + await this.manifestRepo.save(manifest); + if (!force) { + await new GitignoreUseCase(this.fs).execute(projectRoot, [`${AIDD_DIR}/cache/`]); + } + } +} diff --git a/cli/src/contexts/framework/application/install/content/install-agents-use-case.ts b/cli/src/contexts/framework/application/install/content/install-agents-use-case.ts new file mode 100644 index 000000000..ef87428d5 --- /dev/null +++ b/cli/src/contexts/framework/application/install/content/install-agents-use-case.ts @@ -0,0 +1,35 @@ +import type { InstallationFile } from "../../../../../kernel/file.js"; +import type { Hasher } from "../../../../../kernel/ports/hasher.js"; +import type { AgentsCapability } from "../../../../tools/domain/capabilities/agents-capability.js"; +import type { AiTool, HasAgents } from "../../../../tools/domain/contracts.js"; +import type { ContentSection } from "../../../../translate/domain/canon.js"; +import { + type ContentSectionDescriptor, + InstallContentSectionUseCase, +} from "./install-content-section-use-case.js"; + +const agentsDescriptor: ContentSectionDescriptor<"agents", AgentsCapability> = { + key: "agents", + acceptsFileName: (cap, fileName, allToolSuffixes) => + cap.acceptsFileName(fileName, allToolSuffixes), + convertFrontmatter: (cap, frontmatter, relativeFileName) => + cap.convertFrontmatter(frontmatter, relativeFileName), +}; + +interface InstallAgentsOptions { + toolConfig: AiTool; + section: ContentSection; + contentFiles: Map; +} + +export class InstallAgentsUseCase { + private readonly inner: InstallContentSectionUseCase<"agents", AgentsCapability>; + + constructor(hasher: Hasher) { + this.inner = new InstallContentSectionUseCase(hasher, agentsDescriptor); + } + + execute(options: InstallAgentsOptions): InstallationFile[] { + return this.inner.execute(options); + } +} diff --git a/cli/src/contexts/framework/application/install/content/install-commands-use-case.ts b/cli/src/contexts/framework/application/install/content/install-commands-use-case.ts new file mode 100644 index 000000000..2f921d055 --- /dev/null +++ b/cli/src/contexts/framework/application/install/content/install-commands-use-case.ts @@ -0,0 +1,34 @@ +import type { InstallationFile } from "../../../../../kernel/file.js"; +import type { Hasher } from "../../../../../kernel/ports/hasher.js"; +import type { CommandsCapability } from "../../../../tools/domain/capabilities/commands-capability.js"; +import type { AiTool, HasCommands } from "../../../../tools/domain/contracts.js"; +import type { ContentSection } from "../../../../translate/domain/canon.js"; +import { + type ContentSectionDescriptor, + InstallContentSectionUseCase, +} from "./install-content-section-use-case.js"; + +const commandsDescriptor: ContentSectionDescriptor<"commands", CommandsCapability> = { + key: "commands", + acceptsFileName: (cap, fileName) => cap.acceptsFileName(fileName), + convertFrontmatter: (cap, frontmatter, relativeFileName) => + cap.convertFrontmatter(frontmatter, relativeFileName), +}; + +interface InstallCommandsOptions { + toolConfig: AiTool; + section: ContentSection; + contentFiles: Map; +} + +export class InstallCommandsUseCase { + private readonly inner: InstallContentSectionUseCase<"commands", CommandsCapability>; + + constructor(hasher: Hasher) { + this.inner = new InstallContentSectionUseCase(hasher, commandsDescriptor); + } + + execute(options: InstallCommandsOptions): InstallationFile[] { + return this.inner.execute(options); + } +} diff --git a/cli/src/contexts/framework/application/install/content/install-content-section-use-case.ts b/cli/src/contexts/framework/application/install/content/install-content-section-use-case.ts new file mode 100644 index 000000000..5e5587972 --- /dev/null +++ b/cli/src/contexts/framework/application/install/content/install-content-section-use-case.ts @@ -0,0 +1,118 @@ +import { GITKEEP_FILE, InstallationFile } from "../../../../../kernel/file.js"; +import { parseFrontmatter } from "../../../../../kernel/markdown.js"; +import type { Hasher } from "../../../../../kernel/ports/hasher.js"; +import { AI_TOOL_IDS } from "../../../../../kernel/tool.js"; +import type { AiTool } from "../../../../tools/domain/contracts.js"; +import type { UserFileSection } from "../../../../tools/domain/formats/command.js"; +import type { ContentSection } from "../../../../translate/domain/canon.js"; + +const ALL_TOOL_SUFFIXES: readonly string[] = AI_TOOL_IDS.map((id) => `.${id}.md`); + +/** + * The shape every content-section capability exposes with identical arity, so this engine calls + * them without per-section branching. Where arity genuinely differs, a `ContentSectionDescriptor` + * supplies the per-section adapter instead. + */ +export interface ContentSectionCapability { + buildInstallPath(fileName: string): string | null; + serialize(frontmatter: Record, body: string): string; +} + +/** + * Per-section behaviour no uniform signature can express. `key` also drives which capability is + * read off `toolConfig.capabilities`, keeping K, Cap and the toolConfig type correlated through + * generics instead of an `as` cast at the call site. + */ +export interface ContentSectionDescriptor< + K extends UserFileSection, + Cap extends ContentSectionCapability, +> { + readonly key: K; + acceptsFileName(cap: Cap, fileName: string, allToolSuffixes: readonly string[]): boolean; + convertFrontmatter( + cap: Cap, + frontmatter: Record, + relativeFileName: string + ): Record; +} + +export interface InstallContentSectionOptions< + K extends UserFileSection, + Cap extends ContentSectionCapability, +> { + toolConfig: AiTool>; + section: ContentSection; + contentFiles: Map; +} + +export class InstallContentSectionUseCase< + K extends UserFileSection, + Cap extends ContentSectionCapability, +> { + constructor( + private readonly hasher: Hasher, + private readonly descriptor: ContentSectionDescriptor + ) {} + + execute(options: InstallContentSectionOptions): InstallationFile[] { + const { toolConfig, section, contentFiles } = options; + const cap = toolConfig.capabilities[this.descriptor.key]; + const results: InstallationFile[] = []; + for (const [filePath, rawContent] of contentFiles) { + const file = this.processFile(filePath, rawContent, section, cap, toolConfig); + if (file !== null) results.push(file); + } + return results; + } + + private processFile( + filePath: string, + rawContent: string, + section: ContentSection, + cap: Cap, + toolConfig: AiTool> + ): InstallationFile | null { + if (!filePath.startsWith(`${section.directory}/`)) return null; + const relativeFileName = filePath.slice(`${section.directory}/`.length); + if (!this.descriptor.acceptsFileName(cap, relativeFileName, ALL_TOOL_SUFFIXES)) return null; + if (section.entryFile !== null) { + const basename = relativeFileName.split("/").at(-1) ?? relativeFileName; + if (basename !== section.entryFile) return null; + } + const outputPath = cap.buildInstallPath(relativeFileName); + if (outputPath === null) return null; + if (relativeFileName.endsWith(GITKEEP_FILE)) { + return new InstallationFile({ + relativePath: outputPath, + content: "", + hash: this.hasher.hash(""), + frameworkPath: filePath, + }); + } + return this.buildFile(filePath, outputPath, relativeFileName, rawContent, cap, toolConfig); + } + + private buildFile( + filePath: string, + outputPath: string, + relativeFileName: string, + rawContent: string, + cap: Cap, + toolConfig: AiTool> + ): InstallationFile { + const rewrittenRaw = toolConfig.rewriteContent(rawContent); + const { frontmatter, body } = parseFrontmatter(rewrittenRaw); + const convertedFrontmatter = this.descriptor.convertFrontmatter( + cap, + frontmatter, + relativeFileName + ); + const outputContent = cap.serialize(convertedFrontmatter, body); + return new InstallationFile({ + relativePath: outputPath, + content: outputContent, + hash: this.hasher.hash(outputContent), + frameworkPath: filePath, + }); + } +} diff --git a/cli/src/contexts/framework/application/install/content/install-rules-use-case.ts b/cli/src/contexts/framework/application/install/content/install-rules-use-case.ts new file mode 100644 index 000000000..6299df7f3 --- /dev/null +++ b/cli/src/contexts/framework/application/install/content/install-rules-use-case.ts @@ -0,0 +1,33 @@ +import type { InstallationFile } from "../../../../../kernel/file.js"; +import type { Hasher } from "../../../../../kernel/ports/hasher.js"; +import type { RulesCapability } from "../../../../tools/domain/capabilities/rules-capability.js"; +import type { AiTool, HasRules } from "../../../../tools/domain/contracts.js"; +import type { ContentSection } from "../../../../translate/domain/canon.js"; +import { + type ContentSectionDescriptor, + InstallContentSectionUseCase, +} from "./install-content-section-use-case.js"; + +const rulesDescriptor: ContentSectionDescriptor<"rules", RulesCapability> = { + key: "rules", + acceptsFileName: (cap, fileName) => cap.acceptsFileName(fileName), + convertFrontmatter: (cap, frontmatter) => cap.convertFrontmatter(frontmatter), +}; + +interface InstallRulesOptions { + toolConfig: AiTool; + section: ContentSection; + contentFiles: Map; +} + +export class InstallRulesUseCase { + private readonly inner: InstallContentSectionUseCase<"rules", RulesCapability>; + + constructor(hasher: Hasher) { + this.inner = new InstallContentSectionUseCase(hasher, rulesDescriptor); + } + + execute(options: InstallRulesOptions): InstallationFile[] { + return this.inner.execute(options); + } +} diff --git a/cli/src/contexts/framework/application/install/content/install-skills-use-case.ts b/cli/src/contexts/framework/application/install/content/install-skills-use-case.ts new file mode 100644 index 000000000..87a0b1988 --- /dev/null +++ b/cli/src/contexts/framework/application/install/content/install-skills-use-case.ts @@ -0,0 +1,33 @@ +import type { InstallationFile } from "../../../../../kernel/file.js"; +import type { Hasher } from "../../../../../kernel/ports/hasher.js"; +import type { SkillsCapability } from "../../../../tools/domain/capabilities/skills-capability.js"; +import type { AiTool, HasSkills } from "../../../../tools/domain/contracts.js"; +import type { ContentSection } from "../../../../translate/domain/canon.js"; +import { + type ContentSectionDescriptor, + InstallContentSectionUseCase, +} from "./install-content-section-use-case.js"; + +const skillsDescriptor: ContentSectionDescriptor<"skills", SkillsCapability> = { + key: "skills", + acceptsFileName: (cap, fileName) => cap.acceptsFileName(fileName), + convertFrontmatter: (cap, frontmatter) => cap.convertFrontmatter(frontmatter), +}; + +interface InstallSkillsOptions { + toolConfig: AiTool; + section: ContentSection; + contentFiles: Map; +} + +export class InstallSkillsUseCase { + private readonly inner: InstallContentSectionUseCase<"skills", SkillsCapability>; + + constructor(hasher: Hasher) { + this.inner = new InstallContentSectionUseCase(hasher, skillsDescriptor); + } + + execute(options: InstallSkillsOptions): InstallationFile[] { + return this.inner.execute(options); + } +} diff --git a/cli/src/application/use-cases/install/install-ai-tool-use-case.ts b/cli/src/contexts/framework/application/install/install-ai-tool-use-case.ts similarity index 77% rename from cli/src/application/use-cases/install/install-ai-tool-use-case.ts rename to cli/src/contexts/framework/application/install/install-ai-tool-use-case.ts index ada7a9a8f..545b36e3e 100644 --- a/cli/src/application/use-cases/install/install-ai-tool-use-case.ts +++ b/cli/src/contexts/framework/application/install/install-ai-tool-use-case.ts @@ -1,9 +1,12 @@ -import { Manifest } from "../../../domain/models/manifest.js"; -import type { Plugin } from "../../../domain/models/plugin.js"; -import type { AiToolId } from "../../../domain/models/tool-ids.js"; -import type { Logger } from "../../../domain/ports/logger.js"; -import type { ManifestRepository } from "../../../domain/ports/manifest-repository.js"; -import type { MarketplaceSyncSettings } from "../marketplace/marketplace-sync-settings-use-case.js"; +import type { Logger } from "../../../../kernel/ports/logger.js"; +import type { AiToolId } from "../../../../kernel/tool.js"; +import { Manifest } from "../../domain/manifest.js"; +import type { InstalledPlugin } from "../../domain/plugins/installed-plugin.js"; +import type { ManifestRepository } from "../../domain/ports/manifest-repository.js"; +import type { + MarketplaceSyncSettings, + MarketplaceSyncSettingsResult, +} from "../flows/marketplace-sync-settings-use-case.js"; import type { PluginInstallFromMarketplace } from "../plugin/plugin-install-from-marketplace-use-case.js"; import type { InstallRuntimeConfigResult, @@ -22,6 +25,9 @@ export interface InstallAiToolResult { runtimeResult: InstallRuntimeConfigResult; propagatedPlugins: string[]; propagationWarnings: string[]; + /** What native activation did while re-registering the plugins this run propagated — + * `undefined` when nothing was propagated, so activation never ran. */ + activation?: MarketplaceSyncSettingsResult; } export class InstallAiToolUseCase { @@ -68,19 +74,25 @@ export class InstallAiToolUseCase { for (const plugin of plugins) { await this.propagatePlugin(plugin, toolId, projectRoot, propagated, warnings); } - if (propagated.length > 0) { - await this.marketplaceSyncSettings.execute({ projectRoot }); - } - return { runtimeResult, propagatedPlugins: propagated, propagationWarnings: warnings }; + const activation = + propagated.length > 0 + ? await this.marketplaceSyncSettings.execute({ projectRoot }) + : undefined; + return { + runtimeResult, + propagatedPlugins: propagated, + propagationWarnings: warnings, + activation, + }; } private collectUniquePlugins( allToolIds: AiToolId[], excludeToolId: AiToolId, - getPlugins: (id: AiToolId) => readonly Plugin[] - ): Plugin[] { + getPlugins: (id: AiToolId) => readonly InstalledPlugin[] + ): InstalledPlugin[] { const seen = new Set(); - const result: Plugin[] = []; + const result: InstalledPlugin[] = []; for (const id of allToolIds) { if (id === excludeToolId) continue; for (const plugin of getPlugins(id)) { @@ -94,7 +106,7 @@ export class InstallAiToolUseCase { } private async propagatePlugin( - plugin: Plugin, + plugin: InstalledPlugin, toolId: AiToolId, projectRoot: string, propagated: string[], diff --git a/cli/src/application/use-cases/install/install-config-use-case.ts b/cli/src/contexts/framework/application/install/install-config-use-case.ts similarity index 82% rename from cli/src/application/use-cases/install/install-config-use-case.ts rename to cli/src/contexts/framework/application/install/install-config-use-case.ts index 58ded6f07..da935fd19 100644 --- a/cli/src/application/use-cases/install/install-config-use-case.ts +++ b/cli/src/contexts/framework/application/install/install-config-use-case.ts @@ -1,16 +1,15 @@ -import { McpCapability } from "../../../domain/capabilities/mcp-capability.js"; -import { SettingsCapability } from "../../../domain/capabilities/settings-capability.js"; -import type { ConfigCapability } from "../../../domain/models/config-capability.js"; -import { InstallationFile } from "../../../domain/models/file.js"; -import type { ConfigRef } from "../../../domain/models/framework.js"; -import { CONFIG_MCP } from "../../../domain/models/framework.js"; -import { transformFor as transformMcpForPlatform } from "../../../domain/models/mcp-exclusion.js"; -import type { MergeStrategy } from "../../../domain/models/merge.js"; -import type { AiToolId } from "../../../domain/models/tool-ids.js"; -import type { AssetProvider } from "../../../domain/ports/asset-provider.js"; -import type { FileReader } from "../../../domain/ports/file-reader.js"; -import type { Hasher } from "../../../domain/ports/hasher.js"; -import type { Platform } from "../../../domain/ports/platform.js"; +import { InstallationFile } from "../../../../kernel/file.js"; +import type { MergeStrategy } from "../../../../kernel/merge.js"; +import type { AssetProvider } from "../../../../kernel/ports/asset-provider.js"; +import type { FileReader } from "../../../../kernel/ports/file-reader.js"; +import type { Hasher } from "../../../../kernel/ports/hasher.js"; +import type { AiToolId } from "../../../../kernel/tool.js"; +import type { Platform } from "../../../../runtime/platform/platform.js"; +import { CONFIG_MCP, type ConfigRef } from "../../../tools/domain/capabilities/config-refs.js"; +import { McpCapability } from "../../../tools/domain/capabilities/mcp-capability.js"; +import { SettingsCapability } from "../../../tools/domain/capabilities/settings-capability.js"; +import { transformFor as transformMcpForPlatform } from "../../../tools/domain/mcp-exclusion.js"; +import type { ConfigCapability } from "../../domain/config-capability.js"; interface InstallConfigOptions { capabilities: readonly ConfigCapability[]; diff --git a/cli/src/application/use-cases/install/install-ide-config-use-case.ts b/cli/src/contexts/framework/application/install/install-ide-config-use-case.ts similarity index 86% rename from cli/src/application/use-cases/install/install-ide-config-use-case.ts rename to cli/src/contexts/framework/application/install/install-ide-config-use-case.ts index 50f7f9eb3..a2f90799e 100644 --- a/cli/src/application/use-cases/install/install-ide-config-use-case.ts +++ b/cli/src/contexts/framework/application/install/install-ide-config-use-case.ts @@ -1,17 +1,17 @@ import { basename, join } from "node:path"; -import type { SettingsCapability } from "../../../domain/capabilities/settings-capability.js"; -import { InstallationFile } from "../../../domain/models/file.js"; -import type { Manifest } from "../../../domain/models/manifest.js"; -import { extractMergeEntries, type MergeFileEntry } from "../../../domain/models/merge.js"; -import type { IdeToolId } from "../../../domain/models/tool-ids.js"; -import type { AssetProvider } from "../../../domain/ports/asset-provider.js"; -import type { FileMerger } from "../../../domain/ports/file-merger.js"; -import type { FileReader } from "../../../domain/ports/file-reader.js"; -import type { FileWriter } from "../../../domain/ports/file-writer.js"; -import type { Hasher } from "../../../domain/ports/hasher.js"; -import type { Logger } from "../../../domain/ports/logger.js"; -import { getToolConfig } from "../../../domain/tools/registry.js"; -import type { PostInstallPipelineUseCase } from "../shared/post-install-pipeline-use-case.js"; +import { InstallationFile } from "../../../../kernel/file.js"; +import { extractMergeEntries, type MergeFileEntry } from "../../../../kernel/merge.js"; +import type { AssetProvider } from "../../../../kernel/ports/asset-provider.js"; +import type { FileReader } from "../../../../kernel/ports/file-reader.js"; +import type { FileWriter } from "../../../../kernel/ports/file-writer.js"; +import type { Hasher } from "../../../../kernel/ports/hasher.js"; +import type { Logger } from "../../../../kernel/ports/logger.js"; +import type { IdeToolId } from "../../../../kernel/tool.js"; +import type { SettingsCapability } from "../../../tools/domain/capabilities/settings-capability.js"; +import type { FileMerger } from "../../../tools/domain/ports/file-merger.js"; +import { getToolConfig } from "../../../tools/domain/registry.js"; +import type { Manifest } from "../../domain/manifest.js"; +import type { PostInstallPipelineUseCase } from "./post-install-pipeline-use-case.js"; export interface InstallIdeConfigOptions { toolId: IdeToolId; diff --git a/cli/src/application/use-cases/install/install-ide-tool-use-case.ts b/cli/src/contexts/framework/application/install/install-ide-tool-use-case.ts similarity index 79% rename from cli/src/application/use-cases/install/install-ide-tool-use-case.ts rename to cli/src/contexts/framework/application/install/install-ide-tool-use-case.ts index 9b236e54f..f094a75c2 100644 --- a/cli/src/application/use-cases/install/install-ide-tool-use-case.ts +++ b/cli/src/contexts/framework/application/install/install-ide-tool-use-case.ts @@ -1,21 +1,21 @@ import { join } from "node:path"; -import { SettingsCapability } from "../../../domain/capabilities/settings-capability.js"; -import type { Manifest } from "../../../domain/models/manifest.js"; -import { extractMergeEntries, type MergeFileEntry } from "../../../domain/models/merge.js"; -import type { AiToolId, IdeToolId } from "../../../domain/models/tool-ids.js"; -import { AI_TOOL_IDS } from "../../../domain/models/tool-ids.js"; -import type { AssetProvider } from "../../../domain/ports/asset-provider.js"; -import type { FileMerger } from "../../../domain/ports/file-merger.js"; -import type { FileReader } from "../../../domain/ports/file-reader.js"; -import type { FileWriter } from "../../../domain/ports/file-writer.js"; -import type { Hasher } from "../../../domain/ports/hasher.js"; -import type { ManifestRepository } from "../../../domain/ports/manifest-repository.js"; -import { getToolConfig, isAiTool } from "../../../domain/tools/registry.js"; -import type { PostInstallPipelineUseCase } from "../shared/post-install-pipeline-use-case.js"; +import { extractMergeEntries, type MergeFileEntry } from "../../../../kernel/merge.js"; +import type { AssetProvider } from "../../../../kernel/ports/asset-provider.js"; +import type { FileReader } from "../../../../kernel/ports/file-reader.js"; +import type { FileWriter } from "../../../../kernel/ports/file-writer.js"; +import type { Hasher } from "../../../../kernel/ports/hasher.js"; +import type { AiToolId, IdeToolId } from "../../../../kernel/tool.js"; +import { AI_TOOL_IDS } from "../../../../kernel/tool.js"; +import { SettingsCapability } from "../../../tools/domain/capabilities/settings-capability.js"; +import type { FileMerger } from "../../../tools/domain/ports/file-merger.js"; +import { getToolConfig, isAiTool } from "../../../tools/domain/registry.js"; +import type { Manifest } from "../../domain/manifest.js"; +import type { ManifestRepository } from "../../domain/ports/manifest-repository.js"; import type { InstallIdeConfigResult, InstallIdeConfigUseCase, } from "./install-ide-config-use-case.js"; +import type { PostInstallPipelineUseCase } from "./post-install-pipeline-use-case.js"; export interface InstallIdeToolOptions { toolId: IdeToolId; diff --git a/cli/src/application/use-cases/install/install-runtime-config-use-case.ts b/cli/src/contexts/framework/application/install/install-runtime-config-use-case.ts similarity index 88% rename from cli/src/application/use-cases/install/install-runtime-config-use-case.ts rename to cli/src/contexts/framework/application/install/install-runtime-config-use-case.ts index a4739db15..d67612fdf 100644 --- a/cli/src/application/use-cases/install/install-runtime-config-use-case.ts +++ b/cli/src/contexts/framework/application/install/install-runtime-config-use-case.ts @@ -1,17 +1,17 @@ import { join } from "node:path"; -import { SettingsCapability } from "../../../domain/capabilities/settings-capability.js"; -import { InstallationFile } from "../../../domain/models/file.js"; -import type { Manifest } from "../../../domain/models/manifest.js"; -import { extractMergeEntries, type MergeFileEntry } from "../../../domain/models/merge.js"; -import type { AiToolId } from "../../../domain/models/tool-ids.js"; -import type { AssetProvider } from "../../../domain/ports/asset-provider.js"; -import type { FileMerger } from "../../../domain/ports/file-merger.js"; -import type { FileReader } from "../../../domain/ports/file-reader.js"; -import type { FileWriter } from "../../../domain/ports/file-writer.js"; -import type { Hasher } from "../../../domain/ports/hasher.js"; -import type { Logger } from "../../../domain/ports/logger.js"; -import { getToolConfig, isAiTool } from "../../../domain/tools/registry.js"; -import type { PostInstallPipelineUseCase } from "../shared/post-install-pipeline-use-case.js"; +import { InstallationFile } from "../../../../kernel/file.js"; +import { extractMergeEntries, type MergeFileEntry } from "../../../../kernel/merge.js"; +import type { AssetProvider } from "../../../../kernel/ports/asset-provider.js"; +import type { FileReader } from "../../../../kernel/ports/file-reader.js"; +import type { FileWriter } from "../../../../kernel/ports/file-writer.js"; +import type { Hasher } from "../../../../kernel/ports/hasher.js"; +import type { Logger } from "../../../../kernel/ports/logger.js"; +import type { AiToolId } from "../../../../kernel/tool.js"; +import { SettingsCapability } from "../../../tools/domain/capabilities/settings-capability.js"; +import type { FileMerger } from "../../../tools/domain/ports/file-merger.js"; +import { getToolConfig, isAiTool } from "../../../tools/domain/registry.js"; +import type { Manifest } from "../../domain/manifest.js"; +import type { PostInstallPipelineUseCase } from "./post-install-pipeline-use-case.js"; export interface InstallRuntimeConfigOptions { toolId: AiToolId; diff --git a/cli/src/contexts/framework/application/install/post-install-pipeline-use-case.ts b/cli/src/contexts/framework/application/install/post-install-pipeline-use-case.ts new file mode 100644 index 000000000..34802e557 --- /dev/null +++ b/cli/src/contexts/framework/application/install/post-install-pipeline-use-case.ts @@ -0,0 +1,25 @@ +import type { Manifest } from "../../domain/manifest.js"; +import { aiddGitignoreEntries } from "../../domain/manifest-gitignore-entries.js"; +import type { ManifestRepository } from "../../domain/ports/manifest-repository.js"; +import type { GitignoreUseCase } from "../gitignore-use-case.js"; + +interface PostInstallPipelineOptions { + projectRoot: string; + manifest: Manifest; +} + +export class PostInstallPipelineUseCase { + constructor( + private readonly manifestRepo: ManifestRepository, + private readonly gitignoreUseCase: GitignoreUseCase + ) {} + + async execute(options: PostInstallPipelineOptions): Promise { + const { projectRoot, manifest } = options; + await this.manifestRepo.save(manifest); + // One call for everything this CLI's own writes require ignored: the plugin cache, the run + // journal (which belongs to the repository it describes, never to a commit), and each installed + // tool's machine-local file. + await this.gitignoreUseCase.execute(projectRoot, aiddGitignoreEntries(manifest)); + } +} diff --git a/cli/src/contexts/framework/application/list-installed-rules-use-case.ts b/cli/src/contexts/framework/application/list-installed-rules-use-case.ts new file mode 100644 index 000000000..7204e9818 --- /dev/null +++ b/cli/src/contexts/framework/application/list-installed-rules-use-case.ts @@ -0,0 +1,70 @@ +import { join } from "node:path"; +import { posixRelative } from "../../../kernel/paths.js"; +import type { FileReader } from "../../../kernel/ports/file-reader.js"; +import { AI_TOOL_IDS, type AiToolId } from "../../../kernel/tool.js"; +import { hasRules } from "../../tools/domain/contracts.js"; +import { getToolConfig, isAiTool } from "../../tools/domain/registry.js"; +import type { InstalledRule } from "../domain/installed-rule.js"; +import { toInstalledRule } from "../domain/installed-rule.js"; + +export interface ListInstalledRulesInput { + readonly projectRoot: string; +} + +export interface ListInstalledRulesResult { + readonly rules: readonly InstalledRule[]; +} + +/** Where this tool's installed rules live, asked of the tool. `undefined` for one that + * registers no rules capability at all, and for one whose installer answers no path — both + * mean there is nothing to scan, and neither is a directory guessed here. */ +function locationOf(toolId: AiToolId): { directory: string; extension: string } | undefined { + const tool = getToolConfig(toolId); + if (!isAiTool(tool) || !hasRules(tool)) return undefined; + return tool.capabilities.rules.installedLocation() ?? undefined; +} + +/** `/`-separated whatever the platform hands back, because the path is data a caller reads + * and compares, not a path it opens. A Windows checkout answering `.claude\rules\a.md` + * would make the same project's rules read differently on two machines. */ +function projectRelative(projectRoot: string, absolutePath: string): string { + return posixRelative(projectRoot, absolutePath); +} + +/** + * Every rule installed in a project, across every tool that installs any. Asking each tool where it + * installs is what makes a fifth tool, or a moved directory, impossible to miss. + */ +export class ListInstalledRulesUseCase { + constructor(private readonly files: FileReader) {} + + async execute(input: ListInstalledRulesInput): Promise { + const rules: InstalledRule[] = []; + for (const toolId of AI_TOOL_IDS) { + const location = locationOf(toolId); + if (location === undefined) continue; + rules.push(...(await this.rulesUnder(input.projectRoot, toolId, location))); + } + return { rules }; + } + + /** A directory that is not there yields nothing: `listFilesRecursive` answers an empty + * list for one it cannot read, so a project with a single tool installed is the ordinary + * case here and not a branch. */ + private async rulesUnder( + projectRoot: string, + toolId: AiToolId, + location: { directory: string; extension: string } + ): Promise { + const absolute = join(projectRoot, location.directory); + const found = await this.files.listFilesRecursive(absolute); + const rules: InstalledRule[] = []; + for (const file of found.filter((path) => path.endsWith(location.extension))) { + const content = await this.files.readFile(file); + rules.push( + toInstalledRule(toolId, projectRelative(projectRoot, file), location.extension, content) + ); + } + return rules; + } +} diff --git a/cli/src/application/use-cases/plugin/plugin-add-use-case.ts b/cli/src/contexts/framework/application/plugin/plugin-add-use-case.ts similarity index 82% rename from cli/src/application/use-cases/plugin/plugin-add-use-case.ts rename to cli/src/contexts/framework/application/plugin/plugin-add-use-case.ts index b09851436..a5ca4087a 100644 --- a/cli/src/application/use-cases/plugin/plugin-add-use-case.ts +++ b/cli/src/contexts/framework/application/plugin/plugin-add-use-case.ts @@ -4,31 +4,31 @@ import { DuplicatePluginError, MissingPluginMetadataError, VersionMismatchError, -} from "../../../domain/errors.js"; -import type { InstallationFile } from "../../../domain/models/file.js"; -import type { Manifest } from "../../../domain/models/manifest.js"; -import { DOCS_DIR, PLUGIN_CACHE_SUBDIR } from "../../../domain/models/paths.js"; -import { Plugin } from "../../../domain/models/plugin.js"; -import { PluginContentTranslator } from "../../../domain/models/plugin-content-translator.js"; -import type { PluginDistribution } from "../../../domain/models/plugin-distribution.js"; -import type { ReadonlyNoticeList } from "../../../domain/models/plugin-install-notice.js"; -import type { PluginSource } from "../../../domain/models/plugin-source.js"; -import type { ReadonlySkipList } from "../../../domain/models/plugin-translation-skip.js"; -import type { AiToolId } from "../../../domain/models/tool-ids.js"; -import type { FileReader } from "../../../domain/ports/file-reader.js"; -import type { FileWriter } from "../../../domain/ports/file-writer.js"; -import type { Hasher } from "../../../domain/ports/hasher.js"; -import type { Logger } from "../../../domain/ports/logger.js"; -import type { ManifestRepository } from "../../../domain/ports/manifest-repository.js"; -import type { MarketplaceRegistry } from "../../../domain/ports/marketplace-registry.js"; -import type { PluginDistributionReader } from "../../../domain/ports/plugin-distribution-reader.js"; -import type { PluginFetcher } from "../../../domain/ports/plugin-fetcher.js"; -import { getToolConfig, isAiTool } from "../../../domain/tools/registry.js"; +} from "../../../../kernel/errors.js"; +import type { InstallationFile } from "../../../../kernel/file.js"; +import { PLUGIN_CACHE_SUBDIR } from "../../../../kernel/paths.js"; +import type { FileReader } from "../../../../kernel/ports/file-reader.js"; +import type { FileWriter } from "../../../../kernel/ports/file-writer.js"; +import type { Hasher } from "../../../../kernel/ports/hasher.js"; +import type { Logger } from "../../../../kernel/ports/logger.js"; +import type { PluginSource } from "../../../../kernel/source.js"; +import type { AiToolId } from "../../../../kernel/tool.js"; +import type { MarketplaceRegistry } from "../../../distribution/domain/ports/marketplace-registry.js"; +import type { PluginFetcher } from "../../../distribution/domain/ports/plugin-fetcher.js"; +import type { ReadonlyNoticeList } from "../../../tools/domain/models/plugin-install-notice.js"; +import { getToolConfig, isAiTool } from "../../../tools/domain/registry.js"; +import { PluginContentTranslator } from "../../../translate/domain/content-translator.js"; +import type { PluginDistribution } from "../../../translate/domain/plugin-distribution.js"; +import type { ReadonlySkipList } from "../../../translate/domain/plugin-translation-skip.js"; +import type { Manifest } from "../../domain/manifest.js"; +import { InstalledPlugin } from "../../domain/plugins/installed-plugin.js"; +import type { ManifestRepository } from "../../domain/ports/manifest-repository.js"; +import type { PluginDistributionReader } from "../../domain/ports/plugin-distribution-reader.js"; +import type { PluginTranslator } from "../framework/translator/plugin-translator.js"; +import { resolvePluginTranslator } from "../framework/translator/resolve-plugin-translator.js"; import type { EnsureBuiltMarketplace } from "../shared/ensure-built-marketplace-use-case.js"; -import { loadPluginManifest, writePluginFiles } from "./plugin-file-sync.js"; -import { resolvePluginToolIds } from "./plugin-target-resolution.js"; -import type { PluginTranslator } from "./translator/plugin-translator.js"; -import { resolvePluginTranslator } from "./translator/resolve-plugin-translator.js"; +import { loadPluginManifest, writePluginFiles } from "./plugin-helpers.js"; +import { resolvePluginToolIds, resolveScopeForInstall } from "./plugin-target-resolution.js"; export interface PluginAddOptions { source: PluginSource; @@ -42,7 +42,6 @@ export interface PluginAddOptions { replace?: boolean; } -/** Adding a plugin to the tools that host it, as its callers need it. */ export interface PluginAdd { execute(options: PluginAddOptions): Promise; } @@ -120,11 +119,12 @@ export class PluginAddUseCase implements PluginAdd { for (const toolId of toolIds) { manifest.addPlugin( toolId, - Plugin.fromMetadata( + InstalledPlugin.fromMetadata( pluginMetadata.name, version, source, pluginMetadata.strict, + resolveScopeForInstall(toolId), marketplace ) ); @@ -158,8 +158,9 @@ export class PluginAddUseCase implements PluginAdd { source: PluginSource, projectRoot: string ): Promise { - const { marketplace, requiredVersion, replace } = options; - const dist = await this.readDistribution(source, projectRoot); + const { marketplace, requiredVersion, replace, pluginMetadata } = options; + const read = await this.readDistribution(source, projectRoot); + const dist = pluginMetadata === undefined ? read : read.withStrict(pluginMetadata.strict); const pluginName = dist.manifest.name; this.assertPluginVersionMatches(pluginName, dist.manifest.version, requiredVersion); const { prevMcpMap } = this.prepareForInstall(pluginName, resolvedToolIds, manifest, replace); @@ -209,7 +210,6 @@ export class PluginAddUseCase implements PluginAdd { projectRoot, manifest, marketplace, - DOCS_DIR, prev ); allSkipped.push(skipped); @@ -264,7 +264,6 @@ export class PluginAddUseCase implements PluginAdd { projectRoot: string, manifest: Manifest, marketplace: string | undefined, - docsDir: string, previousMcpEntries: ReadonlyMap = new Map() ): Promise<{ skipped: ReadonlySkipList; notices: ReadonlyNoticeList }> { const toolConfig = getToolConfig(toolId); @@ -278,15 +277,13 @@ export class PluginAddUseCase implements PluginAdd { projectRoot, manifest, marketplace, - docsDir, previousMcpEntries ); return { ...result, notices: [] }; } const translated = new PluginContentTranslator(this.hasher).translateWithComponentPaths( dist, - toolConfig, - docsDir + toolConfig ); return this.materializeNativePlugin( dist, @@ -295,7 +292,6 @@ export class PluginAddUseCase implements PluginAdd { projectRoot, manifest, marketplace, - docsDir, adapter, translated ); @@ -311,7 +307,6 @@ export class PluginAddUseCase implements PluginAdd { projectRoot: string, manifest: Manifest, marketplace: string | undefined, - docsDir: string, adapter: PluginTranslator | null, translated: { files: InstallationFile[]; @@ -329,15 +324,21 @@ export class PluginAddUseCase implements PluginAdd { source, projectRoot, manifest, - marketplace, - docsDir + marketplace ); return { ...result, notices }; } await writePluginFiles(files, projectRoot, this.fs); manifest.addPlugin( toolId, - Plugin.fromDistribution(dist, source, files, componentPaths, marketplace) + InstalledPlugin.fromDistribution( + dist, + source, + files, + resolveScopeForInstall(toolId), + componentPaths, + marketplace + ) ); return { skipped, notices }; } diff --git a/cli/src/contexts/framework/application/plugin/plugin-helpers.ts b/cli/src/contexts/framework/application/plugin/plugin-helpers.ts new file mode 100644 index 000000000..6add4784a --- /dev/null +++ b/cli/src/contexts/framework/application/plugin/plugin-helpers.ts @@ -0,0 +1,106 @@ +import { homedir as nodeHomedir } from "node:os"; +import { dirname, join } from "node:path"; +import { NoManifestError } from "../../../../kernel/errors.js"; +import type { InstallationFile } from "../../../../kernel/file.js"; +import type { FileReader } from "../../../../kernel/ports/file-reader.js"; +import type { FileWriter } from "../../../../kernel/ports/file-writer.js"; +import type { Hasher } from "../../../../kernel/ports/hasher.js"; +import type { AiToolId } from "../../../../kernel/tool.js"; +import type { PluginDistribution } from "../../../translate/domain/plugin-distribution.js"; +import type { Manifest } from "../../domain/manifest.js"; +import type { InstalledPlugin, PluginScope } from "../../domain/plugins/installed-plugin.js"; +import type { ManifestRepository } from "../../domain/ports/manifest-repository.js"; +import type { PluginTranslator } from "../framework/translator/plugin-translator.js"; +import { resolveBaseDirFromRecord } from "./plugin-target-resolution.js"; + +export async function loadPluginManifest(manifestRepo: ManifestRepository): Promise { + const manifest = await manifestRepo.load(); + if (manifest === null) throw new NoManifestError(); + return manifest; +} + +export async function writePluginFiles( + files: InstallationFile[], + baseDir: string, + fs: FileWriter +): Promise { + await Promise.all(files.map((f) => fs.writeFile(join(baseDir, f.relativePath), f.content))); +} + +/** Deletes exactly the paths a plugin's own manifest entry lists, joined to its base dir. + * Never enumerates the directory or deletes by pattern — only manifest-tracked keys. */ +export async function deleteOldFiles( + files: ReadonlyMap, + baseDir: string, + fs: FileWriter +): Promise { + for (const relativePath of files.keys()) { + await fs.deleteFile(join(baseDir, relativePath)); + } +} + +/** + * Deletes a plugin's tracked files from the base directory its manifest entry actually recorded — + * `projectRoot` for a project-scope entry, the resolved user-scope plugins dir otherwise. Every + * plugin-file removal path must resolve the base dir this way, or a user-scope tool's files are + * never removed and a path under `projectRoot` that was never written is asked to delete nothing. + */ +export async function deletePluginFilesForTool( + files: ReadonlyMap, + scope: PluginScope, + toolId: AiToolId, + projectRoot: string, + fs: FileWriter +): Promise { + const baseDir = resolveBaseDirFromRecord(scope, toolId, projectRoot, nodeHomedir); + const deleted: string[] = []; + for (const relativePath of files.keys()) { + const fullPath = join(baseDir, relativePath); + await fs.deleteFile(fullPath); + await fs.deleteEmptyDirectories(dirname(fullPath)); + deleted.push(relativePath); + } + return deleted; +} + +/** Whether the file already on disk matches the content we would write, so a caller can skip the + * write and, more importantly, not count it as restored. */ +export async function isPluginFileAtDesiredState( + fs: FileReader, + hasher: Hasher, + outputPath: string, + expectedHashValue: string +): Promise { + if (!(await fs.fileExists(outputPath))) return false; + const content = await fs.readFile(outputPath); + return hasher.hash(content).value === expectedHashValue; +} + +/** + * Re-registers a marketplace-sourced plugin through its resolved translator: drops the existing + * manifest entry and lets the translator re-add it, so update and restore both end with the same + * single entry an install would have produced. + * + * Returns how many files the translator actually (re)wrote — not the plugin's total file count — so + * a no-op restore reports zero. `written` is undefined for a translator that writes no files, and + * is reported as 0 rather than guessed. + */ +export async function materializeViaTranslator( + translator: PluginTranslator, + dist: PluginDistribution, + toolId: AiToolId, + plugin: InstalledPlugin, + projectRoot: string, + manifest: Manifest +): Promise { + manifest.removePlugin(toolId, plugin.name); + const { written } = await translator.addPlugin( + dist, + toolId, + plugin.source, + projectRoot, + manifest, + plugin.marketplace + ); + return written ?? 0; +} diff --git a/cli/src/application/use-cases/plugin/plugin-install-from-marketplace-use-case.ts b/cli/src/contexts/framework/application/plugin/plugin-install-from-marketplace-use-case.ts similarity index 84% rename from cli/src/application/use-cases/plugin/plugin-install-from-marketplace-use-case.ts rename to cli/src/contexts/framework/application/plugin/plugin-install-from-marketplace-use-case.ts index 009382b05..8a1e40389 100644 --- a/cli/src/application/use-cases/plugin/plugin-install-from-marketplace-use-case.ts +++ b/cli/src/contexts/framework/application/plugin/plugin-install-from-marketplace-use-case.ts @@ -2,20 +2,20 @@ import { AmbiguousPluginMatchError, PluginNotInMarketplaceError, VersionMismatchError, -} from "../../../domain/errors.js"; -import type { Marketplace } from "../../../domain/models/marketplace.js"; -import type { PluginCatalogEntry } from "../../../domain/models/plugin-catalog.js"; -import { resolvePluginSourceFromMarketplace } from "../../../domain/models/plugin-source-resolver.js"; +} from "../../../../kernel/errors.js"; +import type { Logger } from "../../../../kernel/ports/logger.js"; +import type { Prompter } from "../../../../kernel/ports/prompter.js"; +import type { AiToolId } from "../../../../kernel/tool.js"; +import type { ResolveMarketplaceUseCase } from "../../../distribution/application/resolve-marketplace-use-case.js"; +import type { PluginCatalogEntry } from "../../../distribution/domain/catalog.js"; +import type { Marketplace } from "../../../distribution/domain/marketplace.js"; +import type { MarketplaceRegistry } from "../../../distribution/domain/ports/marketplace-registry.js"; +import { resolvePluginSourceFromMarketplace } from "../../domain/plugins/plugin-source-resolver.js"; import { DEFAULT_REQUESTED_VERSION_POLICY, type RequestedVersionPolicy, -} from "../../../domain/models/requested-version-policy.js"; -import type { AiToolId } from "../../../domain/models/tool-ids.js"; -import type { Logger } from "../../../domain/ports/logger.js"; -import type { MarketplaceRegistry } from "../../../domain/ports/marketplace-registry.js"; -import type { Prompter } from "../../../domain/ports/prompter.js"; -import type { ResolveMarketplaceUseCase } from "../shared/resolve-marketplace-use-case.js"; -import type { PluginAddUseCase } from "./plugin-add-use-case.js"; +} from "../../domain/plugins/requested-version-policy.js"; +import type { PluginAdd } from "./plugin-add-use-case.js"; export interface PluginInstallFromMarketplaceOptions { pluginName: string; @@ -41,7 +41,6 @@ interface MatchEntry { localPath: string; } -/** Installing a plugin named in a marketplace catalog, as its callers need it. */ export interface PluginInstallFromMarketplace { execute( options: PluginInstallFromMarketplaceOptions @@ -52,7 +51,7 @@ export class PluginInstallFromMarketplaceUseCase implements PluginInstallFromMar constructor( private readonly resolveMarketplace: ResolveMarketplaceUseCase, private readonly registry: MarketplaceRegistry, - private readonly pluginAddUseCase: PluginAddUseCase, + private readonly pluginAddUseCase: PluginAdd, private readonly prompter: Prompter, private readonly logger?: Logger ) {} diff --git a/cli/src/application/use-cases/plugin/plugin-install-use-case.ts b/cli/src/contexts/framework/application/plugin/plugin-install-use-case.ts similarity index 83% rename from cli/src/application/use-cases/plugin/plugin-install-use-case.ts rename to cli/src/contexts/framework/application/plugin/plugin-install-use-case.ts index 3e71abbd9..0791d9da7 100644 --- a/cli/src/application/use-cases/plugin/plugin-install-use-case.ts +++ b/cli/src/contexts/framework/application/plugin/plugin-install-use-case.ts @@ -1,22 +1,20 @@ import { isAbsolute } from "node:path"; -import { InteractiveOnlyError, TrustDeniedError } from "../../../domain/errors.js"; -import { - assertToolSupportsScope, - type InstallScope, -} from "../../../domain/models/install-scope.js"; -import { parsePluginSpec } from "../../../domain/models/plugin.js"; +import { InteractiveOnlyError, TrustDeniedError } from "../../../../kernel/errors.js"; +import type { Prompter } from "../../../../kernel/ports/prompter.js"; import { describePluginSource, type PluginSource, parsePluginSourceShorthand, -} from "../../../domain/models/plugin-source.js"; -import { AI_TOOL_IDS, type AiToolId } from "../../../domain/models/tool-ids.js"; -import type { ManifestRepository } from "../../../domain/ports/manifest-repository.js"; -import type { MarketplaceTrustStore } from "../../../domain/ports/marketplace-trust-store.js"; -import type { Prompter } from "../../../domain/ports/prompter.js"; +} from "../../../../kernel/source.js"; +import { AI_TOOL_IDS, type AiToolId } from "../../../../kernel/tool.js"; +import type { PluginPick } from "../../../../presentation/prompts/plugin-pick-use-case.js"; +import type { MarketplaceTrustStore } from "../../../distribution/domain/ports/marketplace-trust-store.js"; +import { assertToolSupportsScope, type InstallScope } from "../../domain/install-scope.js"; +import { parsePluginSpec } from "../../domain/plugins/installed-plugin.js"; +import type { Environment } from "../../domain/ports/environment.js"; +import type { ManifestRepository } from "../../domain/ports/manifest-repository.js"; import type { PluginAdd } from "./plugin-add-use-case.js"; import type { PluginInstallFromMarketplace } from "./plugin-install-from-marketplace-use-case.js"; -import type { PluginPick } from "./plugin-pick-use-case.js"; export interface PluginInstallOptions { pluginArg: string | undefined; @@ -41,7 +39,8 @@ export class PluginInstallUseCase { private readonly pluginInstallFromMarketplaceUseCase: PluginInstallFromMarketplace, private readonly manifestRepo: ManifestRepository, private readonly trustStore: MarketplaceTrustStore, - private readonly prompter: Prompter + private readonly prompter: Prompter, + private readonly environment: Environment ) {} async execute(options: PluginInstallOptions): Promise { @@ -110,7 +109,7 @@ export class PluginInstallUseCase { private async executeMarketplace(options: PluginInstallOptions): Promise { const { name, version } = parsePluginSpec(options.pluginArg as string); - if (options.token) process.env.AIDD_TOKEN = options.token; + if (options.token) this.environment.set("AIDD_TOKEN", options.token); const result = await this.pluginInstallFromMarketplaceUseCase.execute({ pluginName: name, version, diff --git a/cli/src/contexts/framework/application/plugin/plugin-list-use-case.ts b/cli/src/contexts/framework/application/plugin/plugin-list-use-case.ts new file mode 100644 index 000000000..ca0365094 --- /dev/null +++ b/cli/src/contexts/framework/application/plugin/plugin-list-use-case.ts @@ -0,0 +1,30 @@ +import type { AiToolId } from "../../../../kernel/tool.js"; +import type { Manifest } from "../../domain/manifest.js"; +import type { InstalledPlugin } from "../../domain/plugins/installed-plugin.js"; +import type { ManifestRepository } from "../../domain/ports/manifest-repository.js"; +import { loadPluginManifest } from "./plugin-helpers.js"; +import { resolvePluginToolIds } from "./plugin-target-resolution.js"; + +export interface PluginListOptions { + toolIds: AiToolId[] | "all"; +} + +export type PluginListResult = Map; + +export class PluginListUseCase { + constructor(private readonly manifestRepo: ManifestRepository) {} + + async execute(options: PluginListOptions): Promise { + const manifest = await loadPluginManifest(this.manifestRepo); + const resolvedToolIds = resolvePluginToolIds(options.toolIds, manifest); + return this.buildResult(resolvedToolIds, manifest); + } + + private buildResult(toolIds: AiToolId[], manifest: Manifest): PluginListResult { + const result: PluginListResult = new Map(); + for (const toolId of toolIds) { + result.set(toolId, manifest.getPlugins(toolId)); + } + return result; + } +} diff --git a/cli/src/contexts/framework/application/plugin/plugin-remove-use-case.ts b/cli/src/contexts/framework/application/plugin/plugin-remove-use-case.ts new file mode 100644 index 000000000..ae055e6dd --- /dev/null +++ b/cli/src/contexts/framework/application/plugin/plugin-remove-use-case.ts @@ -0,0 +1,332 @@ +import { homedir as nodeHomedir } from "node:os"; +import { dirname, join } from "node:path"; +import { NativePluginCliError, PluginNotFoundError } from "../../../../kernel/errors.js"; +import type { FileReader } from "../../../../kernel/ports/file-reader.js"; +import type { FileWriter } from "../../../../kernel/ports/file-writer.js"; +import type { Logger } from "../../../../kernel/ports/logger.js"; +import { resolveHomeDir } from "../../../../kernel/reading/home-dir.js"; +import type { MarketplaceScope } from "../../../../kernel/scope.js"; +import type { AiToolId } from "../../../../kernel/tool.js"; +import type { MarketplaceRegistry } from "../../../distribution/domain/ports/marketplace-registry.js"; +import type { McpCapability } from "../../../tools/domain/capabilities/mcp-capability.js"; +import { unmergeOpencodeMcp } from "../../../tools/domain/formats/opencode-mcp-merge.js"; +import type { HostPluginRegistryReader } from "../../../tools/domain/ports/host-plugin-registry-reader.js"; +import type { NativePluginActivator } from "../../../tools/domain/ports/native-plugin-activator.js"; +import { + getToolConfig, + isAiTool, + nativeActivationOf, + pluginEnablementIsMachineGlobal, + resolvePluginsCapability, +} from "../../../tools/domain/registry.js"; +import type { NativeRegistrations } from "../../domain/manifest/native-registrations.js"; +import type { Manifest } from "../../domain/manifest.js"; +import type { InstalledPlugin } from "../../domain/plugins/installed-plugin.js"; +import type { ManifestRepository } from "../../domain/ports/manifest-repository.js"; +import type { UserSourceReferences } from "../../domain/ports/user-source-references.js"; +import { resolveCacheCandidate } from "../shared/purge-declared-cache.js"; +import { removeProjectHooks } from "../shared/remove-project-hooks.js"; +import { resolveUninstallScopeOrder } from "../shared/resolve-uninstall-scope.js"; +import { + describeGuardedPluginRefMessage, + frameworkSourceIsShared, + otherProjectsReferencing, + refAnotherProjectStillNeeds, + resolveProjectRootForReferences, + toleratingUnreadableSourceReferences, +} from "../shared/shared-source-reference-support.js"; +import { loadPluginManifest } from "./plugin-helpers.js"; +import { + isFrameworkPrimeFlatMcp, + resolveBaseDirFromRecord, + resolvePluginToolIds, +} from "./plugin-target-resolution.js"; + +export interface PluginRemoveOptions { + pluginName: string; + toolIds: AiToolId[] | "all"; + projectRoot: string; +} + +export class PluginRemoveUseCase { + constructor( + private readonly fs: FileWriter & FileReader, + private readonly manifestRepo: ManifestRepository, + private readonly logger: Logger, + /** Native plugin CLI activators keyed by `NativeActivation.binary`. */ + private readonly activators: ReadonlyMap, + /** Host plugin registry readers keyed by `AiToolId`, consulted before uninstalling a ref so + * the scope asked for is the one the host actually registered it at. Absent falls back to the + * manifest's own recorded scope. */ + private readonly hostPluginRegistries: ReadonlyMap< + AiToolId, + HostPluginRegistryReader + > = new Map(), + /** The registry of projects referencing the shared machine-scope source, needed for the + * guard: uninstalling a ref a host enables machine-wide (codex, copilot) would disable it for + * another project on this machine too. Absent skips the guard entirely. */ + private readonly userSourceReferences?: UserSourceReferences, + /** Resolves the scope this project's own registry recorded for `plugin.marketplace` — the one + * fact `frameworkSourceIsShared` needs and a plugin record does not carry. Absent treats every + * marketplace as not shared. */ + private readonly marketplaceRegistry?: MarketplaceRegistry + ) {} + + async execute(options: PluginRemoveOptions): Promise { + const { pluginName, toolIds, projectRoot } = options; + const manifest = await loadPluginManifest(this.manifestRepo); + const resolvedToolIds = resolvePluginToolIds(toolIds, manifest); + const removed = await this.removeFromTools(pluginName, resolvedToolIds, projectRoot, manifest); + if (!removed) throw new PluginNotFoundError(pluginName); + await this.manifestRepo.save(manifest); + } + + private async removeFromTools( + pluginName: string, + toolIds: AiToolId[], + projectRoot: string, + manifest: Manifest + ): Promise { + let removed = false; + for (const toolId of toolIds) { + const plugins = manifest.getPlugins(toolId); + const plugin = plugins.find((p) => p.name === pluginName); + if (plugin === undefined) continue; + const baseDir = resolveBaseDirFromRecord(plugin.scope, toolId, projectRoot, nodeHomedir); + const confirmed = await this.removeNativeActivation(plugin, toolId, projectRoot, manifest); + if (confirmed !== undefined) + await this.purgeCachedPlugin(manifest, toolId, plugin, confirmed); + await this.deletePluginFiles(plugin.files, baseDir); + await this.removeMcpEntries(plugin, toolId, projectRoot); + await removeProjectHooks(this.fs, pluginName, toolId, projectRoot); + manifest.removePlugin(toolId, pluginName); + removed = true; + } + return removed; + } + + // A tool declaring `nativeActivation` (Claude, Codex, Copilot) only loads a plugin once its own + // CLI registered it in a user-global registry install never wrote to directly, so removal drives + // that same CLI rather than editing the registry file. A plugin with no recorded marketplace was + // never activated this way either, so there is nothing to undo. Best-effort: a host that cannot + // be reached warns by name with what is left behind, never fails the whole removal silently. + // + // Returns `undefined` when there was nothing to undo at all, so `purgeCachedPlugin` has nothing + // to gate on either; `true` or `false` otherwise, whether the host's own CLI confirmed it. + private async removeNativeActivation( + plugin: InstalledPlugin, + toolId: AiToolId, + projectRoot: string, + manifest: Manifest + ): Promise { + const nativeActivation = resolvePluginsCapability(toolId)?.nativeActivation; + if (nativeActivation == null || plugin.marketplace === undefined) return undefined; + const activator = this.activators.get(nativeActivation.binary); + if (activator === undefined) return undefined; + const alias = plugin.marketplace; + const registrations = manifest.getNativeRegistrations(toolId); + const registeredHostName = this.hostNameFor(registrations, alias); + if (registeredHostName === undefined && registrations !== undefined) { + this.logger.warn( + `${toolId}: this tool's own native registrations name no entry for '${alias}' — uninstalling '${plugin.name}@${alias}' by that alias rather than the host's own name for it.` + ); + } + const hostName = registeredHostName ?? alias; + const ref = `${plugin.name}@${hostName}`; + const guardMessage = await this.describeGuardedPluginRef( + nativeActivation.binary, + toolId, + ref, + alias, + hostName, + projectRoot + ); + if (guardMessage !== undefined) { + this.logger.warn(guardMessage); + return undefined; + } + return this.uninstallViaActivator( + activator, + nativeActivation.binary, + ref, + toolId, + plugin.scope, + projectRoot + ); + } + + /** The host's own name for `alias`, found in an already-read `NativeRegistrations`. Takes the + * registrations rather than reading them itself: `removeNativeActivation` needs that same read to + * tell "no native registrations at all" apart from "registered, but not under this alias". + * `alias` is aidd's own key into this project's registry, never what a host learns; `undefined` + * when `registrations` is absent, or names no entry for `alias`. */ + private hostNameFor( + registrations: NativeRegistrations | undefined, + alias: string + ): string | undefined { + return registrations?.marketplaces.find((m) => m.alias === alias)?.hostName; + } + + /** + * A ref enabled through the shared, machine-scope source at a host that enables a plugin + * machine-wide (no `scopeArgs` — codex, copilot) must survive a `plugin remove` in one project + * while another project on this machine still references that source: uninstalling it here would + * disable it there too. + * + * `plugin remove` never decrements `references.json` the way `clean` does, so this project's own + * root is still in what `listAllReferencingProjects` returns and is subtracted by hand — + * otherwise a project holding the *only* reference would read itself back as "another project". + * + * `ref` carries the host's own name for the marketplace, never `plugin.marketplace` alone, which + * a host never learns; `marketplaceAlias` stays the key this project's own registry is read by. + * Both sides must move together, or a ref moved to `hostName` while this parameter kept the + * alias would silently stop guarding anything. + */ + private async describeGuardedPluginRef( + binary: string, + toolId: AiToolId, + ref: string, + marketplaceAlias: string, + hostName: string, + projectRoot: string + ): Promise { + if (this.userSourceReferences === undefined) return undefined; + const marketplaces = (await this.marketplaceRegistry?.list(projectRoot)) ?? []; + const marketplace = marketplaces.find((m) => m.name === marketplaceAlias); + if ( + marketplace === undefined || + !frameworkSourceIsShared(marketplace.name, marketplace.scope) + ) { + return undefined; + } + const userSourceReferences = this.userSourceReferences; + const otherProjects = await toleratingUnreadableSourceReferences( + this.logger, + [] as readonly string[], + async () => { + const ownRoot = await resolveProjectRootForReferences(this.fs, projectRoot); + return otherProjectsReferencing(userSourceReferences, ownRoot); + } + ); + const guarded = refAnotherProjectStillNeeds({ + ref, + sharedSourceHostName: hostName, + enablementIsMachineGlobal: pluginEnablementIsMachineGlobal(toolId), + otherProjects, + }); + if (!guarded) return undefined; + return describeGuardedPluginRefMessage({ binary, ref, otherProjects }); + } + + /** + * Tries every scope `resolveUninstallScopeOrder` names, in order, stopping at the first the + * host's own CLI accepts — a real `claude` binary refuses a mismatched-scope uninstall outright, + * so a manifest whose recorded scope disagrees with what was registered gets a corrective + * attempt rather than silently leaving the entry behind. + */ + private async uninstallViaActivator( + activator: NativePluginActivator, + binary: string, + ref: string, + toolId: AiToolId, + manifestScope: MarketplaceScope, + projectRoot: string + ): Promise { + if (!activator.isAvailable()) { + this.logger.warn( + `${binary} CLI not found on PATH — '${ref}' was not uninstalled from ${binary}'s own plugin registry and may still be enabled there.` + ); + return false; + } + const reader = this.hostPluginRegistries.get(toolId); + const order = await resolveUninstallScopeOrder(reader, ref, projectRoot, manifestScope); + let lastMessage = ""; + for (const scope of order) { + try { + activator.uninstallPlugin(ref, scope); + return true; + } catch (error) { + if (!(error instanceof NativePluginCliError)) throw error; + lastMessage = error.message; + } + } + this.logger.warn( + `${binary} plugin uninstall '${ref}' failed: ${lastMessage} — an entry for it may remain in ${binary}'s own plugin registry.` + ); + return false; + } + + /** + * `cache///` under the same declared-root-plus-`realpath` containment + * whitelist `clean`'s own marketplace-level purge shares, but never gated on emptiness the way + * that one is: this directory holds exactly the content the host is being asked to forget, not a + * leftover shell another project's install could still hold. `hostName` comes from this tool's + * own `NativeRegistrations`, never the alias, which a host never learns. + */ + private async purgeCachedPlugin( + manifest: Manifest, + toolId: AiToolId, + plugin: InstalledPlugin, + confirmed: boolean + ): Promise { + if (plugin.marketplace === undefined) return; + const cacheRoot = nativeActivationOf(toolId)?.pluginCacheDir?.(resolveHomeDir()); + if (cacheRoot === undefined) return; + const hostName = this.hostNameFor(manifest.getNativeRegistrations(toolId), plugin.marketplace); + if (hostName === undefined) return; + const label = `${toolId}: cache for '${plugin.name}'`; + const candidate = await resolveCacheCandidate( + this.fs, + this.logger, + cacheRoot, + join(hostName, plugin.name), + label + ); + if (candidate === null) return; + if (!confirmed) { + this.logger.warn(`${label} left in place, its own removal was not confirmed: ${candidate}`); + return; + } + await this.fs.deleteDirectory(candidate); + this.logger.info(`${label} purged: ${candidate}`); + } + + private async removeMcpEntries( + plugin: InstalledPlugin, + toolId: AiToolId, + projectRoot: string + ): Promise { + if (plugin.mcpEntries.size === 0) return; + const toolConfig = getToolConfig(toolId); + if (!isAiTool(toolConfig)) return; + const caps = toolConfig.capabilities as Record; + if (!isFrameworkPrimeFlatMcp(caps)) return; + const mcpCap = caps.mcp as McpCapability; + const outputRelPath = await mcpCap.resolveOutput(projectRoot, this.fs); + const outputPath = join(projectRoot, outputRelPath); + const existing = await this.readExistingJson(outputPath); + if (existing === null) return; + const updated = unmergeOpencodeMcp(existing, plugin.mcpEntries); + await this.fs.writeFile(outputPath, updated); + } + + private async readExistingJson(path: string): Promise { + try { + return await this.fs.readFile(path); + } catch (err) { + if ((err as NodeJS.ErrnoException).code === "ENOENT") return null; + throw err; + } + } + + private async deletePluginFiles( + files: ReadonlyMap, + baseDir: string + ): Promise { + for (const relativePath of files.keys()) { + const fullPath = join(baseDir, relativePath); + await this.fs.deleteFile(fullPath); + await this.fs.deleteEmptyDirectories(dirname(fullPath)); + } + } +} diff --git a/cli/src/application/use-cases/plugin/plugin-search-use-case.ts b/cli/src/contexts/framework/application/plugin/plugin-search-use-case.ts similarity index 81% rename from cli/src/application/use-cases/plugin/plugin-search-use-case.ts rename to cli/src/contexts/framework/application/plugin/plugin-search-use-case.ts index 0f04c7feb..2e545c3ab 100644 --- a/cli/src/application/use-cases/plugin/plugin-search-use-case.ts +++ b/cli/src/contexts/framework/application/plugin/plugin-search-use-case.ts @@ -1,7 +1,7 @@ -import type { Marketplace } from "../../../domain/models/marketplace.js"; -import type { PluginCatalogEntry } from "../../../domain/models/plugin-catalog.js"; -import type { MarketplaceRegistry } from "../../../domain/ports/marketplace-registry.js"; -import type { ResolveMarketplaceUseCase } from "../shared/resolve-marketplace-use-case.js"; +import type { ResolveMarketplaceUseCase } from "../../../distribution/application/resolve-marketplace-use-case.js"; +import type { PluginCatalogEntry } from "../../../distribution/domain/catalog.js"; +import type { Marketplace } from "../../../distribution/domain/marketplace.js"; +import type { MarketplaceRegistry } from "../../../distribution/domain/ports/marketplace-registry.js"; export interface PluginSearchOptions { query: string; diff --git a/cli/src/contexts/framework/application/plugin/plugin-target-resolution.ts b/cli/src/contexts/framework/application/plugin/plugin-target-resolution.ts new file mode 100644 index 000000000..d4ac585be --- /dev/null +++ b/cli/src/contexts/framework/application/plugin/plugin-target-resolution.ts @@ -0,0 +1,46 @@ +import { UnresolvableUserScopeError } from "../../../../kernel/errors.js"; +import type { AiToolId } from "../../../../kernel/tool.js"; +import { AI_TOOL_IDS } from "../../../../kernel/tool.js"; +import { McpCapability } from "../../../tools/domain/capabilities/mcp-capability.js"; +import type { PluginsCapability } from "../../../tools/domain/capabilities/plugins-capability.js"; +import { resolvePluginsCapability } from "../../../tools/domain/registry.js"; +import { getToolSupportedScope } from "../../domain/install-scope.js"; +import type { Manifest } from "../../domain/manifest.js"; +import type { PluginScope } from "../../domain/plugins/installed-plugin.js"; + +export function resolvePluginToolIds(toolIds: AiToolId[] | "all", manifest: Manifest): AiToolId[] { + if (toolIds !== "all") return toolIds; + return AI_TOOL_IDS.filter((id) => manifest.hasTool(id)) as AiToolId[]; +} + +/** The scope a fresh install writes to the manifest, read once from the tool's own profile. + * Nothing else calls this: every later command reads the scope the manifest already recorded, + * which the profile can disagree with. */ +export function resolveScopeForInstall(toolId: AiToolId): PluginScope { + return getToolSupportedScope(toolId); +} + +/** The base directory a plugin's `files` are relative to, from the manifest's own recorded + * `scope` — never from the tool's current profile. Throws rather than falling back to `projectRoot` + * for a `"user"` scope the profile no longer explains: a silent fallback would resolve a + * suppression, a deletion or a drift check against the wrong directory. */ +export function resolveBaseDirFromRecord( + scope: PluginScope, + toolId: AiToolId, + projectRoot: string, + homedir: () => string +): string { + if (scope === "project") return projectRoot; + const dir = resolvePluginsCapability(toolId)?.userPluginsBaseDir(homedir()); + if (dir === null || dir === undefined) throw new UnresolvableUserScopeError(toolId); + return dir; +} + +export function isFrameworkPrimeFlatMcp(caps: Record): boolean { + if (!("mcp" in caps)) return false; + const mcp = caps.mcp; + if (!(mcp instanceof McpCapability)) return false; + if (mcp.params.mergeStrategy !== "framework-prime") return false; + const plugins = caps.plugins as PluginsCapability; + return plugins.mode === "flat"; +} diff --git a/cli/src/contexts/framework/application/plugin/plugin-update-use-case.ts b/cli/src/contexts/framework/application/plugin/plugin-update-use-case.ts new file mode 100644 index 000000000..9a79e9eeb --- /dev/null +++ b/cli/src/contexts/framework/application/plugin/plugin-update-use-case.ts @@ -0,0 +1,137 @@ +import { homedir as nodeHomedir } from "node:os"; +import { join } from "node:path"; +import { PLUGIN_CACHE_SUBDIR } from "../../../../kernel/paths.js"; +import type { FileReader } from "../../../../kernel/ports/file-reader.js"; +import type { FileWriter } from "../../../../kernel/ports/file-writer.js"; +import type { Hasher } from "../../../../kernel/ports/hasher.js"; +import { compareSemver } from "../../../../kernel/semver.js"; +import type { AiToolId } from "../../../../kernel/tool.js"; +import type { PluginFetcher } from "../../../distribution/domain/ports/plugin-fetcher.js"; +import { getToolConfig, type ToolConfig } from "../../../tools/domain/registry.js"; +import { PluginContentTranslator } from "../../../translate/domain/content-translator.js"; +import type { PluginDistribution } from "../../../translate/domain/plugin-distribution.js"; +import type { Manifest } from "../../domain/manifest.js"; +import { InstalledPlugin } from "../../domain/plugins/installed-plugin.js"; +import type { ManifestRepository } from "../../domain/ports/manifest-repository.js"; +import type { PluginDistributionReader } from "../../domain/ports/plugin-distribution-reader.js"; +import type { PluginTranslator } from "../framework/translator/plugin-translator.js"; +import { resolvePluginTranslator } from "../framework/translator/resolve-plugin-translator.js"; +import type { BuiltMaterializationDeps } from "../shared/apply-plugin-files-use-case.js"; +import { + deleteOldFiles, + loadPluginManifest, + materializeViaTranslator, + writePluginFiles, +} from "./plugin-helpers.js"; +import { resolveBaseDirFromRecord, resolvePluginToolIds } from "./plugin-target-resolution.js"; + +export interface PluginUpdateOptions { + pluginNames?: string[]; + toolIds: AiToolId[] | "all"; + projectRoot: string; +} + +export class PluginUpdateUseCase { + constructor( + private readonly fs: FileReader & FileWriter, + private readonly manifestRepo: ManifestRepository, + private readonly pluginFetcher: PluginFetcher, + private readonly pluginDistributionReader: PluginDistributionReader, + private readonly hasher: Hasher, + private readonly builtDeps?: BuiltMaterializationDeps + ) {} + + async execute(options: PluginUpdateOptions): Promise { + const { pluginNames, toolIds, projectRoot } = options; + const manifest = await loadPluginManifest(this.manifestRepo); + const resolvedToolIds = resolvePluginToolIds(toolIds, manifest); + const cacheDir = join(projectRoot, PLUGIN_CACHE_SUBDIR); + const updated: string[] = []; + for (const toolId of resolvedToolIds) { + const names = await this.updatePluginsForTool( + toolId, + pluginNames, + projectRoot, + cacheDir, + manifest + ); + updated.push(...names); + } + await this.manifestRepo.save(manifest); + return updated; + } + + private async updatePluginsForTool( + toolId: AiToolId, + pluginNames: string[] | undefined, + projectRoot: string, + cacheDir: string, + manifest: Manifest + ): Promise { + const plugins = manifest.getPlugins(toolId); + const targets = pluginNames + ? plugins.filter((p) => pluginNames.includes(p.name)) + : [...plugins]; + const updated: string[] = []; + for (const plugin of targets) { + const didUpdate = await this.updateOnePlugin(plugin, toolId, projectRoot, cacheDir, manifest); + if (didUpdate) updated.push(plugin.name); + } + return updated; + } + + private async updateOnePlugin( + plugin: InstalledPlugin, + toolId: AiToolId, + projectRoot: string, + cacheDir: string, + manifest: Manifest + ): Promise { + const localPath = await this.pluginFetcher.fetch(plugin.source, cacheDir, { + forceRefresh: true, + }); + const dist = await this.pluginDistributionReader.read(localPath); + if (compareSemver(dist.manifest.version, plugin.version) <= 0) return false; + await this.replacePluginFiles(plugin, dist, toolId, projectRoot, manifest); + return true; + } + + private async replacePluginFiles( + plugin: InstalledPlugin, + dist: PluginDistribution, + toolId: AiToolId, + projectRoot: string, + manifest: Manifest + ): Promise { + const baseDir = resolveBaseDirFromRecord(plugin.scope, toolId, projectRoot, nodeHomedir); + await deleteOldFiles(plugin.files, baseDir, this.fs); + const toolConfig = getToolConfig(toolId); + const translator = this.resolveTranslator(toolConfig); + if (translator !== null && plugin.marketplace !== undefined) { + await materializeViaTranslator(translator, dist, toolId, plugin, projectRoot, manifest); + return; + } + const { files: newFiles, componentPaths } = new PluginContentTranslator( + this.hasher + ).translateWithComponentPaths(dist, toolConfig); + await writePluginFiles(newFiles, baseDir, this.fs); + manifest.updatePlugin( + toolId, + InstalledPlugin.fromDistribution(dist, plugin.source, newFiles, plugin.scope, componentPaths) + ); + } + + // Materializing tools (cursor/opencode) re-materialize from the BUILT tree, and Mode A + // marketplace tools (claude/codex/copilot) re-register without writing files, so an + // update matches whatever install would have done for that tool. + private resolveTranslator(toolConfig: ToolConfig): PluginTranslator | null { + if (this.builtDeps === undefined) return null; + return resolvePluginTranslator(toolConfig, { + fs: this.fs, + hasher: this.hasher, + homedir: this.builtDeps.homedir, + ensureBuilt: this.builtDeps.ensureBuilt, + marketplaceRegistry: this.builtDeps.marketplaceRegistry, + }); + } +} diff --git a/cli/src/application/use-cases/shared/generate-tool-distribution-use-case.ts b/cli/src/contexts/framework/application/restore/generate-tool-distribution-use-case.ts similarity index 77% rename from cli/src/application/use-cases/shared/generate-tool-distribution-use-case.ts rename to cli/src/contexts/framework/application/restore/generate-tool-distribution-use-case.ts index 25e5cbe63..2c6ac83c6 100644 --- a/cli/src/application/use-cases/shared/generate-tool-distribution-use-case.ts +++ b/cli/src/contexts/framework/application/restore/generate-tool-distribution-use-case.ts @@ -1,30 +1,29 @@ -import { extractConfigCapabilities } from "../../../domain/models/config-capability.js"; -import { InstallationFile, removeRedundantGitkeeps } from "../../../domain/models/file.js"; -import type { ContentSection, FrameworkDescriptor } from "../../../domain/models/framework.js"; -import type { AiToolId } from "../../../domain/models/tool-ids.js"; -import type { AssetProvider } from "../../../domain/ports/asset-provider.js"; -import type { FileReader } from "../../../domain/ports/file-reader.js"; -import type { Hasher } from "../../../domain/ports/hasher.js"; -import type { Platform } from "../../../domain/ports/platform.js"; +import { InstallationFile, removeRedundantGitkeeps } from "../../../../kernel/file.js"; +import type { AssetProvider } from "../../../../kernel/ports/asset-provider.js"; +import type { FileReader } from "../../../../kernel/ports/file-reader.js"; +import type { Hasher } from "../../../../kernel/ports/hasher.js"; +import type { AiToolId } from "../../../../kernel/tool.js"; +import type { Platform } from "../../../../runtime/platform/platform.js"; import type { AiTool, HasAgents, HasCommands, HasRules, HasSkills, -} from "../../../domain/tools/contracts.js"; -import { isAiTool, type ToolConfig } from "../../../domain/tools/registry.js"; -import { InstallAgentsUseCase } from "../install/install-agents-use-case.js"; -import { InstallCommandsUseCase } from "../install/install-commands-use-case.js"; +} from "../../../tools/domain/contracts.js"; +import { isAiTool, type ToolConfig } from "../../../tools/domain/registry.js"; +import type { ContentSection, FrameworkDescriptor } from "../../../translate/domain/canon.js"; +import { extractConfigCapabilities } from "../../domain/config-capability.js"; +import { InstallAgentsUseCase } from "../install/content/install-agents-use-case.js"; +import { InstallCommandsUseCase } from "../install/content/install-commands-use-case.js"; +import { InstallRulesUseCase } from "../install/content/install-rules-use-case.js"; +import { InstallSkillsUseCase } from "../install/content/install-skills-use-case.js"; import { InstallConfigUseCase } from "../install/install-config-use-case.js"; -import { InstallRulesUseCase } from "../install/install-rules-use-case.js"; -import { InstallSkillsUseCase } from "../install/install-skills-use-case.js"; interface GenerateToolDistributionOptions { config: ToolConfig; descriptor: FrameworkDescriptor; contentFiles: Map; - docsDir: string; projectRoot: string; } @@ -37,11 +36,11 @@ export class GenerateToolDistributionUseCase { ) {} async execute(options: GenerateToolDistributionOptions): Promise { - const { config, descriptor, contentFiles, docsDir, projectRoot } = options; + const { config, descriptor, contentFiles, projectRoot } = options; if (!isAiTool(config)) { return this.generateIdeToolFiles(config, descriptor, contentFiles, projectRoot); } - return this.generateAiToolFiles(config, descriptor, contentFiles, docsDir, projectRoot); + return this.generateAiToolFiles(config, descriptor, contentFiles, projectRoot); } private async generateIdeToolFiles( @@ -64,7 +63,6 @@ export class GenerateToolDistributionUseCase { config: AiTool, descriptor: FrameworkDescriptor, contentFiles: Map, - docsDir: string, projectRoot: string ): Promise { const caps = config.capabilities as Record; @@ -72,8 +70,7 @@ export class GenerateToolDistributionUseCase { caps, config, descriptor, - contentFiles, - docsDir + contentFiles ); const configFiles = await new InstallConfigUseCase(this.fs, this.hasher).execute({ capabilities: extractConfigCapabilities(config), @@ -111,13 +108,12 @@ export class GenerateToolDistributionUseCase { caps: Record, config: AiTool, descriptor: FrameworkDescriptor, - contentFiles: Map, - docsDir: string + contentFiles: Map ): InstallationFile[] { const results: InstallationFile[] = []; for (const section of descriptor.contentSections) { if (!(section.name in caps)) continue; - results.push(...this.generateSectionFiles(config, section, contentFiles, docsDir)); + results.push(...this.generateSectionFiles(config, section, contentFiles)); } return results; } @@ -125,10 +121,9 @@ export class GenerateToolDistributionUseCase { private generateSectionFiles( config: AiTool, section: ContentSection, - contentFiles: Map, - docsDir: string + contentFiles: Map ): InstallationFile[] { - const base = { section, contentFiles, docsDir }; + const base = { section, contentFiles }; switch (section.name) { case "agents": return new InstallAgentsUseCase(this.hasher).execute({ diff --git a/cli/src/application/use-cases/shared/resolve-restore-decision.ts b/cli/src/contexts/framework/application/restore/resolve-restore-decision.ts similarity index 87% rename from cli/src/application/use-cases/shared/resolve-restore-decision.ts rename to cli/src/contexts/framework/application/restore/resolve-restore-decision.ts index b2b1a74ac..3dbdc1b1a 100644 --- a/cli/src/application/use-cases/shared/resolve-restore-decision.ts +++ b/cli/src/contexts/framework/application/restore/resolve-restore-decision.ts @@ -1,5 +1,5 @@ -import type { Prompter } from "../../../domain/ports/prompter.js"; -import { InputRequiredError } from "../../errors.js"; +import { InputRequiredError } from "../../../../kernel/errors.js"; +import type { Prompter } from "../../../../kernel/ports/prompter.js"; interface ResolveRestoreDecisionOptions { relativePath: string; diff --git a/cli/src/contexts/framework/application/restore/restore-all-plugins-use-case.ts b/cli/src/contexts/framework/application/restore/restore-all-plugins-use-case.ts new file mode 100644 index 000000000..9b59e57ad --- /dev/null +++ b/cli/src/contexts/framework/application/restore/restore-all-plugins-use-case.ts @@ -0,0 +1,127 @@ +import { join } from "node:path"; +import { PLUGIN_CACHE_SUBDIR } from "../../../../kernel/paths.js"; +import type { FileReader } from "../../../../kernel/ports/file-reader.js"; +import type { FileWriter } from "../../../../kernel/ports/file-writer.js"; +import type { Hasher } from "../../../../kernel/ports/hasher.js"; +import type { AiToolId, ToolId } from "../../../../kernel/tool.js"; +import { AI_TOOL_IDS } from "../../../../kernel/tool.js"; +import type { PluginFetcher } from "../../../distribution/domain/ports/plugin-fetcher.js"; +import { + getToolConfig, + isAiTool, + nativeActivationOf, + type ToolConfig, +} from "../../../tools/domain/registry.js"; +import type { Manifest } from "../../domain/manifest.js"; +import type { InstalledPlugin } from "../../domain/plugins/installed-plugin.js"; +import type { PluginDistributionReader } from "../../domain/ports/plugin-distribution-reader.js"; +import { + ApplyPluginFilesUseCase, + type BuiltMaterializationDeps, +} from "../shared/apply-plugin-files-use-case.js"; + +interface RestoreAllPluginsOptions { + projectRoot: string; + manifest: Manifest; + fileFilter: ((p: string) => boolean) | null; + pluginName?: string; + /** Restrict which AI tools' plugins get touched. Undefined means every installed AI tool (unscoped). */ + toolIds?: readonly ToolId[]; +} + +export interface RestoreAllPluginsResult { + totalFiles: number; + /** Names of plugins that had >=1 file actually restored, deduped across tools. */ + pluginNames: string[]; + /** AI tools with an installed plugin this pass could not restore anything for, + * because that tool's own CLI owns the registration and this CLI tracks zero + * files for it — not a failure, a fact about who owns the file tree. */ + nativeOnlyToolIds: AiToolId[]; +} + +export class RestoreAllPluginsUseCase { + constructor( + private readonly fs: FileReader & FileWriter, + private readonly hasher: Hasher, + private readonly pluginFetcher: PluginFetcher, + private readonly pluginDistributionReader: PluginDistributionReader, + private readonly builtDeps?: BuiltMaterializationDeps + ) {} + + async execute(options: RestoreAllPluginsOptions): Promise { + const { projectRoot, manifest, fileFilter, pluginName, toolIds } = options; + const cacheDir = join(projectRoot, PLUGIN_CACHE_SUBDIR); + let totalFiles = 0; + const restoredNames = new Set(); + const nativeOnlyToolIds: AiToolId[] = []; + for (const toolId of AI_TOOL_IDS) { + if (!manifest.hasTool(toolId)) continue; + if (toolIds !== undefined && !toolIds.includes(toolId)) continue; + const toolConfig = getToolConfig(toolId); + if (!isAiTool(toolConfig)) continue; + const targets = this.targetPlugins(toolId, manifest, pluginName); + if (targets.length > 0 && this.everyTargetUntracked(targets) && nativeActivationOf(toolId)) { + nativeOnlyToolIds.push(toolId); + } + const result = await this.restoreToolPlugins( + toolId, + manifest, + toolConfig, + projectRoot, + cacheDir, + fileFilter, + pluginName + ); + totalFiles += result.totalFiles; + for (const name of result.pluginNames) restoredNames.add(name); + } + return { totalFiles, pluginNames: [...restoredNames], nativeOnlyToolIds }; + } + + private targetPlugins( + toolId: (typeof AI_TOOL_IDS)[number], + manifest: Manifest, + pluginName: string | undefined + ): readonly InstalledPlugin[] { + const plugins = manifest.getPlugins(toolId); + return pluginName !== undefined ? plugins.filter((p) => p.name === pluginName) : plugins; + } + + private everyTargetUntracked(targets: readonly InstalledPlugin[]): boolean { + return targets.every((plugin) => plugin.files.size === 0); + } + + private async restoreToolPlugins( + toolId: (typeof AI_TOOL_IDS)[number], + manifest: Manifest, + toolConfig: ToolConfig, + projectRoot: string, + cacheDir: string, + fileFilter: ((p: string) => boolean) | null, + pluginName: string | undefined + ): Promise<{ totalFiles: number; pluginNames: string[] }> { + let totalFiles = 0; + const pluginNames: string[] = []; + const targets = this.targetPlugins(toolId, manifest, pluginName); + for (const plugin of targets) { + const filesWritten = await new ApplyPluginFilesUseCase( + this.fs, + this.hasher, + this.pluginFetcher, + this.pluginDistributionReader, + this.builtDeps + ).execute({ + toolId, + plugin, + toolConfig, + projectRoot, + cacheDir, + manifest, + fileFilter, + }); + totalFiles += filesWritten; + if (filesWritten > 0) pluginNames.push(plugin.name); + } + return { totalFiles, pluginNames }; + } +} diff --git a/cli/src/contexts/framework/application/restore/restore-drift-entries-use-case.ts b/cli/src/contexts/framework/application/restore/restore-drift-entries-use-case.ts new file mode 100644 index 000000000..b45cf0c45 --- /dev/null +++ b/cli/src/contexts/framework/application/restore/restore-drift-entries-use-case.ts @@ -0,0 +1,69 @@ +import type { Prompter } from "../../../../kernel/ports/prompter.js"; +import { ResolveRestoreDecisionUseCase } from "./resolve-restore-decision.js"; + +export interface DriftDescriptor { + relativePath: string; + reason: "deleted" | "modified"; +} + +/** + * Entries that can actually be restored (`drift`), and entries the manifest still tracks as drifted + * but the current distribution no longer provides anything to restore them from (`unrestorable`). + */ +export interface DriftCollection { + drift: TDrift[]; + unrestorable: DriftDescriptor[]; +} + +/** + * The I/O leaf: everything that differs between restoring a whole file and merging drifted keys + * back into one. The skeleton never branches on which leaf it is running. + */ +export interface RestoreDriftLeaf { + collectDrift(): Promise>; + restore(entry: TDrift): Promise; + buildResult(restored: string[], kept: string[], unrestorable: string[]): TResult; +} + +/** The single place the keep/overwrite decision lives: both restore flows inject their own leaf + * rather than duplicating the loop. */ +export class RestoreDriftEntriesUseCase { + private readonly resolveDecision: ResolveRestoreDecisionUseCase; + + constructor(prompter: Prompter) { + this.resolveDecision = new ResolveRestoreDecisionUseCase(prompter); + } + + async execute( + leaf: RestoreDriftLeaf, + force: boolean, + interactive: boolean + ): Promise { + const { drift, unrestorable } = await leaf.collectDrift(); + if (drift.length === 0 && unrestorable.length === 0) return null; + + const restored: string[] = []; + const kept: string[] = []; + + for (const entry of drift) { + const skip = await this.resolveDecision.execute({ + relativePath: entry.relativePath, + reason: entry.reason, + force, + interactive, + }); + if (skip) { + kept.push(entry.relativePath); + continue; + } + await leaf.restore(entry); + restored.push(entry.relativePath); + } + + return leaf.buildResult( + restored, + kept, + unrestorable.map((entry) => entry.relativePath) + ); + } +} diff --git a/cli/src/application/use-cases/shared/restore-merge-files-use-case.ts b/cli/src/contexts/framework/application/restore/restore-merge-files-use-case.ts similarity index 91% rename from cli/src/application/use-cases/shared/restore-merge-files-use-case.ts rename to cli/src/contexts/framework/application/restore/restore-merge-files-use-case.ts index cac2c371d..6de2a27e0 100644 --- a/cli/src/application/use-cases/shared/restore-merge-files-use-case.ts +++ b/cli/src/contexts/framework/application/restore/restore-merge-files-use-case.ts @@ -1,14 +1,14 @@ import { join } from "node:path"; -import type { InstallationFile } from "../../../domain/models/file.js"; +import type { InstallationFile } from "../../../../kernel/file.js"; import { extractMergeEntries, type MergeFileEntry, type MergeStrategy, -} from "../../../domain/models/merge.js"; -import type { FileMerger } from "../../../domain/ports/file-merger.js"; -import type { FileReader } from "../../../domain/ports/file-reader.js"; -import type { Hasher } from "../../../domain/ports/hasher.js"; -import type { Prompter } from "../../../domain/ports/prompter.js"; +} from "../../../../kernel/merge.js"; +import type { FileReader } from "../../../../kernel/ports/file-reader.js"; +import type { Hasher } from "../../../../kernel/ports/hasher.js"; +import type { Prompter } from "../../../../kernel/ports/prompter.js"; +import type { FileMerger } from "../../../tools/domain/ports/file-merger.js"; import type { DriftCollection, DriftDescriptor } from "./restore-drift-entries-use-case.js"; import { RestoreDriftEntriesUseCase } from "./restore-drift-entries-use-case.js"; diff --git a/cli/src/application/use-cases/shared/restore-regular-files-use-case.ts b/cli/src/contexts/framework/application/restore/restore-regular-files-use-case.ts similarity index 92% rename from cli/src/application/use-cases/shared/restore-regular-files-use-case.ts rename to cli/src/contexts/framework/application/restore/restore-regular-files-use-case.ts index 7cb4dc6ae..7b365ed53 100644 --- a/cli/src/application/use-cases/shared/restore-regular-files-use-case.ts +++ b/cli/src/contexts/framework/application/restore/restore-regular-files-use-case.ts @@ -1,8 +1,8 @@ import { join } from "node:path"; -import { type FileHash, InstallationFile } from "../../../domain/models/file.js"; -import type { FileReader } from "../../../domain/ports/file-reader.js"; -import type { FileWriter } from "../../../domain/ports/file-writer.js"; -import type { Prompter } from "../../../domain/ports/prompter.js"; +import { type FileHash, InstallationFile } from "../../../../kernel/file.js"; +import type { FileReader } from "../../../../kernel/ports/file-reader.js"; +import type { FileWriter } from "../../../../kernel/ports/file-writer.js"; +import type { Prompter } from "../../../../kernel/ports/prompter.js"; import type { DriftCollection, DriftDescriptor } from "./restore-drift-entries-use-case.js"; import { RestoreDriftEntriesUseCase } from "./restore-drift-entries-use-case.js"; diff --git a/cli/src/application/use-cases/restore/restore-tool-files-use-case.ts b/cli/src/contexts/framework/application/restore/restore-tool-files-use-case.ts similarity index 75% rename from cli/src/application/use-cases/restore/restore-tool-files-use-case.ts rename to cli/src/contexts/framework/application/restore/restore-tool-files-use-case.ts index 44eceb3e5..0a035de35 100644 --- a/cli/src/application/use-cases/restore/restore-tool-files-use-case.ts +++ b/cli/src/contexts/framework/application/restore/restore-tool-files-use-case.ts @@ -1,26 +1,26 @@ -import { type FileHash, InstallationFile } from "../../../domain/models/file.js"; -import type { FrameworkDescriptor } from "../../../domain/models/framework.js"; -import type { Manifest } from "../../../domain/models/manifest.js"; -import type { MergeFileEntry } from "../../../domain/models/merge.js"; -import type { AssetProvider } from "../../../domain/ports/asset-provider.js"; -import type { FileMerger } from "../../../domain/ports/file-merger.js"; -import type { FileReader } from "../../../domain/ports/file-reader.js"; -import type { FileWriter } from "../../../domain/ports/file-writer.js"; -import type { Hasher } from "../../../domain/ports/hasher.js"; -import type { Logger } from "../../../domain/ports/logger.js"; -import type { Platform } from "../../../domain/ports/platform.js"; -import type { Prompter } from "../../../domain/ports/prompter.js"; -import { getToolConfig, type ToolId } from "../../../domain/tools/registry.js"; -import { GenerateToolDistributionUseCase } from "../shared/generate-tool-distribution-use-case.js"; -import { RestoreMergeFilesUseCase } from "../shared/restore-merge-files-use-case.js"; -import { RestoreRegularFilesUseCase } from "../shared/restore-regular-files-use-case.js"; +import { type FileHash, InstallationFile } from "../../../../kernel/file.js"; +import type { MergeFileEntry } from "../../../../kernel/merge.js"; +import type { AssetProvider } from "../../../../kernel/ports/asset-provider.js"; +import type { FileReader } from "../../../../kernel/ports/file-reader.js"; +import type { FileWriter } from "../../../../kernel/ports/file-writer.js"; +import type { Hasher } from "../../../../kernel/ports/hasher.js"; +import type { Logger } from "../../../../kernel/ports/logger.js"; +import type { Prompter } from "../../../../kernel/ports/prompter.js"; +import type { ToolId } from "../../../../kernel/tool.js"; +import type { Platform } from "../../../../runtime/platform/platform.js"; +import type { FileMerger } from "../../../tools/domain/ports/file-merger.js"; +import { getToolConfig } from "../../../tools/domain/registry.js"; +import type { FrameworkDescriptor } from "../../../translate/domain/canon.js"; +import type { Manifest } from "../../domain/manifest.js"; +import { GenerateToolDistributionUseCase } from "./generate-tool-distribution-use-case.js"; +import { RestoreMergeFilesUseCase } from "./restore-merge-files-use-case.js"; +import { RestoreRegularFilesUseCase } from "./restore-regular-files-use-case.js"; export interface RestoreToolFilesOptions { toolId: ToolId; manifest: Manifest; descriptor: FrameworkDescriptor; contentFiles: Map; - docsDir: string; projectRoot: string; version: string; force: boolean; @@ -80,14 +80,14 @@ export class RestoreToolFilesUseCase { private async buildDistributionMap( options: RestoreToolFilesOptions ): Promise> { - const { toolId, descriptor, contentFiles, docsDir, projectRoot } = options; + const { toolId, descriptor, contentFiles, projectRoot } = options; const config = getToolConfig(toolId); const distribution = await new GenerateToolDistributionUseCase( this.fs, this.hasher, this.platform, this.assetProvider - ).execute({ config, descriptor, contentFiles, docsDir, projectRoot }); + ).execute({ config, descriptor, contentFiles, projectRoot }); return new Map(distribution.map((f) => [f.relativePath, f])); } diff --git a/cli/src/contexts/framework/application/restore/restore-use-case.ts b/cli/src/contexts/framework/application/restore/restore-use-case.ts new file mode 100644 index 000000000..2dfefba47 --- /dev/null +++ b/cli/src/contexts/framework/application/restore/restore-use-case.ts @@ -0,0 +1,226 @@ +import { join } from "node:path"; +import { NoManifestError } from "../../../../kernel/errors.js"; +import type { AssetProvider } from "../../../../kernel/ports/asset-provider.js"; +import type { FileReader } from "../../../../kernel/ports/file-reader.js"; +import type { FileWriter } from "../../../../kernel/ports/file-writer.js"; +import type { Hasher } from "../../../../kernel/ports/hasher.js"; +import type { Logger } from "../../../../kernel/ports/logger.js"; +import type { Prompter } from "../../../../kernel/ports/prompter.js"; +import type { AiToolId, ToolId } from "../../../../kernel/tool.js"; +import type { Platform } from "../../../../runtime/platform/platform.js"; +import type { PluginFetcher } from "../../../distribution/domain/ports/plugin-fetcher.js"; +import { + CONFIG_MCP, + CONFIG_OPENCODE, + CONFIG_VSCODE_EXTENSIONS, + CONFIG_VSCODE_KEYBINDINGS, + CONFIG_VSCODE_SETTINGS, + type ConfigRef, +} from "../../../tools/domain/capabilities/config-refs.js"; +import type { FileMerger } from "../../../tools/domain/ports/file-merger.js"; +import { FRAMEWORK_CONFIG_PREFIX, FrameworkDescriptor } from "../../../translate/domain/canon.js"; +import type { Manifest } from "../../domain/manifest.js"; +import type { ManifestRepository } from "../../domain/ports/manifest-repository.js"; +import type { PluginDistributionReader } from "../../domain/ports/plugin-distribution-reader.js"; +import type { BuiltMaterializationDeps } from "../shared/apply-plugin-files-use-case.js"; +import { + type RestoreAllPluginsResult, + RestoreAllPluginsUseCase, +} from "./restore-all-plugins-use-case.js"; +import { + type RestoreToolFilesResult, + RestoreToolFilesUseCase, +} from "./restore-tool-files-use-case.js"; + +/** + * Where each config artifact sits in the canonical source, keyed by the same names a tool's + * capability declares in its `consumes` list — so restoring knows artifacts, not tools. + */ +const CONFIG_REFS: readonly ConfigRef[] = [ + { name: CONFIG_MCP, path: `${FRAMEWORK_CONFIG_PREFIX}mcp.json` }, + { name: CONFIG_VSCODE_EXTENSIONS, path: `${FRAMEWORK_CONFIG_PREFIX}vscode/extensions.json` }, + { name: CONFIG_VSCODE_KEYBINDINGS, path: `${FRAMEWORK_CONFIG_PREFIX}vscode/keybindings.json` }, + { name: CONFIG_VSCODE_SETTINGS, path: `${FRAMEWORK_CONFIG_PREFIX}vscode/settings.json` }, + { name: CONFIG_OPENCODE, path: `${FRAMEWORK_CONFIG_PREFIX}.opencode/opencode.json` }, +]; + +interface RestoreOptions { + frameworkPath?: string; + version?: string; + projectRoot: string; + toolIds?: ToolId[]; + files?: string[]; + force?: boolean; + interactive?: boolean; + manifest?: Manifest; + pluginName?: string; +} + +interface RestoreCtx { + manifest: Manifest; + descriptor: FrameworkDescriptor; + contentFiles: Map; + projectRoot: string; + version: string; + force: boolean; + interactive: boolean; + fileFilter: ((p: string) => boolean) | null; + toolIds: ToolId[]; + pluginName?: string; +} + +interface RestoreResult { + tools: RestoreToolFilesResult[]; + totalRestored: number; + totalKept: number; + totalPluginFilesRestored: number; + restoredPluginNames: string[]; + unrestorable: string[]; + /** AI tools this pass could not restore any plugin file for, because their registration is + * native. */ + nativeOnlyToolIds: AiToolId[]; +} + +export class RestoreUseCase { + constructor( + private readonly fs: FileReader & FileWriter & FileMerger, + private readonly manifestRepo: ManifestRepository, + private readonly hasher: Hasher, + private readonly logger: Logger, + private readonly platform: Platform, + private readonly prompter: Prompter, + private readonly pluginFetcher?: PluginFetcher, + private readonly pluginDistributionReader?: PluginDistributionReader, + private readonly assetProvider?: AssetProvider, + private readonly builtDeps?: BuiltMaterializationDeps + ) {} + + async execute(options: RestoreOptions): Promise { + const manifest = options.manifest ?? (await this.manifestRepo.load()); + if (manifest === null) throw new NoManifestError(); + const ctx = await this.buildRestoreContext(options, manifest); + return this.executeRestore(ctx); + } + + private async buildRestoreContext( + options: RestoreOptions, + manifest: Manifest + ): Promise { + const resolvedVersion = options.version ?? "unknown"; + return { + manifest, + descriptor: this.buildStaticDescriptor(resolvedVersion), + contentFiles: options.frameworkPath + ? await this.buildContentFiles(options.frameworkPath) + : new Map(), + projectRoot: options.projectRoot, + version: resolvedVersion, + force: options.force ?? false, + interactive: options.interactive ?? false, + fileFilter: buildFileFilter(options.files), + toolIds: options.toolIds?.length ? options.toolIds : manifest.getInstalledToolIds(), + pluginName: options.pluginName, + }; + } + + private async executeRestore(ctx: RestoreCtx): Promise { + const toolResults = await this.runToolRestores(ctx); + const pluginResult = await this.runPluginRestore(ctx); + await this.saveIfChanged(toolResults, pluginResult.totalFiles, ctx.manifest); + return this.buildTotals(toolResults, pluginResult); + } + + private async runToolRestores(ctx: RestoreCtx): Promise { + const toolUseCase = new RestoreToolFilesUseCase( + this.fs, + this.hasher, + this.logger, + this.platform, + this.prompter, + this.assetProvider + ); + const results: RestoreToolFilesResult[] = []; + for (const toolId of ctx.toolIds) { + results.push(await toolUseCase.execute({ toolId, ...ctx })); + } + return results; + } + + private async runPluginRestore(ctx: RestoreCtx): Promise { + if (this.pluginFetcher === undefined || this.pluginDistributionReader === undefined) { + return { totalFiles: 0, pluginNames: [], nativeOnlyToolIds: [] }; + } + return new RestoreAllPluginsUseCase( + this.fs, + this.hasher, + this.pluginFetcher, + this.pluginDistributionReader, + this.builtDeps + ).execute({ + projectRoot: ctx.projectRoot, + manifest: ctx.manifest, + fileFilter: ctx.fileFilter, + pluginName: ctx.pluginName, + toolIds: ctx.toolIds, + }); + } + + private async saveIfChanged( + toolResults: RestoreToolFilesResult[], + totalPluginFilesRestored: number, + manifest: Manifest + ): Promise { + const hasChanges = + toolResults.some((t) => t.restored.length > 0) || totalPluginFilesRestored > 0; + if (hasChanges) await this.manifestRepo.save(manifest); + } + + private buildTotals( + toolResults: RestoreToolFilesResult[], + pluginResult: RestoreAllPluginsResult + ): RestoreResult { + return { + tools: toolResults, + totalRestored: toolResults.reduce((s, t) => s + t.restored.length, 0), + totalKept: toolResults.reduce((s, t) => s + t.kept.length, 0), + totalPluginFilesRestored: pluginResult.totalFiles, + restoredPluginNames: pluginResult.pluginNames, + unrestorable: toolResults.flatMap((t) => t.unrestorable), + nativeOnlyToolIds: pluginResult.nativeOnlyToolIds, + }; + } + + private buildStaticDescriptor(version: string): FrameworkDescriptor { + return new FrameworkDescriptor({ + version, + contentSections: [], + templateRefs: [], + configRefs: [...CONFIG_REFS], + }); + } + + private async buildContentFiles(frameworkPath: string): Promise> { + const contentFiles = new Map(); + for (const ref of CONFIG_REFS) { + const absPath = join(frameworkPath, ref.path); + if (await this.fs.fileExists(absPath)) { + contentFiles.set(ref.path, await this.fs.readFile(absPath)); + } + } + return contentFiles; + } +} + +function buildFileFilter(files: string[] | undefined): ((p: string) => boolean) | null { + if (!files || files.length === 0) return null; + return (relativePath: string) => + files.some((entry) => { + const basename = entry.split("/").at(-1) ?? entry; + const isDirectoryPrefix = entry.endsWith("/") || !basename.includes("."); + if (isDirectoryPrefix) { + const prefix = entry.endsWith("/") ? entry : `${entry}/`; + return relativePath.startsWith(prefix); + } + return relativePath === entry; + }); +} diff --git a/cli/src/contexts/framework/application/setup-use-case.ts b/cli/src/contexts/framework/application/setup-use-case.ts new file mode 100644 index 000000000..1424928bc --- /dev/null +++ b/cli/src/contexts/framework/application/setup-use-case.ts @@ -0,0 +1,139 @@ +import { UserScopeUnavailableError } from "../../../kernel/errors.js"; +import type { FileReader } from "../../../kernel/ports/file-reader.js"; +import type { FileWriter } from "../../../kernel/ports/file-writer.js"; +import type { VersionReader } from "../../../kernel/ports/version-reader.js"; +import type { AiToolId, IdeToolId } from "../../../kernel/tool.js"; +import type { SetupPluginsPromptUseCase } from "../../../presentation/prompts/setup-plugins-prompt-use-case.js"; +import type { SetupToolsPromptUseCase } from "../../../presentation/prompts/setup-tools-prompt-use-case.js"; +import type { ManifestRepository } from "../domain/ports/manifest-repository.js"; +import type { ProjectContext } from "../domain/project-context.js"; +import type { SetupFlow } from "../domain/setup-flow.js"; +import type { + MarketplaceSyncSettings, + MarketplaceSyncSettingsResult, +} from "./flows/marketplace-sync-settings-use-case.js"; +import { InitUseCase } from "./init-use-case.js"; +import type { ProjectContextDetectorUseCase } from "./setup/project-context-detector-use-case.js"; +import type { SetupMachineScopeUseCase } from "./setup/setup-machine-scope-use-case.js"; +import type { SetupToolsResult, SetupToolsUseCase } from "./setup/setup-tools-use-case.js"; +import type { SetupMarketplaceRegistrationUseCase } from "./shared/setup-marketplace-registration-use-case.js"; + +export type SetupResult = + | { + kind: "initialized"; + install: SetupToolsResult; + activation: MarketplaceSyncSettingsResult; + context?: ProjectContext; + } + | { + kind: "up-to-date"; + install: SetupToolsResult; + activation: MarketplaceSyncSettingsResult; + context?: ProjectContext; + }; + +export class SetupUseCase { + constructor( + private readonly fs: FileReader & FileWriter, + private readonly manifestRepo: ManifestRepository, + private readonly setupMarketplaceRegistration: SetupMarketplaceRegistrationUseCase, + private readonly marketplaceSyncSettingsUseCase: MarketplaceSyncSettings, + private readonly setupToolsUseCase: SetupToolsUseCase, + private readonly setupPluginsPromptUseCase: SetupPluginsPromptUseCase, + private readonly currentVersionProvider: VersionReader, + private readonly setupToolsPromptUseCase?: SetupToolsPromptUseCase, + private readonly projectContextDetector?: ProjectContextDetectorUseCase, + /** Handles `flow.scope === "user"` entirely. Absent, `execute` refuses a request it has + * nothing to serve. */ + private readonly setupMachineScopeUseCase?: SetupMachineScopeUseCase + ) {} + + async execute(flow: SetupFlow): Promise { + if (flow.scope === "user") return this.runMachineScope(flow); + const context = await this.detectContext(flow); + // Resolved before initManifest: a non-interactive run with no --source must reject + // before it ever writes .aidd/manifest.json or touches .gitignore, not after. + const source = await this.setupMarketplaceRegistration.resolveSourceIfNeeded(flow); + const isNew = await this.initManifest(flow); + await this.setupMarketplaceRegistration.registerIfPresent(flow, source); + const install = await this.installTools(flow, context); + if (flow.registerDefaultMarketplace) await this.promptPlugins(flow); + const activation = await this.syncSettings(flow); + return this.buildResult(isNew, install, activation, context); + } + + private async runMachineScope(flow: SetupFlow): Promise { + if (this.setupMachineScopeUseCase === undefined) { + throw new UserScopeUnavailableError(); + } + return this.setupMachineScopeUseCase.execute(flow); + } + + private async detectContext(flow: SetupFlow): Promise { + if (this.projectContextDetector === undefined) return undefined; + return this.projectContextDetector.execute({ projectRoot: flow.projectRoot }); + } + + private async syncSettings(flow: SetupFlow): Promise { + return this.marketplaceSyncSettingsUseCase.execute({ projectRoot: flow.projectRoot }); + } + + private async initManifest(flow: SetupFlow): Promise { + const existing = await this.manifestRepo.load(); + if (existing !== null) return false; + await new InitUseCase(this.fs, this.manifestRepo).execute({ + projectRoot: flow.projectRoot, + force: false, + }); + return true; + } + + private async installTools( + flow: SetupFlow, + context: ProjectContext | undefined + ): Promise { + const { aiTools, ideTools } = await this.resolveTools(flow, context); + const version = this.currentVersionProvider.get(); + return this.setupToolsUseCase.execute({ + projectRoot: flow.projectRoot, + aiTools, + ideTools, + force: flow.force, + version, + }); + } + + private async resolveTools( + flow: SetupFlow, + context: ProjectContext | undefined + ): Promise<{ aiTools: readonly AiToolId[]; ideTools: readonly IdeToolId[] }> { + if (this.setupToolsPromptUseCase === undefined) { + return { aiTools: flow.aiTools as AiToolId[], ideTools: flow.ideTools as IdeToolId[] }; + } + return this.setupToolsPromptUseCase.execute({ + interactive: flow.interactive, + aiTools: flow.aiTools as AiToolId[], + ideTools: flow.ideTools as IdeToolId[], + context, + }); + } + + private async promptPlugins(flow: SetupFlow): Promise { + await this.setupPluginsPromptUseCase.execute({ + projectRoot: flow.projectRoot, + mode: flow.pluginMode, + pluginNames: [...flow.pluginNames], + interactive: flow.interactive, + }); + } + + private buildResult( + isNew: boolean, + install: SetupToolsResult, + activation: MarketplaceSyncSettingsResult, + context: ProjectContext | undefined + ): SetupResult { + if (isNew) return { kind: "initialized", install, activation, context }; + return { kind: "up-to-date", install, activation, context }; + } +} diff --git a/cli/src/application/use-cases/setup/project-context-detector-use-case.ts b/cli/src/contexts/framework/application/setup/project-context-detector-use-case.ts similarity index 92% rename from cli/src/application/use-cases/setup/project-context-detector-use-case.ts rename to cli/src/contexts/framework/application/setup/project-context-detector-use-case.ts index e6153f697..45f097906 100644 --- a/cli/src/application/use-cases/setup/project-context-detector-use-case.ts +++ b/cli/src/contexts/framework/application/setup/project-context-detector-use-case.ts @@ -1,6 +1,6 @@ import { join } from "node:path"; -import { ProjectContext, type Stack } from "../../../domain/models/project-context.js"; -import type { FileReader } from "../../../domain/ports/file-reader.js"; +import type { FileReader } from "../../../../kernel/ports/file-reader.js"; +import { ProjectContext, type Stack } from "../../domain/project-context.js"; const TS_SIGNALS = ["tsconfig.json", "package.json"]; const PYTHON_SIGNALS = ["pyproject.toml", "setup.py", "requirements.txt"]; diff --git a/cli/src/contexts/framework/application/setup/setup-machine-scope-use-case.ts b/cli/src/contexts/framework/application/setup/setup-machine-scope-use-case.ts new file mode 100644 index 000000000..0d1a2053e --- /dev/null +++ b/cli/src/contexts/framework/application/setup/setup-machine-scope-use-case.ts @@ -0,0 +1,70 @@ +import type { VersionReader } from "../../../../kernel/ports/version-reader.js"; +import { Manifest } from "../../domain/manifest.js"; +import type { ManifestRepository } from "../../domain/ports/manifest-repository.js"; +import type { SetupFlow } from "../../domain/setup-flow.js"; +import type { + MarketplaceSyncSettings, + MarketplaceSyncSettingsResult, +} from "../flows/marketplace-sync-settings-use-case.js"; +import type { SetupResult } from "../setup-use-case.js"; +import type { SetupMarketplaceRegistrationUseCase } from "../shared/setup-marketplace-registration-use-case.js"; +import type { SetupToolsResult } from "./setup-tools-use-case.js"; + +/** + * `--scope user`: registers the shared framework source and drives native activation machine-wide, + * writing nothing under `flow.projectRoot` at all — no tool content install, no plugin prompt, no + * project-context detection. + * + * Records no shared-source reference: `references.json` tracks which *project* still claims the + * shared source, and a `--scope user` run has no project-scope manifest for a later `clean` to read + * that claim back from, so one recorded here could never be decremented. Absence is the honest + * state until `clean --scope user` purges the source unconditionally regardless. + */ +export class SetupMachineScopeUseCase { + constructor( + private readonly userManifestRepo: ManifestRepository, + private readonly setupMarketplaceRegistration: SetupMarketplaceRegistrationUseCase, + private readonly marketplaceSyncSettingsUseCase: MarketplaceSyncSettings, + private readonly currentVersionProvider: VersionReader + ) {} + + async execute(flow: SetupFlow): Promise { + const source = await this.setupMarketplaceRegistration.resolveSourceIfNeeded(flow); + const isNew = await this.initUserManifest(); + await this.setupMarketplaceRegistration.registerIfPresent(flow, source); + await this.registerUserScopeTools(flow); + const activation: MarketplaceSyncSettingsResult = + await this.marketplaceSyncSettingsUseCase.execute({ + projectRoot: flow.projectRoot, + scope: "user", + manifestRepo: this.userManifestRepo, + }); + const install: SetupToolsResult = { results: [] }; + return isNew + ? { kind: "initialized", install, activation, context: undefined } + : { kind: "up-to-date", install, activation, context: undefined }; + } + + private async initUserManifest(): Promise { + const existing = await this.userManifestRepo.load(); + if (existing !== null) return false; + await this.userManifestRepo.save(Manifest.create()); + return true; + } + + /** Every requested AI tool gets a manifest entry with no files at all — the honest record of + * "registered at user scope, nothing installed under any project" — so tool selection has + * something to iterate. A tool already present is left alone rather than reset. */ + private async registerUserScopeTools(flow: SetupFlow): Promise { + const manifest = await this.userManifestRepo.load(); + if (manifest === null) return; + const version = this.currentVersionProvider.get(); + let changed = false; + for (const toolId of flow.aiTools) { + if (manifest.hasTool(toolId)) continue; + manifest.addTool(toolId, version, []); + changed = true; + } + if (changed) await this.userManifestRepo.save(manifest); + } +} diff --git a/cli/src/application/use-cases/setup/setup-marketplace-source-use-case.ts b/cli/src/contexts/framework/application/setup/setup-marketplace-source-use-case.ts similarity index 89% rename from cli/src/application/use-cases/setup/setup-marketplace-source-use-case.ts rename to cli/src/contexts/framework/application/setup/setup-marketplace-source-use-case.ts index fb0ba2a3f..3ecd7d206 100644 --- a/cli/src/application/use-cases/setup/setup-marketplace-source-use-case.ts +++ b/cli/src/contexts/framework/application/setup/setup-marketplace-source-use-case.ts @@ -1,8 +1,8 @@ import { resolve } from "node:path"; -import { MarketplaceSourceMode } from "../../../domain/models/marketplace-source-mode.js"; -import type { LatestReleaseResolver } from "../../../domain/ports/latest-release-resolver.js"; -import type { Prompter } from "../../../domain/ports/prompter.js"; -import { InputRequiredError } from "../../errors.js"; +import { InputRequiredError } from "../../../../kernel/errors.js"; +import type { Prompter } from "../../../../kernel/ports/prompter.js"; +import type { LatestReleaseResolver } from "../../../../runtime/self-update/latest-release-resolver.js"; +import { MarketplaceSourceMode } from "../../../distribution/domain/marketplace-source-mode.js"; /** Sentinel select value for "install from main branch tip" — maps to ref undefined. */ const HEAD_CHOICE = "__HEAD__"; diff --git a/cli/src/application/use-cases/setup/setup-tools-use-case.ts b/cli/src/contexts/framework/application/setup/setup-tools-use-case.ts similarity index 83% rename from cli/src/application/use-cases/setup/setup-tools-use-case.ts rename to cli/src/contexts/framework/application/setup/setup-tools-use-case.ts index ef2bf6274..3af30ac23 100644 --- a/cli/src/application/use-cases/setup/setup-tools-use-case.ts +++ b/cli/src/contexts/framework/application/setup/setup-tools-use-case.ts @@ -1,9 +1,9 @@ -import { CategoryMismatchError } from "../../../domain/errors.js"; -import { Manifest } from "../../../domain/models/manifest.js"; -import type { AiToolId, IdeToolId, ToolId } from "../../../domain/models/tool-ids.js"; -import { AI_TOOL_IDS } from "../../../domain/models/tool-ids.js"; -import type { ManifestRepository } from "../../../domain/ports/manifest-repository.js"; -import { getToolConfig, isAiTool } from "../../../domain/tools/registry.js"; +import { CategoryMismatchError } from "../../../../kernel/errors.js"; +import type { AiToolId, IdeToolId, ToolId } from "../../../../kernel/tool.js"; +import { AI_TOOL_IDS } from "../../../../kernel/tool.js"; +import { getToolConfig, isAiTool } from "../../../tools/domain/registry.js"; +import { Manifest } from "../../domain/manifest.js"; +import type { ManifestRepository } from "../../domain/ports/manifest-repository.js"; import type { InstallIdeConfigResult, InstallIdeConfigUseCase, diff --git a/cli/src/contexts/framework/application/shared/apply-plugin-files-use-case.ts b/cli/src/contexts/framework/application/shared/apply-plugin-files-use-case.ts new file mode 100644 index 000000000..0dbc7c87f --- /dev/null +++ b/cli/src/contexts/framework/application/shared/apply-plugin-files-use-case.ts @@ -0,0 +1,117 @@ +import { join } from "node:path"; +import type { FileReader } from "../../../../kernel/ports/file-reader.js"; +import type { FileWriter } from "../../../../kernel/ports/file-writer.js"; +import type { Hasher } from "../../../../kernel/ports/hasher.js"; +import type { AiToolId } from "../../../../kernel/tool.js"; +import type { MarketplaceRegistry } from "../../../distribution/domain/ports/marketplace-registry.js"; +import type { PluginFetcher } from "../../../distribution/domain/ports/plugin-fetcher.js"; +import type { ToolConfig } from "../../../tools/domain/registry.js"; +import { PluginContentTranslator } from "../../../translate/domain/content-translator.js"; +import type { PluginDistribution } from "../../../translate/domain/plugin-distribution.js"; +import type { Manifest } from "../../domain/manifest.js"; +import type { InstalledPlugin } from "../../domain/plugins/installed-plugin.js"; +import type { PluginDistributionReader } from "../../domain/ports/plugin-distribution-reader.js"; +import type { PluginTranslator } from "../framework/translator/plugin-translator.js"; +import { resolvePluginTranslator } from "../framework/translator/resolve-plugin-translator.js"; +import { + deleteOldFiles, + isPluginFileAtDesiredState, + materializeViaTranslator, +} from "../plugin/plugin-helpers.js"; +import { resolveBaseDirFromRecord } from "../plugin/plugin-target-resolution.js"; +import type { EnsureBuiltMarketplace } from "./ensure-built-marketplace-use-case.js"; + +interface ApplyPluginFilesOptions { + toolId: AiToolId; + plugin: InstalledPlugin; + toolConfig: ToolConfig; + projectRoot: string; + cacheDir: string; + manifest: Manifest; + fileFilter?: ((relativePath: string) => boolean) | null; +} + +/** Optional deps that let restore re-materialize through the build pipeline, as install does. */ +export interface BuiltMaterializationDeps { + ensureBuilt: EnsureBuiltMarketplace; + marketplaceRegistry: MarketplaceRegistry; + homedir: () => string; +} + +export class ApplyPluginFilesUseCase { + constructor( + private readonly fs: FileReader & FileWriter, + private readonly hasher: Hasher, + private readonly pluginFetcher: PluginFetcher, + private readonly pluginDistributionReader: PluginDistributionReader, + private readonly builtDeps?: BuiltMaterializationDeps + ) {} + + async execute(options: ApplyPluginFilesOptions): Promise { + const localPath = await this.pluginFetcher.fetch(options.plugin.source, options.cacheDir); + const dist = await this.pluginDistributionReader.read(localPath); + const translator = this.resolveTranslator(options.toolConfig); + if (translator !== null && options.plugin.marketplace !== undefined) { + return this.restoreViaTranslator(translator, dist, options); + } + return this.restoreViaTranslate(dist, options); + } + + // Materializing tools (cursor/opencode) must re-materialize from the BUILT tree so + // restored content + hashes match what install wrote, and Mode A marketplace tools + // (claude/codex/copilot) must re-register without writing files — not the raw source + // transform in either case. + private resolveTranslator(toolConfig: ToolConfig): PluginTranslator | null { + if (this.builtDeps === undefined) return null; + return resolvePluginTranslator(toolConfig, { + fs: this.fs, + hasher: this.hasher, + homedir: this.builtDeps.homedir, + ensureBuilt: this.builtDeps.ensureBuilt, + marketplaceRegistry: this.builtDeps.marketplaceRegistry, + }); + } + + private async restoreViaTranslator( + translator: PluginTranslator, + dist: PluginDistribution, + options: ApplyPluginFilesOptions + ): Promise { + const { toolId, plugin, projectRoot, manifest } = options; + // Mode A never materializes files, so any manifest-tracked path here is a leftover from a run + // before that was true. Scoped to the manifest's own keys under the plugin's base dir — never a + // directory scan — so it cannot touch files the plugin never wrote. + if (translator.mode === "marketplace" && this.builtDeps !== undefined) { + const baseDir = resolveBaseDirFromRecord( + plugin.scope, + toolId, + projectRoot, + this.builtDeps.homedir + ); + await deleteOldFiles(plugin.files, baseDir, this.fs); + } + return materializeViaTranslator(translator, dist, toolId, plugin, projectRoot, manifest); + } + + private async restoreViaTranslate( + dist: PluginDistribution, + options: ApplyPluginFilesOptions + ): Promise { + const { toolId, plugin, toolConfig, projectRoot, manifest, fileFilter } = options; + const files = new PluginContentTranslator(this.hasher).translate(dist, toolConfig); + let restored = 0; + for (const f of files) { + if (fileFilter !== null && fileFilter !== undefined && !fileFilter(f.relativePath)) continue; + const outputPath = join(projectRoot, f.relativePath); + if (!(await isPluginFileAtDesiredState(this.fs, this.hasher, outputPath, f.hash.value))) { + await this.fs.writeFile(outputPath, f.content); + restored++; + } + } + manifest.updatePlugin( + toolId, + plugin.withFiles(new Map(files.map((f) => [f.relativePath, f.hash.value]))) + ); + return restored; + } +} diff --git a/cli/src/contexts/framework/application/shared/best-effort-native-call.ts b/cli/src/contexts/framework/application/shared/best-effort-native-call.ts new file mode 100644 index 000000000..de52a0a22 --- /dev/null +++ b/cli/src/contexts/framework/application/shared/best-effort-native-call.ts @@ -0,0 +1,22 @@ +import { NativePluginCliError } from "../../../../kernel/errors.js"; +import type { Logger } from "../../../../kernel/ports/logger.js"; + +/** + * Runs `action`, a call into a tool's own native plugin CLI, and reports whether it ran to + * completion rather than throwing: a marketplace or plugin ref the host refused to drop must be + * told apart from one it actually forgot, so a later cache purge never trusts a removal that never + * happened. + * + * Only `NativePluginCliError` is swallowed — every throw a real activator produces is that class, + * so anything else is a bug in the activator and must propagate. + */ +export function bestEffortNativeCall(logger: Logger, action: () => void, label: string): boolean { + try { + action(); + return true; + } catch (error) { + if (!(error instanceof NativePluginCliError)) throw error; + logger.warn(`${label} failed: ${error.message}`); + return false; + } +} diff --git a/cli/src/contexts/framework/application/shared/detect-plugin-drift-use-case.ts b/cli/src/contexts/framework/application/shared/detect-plugin-drift-use-case.ts new file mode 100644 index 000000000..b5a332bd0 --- /dev/null +++ b/cli/src/contexts/framework/application/shared/detect-plugin-drift-use-case.ts @@ -0,0 +1,83 @@ +import { homedir } from "node:os"; +import { join } from "node:path"; +import type { FileReader } from "../../../../kernel/ports/file-reader.js"; +import type { AiToolId, ToolId } from "../../../../kernel/tool.js"; +import type { Manifest } from "../../domain/manifest.js"; +import { resolveBaseDirFromRecord } from "../plugin/plugin-target-resolution.js"; + +export type PluginFileDriftKind = "missing" | "hash-mismatch"; + +export interface PluginFileDrift { + relativePath: string; + kind: PluginFileDriftKind; +} + +export interface PluginDrift { + toolId: AiToolId; + pluginName: string; + files: PluginFileDrift[]; + /** + * True when every one of this plugin's tracked files is missing and the tool installs to a + * user-scope directory rather than the project: a committed manifest describes that directory as + * it stood on the machine that wrote it, and on any other it is unpopulated until `aidd sync` + * runs — not the same fact as a project-scope file someone deleted. `files` stays empty, since + * there is nothing to enumerate file by file. + */ + notInstalledOnMachine: boolean; +} + +export interface DetectPluginDriftOptions { + manifest: Manifest; + projectRoot: string; + toolIds: Iterable; + pluginName?: string; +} + +/** + * Single source of truth for "which of a plugin's installed files no longer match the manifest". + */ +export class DetectPluginDriftUseCase { + constructor(private readonly fs: FileReader) {} + + async execute(options: DetectPluginDriftOptions): Promise { + const { manifest, projectRoot, toolIds, pluginName } = options; + const drifts: PluginDrift[] = []; + for (const id of toolIds) { + const toolId = id as AiToolId; + const plugins = manifest.getPlugins(toolId); + const targets = pluginName ? plugins.filter((p) => p.name === pluginName) : plugins; + for (const plugin of targets) { + const baseDir = resolveBaseDirFromRecord(plugin.scope, toolId, projectRoot, homedir); + const files = await this.driftedFiles(plugin.files, baseDir); + if (files.length === 0) continue; + const allMissing = + files.length === plugin.files.size && files.every((f) => f.kind === "missing"); + if (allMissing && plugin.scope === "user") { + drifts.push({ toolId, pluginName: plugin.name, files: [], notInstalledOnMachine: true }); + } else { + drifts.push({ toolId, pluginName: plugin.name, files, notInstalledOnMachine: false }); + } + } + } + return drifts; + } + + private async driftedFiles( + files: ReadonlyMap, + baseDir: string + ): Promise { + const drifted: PluginFileDrift[] = []; + for (const [relativePath, expectedHash] of files.entries()) { + const fullPath = join(baseDir, relativePath); + if (!(await this.fs.fileExists(fullPath))) { + drifted.push({ relativePath, kind: "missing" }); + continue; + } + const diskHash = await this.fs.readFileHash(fullPath); + if (diskHash.value !== expectedHash) { + drifted.push({ relativePath, kind: "hash-mismatch" }); + } + } + return drifted; + } +} diff --git a/cli/src/contexts/framework/application/shared/ensure-built-marketplace-use-case.ts b/cli/src/contexts/framework/application/shared/ensure-built-marketplace-use-case.ts new file mode 100644 index 000000000..09cf30f4c --- /dev/null +++ b/cli/src/contexts/framework/application/shared/ensure-built-marketplace-use-case.ts @@ -0,0 +1,198 @@ +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import { + builtMarketplaceDir, + pathsOverlap, + userBuiltMarketplaceDir, +} from "../../../../kernel/paths.js"; +import type { FileReader } from "../../../../kernel/ports/file-reader.js"; +import type { FileWriter } from "../../../../kernel/ports/file-writer.js"; +import type { VersionReader } from "../../../../kernel/ports/version-reader.js"; +import type { ResolveMarketplace } from "../../../distribution/application/resolve-marketplace-use-case.js"; +import type { Marketplace } from "../../../distribution/domain/marketplace.js"; +import type { FrameworkBuildMode } from "../../../tools/domain/registry.js"; +import type { FrameworkBuild } from "../../../translate/application/translate-source.js"; +import type { FrameworkBuildTarget } from "../../../translate/domain/build-target.js"; + +/** `undefined` when the target/mode pair has no build. */ +export type FrameworkBuildFor = ( + target: FrameworkBuildTarget, + mode: FrameworkBuildMode, + outDir: string +) => FrameworkBuild | undefined; + +export interface EnsureBuiltMarketplaceOptions { + readonly projectRoot: string; + readonly marketplace: Marketplace; + readonly target: FrameworkBuildTarget; + readonly mode: FrameworkBuildMode; + readonly forceRefresh?: boolean; +} + +export interface EnsureBuiltMarketplaceResult { + readonly builtDir: string; + readonly version: string | undefined; + readonly rebuilt: boolean; +} + +const SENTINEL_FILE = ".build-version"; +const UNVERSIONED = "unversioned"; + +/** + * Guarantees a per-target built tree exists in cache, so install consumers read the same + * transformed content a build produces. Owns source resolution, staleness, and the guard-safe + * outDir: build to temp then copy when the cache nests under the source. + */ +export interface EnsureBuiltMarketplace { + execute(options: EnsureBuiltMarketplaceOptions): Promise; +} + +export class EnsureBuiltMarketplaceUseCase implements EnsureBuiltMarketplace { + private readonly memo = new Map(); + + constructor( + private readonly fs: FileReader & FileWriter, + private readonly resolveMarketplace: ResolveMarketplace, + private readonly buildFor: FrameworkBuildFor, + private readonly version: VersionReader, + /** + * Where a user-scope marketplace's built tree belongs. Building it under the project that + * happened to register it would tie a declaration meant for every project to the life of one + * of them: delete that project and the global registration points at nothing. + */ + private readonly userCacheRoot: () => string + ) {} + + async execute(options: EnsureBuiltMarketplaceOptions): Promise { + // `resolve()`, matching `sourceDir` below: on Windows a drive-less `projectRoot` yields a + // drive-less `builtDir` here while the build resolves its own outDir copy for validation only, + // leaving the write target diverging from the path that gets checked. Both scopes need it. + const builtDir = resolve( + options.marketplace.scope === "user" + ? userBuiltMarketplaceDir( + this.userCacheRoot(), + this.version.get(), + options.marketplace.name, + options.target + ) + : builtMarketplaceDir(options.projectRoot, options.marketplace.name, options.target) + ); + const resolved = await this.resolveMarketplace.execute({ + marketplace: options.marketplace, + projectRoot: options.projectRoot, + forceRefresh: options.forceRefresh, + }); + const sentinel = this.sentinelValue(resolved.catalog?.version); + const memoKey = `${options.marketplace.name}:${options.target}:${sentinel}`; + const memoized = this.memo.get(memoKey); + if (memoized !== undefined) return memoized; + const result = await this.ensure( + options, + builtDir, + resolve(resolved.localPath), + sentinel, + this.versionIsTrustworthy(options) + ); + this.memo.set(memoKey, result); + return result; + } + + private sentinelValue(catalogVersion: string | undefined): string { + return `${this.version.get()}:${catalogVersion ?? UNVERSIONED}`; + } + + /** + * Whether the catalog version can be believed when it says nothing changed. For a published + * source it can: different content carries a different version. For a directory on this machine + * it cannot — someone edits a file and the version stays put, which is the whole of framework + * development — and an explicit refresh asks for the source to be re-read. + * + * Rebuilding the real framework costs about two tenths of a second, so the safe answer is also + * the cheap one. + */ + private versionIsTrustworthy(options: EnsureBuiltMarketplaceOptions): boolean { + if (options.forceRefresh === true) return false; + return options.marketplace.source.kind !== "local"; + } + + private async ensure( + options: EnsureBuiltMarketplaceOptions, + builtDir: string, + sourceDir: string, + sentinel: string, + versionIsTrustworthy: boolean + ): Promise { + const version = sentinel.split(":")[1]; + if (versionIsTrustworthy && (await this.isFresh(builtDir, sentinel))) { + return { builtDir, version, rebuilt: false }; + } + await this.build(options.target, options.mode, sourceDir, builtDir); + await this.fs.writeFile(join(builtDir, SENTINEL_FILE), sentinel); + return { builtDir, version, rebuilt: true }; + } + + private async isFresh(builtDir: string, sentinel: string): Promise { + if (sentinel.endsWith(`:${UNVERSIONED}`)) return false; + const path = join(builtDir, SENTINEL_FILE); + if (!(await this.fs.fileExists(path))) return false; + const current = await this.fs.readFile(path).catch(() => ""); + return current === sentinel; + } + + private async build( + target: FrameworkBuildTarget, + mode: FrameworkBuildMode, + sourceDir: string, + builtDir: string + ): Promise { + if (this.nested(sourceDir, builtDir)) { + await this.buildViaTemp(target, mode, sourceDir, builtDir); + return; + } + await this.runBuild(target, mode, sourceDir, builtDir); + } + + private nested(sourceDir: string, builtDir: string): boolean { + return pathsOverlap(sourceDir, builtDir); + } + + private async buildViaTemp( + target: FrameworkBuildTarget, + mode: FrameworkBuildMode, + sourceDir: string, + builtDir: string + ): Promise { + const temp = join(tmpdir(), `aidd-built-${target}-${mode}`); + await this.fs.deleteDirectory(temp); + await this.runBuild(target, mode, sourceDir, temp); + await this.fs.deleteDirectory(builtDir); + await this.copyDir(temp, builtDir); + await this.fs.deleteDirectory(temp); + } + + // Every outDir reaching this method is either `builtMarketplaceDir()` or a temp dir this class + // just deleted — an aidd-owned cache, never a user directory — so a collision here is stale-cache + // reuse, not data loss. + private async runBuild( + target: FrameworkBuildTarget, + mode: FrameworkBuildMode, + sourceDir: string, + outDir: string + ): Promise { + await this.fs.createDirectory(outDir); + const build = this.buildFor(target, mode, outDir); + if (build === undefined) { + throw new Error(`No framework build for target '${target}' mode '${mode}'.`); + } + await build.execute({ sourceDir, outDir, target, mode }); + } + + private async copyDir(from: string, to: string): Promise { + const files = await this.fs.listFilesRecursive(from); + for (const abs of files) { + const rel = abs.slice(from.length + 1); + const content = await this.fs.readFile(abs); + await this.fs.writeFile(join(to, rel), content); + } + } +} diff --git a/cli/src/contexts/framework/application/shared/host-marketplace-source-conflict.ts b/cli/src/contexts/framework/application/shared/host-marketplace-source-conflict.ts new file mode 100644 index 000000000..73a420cc4 --- /dev/null +++ b/cli/src/contexts/framework/application/shared/host-marketplace-source-conflict.ts @@ -0,0 +1,113 @@ +import type { FileReader } from "../../../../kernel/ports/file-reader.js"; +import type { AiToolId } from "../../../../kernel/tool.js"; +import { + type MarketplaceCatalogIdentity, + type MarketplaceSourceConflict, + marketplaceSourceConflict, +} from "../../../tools/domain/marketplace-source-conflict.js"; +import type { HostMarketplaceRegistryReader } from "../../../tools/domain/ports/host-marketplace-registry-reader.js"; +import { + type MarketplaceSourceDrift, + type MarketplaceSourceDriftContext, + marketplaceSourceDrift, +} from "../../domain/marketplace-source-drift.js"; +import { readMarketplaceCatalogIdentity } from "./read-marketplace-catalog-identity.js"; + +/** + * A version or migration drift decided from the path's own segments — never a different-catalog + * conflict, so it carries no identity at all. + */ +export interface MarketplaceSourceDriftFound { + readonly name: string; + readonly registeredSource: string; + readonly requestedSource: string; + readonly location: string; + readonly drift: MarketplaceSourceDrift; +} + +export type HostMarketplaceSourceCheck = + | MarketplaceSourceConflict + | MarketplaceSourceDriftFound + | undefined; + +/** `drift` is the one field `MarketplaceSourceConflict` does not have, so its presence alone + * discriminates. */ +export function isDriftFound( + check: HostMarketplaceSourceCheck +): check is MarketplaceSourceDriftFound { + return check !== undefined && "drift" in check; +} + +/** `fs.realpath`, falling back to the path itself when it cannot resolve: a dead registration must + * not cost every other comparison its answer. */ +async function resolvedOrSelf(fs: FileReader, path: string): Promise { + return fs.realpath(path).catch(() => path); +} + +/** + * Asks a host's own marketplace registry whether registering `requestedSource` would silently + * replace a different catalog, or repeat a version/migration drift this project's build + * recognises. Always keyed by `requestedIdentity.name`, never a caller's own local alias, which + * the host's registry was never asked about — folding both reads here is what keeps two callers + * from keying them differently. + * + * Reads the registry fresh on every call, so a caller iterating several marketplaces asks once per + * marketplace. Every path is resolved through `fs.realpath` before the drift decision compares + * them: the drift parsers compare spelling, not identity, so a `userConfigDir()` reached through a + * symlink (`/var` → `/private/var` on macOS) would fail every containment check silently. + */ +export async function hostMarketplaceSourceConflict( + fs: FileReader, + toolId: AiToolId, + reader: HostMarketplaceRegistryReader, + requestedSource: string, + requestedIdentity: MarketplaceCatalogIdentity, + /** Present only for a caller that wants the version/migration drift decided before falling back + * to the catalog-identity check — computed for every `aidd-framework` entry regardless of its + * own `scope`, since an unmigrated project-scope registration is exactly what it decides. */ + driftContext?: MarketplaceSourceDriftContext +): Promise { + const reading = await reader.read(); + const registeredSource = reading.entries?.get(requestedIdentity.name); + if (registeredSource === undefined) return undefined; + if (driftContext !== undefined) { + const drift = await resolvedDrift(fs, registeredSource, requestedSource, driftContext); + if (drift !== undefined) { + return { + name: requestedIdentity.name, + registeredSource, + requestedSource, + location: reading.location, + drift, + }; + } + } + const registeredIdentity = await readMarketplaceCatalogIdentity(fs, toolId, registeredSource); + return marketplaceSourceConflict( + reading, + requestedIdentity.name, + requestedSource, + registeredIdentity, + requestedIdentity + ); +} + +async function resolvedDrift( + fs: FileReader, + registeredSource: string, + requestedSource: string, + context: MarketplaceSourceDriftContext +): Promise { + const [resolvedRegistered, resolvedRequested, resolvedUserCacheRoot, resolvedProjectRoot] = + await Promise.all([ + resolvedOrSelf(fs, registeredSource), + resolvedOrSelf(fs, requestedSource), + resolvedOrSelf(fs, context.userCacheRoot), + resolvedOrSelf(fs, context.projectRoot), + ]); + return marketplaceSourceDrift(resolvedRegistered, resolvedRequested, { + ...context, + userCacheRoot: resolvedUserCacheRoot, + projectRoot: resolvedProjectRoot, + }); +} diff --git a/cli/src/contexts/framework/application/shared/purge-declared-cache.ts b/cli/src/contexts/framework/application/shared/purge-declared-cache.ts new file mode 100644 index 000000000..78ebb61d6 --- /dev/null +++ b/cli/src/contexts/framework/application/shared/purge-declared-cache.ts @@ -0,0 +1,82 @@ +import { join } from "node:path"; +import { describeError } from "../../../../kernel/describe-error.js"; +import type { FileReader } from "../../../../kernel/ports/file-reader.js"; +import type { FileWriter } from "../../../../kernel/ports/file-writer.js"; +import type { Logger } from "../../../../kernel/ports/logger.js"; +import { isStrictlyWithinUserScope } from "../../domain/plugins/user-scope-containment.js"; + +/** + * Returns `/` once its real, `realpath`-resolved location is proven to + * sit strictly inside `cacheRoot` — never on the manifest's word alone, since `relativeSegments` is + * data a corrupted entry could carry a `..` segment in, or a path a symlink could have escaped + * through after install. `null` for a root or candidate that does not exist (silent), a `realpath` + * that failed for any other reason (named and kept), or one resolving outside it (named and kept). + */ +export async function resolveCacheCandidate( + fs: FileReader, + logger: Logger, + cacheRoot: string, + relativeSegments: string, + label: string +): Promise { + const candidate = join(cacheRoot, relativeSegments); + let resolvedBoundary: string | null; + let resolvedCandidate: string | null; + try { + resolvedBoundary = await tryRealpath(fs, cacheRoot); + if (resolvedBoundary === null) return null; + resolvedCandidate = await tryRealpath(fs, candidate); + } catch (error) { + logger.warn( + `${label} could not be resolved, ${candidate}: ${describeError(error)}; left in place.` + ); + return null; + } + if (resolvedCandidate === null) return null; + if (!isStrictlyWithinUserScope(resolvedCandidate, resolvedBoundary)) { + logger.warn(`${label} does not resolve inside ${cacheRoot}; left in place: ${candidate}`); + return null; + } + return candidate; +} + +/** + * Deletes an already-`resolveCacheCandidate`d path once two proofs both hold, neither one alone: + * `confirmed`, the host's own CLI reporting success, and the directory itself proven empty — the + * one fact this can read back without a registry to reread. Either missing keeps the path and names + * why; a directory that no longer exists is nothing to purge, silently. + */ +export async function purgeCacheIfEmptyAndConfirmed( + fs: FileReader & FileWriter, + logger: Logger, + candidate: string, + confirmed: boolean, + label: string +): Promise { + if (!confirmed) { + logger.warn(`${label} left in place, its own removal was not confirmed: ${candidate}`); + return; + } + let entries: string[]; + try { + entries = await fs.listDirectory(candidate); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return; + throw error; + } + if (entries.length > 0) { + logger.warn(`${label} left in place, it still holds ${entries.length} file(s): ${candidate}`); + return; + } + await fs.deleteDirectory(candidate); + logger.info(`${label} purged: ${candidate}`); +} + +async function tryRealpath(fs: FileReader, path: string): Promise { + try { + return await fs.realpath(path); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return null; + throw error; + } +} diff --git a/cli/src/contexts/framework/application/shared/purge-native-marketplace-cache.ts b/cli/src/contexts/framework/application/shared/purge-native-marketplace-cache.ts new file mode 100644 index 000000000..0e5c2476e --- /dev/null +++ b/cli/src/contexts/framework/application/shared/purge-native-marketplace-cache.ts @@ -0,0 +1,130 @@ +import type { FileReader } from "../../../../kernel/ports/file-reader.js"; +import type { FileWriter } from "../../../../kernel/ports/file-writer.js"; +import type { Logger } from "../../../../kernel/ports/logger.js"; +import { type AiToolId, isAiToolId, type ToolId } from "../../../../kernel/tool.js"; +import type { HostMarketplaceRegistryReader } from "../../../tools/domain/ports/host-marketplace-registry-reader.js"; +import { nativeActivationOf } from "../../../tools/domain/registry.js"; +import type { NativeRegistrations } from "../../domain/manifest/native-registrations.js"; +import { purgeCacheIfEmptyAndConfirmed, resolveCacheCandidate } from "./purge-declared-cache.js"; + +export interface UndoneToolRegistrations { + readonly registrations: NativeRegistrations; + readonly removedHostNames: ReadonlySet; +} + +/** Never a tool whose binary was absent — `undone` holds only the tools a pass ran for. A tool + * whose profile declares no `NativeActivation.pluginCacheDir` is not looked at: no cache path is + * invented for a tool that never named one. */ +export async function purgeAllNativeCaches( + fs: FileReader & FileWriter, + logger: Logger, + home: string, + hostMarketplaceRegistries: ReadonlyMap, + undone: ReadonlyMap +): Promise { + for (const [toolId, { registrations, removedHostNames }] of undone) { + if (!isAiToolId(toolId)) continue; + const cacheRoot = nativeActivationOf(toolId)?.pluginCacheDir?.(home); + if (cacheRoot === undefined) continue; + for (const { hostName } of registrations.marketplaces) { + await purgeNativeMarketplaceCache( + fs, + logger, + hostMarketplaceRegistries.get(toolId), + cacheRoot, + registrations.binary, + hostName, + removedHostNames.has(hostName) + ); + } + } +} + +/** + * A host's cache directory is indexed by a name global to the machine, not to whichever caller is + * running, so containment alone proves the path cannot escape the declared root — never that the + * caller still owns what sits inside it. Two proofs, one per declaration: + * + * - a profile declaring `marketplaceRegistry` (claude) is reread after the undo: the name gone + * from that registry is the host's own admission nothing there resolves any more; + * - a profile declaring `pluginCacheDir` alone (codex) drives a host that deletes the cached + * content itself and leaves only an empty shell — measured. Emptiness proves no data would be + * lost, never that this caller emptied it, so `removed` (the host's own confirmation) is + * required alongside it. + * + * A path failing containment, a registry still naming the tenant, or a removal never confirmed is + * left in place and named. + */ +export async function purgeNativeMarketplaceCache( + fs: FileReader & FileWriter, + logger: Logger, + reader: HostMarketplaceRegistryReader | undefined, + cacheRoot: string, + binary: string, + hostName: string, + removed: boolean +): Promise { + const candidate = await resolveCacheCandidate( + fs, + logger, + cacheRoot, + hostName, + `${binary}: cache path for '${hostName}'` + ); + if (candidate === null) return; + if (reader === undefined) { + await purgeCacheIfEmptyAndConfirmed( + fs, + logger, + candidate, + removed, + `${binary}: cache for '${hostName}'` + ); + return; + } + await purgeOnceRegistryClears(fs, logger, reader, candidate, binary, hostName); +} + +/** + * Fail-closed: a purge happens on exactly two answers — the registry never existed, or it exists + * and no longer names this `hostName`. Anything else, `unreadable` included, keeps the cache and + * names why. + */ +async function purgeOnceRegistryClears( + fs: FileReader & FileWriter, + logger: Logger, + reader: HostMarketplaceRegistryReader, + candidate: string, + binary: string, + hostName: string +): Promise { + const reading = await reader.read(); + if (reading.absent === true) { + await purgeCache(fs, logger, candidate, binary, hostName); + return; + } + if (reading.entries !== undefined) { + if (reading.entries.has(hostName)) { + logger.warn( + `${binary}: cache for '${hostName}' left in place, ${reading.location} still names it: ${candidate}` + ); + return; + } + await purgeCache(fs, logger, candidate, binary, hostName); + return; + } + logger.warn( + `${binary}: plugin cache left in place, its registry could not be read: ${reading.location}` + ); +} + +async function purgeCache( + fs: FileReader & FileWriter, + logger: Logger, + candidate: string, + binary: string, + hostName: string +): Promise { + await fs.deleteDirectory(candidate); + logger.info(`${binary}: cache for '${hostName}' purged: ${candidate}`); +} diff --git a/cli/src/contexts/framework/application/shared/read-marketplace-catalog-identity.ts b/cli/src/contexts/framework/application/shared/read-marketplace-catalog-identity.ts new file mode 100644 index 000000000..06d9c7c65 --- /dev/null +++ b/cli/src/contexts/framework/application/shared/read-marketplace-catalog-identity.ts @@ -0,0 +1,52 @@ +import { join } from "node:path"; +import type { FileReader } from "../../../../kernel/ports/file-reader.js"; +import { isAiToolId, type ToolId } from "../../../../kernel/tool.js"; +import type { MarketplaceCatalogIdentity } from "../../../tools/domain/marketplace-source-conflict.js"; +import { getAiToolConfig } from "../../../tools/domain/registry.js"; + +/** + * What a directory's own catalog declares about itself — `name` and its plugin names, never + * `version` — read from the same relative file a tool's own `distributionProbes.marketplace` names, + * so this agrees with whatever that tool would actually read there. `undefined` when the directory + * carries no readable catalog at that path, or the JSON found there names nothing: silence, not a + * fact to invent, which is what lets a dead registry entry or an unbuilt tree answer "no fact" + * instead of a false "different catalog". + */ +export async function readMarketplaceCatalogIdentity( + fs: FileReader, + toolId: ToolId, + dir: string +): Promise { + const path = marketplaceCatalogProbePath(toolId, dir); + if (path === undefined) return undefined; + const content = await fs.readFile(path).catch(() => undefined); + if (content === undefined) return undefined; + try { + const parsed = JSON.parse(content) as { name?: unknown; plugins?: unknown }; + if (typeof parsed.name !== "string") return undefined; + const pluginNames = Array.isArray(parsed.plugins) ? pluginNamesOf(parsed.plugins) : []; + return { name: parsed.name, pluginNames }; + } catch { + return undefined; + } +} + +/** The exact path {@link readMarketplaceCatalogIdentity} reads, named so a caller whose read came + * back `undefined` can report *which file* it found nothing readable at. `undefined` for the same + * reason the read would answer nothing: not an AI tool, or one whose profile declares no + * `distributionProbes.marketplace`. */ +export function marketplaceCatalogProbePath(toolId: ToolId, dir: string): string | undefined { + if (!isAiToolId(toolId)) return undefined; + const catalogRelative = getAiToolConfig(toolId).distributionProbes?.marketplace?.[0]; + return catalogRelative === undefined ? undefined : join(dir, catalogRelative); +} + +function pluginNamesOf(plugins: readonly unknown[]): string[] { + const names: string[] = []; + for (const plugin of plugins) { + if (plugin === null || typeof plugin !== "object") continue; + const name = (plugin as { name?: unknown }).name; + if (typeof name === "string") names.push(name); + } + return names; +} diff --git a/cli/src/contexts/framework/application/shared/remove-project-hooks.ts b/cli/src/contexts/framework/application/shared/remove-project-hooks.ts new file mode 100644 index 000000000..cc1e42906 --- /dev/null +++ b/cli/src/contexts/framework/application/shared/remove-project-hooks.ts @@ -0,0 +1,61 @@ +import { dirname, join } from "node:path"; +import type { FileReader } from "../../../../kernel/ports/file-reader.js"; +import type { FileWriter } from "../../../../kernel/ports/file-writer.js"; +import type { AiToolId } from "../../../../kernel/tool.js"; +import { + cursorProjectHooksScriptDir, + unmergeCursorProjectHooksJson, +} from "../../../tools/domain/formats/cursor-hooks-project-merge.js"; +import { resolvePluginsCapability } from "../../../tools/domain/registry.js"; + +/** + * Undoes what `ProjectHooksMaterializer` wrote for one plugin: a tool declaring + * `hooksDestination: "project"` (Cursor) merges a plugin's hooks into the project's own hooks file + * rather than tracking them in `Plugin.files`, so removal needs an unmerge instead of a + * baseDir-relative file delete. Both destinations are recomputed from `pluginName` alone, exactly + * as install computed them, so there is no extra state to keep in sync. + * + * Returns whether anything was actually there to undo. A no-op for a tool declaring no + * project-merged hooks destination. + */ +export async function removeProjectHooks( + fs: FileReader & FileWriter, + pluginName: string, + toolId: AiToolId, + projectRoot: string +): Promise { + const pluginsCap = resolvePluginsCapability(toolId); + if (pluginsCap?.hooksDestination !== "project") return false; + const projectHooksRelativePath = pluginsCap.projectHooksRelativePath; + if (projectHooksRelativePath === null) return false; + const hooksPath = join(projectRoot, projectHooksRelativePath); + const existing = await readExistingJson(fs, hooksPath); + if (existing !== null) { + const unmerged = unmergeCursorProjectHooksJson(existing, pluginName); + // A file this route wrote in the first place — leaving it as an empty shell once + // its last plugin is gone is the same residue clean exists to stop leaving. + if (isHooksFileEmpty(unmerged)) await fs.deleteFile(hooksPath); + else await fs.writeFile(hooksPath, unmerged); + } + const scriptDir = join(projectRoot, cursorProjectHooksScriptDir(pluginName)); + const hadScriptDir = await fs.fileExists(scriptDir); + if (hadScriptDir) { + await fs.deleteDirectory(scriptDir); + await fs.deleteEmptyDirectories(dirname(scriptDir)); + } + return existing !== null || hadScriptDir; +} + +async function readExistingJson(fs: FileReader, path: string): Promise { + try { + return await fs.readFile(path); + } catch (err) { + if ((err as NodeJS.ErrnoException).code === "ENOENT") return null; + throw err; + } +} + +function isHooksFileEmpty(hooksJson: string): boolean { + const parsed = JSON.parse(hooksJson) as { hooks?: Record }; + return Object.keys(parsed.hooks ?? {}).length === 0; +} diff --git a/cli/src/contexts/framework/application/shared/resolve-uninstall-scope.ts b/cli/src/contexts/framework/application/shared/resolve-uninstall-scope.ts new file mode 100644 index 000000000..74a2988ad --- /dev/null +++ b/cli/src/contexts/framework/application/shared/resolve-uninstall-scope.ts @@ -0,0 +1,35 @@ +import type { MarketplaceScope } from "../../../../kernel/scope.js"; +import type { HostPluginRegistryReader } from "../../../tools/domain/ports/host-plugin-registry-reader.js"; + +/** + * The scope(s) worth asking a host's own CLI to uninstall `ref` at, in the order to try them. + * + * A host's own registry is authoritative when it answers for this ref (claude records the scope an + * entry was written at) — a single scope, trusted outright. Otherwise — no reader, the ref absent, + * or a host with no per-entry scope concept (codex, copilot are machine-global) — this falls back + * to `manifestScope`, then the other one, since a plugin enabled before this CLI passed a scope at + * all sits at claude's own implicit `"user"` default whatever the manifest recorded. Trying the + * wrong scope first costs one failed, best-effort attempt: measured, a real `claude` binary refuses + * a mismatched-scope uninstall outright rather than silently missing it. + */ +export async function resolveUninstallScopeOrder( + reader: HostPluginRegistryReader | undefined, + ref: string, + projectRoot: string, + manifestScope: MarketplaceScope +): Promise { + const registryScope = await readRegistryScope(reader, ref, projectRoot); + if (registryScope !== undefined) return [registryScope]; + const other: MarketplaceScope = manifestScope === "project" ? "user" : "project"; + return [manifestScope, other]; +} + +async function readRegistryScope( + reader: HostPluginRegistryReader | undefined, + ref: string, + projectRoot: string +): Promise { + if (reader === undefined) return undefined; + const reading = await reader.read(projectRoot); + return reading.refs?.get(ref)?.scope; +} diff --git a/cli/src/contexts/framework/application/shared/setup-marketplace-registration-use-case.ts b/cli/src/contexts/framework/application/shared/setup-marketplace-registration-use-case.ts new file mode 100644 index 000000000..b4f78becc --- /dev/null +++ b/cli/src/contexts/framework/application/shared/setup-marketplace-registration-use-case.ts @@ -0,0 +1,123 @@ +import { CatalogFetchAuthError } from "../../../../kernel/errors.js"; +import type { FileReader } from "../../../../kernel/ports/file-reader.js"; +import type { Logger } from "../../../../kernel/ports/logger.js"; +import type { VersionReader } from "../../../../kernel/ports/version-reader.js"; +import type { PluginSource } from "../../../../kernel/source.js"; +import type { TokenProvider } from "../../../../runtime/auth/ports/token-provider.js"; +import type { LatestReleaseResolver } from "../../../../runtime/self-update/latest-release-resolver.js"; +import type { MarketplaceRefresh } from "../../../distribution/application/marketplace-refresh-use-case.js"; +import type { + MarketplaceRegisterFramework, + MarketplaceRegisterFrameworkOptions, +} from "../../../distribution/application/marketplace-register-framework-use-case.js"; +import { FRAMEWORK_MARKETPLACE_NAME } from "../../../distribution/domain/marketplace.js"; +import type { MarketplaceSourceMode } from "../../../distribution/domain/marketplace-source-mode.js"; +import type { Environment } from "../../domain/ports/environment.js"; +import type { UserSourceReferences } from "../../domain/ports/user-source-references.js"; +import type { SetupFlow } from "../../domain/setup-flow.js"; +import type { SetupMarketplaceSourceUseCase } from "../setup/setup-marketplace-source-use-case.js"; +import { + frameworkSourceIsShared, + resolveProjectRootForReferences, + toleratingUnreadableSourceReferences, +} from "./shared-source-reference-support.js"; + +/** + * The one sequence project-scope and machine-scope setup both run, in the same order and gated the + * same way: resolve, guard remote auth, register, refresh. + */ +export class SetupMarketplaceRegistrationUseCase { + constructor( + private readonly fs: FileReader, + private readonly setupMarketplaceSourceUseCase: SetupMarketplaceSourceUseCase, + private readonly marketplaceRegisterFrameworkUseCase: MarketplaceRegisterFramework, + private readonly marketplaceRefreshUseCase: MarketplaceRefresh, + private readonly currentVersionProvider: VersionReader, + private readonly logger: Logger, + private readonly environment: Environment, + private readonly tokenProvider?: TokenProvider, + private readonly releaseResolver?: LatestReleaseResolver, + /** The registry of projects referencing the shared machine-scope source. Absent skips + * recording a reference rather than guessing one. */ + private readonly userSourceReferences?: UserSourceReferences + ) {} + + /** Resolves `flow`'s source, when it asks for one at all. Called before a caller's own manifest + * is initialized: a non-interactive run with no `--source` must reject before writing anything. */ + async resolveSourceIfNeeded(flow: SetupFlow): Promise { + if (!flow.registerDefaultMarketplace) return null; + return this.setupMarketplaceSourceUseCase.execute({ + projectRoot: flow.projectRoot, + sourceFromCli: flow.source, + interactive: flow.interactive, + }); + } + + /** Guards, registers and refreshes the source `resolveSourceIfNeeded` returned, once + * the caller's own manifest exists — a no-op when there was no source to register. */ + async registerIfPresent(flow: SetupFlow, source: MarketplaceSourceMode | null): Promise { + if (source === null) return; + await this.guardRemoteAuth(source); + await this.registerMarketplace(flow, source); + await this.refreshCatalog(flow); + } + + // Auth is only required to fetch a PRIVATE framework. A token can reach either; + // without one, allow public repos through and gate only private/unreachable ones. + private async guardRemoteAuth(source: MarketplaceSourceMode): Promise { + if (source.kind !== "remote") return; + if (this.tokenProvider === undefined) return; + const token = await this.tokenProvider.resolve(); + if (token !== null) return; + if ( + this.releaseResolver !== undefined && + (await this.releaseResolver.isRepoPublic(source.repo)) + ) { + return; + } + throw new CatalogFetchAuthError(`https://github.com/${source.repo}`); + } + + private async registerMarketplace(flow: SetupFlow, source: MarketplaceSourceMode): Promise { + const opts = this.buildRegisterOptions(flow, source); + const result = await this.marketplaceRegisterFrameworkUseCase.execute(opts); + // `--scope user` has no project-scope manifest for a later `clean` to ever decrement this + // claim from, so absence, not a recorded reference, is the honest state there. + if (flow.scope === "user") return; + // The same name-and-scope predicate `sync` and `clean` apply before touching + // `references.json`: always true today, but a future change must not silently start writing a + // reference for a registration that is no longer the shared one. + if (frameworkSourceIsShared(FRAMEWORK_MARKETPLACE_NAME, result.scope)) { + await this.recordSharedSourceReference(flow.projectRoot); + } + } + + // Written every time this runs, not only the first: another project on this machine may have + // registered the shared source before this one did, leaving this project's reference missing. + private async recordSharedSourceReference(projectRoot: string): Promise { + if (this.userSourceReferences === undefined) return; + const userSourceReferences = this.userSourceReferences; + await toleratingUnreadableSourceReferences(this.logger, undefined, async () => { + const resolvedRoot = await resolveProjectRootForReferences(this.fs, projectRoot); + await userSourceReferences.addReference(this.currentVersionProvider.get(), resolvedRoot); + }); + } + + private buildRegisterOptions( + flow: SetupFlow, + source: MarketplaceSourceMode + ): MarketplaceRegisterFrameworkOptions { + const pluginSource = this.toPluginSource(source); + return { projectRoot: flow.projectRoot, pluginSource, force: true }; + } + + private toPluginSource(source: MarketplaceSourceMode): PluginSource { + if (source.kind === "local") return { kind: "local", path: source.path }; + return { kind: "github", repo: source.repo, ref: source.ref }; + } + + private async refreshCatalog(flow: SetupFlow): Promise { + if (this.environment.get("AIDD_SKIP_MARKETPLACE_REFRESH") === "1") return; + await this.marketplaceRefreshUseCase.execute({ projectRoot: flow.projectRoot }); + } +} diff --git a/cli/src/contexts/framework/application/shared/shared-source-reference-support.ts b/cli/src/contexts/framework/application/shared/shared-source-reference-support.ts new file mode 100644 index 000000000..efca4993a --- /dev/null +++ b/cli/src/contexts/framework/application/shared/shared-source-reference-support.ts @@ -0,0 +1,119 @@ +import { UnreadableUserSourceReferencesError } from "../../../../kernel/errors.js"; +import { samePathSegment } from "../../../../kernel/paths.js"; +import type { FileReader } from "../../../../kernel/ports/file-reader.js"; +import type { Logger } from "../../../../kernel/ports/logger.js"; +import type { MarketplaceScope } from "../../../../kernel/scope.js"; +import { FRAMEWORK_MARKETPLACE_NAME } from "../../../distribution/domain/marketplace.js"; +import type { UserSourceReferences } from "../../domain/ports/user-source-references.js"; + +/** The reserved name at scope `"user"`: the one shared, machine-scope source every project's own + * `references.json` claim tracks. A single predicate rather than an inline check per caller, so a + * fourth site cannot drift onto a different spelling and skip the check. */ +export function frameworkSourceIsShared(name: string, scope: MarketplaceScope): boolean { + return name === FRAMEWORK_MARKETPLACE_NAME && scope === "user"; +} + +/** + * Whether uninstalling `ref` here would take away a plugin another project on this machine still + * needs — applied before driving a host's own CLI to uninstall, never after. + * + * `ref` is matched against `sharedSourceHostName`, never the project's own local alias, which a + * host never learns. Any one condition failing means uninstalling `ref` here cannot break another + * project, so it proceeds. + */ +export function refAnotherProjectStillNeeds(input: { + ref: string; + sharedSourceHostName: string | undefined; + enablementIsMachineGlobal: boolean; + otherProjects: readonly string[]; +}): boolean { + const { ref, sharedSourceHostName, enablementIsMachineGlobal, otherProjects } = input; + if (sharedSourceHostName === undefined) return false; + if (!ref.endsWith(`@${sharedSourceHostName}`)) return false; + if (!enablementIsMachineGlobal) return false; + return otherProjects.length > 0; +} + +/** + * The instruction for fully removing the shared source, once a message has already named which + * other projects still need it. Each command name stays whole inside this one string literal: + * `errors-that-instruct.arch.test.ts` reads every string and template literal under `application/` + * and checks each command it names against the ones the CLI declares. + */ +export function describeFullRemovalInstruction(): string { + return "full removal is `aidd clean` in each of them, then `aidd clean --scope user`."; +} + +/** + * The message both `clean` and `plugin remove` warn with instead of ever uninstalling `ref`, once + * `refAnotherProjectStillNeeds` says it is guarded — the two callers differ only in how they + * resolve the inputs, never in the sentence itself. + */ +export function describeGuardedPluginRefMessage(input: { + binary: string; + ref: string; + otherProjects: readonly string[]; +}): string { + const { binary, ref, otherProjects } = input; + const plural = otherProjects.length === 1 ? "project" : "projects"; + const verb = otherProjects.length === 1 ? "references" : "reference"; + return ( + `${binary}: '${ref}' left enabled — ${binary} enables a plugin machine-wide, and ` + + `${otherProjects.length} other ${plural} still ${verb} the shared source: ` + + `${otherProjects.join(", ")} — which is why it stays; ${describeFullRemovalInstruction()}` + ); +} + +/** + * Every project this file still names as referencing the shared source, minus `ownRoot` — the one + * denominator every caller reads, so a project that never had a claim of its own to drop reads the + * same "other projects" fact as one that just dropped one. `ownRoot` must already be resolved + * through `resolveProjectRootForReferences`; this does not resolve it itself. + */ +export async function otherProjectsReferencing( + userSourceReferences: UserSourceReferences, + ownRoot: string +): Promise { + return (await userSourceReferences.listAllReferencingProjects()).filter( + (root) => !samePathSegment(root, ownRoot) + ); +} + +/** + * Resolves `projectRoot` through every symlink, the same real location `clean` insists on before + * deleting a user-scope file — a reference recorded under a syntactic path a symlink later moved + * would never match what a later `clean` resolves for the same project. Falls back to the path as + * given only on `ENOENT`, so `clean` can still drop a reference to a project since removed. + */ +export async function resolveProjectRootForReferences( + fs: FileReader, + projectRoot: string +): Promise { + try { + return await fs.realpath(projectRoot); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return projectRoot; + throw error; + } +} + +/** + * Runs `action`, treating a `references.json` this CLI cannot make sense of exactly like an absent + * one: the file is a help, not an authority, so reading or writing it must never block the command + * it does not gate. Every caller reaches the port through this, so none can reintroduce that + * failure by forgetting to catch it. Anything other than `UnreadableUserSourceReferencesError` is + * a bug, not a corrupted file, and propagates. + */ +export async function toleratingUnreadableSourceReferences( + logger: Logger, + fallback: T, + action: () => Promise +): Promise { + try { + return await action(); + } catch (error) { + if (!(error instanceof UnreadableUserSourceReferencesError)) throw error; + logger.warn(error.message); + return fallback; + } +} diff --git a/cli/src/contexts/framework/application/shared/user-scope-plugin-files.ts b/cli/src/contexts/framework/application/shared/user-scope-plugin-files.ts new file mode 100644 index 000000000..3937f01f5 --- /dev/null +++ b/cli/src/contexts/framework/application/shared/user-scope-plugin-files.ts @@ -0,0 +1,50 @@ +import { join } from "node:path"; +import type { FileReader } from "../../../../kernel/ports/file-reader.js"; +import type { Logger } from "../../../../kernel/ports/logger.js"; +import type { AiToolId } from "../../../../kernel/tool.js"; +import { resolvePluginsCapability } from "../../../tools/domain/registry.js"; +import type { InstalledPlugin } from "../../domain/plugins/installed-plugin.js"; +import { isStrictlyWithinUserScope } from "../../domain/plugins/user-scope-containment.js"; + +/** + * The files of a user-scope plugin that are actually safe to delete: safe only once the real, + * `realpath`-resolved location still sits strictly inside the tool's own declared user-scope + * directory. A `..` segment a corrupted manifest entry carries, or a plugin directory that became a + * symlink after install, both fail this and are left in place and named. + */ +export async function userScopeFilesSafeToDelete( + fs: FileReader, + logger: Logger, + plugin: InstalledPlugin, + toolId: AiToolId, + homedir: string +): Promise> { + const boundary = resolvePluginsCapability(toolId)?.userPluginsBaseDir(homedir); + if (boundary === null || boundary === undefined) return new Map(); + const resolvedBoundary = await tryRealpath(fs, boundary); + if (resolvedBoundary === null) return new Map(); + const allowed = new Map(); + for (const [relativePath, hash] of plugin.files) { + const resolvedCandidate = await tryRealpath(fs, join(boundary, relativePath)); + if ( + resolvedCandidate !== null && + isStrictlyWithinUserScope(resolvedCandidate, resolvedBoundary) + ) { + allowed.set(relativePath, hash); + continue; + } + logger.warn( + `${toolId}: '${plugin.name}' file '${relativePath}' does not resolve inside ${boundary}; left in place.` + ); + } + return allowed; +} + +async function tryRealpath(fs: FileReader, path: string): Promise { + try { + return await fs.realpath(path); + } catch (err) { + if ((err as NodeJS.ErrnoException).code === "ENOENT") return null; + throw err; + } +} diff --git a/cli/src/application/use-cases/status-use-case.ts b/cli/src/contexts/framework/application/status-use-case.ts similarity index 84% rename from cli/src/application/use-cases/status-use-case.ts rename to cli/src/contexts/framework/application/status-use-case.ts index dd7b808f4..125789b8c 100644 --- a/cli/src/application/use-cases/status-use-case.ts +++ b/cli/src/contexts/framework/application/status-use-case.ts @@ -1,18 +1,17 @@ import { join } from "node:path"; -import type { FileHash } from "../../domain/models/file.js"; -import type { Manifest } from "../../domain/models/manifest.js"; -import { extractMergeEntries, type MergeFileEntry } from "../../domain/models/merge.js"; -import type { AiToolId } from "../../domain/models/tool-ids.js"; -import type { FileReader } from "../../domain/ports/file-reader.js"; -import type { Hasher } from "../../domain/ports/hasher.js"; -import type { ManifestRepository } from "../../domain/ports/manifest-repository.js"; +import { NoManifestError, ToolNotInstalledError } from "../../../kernel/errors.js"; +import type { FileHash } from "../../../kernel/file.js"; +import { extractMergeEntries, type MergeFileEntry } from "../../../kernel/merge.js"; +import type { FileReader } from "../../../kernel/ports/file-reader.js"; +import type { Hasher } from "../../../kernel/ports/hasher.js"; +import type { AiToolId, ToolCategory, ToolId } from "../../../kernel/tool.js"; import { getToolConfig, - type ToolCategory, - type ToolId, + machineLocalFilesOf, toolIdsForCategory, -} from "../../domain/tools/registry.js"; -import { NoManifestError, ToolNotInstalledError } from "../errors.js"; +} from "../../tools/domain/registry.js"; +import type { Manifest } from "../domain/manifest.js"; +import type { ManifestRepository } from "../domain/ports/manifest-repository.js"; import type { DetectPluginDriftUseCase } from "./shared/detect-plugin-drift-use-case.js"; type FileStatusKind = "modified" | "deleted" | "added"; @@ -32,6 +31,7 @@ interface PluginDriftEntry { toolId: AiToolId; pluginName: string; driftedFiles: string[]; + notInstalledOnMachine: boolean; } export interface StatusReport { @@ -48,8 +48,7 @@ export interface StatusOptions { } /** - * The question an orchestrator asks of status: one report per scope. Callers that only - * ask depend on this, not on the class that answers it - so a double is a real + * Callers that only ask depend on this, not on the class that answers it, so a double is a real * implementation rather than a cast. */ export interface StatusQuery { @@ -122,14 +121,20 @@ export class StatusUseCase implements StatusQuery { drifted.push(...(await this.checkMergeFiles(mergeFiles, projectRoot))); const dir = getToolConfig(toolId).directory; const trackedSet = manifest.getTrackedPathsInDirectory(dir); - drifted.push(...(await this.detectAddedFiles(dir, trackedSet, projectRoot))); + drifted.push( + ...(await this.detectAddedFiles(dir, trackedSet, projectRoot, machineLocalFilesOf(toolId))) + ); return { toolId, version, drifted }; } private async detectAddedFiles( directory: string, trackedSet: Set, - projectRoot: string + projectRoot: string, + // Written by this CLI on purpose and never tracked — reporting one as something the user added + // would be a lie. The `.backup` skip below is not the same thing: nothing here writes such a + // file, so it only spares one an older version left behind. + machineLocal: readonly string[] // User-scope plugin dirs (e.g. ~/.cursor/plugins/local/) are not scanned for added files; // only tracked-file drift is detected for user-scope plugins. ): Promise { @@ -140,6 +145,7 @@ export class StatusUseCase implements StatusQuery { for (const diskRelPath of diskFiles) { if (diskRelPath.endsWith(".backup")) continue; const fullRelPath = `${directory}${diskRelPath}`; + if (machineLocal.includes(fullRelPath)) continue; if (!trackedSet.has(fullRelPath)) added.push({ relativePath: fullRelPath, status: "added" }); } return added; @@ -223,6 +229,7 @@ export class StatusUseCase implements StatusQuery { toolId: drift.toolId, pluginName: drift.pluginName, driftedFiles: drift.files.map((file) => file.relativePath), + notInstalledOnMachine: drift.notInstalledOnMachine, })); } } diff --git a/cli/src/application/use-cases/uninstall/uninstall-ide-use-case.ts b/cli/src/contexts/framework/application/uninstall/uninstall-ide-use-case.ts similarity index 81% rename from cli/src/application/use-cases/uninstall/uninstall-ide-use-case.ts rename to cli/src/contexts/framework/application/uninstall/uninstall-ide-use-case.ts index 031d662d8..d8f65f82d 100644 --- a/cli/src/application/use-cases/uninstall/uninstall-ide-use-case.ts +++ b/cli/src/contexts/framework/application/uninstall/uninstall-ide-use-case.ts @@ -1,6 +1,6 @@ -import type { IdeToolId } from "../../../domain/models/tool-ids.js"; -import type { ManifestRepository } from "../../../domain/ports/manifest-repository.js"; -import { NoManifestError, ToolNotInstalledError } from "../../errors.js"; +import { NoManifestError, ToolNotInstalledError } from "../../../../kernel/errors.js"; +import type { IdeToolId } from "../../../../kernel/tool.js"; +import type { ManifestRepository } from "../../domain/ports/manifest-repository.js"; import type { UninstallToolsUseCase } from "./uninstall-tools-use-case.js"; export interface UninstallIdeOptions { diff --git a/cli/src/application/use-cases/uninstall/uninstall-mcp-exclusion-use-case.ts b/cli/src/contexts/framework/application/uninstall/uninstall-mcp-exclusion-use-case.ts similarity index 85% rename from cli/src/application/use-cases/uninstall/uninstall-mcp-exclusion-use-case.ts rename to cli/src/contexts/framework/application/uninstall/uninstall-mcp-exclusion-use-case.ts index 73721f724..695221f92 100644 --- a/cli/src/application/use-cases/uninstall/uninstall-mcp-exclusion-use-case.ts +++ b/cli/src/contexts/framework/application/uninstall/uninstall-mcp-exclusion-use-case.ts @@ -1,11 +1,11 @@ import { join } from "node:path"; -import type { Manifest } from "../../../domain/models/manifest.js"; -import type { McpExclusion } from "../../../domain/models/mcp-exclusion.js"; -import { type MergeFileEntry, removeEntriesFromJson } from "../../../domain/models/merge.js"; -import type { FileReader } from "../../../domain/ports/file-reader.js"; -import type { FileWriter } from "../../../domain/ports/file-writer.js"; -import type { Logger } from "../../../domain/ports/logger.js"; -import type { ToolId } from "../../../domain/tools/registry.js"; +import { type MergeFileEntry, removeEntriesFromJson } from "../../../../kernel/merge.js"; +import type { FileReader } from "../../../../kernel/ports/file-reader.js"; +import type { FileWriter } from "../../../../kernel/ports/file-writer.js"; +import type { Logger } from "../../../../kernel/ports/logger.js"; +import type { ToolId } from "../../../../kernel/tool.js"; +import type { McpExclusion } from "../../../tools/domain/mcp-exclusion.js"; +import type { Manifest } from "../../domain/manifest.js"; export interface UninstallMcpExclusionOptions { toolId: ToolId; diff --git a/cli/src/contexts/framework/application/uninstall/uninstall-plugin-use-case.ts b/cli/src/contexts/framework/application/uninstall/uninstall-plugin-use-case.ts new file mode 100644 index 000000000..49dbb10a3 --- /dev/null +++ b/cli/src/contexts/framework/application/uninstall/uninstall-plugin-use-case.ts @@ -0,0 +1,65 @@ +import { NoManifestError, PluginNotFoundError } from "../../../../kernel/errors.js"; +import type { FileWriter } from "../../../../kernel/ports/file-writer.js"; +import type { AiToolId, ToolId } from "../../../../kernel/tool.js"; +import { AI_TOOL_IDS } from "../../../../kernel/tool.js"; +import type { Manifest } from "../../domain/manifest.js"; +import type { ManifestRepository } from "../../domain/ports/manifest-repository.js"; +import { deletePluginFilesForTool } from "../plugin/plugin-helpers.js"; + +export interface UninstallPluginOptions { + pluginName: string; + toolIds: ToolId[]; + projectRoot: string; +} + +export interface UninstallPluginResult { + toolId: ToolId; + fileCount: number; + deletedFiles: string[]; +} + +export class UninstallPluginUseCase { + constructor( + private readonly fs: FileWriter, + private readonly manifestRepo: ManifestRepository + ) {} + + async execute(options: UninstallPluginOptions): Promise { + const { pluginName, toolIds, projectRoot } = options; + const manifest = await this.manifestRepo.load(); + if (manifest === null) throw new NoManifestError(); + const scope = this.resolveToolScope(toolIds, manifest); + const results = await this.removeFromTools(pluginName, scope, projectRoot, manifest); + if (results.length === 0) throw new PluginNotFoundError(pluginName); + await this.manifestRepo.save(manifest); + return results; + } + + private resolveToolScope(toolIds: ToolId[], manifest: Manifest): AiToolId[] { + if (toolIds.length > 0) return toolIds.filter((id) => manifest.hasTool(id)) as AiToolId[]; + return AI_TOOL_IDS.filter((id) => manifest.hasTool(id)) as AiToolId[]; + } + + private async removeFromTools( + pluginName: string, + toolIds: AiToolId[], + projectRoot: string, + manifest: Manifest + ): Promise { + const results: UninstallPluginResult[] = []; + for (const toolId of toolIds) { + const plugin = manifest.getPlugins(toolId).find((p) => p.name === pluginName); + if (plugin === undefined) continue; + const deletedFiles = await deletePluginFilesForTool( + plugin.files, + plugin.scope, + toolId, + projectRoot, + this.fs + ); + manifest.removePlugin(toolId, pluginName); + results.push({ toolId, fileCount: deletedFiles.length, deletedFiles }); + } + return results; + } +} diff --git a/cli/src/application/use-cases/uninstall/uninstall-tools-use-case.ts b/cli/src/contexts/framework/application/uninstall/uninstall-tools-use-case.ts similarity index 88% rename from cli/src/application/use-cases/uninstall/uninstall-tools-use-case.ts rename to cli/src/contexts/framework/application/uninstall/uninstall-tools-use-case.ts index ede602dfc..006b1bdb2 100644 --- a/cli/src/application/use-cases/uninstall/uninstall-tools-use-case.ts +++ b/cli/src/contexts/framework/application/uninstall/uninstall-tools-use-case.ts @@ -1,14 +1,17 @@ import { dirname, join } from "node:path"; -import type { Manifest } from "../../../domain/models/manifest.js"; import { isMergeContentEmpty, type MergeFileEntry, removeEntriesFromJson, -} from "../../../domain/models/merge.js"; -import type { FileReader } from "../../../domain/ports/file-reader.js"; -import type { FileWriter } from "../../../domain/ports/file-writer.js"; -import type { Logger } from "../../../domain/ports/logger.js"; -import { getToolConfig, isAiTool, type ToolId } from "../../../domain/tools/registry.js"; +} from "../../../../kernel/merge.js"; +import type { FileReader } from "../../../../kernel/ports/file-reader.js"; +import type { FileWriter } from "../../../../kernel/ports/file-writer.js"; +import type { Logger } from "../../../../kernel/ports/logger.js"; +import type { ToolId } from "../../../../kernel/tool.js"; +import { isAiToolId } from "../../../../kernel/tool.js"; +import { getToolConfig, isAiTool } from "../../../tools/domain/registry.js"; +import type { Manifest } from "../../domain/manifest.js"; +import { deletePluginFilesForTool } from "../plugin/plugin-helpers.js"; export interface UninstallToolsOptions { toolIds: ToolId[]; @@ -97,19 +100,9 @@ export class UninstallToolsUseCase { manifest: Manifest, projectRoot: string ): Promise { + if (!isAiToolId(toolId)) return; for (const plugin of manifest.getPlugins(toolId)) { - await this.deletePluginFiles(plugin.files, projectRoot); - } - } - - private async deletePluginFiles( - files: ReadonlyMap, - projectRoot: string - ): Promise { - for (const relativePath of files.keys()) { - const fullPath = join(projectRoot, relativePath); - await this.fs.deleteFile(fullPath); - await this.fs.deleteEmptyDirectories(dirname(fullPath)); + await deletePluginFilesForTool(plugin.files, plugin.scope, toolId, projectRoot, this.fs); } } diff --git a/cli/src/application/use-cases/uninstall/uninstall-use-case.ts b/cli/src/contexts/framework/application/uninstall/uninstall-use-case.ts similarity index 80% rename from cli/src/application/use-cases/uninstall/uninstall-use-case.ts rename to cli/src/contexts/framework/application/uninstall/uninstall-use-case.ts index 45b04756b..0354b0b2f 100644 --- a/cli/src/application/use-cases/uninstall/uninstall-use-case.ts +++ b/cli/src/contexts/framework/application/uninstall/uninstall-use-case.ts @@ -1,10 +1,15 @@ -import type { Manifest } from "../../../domain/models/manifest.js"; -import type { FileReader } from "../../../domain/ports/file-reader.js"; -import type { FileWriter } from "../../../domain/ports/file-writer.js"; -import type { Logger } from "../../../domain/ports/logger.js"; -import type { ManifestRepository } from "../../../domain/ports/manifest-repository.js"; -import { type ToolId, VALID_TOOL_IDS } from "../../../domain/tools/registry.js"; -import { InputRequiredError, NoManifestError, ToolNotInstalledError } from "../../errors.js"; +import { + InputRequiredError, + NoManifestError, + ToolNotInstalledError, +} from "../../../../kernel/errors.js"; +import type { FileReader } from "../../../../kernel/ports/file-reader.js"; +import type { FileWriter } from "../../../../kernel/ports/file-writer.js"; +import type { Logger } from "../../../../kernel/ports/logger.js"; +import type { ToolId } from "../../../../kernel/tool.js"; +import { VALID_TOOL_IDS } from "../../../../kernel/tool.js"; +import type { Manifest } from "../../domain/manifest.js"; +import type { ManifestRepository } from "../../domain/ports/manifest-repository.js"; import { UninstallMcpExclusionUseCase } from "./uninstall-mcp-exclusion-use-case.js"; import { UninstallPluginUseCase } from "./uninstall-plugin-use-case.js"; import { UninstallToolsUseCase } from "./uninstall-tools-use-case.js"; diff --git a/cli/src/contexts/framework/domain/config-capability.ts b/cli/src/contexts/framework/domain/config-capability.ts new file mode 100644 index 000000000..18c4fffac --- /dev/null +++ b/cli/src/contexts/framework/domain/config-capability.ts @@ -0,0 +1,28 @@ +import { HooksCapability } from "../../tools/domain/capabilities/hooks-capability.js"; +import { McpCapability } from "../../tools/domain/capabilities/mcp-capability.js"; +import { SettingsCapability } from "../../tools/domain/capabilities/settings-capability.js"; +import type { ToolConfig } from "../../tools/domain/registry.js"; + +export type ConfigCapability = McpCapability | HooksCapability | SettingsCapability; + +export function extractConfigCapabilities(config: ToolConfig): ConfigCapability[] { + const result: ConfigCapability[] = []; + + if ("settings" in config) { + const s = (config as { settings: unknown }).settings; + if (s instanceof SettingsCapability) result.push(s); + else if (Array.isArray(s)) result.push(...(s as SettingsCapability[])); + } + + if (config.kind === "ai") { + const aiCaps = config.capabilities as Record; + if (typeof aiCaps === "object" && aiCaps !== null) { + if (aiCaps.mcp instanceof McpCapability) result.push(aiCaps.mcp); + if (aiCaps.hooks instanceof HooksCapability) result.push(aiCaps.hooks); + if (aiCaps.settings instanceof SettingsCapability) result.push(aiCaps.settings); + if (Array.isArray(aiCaps.settings)) result.push(...(aiCaps.settings as SettingsCapability[])); + } + } + + return result; +} diff --git a/cli/src/contexts/framework/domain/doctor.ts b/cli/src/contexts/framework/domain/doctor.ts new file mode 100644 index 000000000..1bdbb2f14 --- /dev/null +++ b/cli/src/contexts/framework/domain/doctor.ts @@ -0,0 +1,33 @@ +import type { AiToolId, ToolId } from "../../../kernel/tool.js"; + +export type IssueSeverity = "info" | "warning" | "error"; + +export interface DoctorIssue { + severity: IssueSeverity; + message: string; + fix: string; +} + +export interface ToolHealth { + toolId: ToolId; + fileCount: number; + mergeFileCount: number; +} + +export type PluginIssueKind = "missing" | "hash-mismatch" | "not-installed-on-machine"; + +export interface PluginIssueEntry { + toolId: AiToolId; + pluginName: string; + issue: PluginIssueKind; + /** Absent for `not-installed-on-machine`: the fact is one line for the whole + * plugin, not one path per tracked file. */ + filePath?: string; +} + +export interface DoctorReport { + healthy: boolean; + toolHealth: ToolHealth[]; + issues: DoctorIssue[]; + pluginIssues: PluginIssueEntry[]; +} diff --git a/cli/src/domain/formats/markdown-references.ts b/cli/src/contexts/framework/domain/formats/markdown-references.ts similarity index 100% rename from cli/src/domain/formats/markdown-references.ts rename to cli/src/contexts/framework/domain/formats/markdown-references.ts diff --git a/cli/src/domain/models/install-scope.ts b/cli/src/contexts/framework/domain/install-scope.ts similarity index 84% rename from cli/src/domain/models/install-scope.ts rename to cli/src/contexts/framework/domain/install-scope.ts index 35330a3cf..165b67998 100644 --- a/cli/src/domain/models/install-scope.ts +++ b/cli/src/contexts/framework/domain/install-scope.ts @@ -1,6 +1,6 @@ -import { InvalidInstallScopeError, InvalidPluginScopeError } from "../errors.js"; -import { getToolConfig, isAiTool } from "../tools/registry.js"; -import type { AiToolId } from "./tool-ids.js"; +import { InvalidInstallScopeError, InvalidPluginScopeError } from "../../../kernel/errors.js"; +import type { AiToolId } from "../../../kernel/tool.js"; +import { getToolConfig, isAiTool } from "../../tools/domain/registry.js"; export type InstallScope = "project" | "user"; @@ -20,7 +20,7 @@ export function parseInstallScope(value: string | undefined): InstallScope | und export function getToolSupportedScope(toolId: AiToolId): InstallScope { const tool = getToolConfig(toolId); - if (tool === undefined || !isAiTool(tool)) return "project"; + if (!isAiTool(tool)) return "project"; const caps = tool.capabilities as Record; const plugins = caps.plugins as { installScope?: InstallScope } | undefined; return plugins?.installScope ?? "project"; diff --git a/cli/src/contexts/framework/domain/installed-rule.ts b/cli/src/contexts/framework/domain/installed-rule.ts new file mode 100644 index 000000000..f11ccb9f7 --- /dev/null +++ b/cli/src/contexts/framework/domain/installed-rule.ts @@ -0,0 +1,65 @@ +import { parseFrontmatter } from "../../../kernel/markdown.js"; +import type { AiToolId } from "../../../kernel/tool.js"; + +/** One rule as it sits installed in a project, read back rather than generated. */ +export interface InstalledRule { + readonly tool: AiToolId; + /** Project-relative, `/`-separated, exactly as the scan found it. */ + readonly path: string; + /** The file's own name with the installed extension removed — never a frontmatter field. A + * rule's identity is where it sits: two rules may state the same `name` and still be two. */ + readonly name: string; + /** What the rule says it governs, empty where it says nothing. Empty rather than absent: a + * missing description is a rule that stated none, not a tool that cannot carry one. */ + readonly description: string; + /** Every glob the rule scopes itself to, absent when it names none — which means it + * applies everywhere, a different statement from an empty list. */ + readonly paths?: readonly string[]; +} + +/** Each tool names the scope field differently: `paths` for Claude Code and Codex, `globs` + * for Cursor, `applyTo` for Copilot. Read all three and merge, rather than branch on the + * tool: a file converted from one tool to another carries whichever its source used, and a + * reader asking one question should not have to know which tool answered. */ +const SCOPE_FIELDS = ["paths", "globs", "applyTo"] as const; + +/** A scope stated as one string may hold several globs: `tool-paths.md` tells a generator + * to comma-join them for Cursor and Copilot. Split, so a rule governing two trees reads as + * two and not as one glob containing a comma. */ +function globsIn(value: unknown): readonly string[] { + if (Array.isArray(value)) return value.filter((item): item is string => typeof item === "string"); + if (typeof value !== "string") return []; + return value + .split(",") + .map((glob) => glob.trim()) + .filter((glob) => glob !== ""); +} + +function scopeOf(frontmatter: Record): readonly string[] { + return [...new Set(SCOPE_FIELDS.flatMap((field) => globsIn(frontmatter[field])))]; +} + +/** The installed extension, whole. Trimming at the last dot would leave `.instructions` + * glued to every Copilot rule's name, since what it installs is `.instructions.md`. */ +function nameOf(path: string, extension: string): string { + const basename = path.split("/").at(-1) ?? path; + return basename.endsWith(extension) ? basename.slice(0, -extension.length) : basename; +} + +export function toInstalledRule( + tool: AiToolId, + path: string, + extension: string, + content: string +): InstalledRule { + const { frontmatter } = parseFrontmatter(content); + const description = frontmatter.description; + const paths = scopeOf(frontmatter); + return { + tool, + path, + name: nameOf(path, extension), + description: typeof description === "string" ? description : "", + ...(paths.length === 0 ? {} : { paths }), + }; +} diff --git a/cli/src/contexts/framework/domain/manifest-gitignore-entries.ts b/cli/src/contexts/framework/domain/manifest-gitignore-entries.ts new file mode 100644 index 000000000..6e0e6afc0 --- /dev/null +++ b/cli/src/contexts/framework/domain/manifest-gitignore-entries.ts @@ -0,0 +1,14 @@ +import { AIDD_DIR, RUNS_ENTRY } from "../../../kernel/paths.js"; +import { machineLocalFilesOf } from "../../tools/domain/registry.js"; +import type { Manifest } from "./manifest.js"; + +/** + * The `.gitignore` lines this CLI's own writes require. Install adds exactly these in one call and + * `clean` removes exactly the same set, so both read this one list and neither can drift. + */ +export function aiddGitignoreEntries(manifest: Manifest): string[] { + const machineLocal = manifest + .getInstalledToolIds() + .flatMap((toolId) => machineLocalFilesOf(toolId)); + return [`${AIDD_DIR}/cache/`, RUNS_ENTRY, ...new Set(machineLocal)]; +} diff --git a/cli/src/contexts/framework/domain/manifest-serialization.ts b/cli/src/contexts/framework/domain/manifest-serialization.ts new file mode 100644 index 000000000..877cb8de4 --- /dev/null +++ b/cli/src/contexts/framework/domain/manifest-serialization.ts @@ -0,0 +1,55 @@ +import { InvalidManifestDataError, InvalidManifestToolIdError } from "../../../kernel/errors.js"; +import { asPlainObject } from "../../../kernel/reading/plain-object.js"; +import { type ToolId, VALID_TOOL_IDS } from "../../../kernel/tool.js"; +import { + parseToolEntry, + serializeToolEntry, + type ToolEntry, + type ToolEntryData, +} from "./manifest/tool-entry.js"; + +export const MANIFEST_VERSION = 8; + +export interface ManifestData { + version: 8; + tools: Record; +} + +export function serializeManifestTools( + tools: ReadonlyMap +): Record { + const out: Record = {}; + for (const [toolId, entry] of tools) { + out[toolId] = serializeToolEntry(entry); + } + return out; +} + +export function parseManifestTools(raw: Record): Map { + const tools = new Map(); + if (raw.tools === null || typeof raw.tools !== "object") return tools; + + for (const [key, value] of Object.entries(raw.tools as Record)) { + const toolId = key as ToolId; + if (!VALID_TOOL_IDS.includes(toolId)) { + throw new InvalidManifestToolIdError(key); + } + tools.set(toolId, parseToolEntry(toolId, parseToolEntryData(key, value))); + } + return tools; +} + +/** Narrows one `tools.` entry before it reaches `parseToolEntry`, which otherwise throws a raw + * `TypeError` from deep inside `parseTrackedFiles`. Every failure names the JSON path, so the + * message is actionable. */ +function parseToolEntryData(toolId: string, value: unknown): ToolEntryData { + const entry = asPlainObject(value); + if (entry === null) { + throw new InvalidManifestDataError(`tools.${toolId}: expected an object.`); + } + if (!Array.isArray(entry.files)) { + const got = entry.files === undefined ? "missing" : typeof entry.files; + throw new InvalidManifestDataError(`tools.${toolId}.files: expected an array, got ${got}.`); + } + return value as ToolEntryData; +} diff --git a/cli/src/contexts/framework/domain/manifest.ts b/cli/src/contexts/framework/domain/manifest.ts new file mode 100644 index 000000000..280487cd1 --- /dev/null +++ b/cli/src/contexts/framework/domain/manifest.ts @@ -0,0 +1,277 @@ +import { InvalidManifestDataError, ToolNotInManifestError } from "../../../kernel/errors.js"; +import type { FileHash, InstallationFile } from "../../../kernel/file.js"; +import type { MergeFileEntry } from "../../../kernel/merge.js"; +import { AIDD_DIR, MANIFEST_FILENAME } from "../../../kernel/paths.js"; +import type { ToolId } from "../../../kernel/tool.js"; +import type { McpExclusion } from "../../tools/domain/mcp-exclusion.js"; +import { addExclusions, removeExclusions } from "./manifest/mcp-exclusions.js"; +import type { NativeRegistrations } from "./manifest/native-registrations.js"; +import { + addPluginToEntry, + createToolEntry, + isFileTrackedInEntry, + removePluginFromEntry, + type ToolEntry, + updatePluginInEntry, +} from "./manifest/tool-entry.js"; +import { withUpdatedHash } from "./manifest/tracked-files.js"; +import { + MANIFEST_VERSION, + type ManifestData, + parseManifestTools, + serializeManifestTools, +} from "./manifest-serialization.js"; +import type { InstalledPlugin } from "./plugins/installed-plugin.js"; + +// The project-scope default, named in the refusal below so a person can act on it without hunting +// for the path. A caller reading the user-scope manifest passes its own `ManifestFileContext`, so +// the refusal names the file actually on disk. +const MANIFEST_PATH_HINT = `${AIDD_DIR}/${MANIFEST_FILENAME}`; +const DEFAULT_CONTEXT: ManifestFileContext = { + path: MANIFEST_PATH_HINT, + location: "in this project", + reinstallCommand: "aidd setup", +}; + +/** + * What a version-refusal message names: the file on disk, in words fitting the sentence it lands + * in, and the command that reinstalls once it is gone. `UserManifestRepositoryAdapter` passes its + * own before reaching the guard, so a `--scope user` refusal never names the project's path. + */ +export interface ManifestFileContext { + readonly path: string; + readonly location: string; + readonly reinstallCommand: string; +} + +export class Manifest { + private readonly _tools: Map; + + private constructor(params: { tools: Map }) { + this._tools = new Map(params.tools); + } + + static create(): Manifest { + return new Manifest({ tools: new Map() }); + } + + addTool( + toolId: ToolId, + version: string, + files: InstallationFile[], + mergeFiles: MergeFileEntry[] = [], + excludedMcp: McpExclusion[] = [] + ): void { + const existing = this._tools.get(toolId); + this._tools.set( + toolId, + createToolEntry({ + toolId, + version, + files, + mergeFiles, + excludedMcp, + existingPlugins: existing?.plugins ?? [], + }) + ); + } + + getInstalledToolIds(): ToolId[] { + return [...this._tools.keys()]; + } + + getToolFiles( + toolId: ToolId + ): ReadonlyArray<{ relativePath: string; hash: FileHash; frameworkPath?: string }> { + return this._tools.get(toolId)?.files ?? []; + } + + getMergeFiles(toolId: ToolId): readonly MergeFileEntry[] { + return this._tools.get(toolId)?.mergeFiles ?? []; + } + + /** Tracked files, merge files and plugin files alike, across every tool. */ + getTrackedPathsInDirectory(dir: string): Set { + const tracked = new Set(); + for (const [, entry] of this._tools) { + for (const f of entry.files) { + if (f.relativePath.startsWith(dir)) tracked.add(f.relativePath); + } + for (const m of entry.mergeFiles) { + if (m.relativePath.startsWith(dir)) tracked.add(m.relativePath); + } + for (const plugin of entry.plugins) { + for (const relPath of plugin.files.keys()) { + if (relPath.startsWith(dir)) tracked.add(relPath); + } + } + } + return tracked; + } + + getExcludedMcp(toolId: ToolId): readonly McpExclusion[] { + return this._tools.get(toolId)?.excludedMcp ?? []; + } + + addExcludedMcp(toolId: ToolId, exclusions: McpExclusion[]): void { + const entry = this._tools.get(toolId); + if (!entry) throw new ToolNotInManifestError(toolId); + this._tools.set(toolId, { + ...entry, + excludedMcp: addExclusions(entry.excludedMcp, exclusions), + }); + } + + removeExcludedMcp(toolId: ToolId, exclusions: McpExclusion[]): void { + const entry = this._tools.get(toolId); + if (!entry) throw new ToolNotInManifestError(toolId); + this._tools.set(toolId, { + ...entry, + excludedMcp: removeExclusions(entry.excludedMcp, exclusions), + }); + } + + clearExcludedMcp(toolId: ToolId): void { + const entry = this._tools.get(toolId); + if (!entry) throw new ToolNotInManifestError(toolId); + this._tools.set(toolId, { ...entry, excludedMcp: [] }); + } + + updateTrackedFileHash(toolId: ToolId, relativePath: string, hash: FileHash): void { + const entry = this._tools.get(toolId); + if (!entry) return; + this._tools.set(toolId, { + ...entry, + files: withUpdatedHash(entry.files, relativePath, hash), + }); + } + + updateToolMergeFiles( + toolId: ToolId, + mergeFiles: MergeFileEntry[], + excludedMcp?: McpExclusion[] + ): void { + const entry = this._tools.get(toolId); + if (!entry) throw new ToolNotInManifestError(toolId); + this._tools.set(toolId, { + ...entry, + mergeFiles, + ...(excludedMcp !== undefined && { excludedMcp }), + }); + } + + removeTool(toolId: ToolId): void { + if (!this._tools.has(toolId)) { + throw new ToolNotInManifestError(toolId); + } + this._tools.delete(toolId); + } + + hasTool(toolId: ToolId): boolean { + return this._tools.has(toolId); + } + + getPlugins(toolId: ToolId): readonly InstalledPlugin[] { + return this._tools.get(toolId)?.plugins ?? []; + } + + addPlugin(toolId: ToolId, plugin: InstalledPlugin): void { + const entry = this._tools.get(toolId); + if (!entry) throw new ToolNotInManifestError(toolId); + this._tools.set(toolId, addPluginToEntry(entry, plugin)); + } + + removePlugin(toolId: ToolId, name: string): void { + const entry = this._tools.get(toolId); + if (!entry) throw new ToolNotInManifestError(toolId); + this._tools.set(toolId, removePluginFromEntry(entry, name)); + } + + updatePlugin(toolId: ToolId, plugin: InstalledPlugin): void { + const entry = this._tools.get(toolId); + if (!entry) throw new ToolNotInManifestError(toolId); + this._tools.set(toolId, updatePluginInEntry(entry, plugin)); + } + + getNativeRegistrations(toolId: ToolId): NativeRegistrations | undefined { + return this._tools.get(toolId)?.nativeRegistrations; + } + + setNativeRegistrations(toolId: ToolId, registrations: NativeRegistrations): void { + const entry = this._tools.get(toolId); + if (!entry) throw new ToolNotInManifestError(toolId); + this._tools.set(toolId, { ...entry, nativeRegistrations: registrations }); + } + + isFileTracked(relativePath: string): boolean { + for (const entry of this._tools.values()) { + if (isFileTrackedInEntry(entry, relativePath)) return true; + } + return false; + } + + getToolVersion(toolId: ToolId): string | undefined { + return this._tools.get(toolId)?.version; + } + + getInstalledDirectories(): Set { + const dirs = new Set(); + for (const entry of this._tools.values()) { + for (const file of entry.files) { + dirs.add(`${file.relativePath.split("/")[0]}/`); + } + } + return dirs; + } + + toJSON(): ManifestData { + return { version: MANIFEST_VERSION as 8, tools: serializeManifestTools(this._tools) }; + } + + static fromJSON(data: unknown, context: ManifestFileContext = DEFAULT_CONTEXT): Manifest { + if (data === null || typeof data !== "object") { + throw new InvalidManifestDataError("expected an object."); + } + const raw = data as Record; + Manifest.assertSupportedVersion(raw, context); + const tools = parseManifestTools(raw); + return new Manifest({ tools }); + } + + // This CLI reads exactly MANIFEST_VERSION and refuses every other one, naming a fix for each + // side. Too new means this CLI is behind, so `aidd update` is a real answer. + // + // Too old has none: no published CLI ever wrote v7's mandatory per-plugin `scope` or v8's + // host-name-beside-alias pair, and `load()` calls `fromJSON` before any command reaches a save, + // so every write path refuses an old document before it could overwrite it. Deleting the + // document is the only correction that does not pass back through this same guard. + private static assertSupportedVersion( + raw: Record, + context: ManifestFileContext + ): void { + const version = raw.version; + if (version === MANIFEST_VERSION) return; + if (typeof version === "number" && version > MANIFEST_VERSION) { + throw new InvalidManifestDataError( + `manifest version ${version} was written by a newer CLI than this one. Run \`aidd update\` to update this CLI, then try again.` + ); + } + // A v6 document is the one case admitting a remedy richer than deletion: 5.2.2's own manifest + // reader accepts exactly version 6 — measured against that published version — so its + // `clean --force` can still run against this project before the manifest naming what it + // registered is gone. 5.2.2 refuses a v7 document too, so naming it there would be a false + // remedy. A v6 document is one the project repository ever produced, never the user-scope one. + const remedy = + version === 6 + ? "5.2.2, a published CLI, wrote this version. Before deleting it, run " + + "`npx @ai-driven-dev/cli@5.2.2 clean --force` in this project so it unregisters " + + "what it registered and clears its own cache — once the manifest naming those " + + "is gone, nothing can drive that anymore. Then delete" + : "No published CLI can write this version: delete"; + throw new InvalidManifestDataError( + `manifest version ${String(version)} predates version ${MANIFEST_VERSION}, the only one this CLI reads. ` + + `${remedy} ${context.path} ${context.location}, then run ` + + `\`${context.reinstallCommand}\` to reinstall the framework.` + ); + } +} diff --git a/cli/src/contexts/framework/domain/manifest/mcp-exclusions.ts b/cli/src/contexts/framework/domain/manifest/mcp-exclusions.ts new file mode 100644 index 000000000..877d2d156 --- /dev/null +++ b/cli/src/contexts/framework/domain/manifest/mcp-exclusions.ts @@ -0,0 +1,34 @@ +import { type McpExclusion, mcpExclusionEquals } from "../../../tools/domain/mcp-exclusion.js"; + +export interface McpExclusionData { + configPath: string; + entryKey: string; +} + +export function addExclusions( + existing: readonly McpExclusion[], + toAdd: readonly McpExclusion[] +): McpExclusion[] { + const result = [...existing]; + for (const excl of toAdd) { + if (!result.some((e) => mcpExclusionEquals(e, excl))) { + result.push(excl); + } + } + return result; +} + +export function removeExclusions( + existing: readonly McpExclusion[], + toRemove: readonly McpExclusion[] +): McpExclusion[] { + return existing.filter((e) => !toRemove.some((r) => mcpExclusionEquals(e, r))); +} + +export function toMcpExclusionData(exclusions: readonly McpExclusion[]): McpExclusionData[] { + return exclusions.map((e) => ({ configPath: e.configPath, entryKey: e.entryKey })); +} + +export function parseMcpExclusionData(data: readonly McpExclusionData[]): McpExclusion[] { + return data.map((e) => ({ configPath: e.configPath, entryKey: e.entryKey })); +} diff --git a/cli/src/contexts/framework/domain/manifest/merge-files.ts b/cli/src/contexts/framework/domain/manifest/merge-files.ts new file mode 100644 index 000000000..b7f6d21ff --- /dev/null +++ b/cli/src/contexts/framework/domain/manifest/merge-files.ts @@ -0,0 +1,39 @@ +import { FileHash } from "../../../../kernel/file.js"; +import type { MergeFileEntry } from "../../../../kernel/merge.js"; + +// A merge file is co-owned: framework and user each hold entries inside the same file (e.g. +// `mcpServers` in `.claude/settings.json`), tracked per-key rather than per-file. + +export interface MergeFileEntryData { + relativePath: string; + sectionKey: string | null; + entries: Record; +} + +export function toMergeFileEntryData(mergeFiles: readonly MergeFileEntry[]): MergeFileEntryData[] { + return mergeFiles.map((m) => { + const entries: Record = {}; + for (const [key, hash] of Object.entries(m.entries)) { + entries[key] = hash.value; + } + return { + relativePath: m.relativePath, + sectionKey: m.sectionKey, + entries, + }; + }); +} + +export function parseMergeFileEntries(data: readonly MergeFileEntryData[]): MergeFileEntry[] { + return data.map((m) => { + const entries: Record = {}; + for (const [key, hash] of Object.entries(m.entries)) { + entries[key] = new FileHash(hash); + } + return { + relativePath: m.relativePath, + sectionKey: m.sectionKey, + entries, + }; + }); +} diff --git a/cli/src/contexts/framework/domain/manifest/native-registrations.ts b/cli/src/contexts/framework/domain/manifest/native-registrations.ts new file mode 100644 index 000000000..0a95699fe --- /dev/null +++ b/cli/src/contexts/framework/domain/manifest/native-registrations.ts @@ -0,0 +1,42 @@ +/** One marketplace registration a tool's own CLI was asked to make — aidd's own local name for it + * (`alias`, what this project's registry is keyed by) beside what the host actually registered it + * under (`hostName`, the catalog's own declared name, which every host-facing call must use + * instead). The two differ whenever a project chooses a local alias its catalog does not declare + * itself under, a supported capability. */ +export interface NativeMarketplaceRegistration { + readonly alias: string; + readonly hostName: string; +} + +export interface NativeRegistrations { + readonly binary: string; + readonly marketplaces: readonly NativeMarketplaceRegistration[]; + readonly pluginRefs: readonly string[]; +} + +export interface NativeRegistrationsData { + binary: string; + marketplaces: NativeMarketplaceRegistration[]; + pluginRefs: string[]; +} + +export function toNativeRegistrationsData( + registrations: NativeRegistrations +): NativeRegistrationsData { + return { + binary: registrations.binary, + marketplaces: registrations.marketplaces.map((m) => ({ ...m })), + pluginRefs: [...registrations.pluginRefs], + }; +} + +export function parseNativeRegistrations( + data: NativeRegistrationsData | undefined +): NativeRegistrations | undefined { + if (data === undefined) return undefined; + return { + binary: data.binary, + marketplaces: data.marketplaces.map((m) => ({ alias: m.alias, hostName: m.hostName })), + pluginRefs: [...data.pluginRefs], + }; +} diff --git a/cli/src/contexts/framework/domain/manifest/tool-entry.ts b/cli/src/contexts/framework/domain/manifest/tool-entry.ts new file mode 100644 index 000000000..5e84b596e --- /dev/null +++ b/cli/src/contexts/framework/domain/manifest/tool-entry.ts @@ -0,0 +1,125 @@ +import { DuplicatePluginError, PluginNotFoundError } from "../../../../kernel/errors.js"; +import type { InstallationFile } from "../../../../kernel/file.js"; +import type { MergeFileEntry } from "../../../../kernel/merge.js"; +import type { ToolId } from "../../../../kernel/tool.js"; +import type { McpExclusion } from "../../../tools/domain/mcp-exclusion.js"; +import { InstalledPlugin, type PluginEntryData } from "../plugins/installed-plugin.js"; +import { + type McpExclusionData, + parseMcpExclusionData, + toMcpExclusionData, +} from "./mcp-exclusions.js"; +import { + type MergeFileEntryData, + parseMergeFileEntries, + toMergeFileEntryData, +} from "./merge-files.js"; +import { + type NativeRegistrations, + type NativeRegistrationsData, + parseNativeRegistrations, + toNativeRegistrationsData, +} from "./native-registrations.js"; +import { + parseTrackedFiles, + type TrackedFile, + type TrackedFileData, + toTrackedFileData, + toTrackedFiles, +} from "./tracked-files.js"; + +export interface ToolEntry { + readonly toolId: ToolId; + readonly version: string; + readonly files: readonly TrackedFile[]; + readonly mergeFiles: readonly MergeFileEntry[]; + readonly excludedMcp: readonly McpExclusion[]; + readonly plugins: readonly InstalledPlugin[]; + /** What this tool's own CLI was asked to register, or `undefined` for a tool with + * no `nativeActivation` — see {@link NativeRegistrations}. */ + readonly nativeRegistrations?: NativeRegistrations; +} + +export interface ToolEntryData { + toolId: string; + version: string; + files: TrackedFileData[]; + mergeFiles?: MergeFileEntryData[]; + excludedMcp?: McpExclusionData[]; + plugins?: PluginEntryData[]; + nativeRegistrations?: NativeRegistrationsData; +} + +export function createToolEntry(params: { + toolId: ToolId; + version: string; + files: InstallationFile[]; + mergeFiles: readonly MergeFileEntry[]; + excludedMcp: readonly McpExclusion[]; + existingPlugins: readonly InstalledPlugin[]; +}): ToolEntry { + return { + toolId: params.toolId, + version: params.version, + files: toTrackedFiles(params.files), + mergeFiles: params.mergeFiles, + excludedMcp: params.excludedMcp, + plugins: params.existingPlugins, + }; +} + +export function addPluginToEntry(entry: ToolEntry, plugin: InstalledPlugin): ToolEntry { + if (entry.plugins.some((p) => p.name === plugin.name)) { + throw new DuplicatePluginError(plugin.name); + } + return { ...entry, plugins: [...entry.plugins, plugin] }; +} + +export function removePluginFromEntry(entry: ToolEntry, name: string): ToolEntry { + if (!entry.plugins.some((p) => p.name === name)) { + throw new PluginNotFoundError(name); + } + return { ...entry, plugins: entry.plugins.filter((p) => p.name !== name) }; +} + +export function updatePluginInEntry(entry: ToolEntry, plugin: InstalledPlugin): ToolEntry { + if (!entry.plugins.some((p) => p.name === plugin.name)) { + throw new PluginNotFoundError(plugin.name); + } + return { + ...entry, + plugins: entry.plugins.map((p) => (p.name === plugin.name ? plugin : p)), + }; +} + +export function isFileTrackedInEntry(entry: ToolEntry, relativePath: string): boolean { + if (entry.files.some((f) => f.relativePath === relativePath)) return true; + if (entry.mergeFiles.some((m) => m.relativePath === relativePath)) return true; + return entry.plugins.some((p) => p.isFileTracked(relativePath)); +} + +export function serializeToolEntry(entry: ToolEntry): ToolEntryData { + return { + toolId: entry.toolId, + version: entry.version, + files: toTrackedFileData(entry.files), + mergeFiles: toMergeFileEntryData(entry.mergeFiles), + ...(entry.excludedMcp.length > 0 && { excludedMcp: toMcpExclusionData(entry.excludedMcp) }), + ...(entry.plugins.length > 0 && { plugins: entry.plugins.map((p) => p.toJSON()) }), + ...(entry.nativeRegistrations !== undefined && { + nativeRegistrations: toNativeRegistrationsData(entry.nativeRegistrations), + }), + }; +} + +export function parseToolEntry(toolId: ToolId, data: ToolEntryData): ToolEntry { + return { + toolId, + version: data.version, + files: parseTrackedFiles(data.files), + mergeFiles: parseMergeFileEntries(data.mergeFiles ?? []), + excludedMcp: parseMcpExclusionData(data.excludedMcp ?? []), + plugins: (data.plugins ?? []).map((p) => InstalledPlugin.fromJSON(p)), + nativeRegistrations: parseNativeRegistrations(data.nativeRegistrations), + }; +} diff --git a/cli/src/contexts/framework/domain/manifest/tracked-files.ts b/cli/src/contexts/framework/domain/manifest/tracked-files.ts new file mode 100644 index 000000000..a6a9113a8 --- /dev/null +++ b/cli/src/contexts/framework/domain/manifest/tracked-files.ts @@ -0,0 +1,52 @@ +import { FileHash, type InstallationFile } from "../../../../kernel/file.js"; + +// One tool's paths and hashes: what was written, and, for a framework-owned file, where it came +// from in the source tree. + +export interface TrackedFile { + readonly relativePath: string; + readonly hash: FileHash; + readonly frameworkPath?: string; +} + +export interface TrackedFileData { + relativePath: string; + hash: string; + frameworkPath?: string; +} + +export function toTrackedFiles(files: readonly InstallationFile[]): TrackedFile[] { + return files.map((f) => ({ + relativePath: f.relativePath, + hash: f.hash, + ...(f.frameworkPath !== undefined && { frameworkPath: f.frameworkPath }), + })); +} + +export function toTrackedFileData(files: readonly TrackedFile[]): TrackedFileData[] { + return files.map((f) => ({ + relativePath: f.relativePath, + hash: f.hash.value, + ...(f.frameworkPath !== undefined && { frameworkPath: f.frameworkPath }), + })); +} + +export function parseTrackedFiles(files: readonly TrackedFileData[]): TrackedFile[] { + return files.map((f) => ({ + relativePath: f.relativePath, + hash: new FileHash(f.hash), + ...(f.frameworkPath !== undefined && { frameworkPath: f.frameworkPath }), + })); +} + +/** Replaces the hash for `relativePath`, appending a bare entry if it was not already tracked. */ +export function withUpdatedHash( + files: readonly TrackedFile[], + relativePath: string, + hash: FileHash +): TrackedFile[] { + const existing = files.find((f) => f.relativePath === relativePath); + return existing + ? files.map((f) => (f.relativePath === relativePath ? { ...f, hash } : f)) + : [...files, { relativePath, hash }]; +} diff --git a/cli/src/contexts/framework/domain/marketplace-source-drift.ts b/cli/src/contexts/framework/domain/marketplace-source-drift.ts new file mode 100644 index 000000000..fc8110cae --- /dev/null +++ b/cli/src/contexts/framework/domain/marketplace-source-drift.ts @@ -0,0 +1,101 @@ +import { + parseBuiltMarketplaceDir, + parseBuiltMarketplaceDirAtAnyRoot, + parseUserBuiltMarketplaceDir, + samePathSegment, +} from "../../../kernel/paths.js"; +import { compareSemver, isSemver } from "../../../kernel/semver.js"; + +/** Every path a caller hands `marketplaceSourceDrift` — `userCacheRoot`, `projectRoot` and the two + * sources compared against them — is expected already `realpath`'d: this decides a migration story + * about aidd's own version, not a filesystem question. */ +export interface MarketplaceSourceDriftContext { + readonly userCacheRoot: string; + readonly projectRoot: string; + readonly marketplaceName: string; + readonly target: string; +} + +export type MarketplaceSourceDrift = + | { + /** The host already follows a newer build of aidd's own shared source than + * this run would request — refusing to overwrite it is the rollback refusal: + * a lower version must never make the host follow it backward. */ + readonly kind: "version-behind"; + readonly registeredVersion: string; + readonly requestedVersion: string; + } + | { + /** The host still points at this project's own pre-migration, per-project + * cache rather than the shared, machine-scope one `requestedSource` names. */ + readonly kind: "unmigrated-project-source"; + } + | { + /** The host still points at *another* project's own pre-migration cache, discovered from the + * registered path's own segments alone. A caller that can write `references.json` uses + * `projectRoot` to record that project's own claim on the shared source. */ + readonly kind: "unmigrated-foreign-project-source"; + readonly projectRoot: string; + }; + +/** + * Whether a host's registered source names a path this project recognises as its own — one CLI + * version behind the shared source, or still the pre-migration per-project cache — decided purely + * from each path's own segments, never from a catalog's declared name or plugin set. `undefined` + * when the registered path is neither shape: `marketplaceSourceConflict` decides that case by + * reading each side's own `marketplace.json`. + * + * A version segment that is not valid semver is never compared, so a hand-edited or corrupted path + * degrades to "no drift decided here" rather than to a silently wrong comparison. + */ +export function marketplaceSourceDrift( + registeredSource: string, + requestedSource: string, + context: MarketplaceSourceDriftContext +): MarketplaceSourceDrift | undefined { + const requested = parseUserBuiltMarketplaceDir(context.userCacheRoot, requestedSource); + if (requested === undefined) return undefined; + if ( + !samePathSegment(requested.marketplaceName, context.marketplaceName) || + !samePathSegment(requested.target, context.target) + ) { + return undefined; + } + const registeredShared = parseUserBuiltMarketplaceDir(context.userCacheRoot, registeredSource); + if (registeredShared !== undefined) { + if ( + !samePathSegment(registeredShared.marketplaceName, context.marketplaceName) || + !samePathSegment(registeredShared.target, context.target) + ) { + return undefined; + } + if (!isSemver(registeredShared.version) || !isSemver(requested.version)) return undefined; + if (compareSemver(registeredShared.version, requested.version) <= 0) return undefined; + return { + kind: "version-behind", + registeredVersion: registeredShared.version, + requestedVersion: requested.version, + }; + } + const registeredProject = parseBuiltMarketplaceDir(context.projectRoot, registeredSource); + if ( + registeredProject !== undefined && + samePathSegment(registeredProject.marketplaceName, context.marketplaceName) && + samePathSegment(registeredProject.target, context.target) + ) { + return { kind: "unmigrated-project-source" }; + } + const registeredForeign = parseBuiltMarketplaceDirAtAnyRoot(registeredSource); + if ( + registeredForeign !== undefined && + samePathSegment(registeredForeign.marketplaceName, context.marketplaceName) && + samePathSegment(registeredForeign.target, context.target) && + !samePathSegment(registeredForeign.projectRoot, context.projectRoot) + ) { + return { + kind: "unmigrated-foreign-project-source", + projectRoot: registeredForeign.projectRoot, + }; + } + return undefined; +} diff --git a/cli/src/contexts/framework/domain/plugins/installed-plugin.ts b/cli/src/contexts/framework/domain/plugins/installed-plugin.ts new file mode 100644 index 000000000..2ee323dd1 --- /dev/null +++ b/cli/src/contexts/framework/domain/plugins/installed-plugin.ts @@ -0,0 +1,270 @@ +import { + InvalidPluginNameError, + InvalidPluginVersionError, + MalformedPluginScopeError, +} from "../../../../kernel/errors.js"; +import type { InstallationFile } from "../../../../kernel/file.js"; +import { isSemver } from "../../../../kernel/semver.js"; +import { + type PluginSource, + parsePluginSource, + serializePluginSource, +} from "../../../../kernel/source.js"; +import type { PluginDistribution } from "../../../translate/domain/plugin-distribution.js"; +import { type InstallScope, isInstallScope } from "../install-scope.js"; + +export const PLUGIN_NAME_REGEX = /^[a-z0-9]+(-[a-z0-9]+)*$/; + +/** What a plugin's `files` keys are relative to: the project root, or the tool's user-scope plugins + * directory. Recorded once, at install, and never re-derived from a tool's current profile, which + * can disagree with what was true when the entry was written. */ +export type PluginScope = InstallScope; + +export function parsePluginSpec(arg: string): { name: string; version?: string } { + const at = arg.lastIndexOf("@"); + if (at <= 0) return { name: arg }; + return { name: arg.slice(0, at), version: arg.slice(at + 1) }; +} + +// Branding closes a hole the compiler could not see: three `ReadonlyMap` fields +// told apart only by name meant a value meant for one could be assigned to another. The brand is a +// phantom property, so a plain map built anywhere else is still accepted at the factories below. +declare const mapBrand: unique symbol; +type BrandedMap = ReadonlyMap & { + readonly [mapBrand]: Name; +}; + +/** relativePath → MD5 hash of the installed file's content. */ +export type PathHashMap = BrandedMap<"PathHashMap">; +/** installed relativePath → plugin component path (e.g. rules/01-standards/naming.md). */ +export type ComponentPathMap = BrandedMap<"ComponentPathMap">; +/** MCP server name → MD5 hash of the contributed server JSON (OpenCode merge tracking). */ +export type McpDigestMap = BrandedMap<"McpDigestMap">; + +function asPathHashMap(m: ReadonlyMap): PathHashMap { + return m as PathHashMap; +} + +function asComponentPathMap(m: ReadonlyMap): ComponentPathMap { + return m as ComponentPathMap; +} + +function asMcpDigestMap(m: ReadonlyMap): McpDigestMap { + return m as McpDigestMap; +} + +export interface PluginEntryData { + name: string; + source: Record; + version: string; + strict: boolean; + files: Record; + /** What `files` is relative to. Mandatory: a default here would guess exactly what + * this field exists to stop guessing. */ + scope: PluginScope; + componentPaths?: Record; + mcpEntries?: Record; + marketplace?: string; +} + +export class InstalledPlugin { + readonly name: string; + readonly source: PluginSource; + readonly version: string; + readonly strict: boolean; + readonly files: PathHashMap; + readonly scope: PluginScope; + readonly componentPaths: ComponentPathMap; + readonly mcpEntries: McpDigestMap; + readonly marketplace?: string; + + private constructor(params: { + name: string; + source: PluginSource; + version: string; + strict: boolean; + files: PathHashMap; + scope: PluginScope; + componentPaths: ComponentPathMap; + mcpEntries: McpDigestMap; + marketplace?: string; + }) { + this.name = params.name; + this.source = params.source; + this.version = params.version; + this.strict = params.strict; + this.files = params.files; + this.scope = params.scope; + this.componentPaths = params.componentPaths; + this.mcpEntries = params.mcpEntries; + this.marketplace = params.marketplace; + } + + static fromMetadata( + name: string, + version: string, + source: PluginSource, + strict: boolean, + scope: PluginScope, + marketplace?: string + ): InstalledPlugin { + const data: PluginEntryData = { + name, + source: serializePluginSource(source), + version, + strict, + files: {}, + scope, + }; + if (marketplace !== undefined) data.marketplace = marketplace; + return InstalledPlugin.fromJSON(data); + } + + static withMcpEntries( + plugin: InstalledPlugin, + mcpEntries: ReadonlyMap + ): InstalledPlugin { + return new InstalledPlugin({ + name: plugin.name, + source: plugin.source, + version: plugin.version, + strict: plugin.strict, + files: plugin.files, + scope: plugin.scope, + componentPaths: plugin.componentPaths, + mcpEntries: asMcpDigestMap(mcpEntries), + marketplace: plugin.marketplace, + }); + } + + static fromDistribution( + dist: PluginDistribution, + source: PluginSource, + files: InstallationFile[], + scope: PluginScope, + componentPaths?: ReadonlyMap, + marketplace?: string + ): InstalledPlugin { + const filesRecord: Record = {}; + for (const f of files) { + filesRecord[f.relativePath] = f.hash.value; + } + const componentPathsRecord: Record = {}; + if (componentPaths) { + for (const [k, v] of componentPaths) componentPathsRecord[k] = v; + } + const data: PluginEntryData = { + name: dist.manifest.name, + source: serializePluginSource(source), + version: dist.manifest.version, + strict: dist.manifest.strict ?? false, + files: filesRecord, + scope, + componentPaths: componentPathsRecord, + }; + if (marketplace !== undefined) data.marketplace = marketplace; + return InstalledPlugin.fromJSON(data); + } + + static fromDistributionWithMcp( + dist: PluginDistribution, + source: PluginSource, + files: InstallationFile[], + mcpEntries: ReadonlyMap, + scope: PluginScope, + componentPaths?: ReadonlyMap, + marketplace?: string + ): InstalledPlugin { + const base = InstalledPlugin.fromDistribution( + dist, + source, + files, + scope, + componentPaths, + marketplace + ); + return InstalledPlugin.withMcpEntries(base, mcpEntries); + } + + static fromJSON(data: PluginEntryData): InstalledPlugin { + if (!PLUGIN_NAME_REGEX.test(data.name)) { + throw new InvalidPluginNameError(data.name); + } + if (!isSemver(data.version)) { + throw new InvalidPluginVersionError(data.version); + } + if (!isInstallScope(data.scope)) { + throw new MalformedPluginScopeError(data.name, data.scope); + } + const source = parsePluginSource(data.source); + const files = new Map(Object.entries(data.files)); + const componentPaths = new Map(Object.entries(data.componentPaths ?? {})); + const mcpEntries = new Map(Object.entries(data.mcpEntries ?? {})); + return new InstalledPlugin({ + name: data.name, + source, + version: data.version, + strict: data.strict, + files: asPathHashMap(files), + scope: data.scope, + componentPaths: asComponentPathMap(componentPaths), + mcpEntries: asMcpDigestMap(mcpEntries), + marketplace: data.marketplace, + }); + } + + toJSON(): PluginEntryData { + const data: PluginEntryData = { + name: this.name, + source: serializePluginSource(this.source), + version: this.version, + strict: this.strict, + files: mapToRecord(this.files), + scope: this.scope, + }; + if (this.componentPaths.size > 0) data.componentPaths = mapToRecord(this.componentPaths); + if (this.mcpEntries.size > 0) data.mcpEntries = mapToRecord(this.mcpEntries); + if (this.marketplace !== undefined) data.marketplace = this.marketplace; + return data; + } + + isFileTracked(relPath: string): boolean { + return this.files.has(relPath); + } + + withVersion(v: string): InstalledPlugin { + return new InstalledPlugin({ + name: this.name, + source: this.source, + version: v, + strict: this.strict, + files: this.files, + scope: this.scope, + componentPaths: this.componentPaths, + mcpEntries: this.mcpEntries, + marketplace: this.marketplace, + }); + } + + withFiles(f: ReadonlyMap): InstalledPlugin { + return new InstalledPlugin({ + name: this.name, + source: this.source, + version: this.version, + strict: this.strict, + files: asPathHashMap(f), + scope: this.scope, + componentPaths: this.componentPaths, + mcpEntries: this.mcpEntries, + marketplace: this.marketplace, + }); + } +} + +function mapToRecord(map: ReadonlyMap): Record { + const record: Record = {}; + for (const [key, value] of map) { + record[key] = value; + } + return record; +} diff --git a/cli/src/domain/models/plugin-source-resolver.ts b/cli/src/contexts/framework/domain/plugins/plugin-source-resolver.ts similarity index 76% rename from cli/src/domain/models/plugin-source-resolver.ts rename to cli/src/contexts/framework/domain/plugins/plugin-source-resolver.ts index a5fc5cca2..860c2f131 100644 --- a/cli/src/domain/models/plugin-source-resolver.ts +++ b/cli/src/contexts/framework/domain/plugins/plugin-source-resolver.ts @@ -1,7 +1,6 @@ import { isAbsolute, relative } from "node:path"; -import type { Marketplace } from "./marketplace.js"; -import type { PluginSource, PluginSourceGitSubdir } from "./plugin-source.js"; - +import type { PluginSource, PluginSourceGitSubdir } from "../../../../kernel/source.js"; +import type { Marketplace } from "../../../distribution/domain/marketplace.js"; export function resolvePluginSourceFromMarketplace( entrySource: PluginSource, marketplace: Marketplace, @@ -32,5 +31,7 @@ function toRelativePath(sourcePath: string, localBase: string): string | null { } const rel = relative(localBase, sourcePath); if (rel.startsWith("..") || rel === "") return null; - return rel; + // relative() answers in the platform's own separator - "\"-joined on Windows - but + // `git sparse-checkout set` always reads a "/"-separated path, whichever OS wrote it. + return rel.split("\\").join("/"); } diff --git a/cli/src/domain/models/requested-version-policy.ts b/cli/src/contexts/framework/domain/plugins/requested-version-policy.ts similarity index 100% rename from cli/src/domain/models/requested-version-policy.ts rename to cli/src/contexts/framework/domain/plugins/requested-version-policy.ts diff --git a/cli/src/contexts/framework/domain/plugins/user-scope-containment.ts b/cli/src/contexts/framework/domain/plugins/user-scope-containment.ts new file mode 100644 index 000000000..43a4edd03 --- /dev/null +++ b/cli/src/contexts/framework/domain/plugins/user-scope-containment.ts @@ -0,0 +1,16 @@ +import { isAbsolute, relative } from "node:path"; + +/** + * Whether an already-resolved candidate path sits strictly inside an already-resolved boundary + * directory — never equal to it. Both arguments must come from a real filesystem resolution: a + * comparison of unresolved strings catches neither a `..` segment, which `path.join` erases before + * the string is built, nor a directory that turned into a symlink after install. + */ +export function isStrictlyWithinUserScope( + resolvedCandidate: string, + resolvedBoundary: string +): boolean { + if (resolvedCandidate === resolvedBoundary) return false; + const rel = relative(resolvedBoundary, resolvedCandidate); + return rel.length > 0 && !rel.startsWith("..") && !isAbsolute(rel); +} diff --git a/cli/src/contexts/framework/domain/ports/environment.ts b/cli/src/contexts/framework/domain/ports/environment.ts new file mode 100644 index 000000000..107e51c56 --- /dev/null +++ b/cli/src/contexts/framework/domain/ports/environment.ts @@ -0,0 +1,6 @@ +/** The ambient environment a use case reads a switch from, and publishes a token to. A port, so + * neither layer reaches a global: the composition root supplies what owns `process.env`. */ +export interface Environment { + get(name: string): string | undefined; + set(name: string, value: string): void; +} diff --git a/cli/src/contexts/framework/domain/ports/manifest-repository.ts b/cli/src/contexts/framework/domain/ports/manifest-repository.ts new file mode 100644 index 000000000..fb92cf05b --- /dev/null +++ b/cli/src/contexts/framework/domain/ports/manifest-repository.ts @@ -0,0 +1,10 @@ +import type { Manifest } from "../manifest.js"; + +export interface ManifestRepository { + /** Where the manifest lives, so a diagnostic can name the file it failed to read rather than + * report a failure a person cannot locate. */ + readonly path: string; + load(): Promise; + save(manifest: Manifest): Promise; + delete(): Promise; +} diff --git a/cli/src/contexts/framework/domain/ports/plugin-distribution-reader.ts b/cli/src/contexts/framework/domain/ports/plugin-distribution-reader.ts new file mode 100644 index 000000000..a6c400fef --- /dev/null +++ b/cli/src/contexts/framework/domain/ports/plugin-distribution-reader.ts @@ -0,0 +1,5 @@ +import type { PluginDistribution } from "../../../translate/domain/plugin-distribution.js"; + +export interface PluginDistributionReader { + read(pluginRoot: string): Promise; +} diff --git a/cli/src/contexts/framework/domain/ports/user-source-references.ts b/cli/src/contexts/framework/domain/ports/user-source-references.ts new file mode 100644 index 000000000..bc74e419a --- /dev/null +++ b/cli/src/contexts/framework/domain/ports/user-source-references.ts @@ -0,0 +1,25 @@ +/** + * The registry of projects that reference the one shared, machine-scope source a CLI version + * builds — `userConfigDir()/references.json`, `{ "": ["", …] }`. + * + * A help, not an authority, in two ways: an entry whose `projectRoot` no longer exists is ignored + * rather than counted as still live, and a file this CLI cannot make sense of must never block + * `setup`, `sync` or `clean`, none of which it gates — every caller reaches it through + * `shared-source-reference-support.ts`'s guard, never a bare call. + */ +export interface UserSourceReferences { + /** Replaces any reference `projectRoot` held under a *different* version first, since a project + * holds at most one at a time: an `aidd update` between two runs must not leave a stale claim + * behind. Idempotent under the same version. */ + addReference(version: string, projectRoot: string): Promise; + + /** Drops `projectRoot`'s own reference wherever it is recorded, never asking which version: the + * running CLI's own version is not necessarily the one this project registered under. A no-op + * when it held none — callers read who else references the source from + * `listAllReferencingProjects()`, never from this method's return. */ + removeReference(projectRoot: string): Promise; + + /** Every project this file still names, across every version key at once, deduplicated and + * existing paths only. The one read with no single `projectRoot` of its own to key off. */ + listAllReferencingProjects(): Promise; +} diff --git a/cli/src/domain/models/project-context.ts b/cli/src/contexts/framework/domain/project-context.ts similarity index 100% rename from cli/src/domain/models/project-context.ts rename to cli/src/contexts/framework/domain/project-context.ts diff --git a/cli/src/contexts/framework/domain/setup-flow.ts b/cli/src/contexts/framework/domain/setup-flow.ts new file mode 100644 index 000000000..c63a8bacd --- /dev/null +++ b/cli/src/contexts/framework/domain/setup-flow.ts @@ -0,0 +1,109 @@ +import { + InvalidPluginModeConfigError, + InvalidSetupToolIdError, + UserScopeIdeToolsError, + UserScopeNoToolsError, + UserScopePluginModeError, + UserScopeUnsupportedAiToolsError, +} from "../../../kernel/errors.js"; +import type { MarketplaceScope } from "../../../kernel/scope.js"; +import { type ToolId, VALID_TOOL_IDS } from "../../../kernel/tool.js"; +import type { MarketplaceSourceMode } from "../../distribution/domain/marketplace-source-mode.js"; +import { supportsUserScopeActivation } from "../../tools/domain/registry.js"; + +export type PluginInstallMode = "interactive" | "all" | "recommended" | "named" | "none"; + +/** Same value domain as `MarketplaceScope`, aliased so this field reads about a setup's own scope + * rather than about the kernel type it shares. */ +export type SetupScope = MarketplaceScope; + +export interface SetupFlowParams { + projectRoot: string; + source?: MarketplaceSourceMode; + aiTools?: readonly ToolId[]; + ideTools?: readonly ToolId[]; + pluginMode?: PluginInstallMode; + pluginNames?: readonly string[]; + interactive?: boolean; + force?: boolean; + registerDefaultMarketplace?: boolean; + /** `"project"` (the default) installs into this project alone. `"user"` registers + * the shared framework source and native activation machine-wide instead, writing + * nothing under `projectRoot` — see `architecture.md`'s user-scope section. */ + scope?: SetupScope; +} + +export class SetupFlow { + readonly projectRoot: string; + readonly source?: MarketplaceSourceMode; + readonly aiTools: readonly ToolId[]; + readonly ideTools: readonly ToolId[]; + readonly pluginMode: PluginInstallMode; + readonly pluginNames: readonly string[]; + readonly interactive: boolean; + readonly force: boolean; + readonly registerDefaultMarketplace: boolean; + readonly scope: SetupScope; + + constructor(params: SetupFlowParams) { + this.validateToolIds(params.aiTools ?? [], params.ideTools ?? []); + this.validatePluginMode(params.pluginMode ?? "none", params.pluginNames ?? []); + this.validateScope( + params.scope ?? "project", + params.aiTools ?? [], + params.ideTools ?? [], + params.pluginMode ?? "none" + ); + this.projectRoot = params.projectRoot; + this.source = params.source; + this.aiTools = params.aiTools ?? []; + this.ideTools = params.ideTools ?? []; + this.pluginMode = params.pluginMode ?? "none"; + this.pluginNames = params.pluginNames ?? []; + this.interactive = params.interactive ?? false; + this.force = params.force ?? false; + this.registerDefaultMarketplace = params.registerDefaultMarketplace ?? true; + this.scope = params.scope ?? "project"; + } + + private validateToolIds(aiTools: readonly ToolId[], ideTools: readonly ToolId[]): void { + const all = [...aiTools, ...ideTools]; + for (const id of all) { + if (!(VALID_TOOL_IDS as readonly string[]).includes(id)) { + throw new InvalidSetupToolIdError(id, VALID_TOOL_IDS); + } + } + } + + // `--scope user` writes nothing under `projectRoot`: an IDE tool's project-relative config has + // nowhere to land, an AI tool with neither native activation nor a user-scope install directory + // has nowhere to be registered, an empty `--ai` list registers the shared source for no tool at + // all, and no manifest entry exists yet to enable a plugin against. Each is refused rather than + // silently dropped. + private validateScope( + scope: SetupScope, + aiTools: readonly ToolId[], + ideTools: readonly ToolId[], + pluginMode: PluginInstallMode + ): void { + if (scope !== "user") return; + if (ideTools.length > 0) throw new UserScopeIdeToolsError(ideTools); + if (aiTools.length === 0) throw new UserScopeNoToolsError(); + const unsupported = aiTools.filter((id) => !supportsUserScopeActivation(id)); + if (unsupported.length > 0) throw new UserScopeUnsupportedAiToolsError(unsupported); + if (pluginMode !== "none") throw new UserScopePluginModeError(); + } + + private validatePluginMode(mode: PluginInstallMode, names: readonly string[]): void { + if (mode === "named" && names.length === 0) { + throw new InvalidPluginModeConfigError( + 'Plugin mode "named" requires at least one plugin name.' + ); + } + if (mode !== "named" && names.length > 0) { + throw new InvalidPluginModeConfigError( + `Plugin names provided but mode is "${mode}" (expected "named").` + ); + } + } +} diff --git a/cli/src/domain/models/tool-recommendations.ts b/cli/src/contexts/framework/domain/tool-recommendations.ts similarity index 91% rename from cli/src/domain/models/tool-recommendations.ts rename to cli/src/contexts/framework/domain/tool-recommendations.ts index c3ce78a25..527e00c54 100644 --- a/cli/src/domain/models/tool-recommendations.ts +++ b/cli/src/contexts/framework/domain/tool-recommendations.ts @@ -1,5 +1,5 @@ +import type { AiToolId, IdeToolId } from "../../../kernel/tool.js"; import type { ProjectContext } from "./project-context.js"; -import type { AiToolId, IdeToolId } from "./tool-ids.js"; export function recommendAiTools(context?: ProjectContext): readonly AiToolId[] { if (context === undefined) return ["claude"]; diff --git a/cli/src/contexts/framework/infrastructure/environment-adapter.ts b/cli/src/contexts/framework/infrastructure/environment-adapter.ts new file mode 100644 index 000000000..d87e7efb2 --- /dev/null +++ b/cli/src/contexts/framework/infrastructure/environment-adapter.ts @@ -0,0 +1,13 @@ +import type { Environment } from "../domain/ports/environment.js"; + +/** Reads and writes at call time, never snapshotting at construction: an e2e run sets its + * switches in the child process it spawns, after this adapter exists. */ +export class EnvironmentAdapter implements Environment { + get(name: string): string | undefined { + return process.env[name]; + } + + set(name: string, value: string): void { + process.env[name] = value; + } +} diff --git a/cli/src/contexts/framework/infrastructure/manifest-file-io.ts b/cli/src/contexts/framework/infrastructure/manifest-file-io.ts new file mode 100644 index 000000000..8314b2c08 --- /dev/null +++ b/cli/src/contexts/framework/infrastructure/manifest-file-io.ts @@ -0,0 +1,36 @@ +import { mkdir, readFile, writeFile } from "node:fs/promises"; +import { dirname } from "node:path"; +import { InvalidManifestDataError } from "../../../kernel/errors.js"; +import { isErrnoException } from "../../../kernel/reading/json-file.js"; +import { Manifest, type ManifestFileContext } from "../domain/manifest.js"; + +/** + * The one place a manifest file's bytes are read, parsed and turned into a `Manifest`. The project + * and user-scope adapters differ only in which path they read and what a version-refusal message + * should name to fix it (`ManifestFileContext`). + */ +export async function readManifestFile(context: ManifestFileContext): Promise { + let raw: string; + try { + raw = await readFile(context.path, "utf-8"); + } catch (error) { + if (isErrnoException(error) && error.code === "ENOENT") return null; + throw error; + } + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch (error) { + throw new InvalidManifestDataError( + `${context.path} is not valid JSON: ${(error as Error).message}` + ); + } + return Manifest.fromJSON(parsed, context); +} + +/** `dirname(path)` is `/.aidd` for the project adapter and `userConfigDir()` for the + * user one, so one function serves both without either passing the other's directory convention. */ +export async function writeManifestFile(path: string, manifest: Manifest): Promise { + await mkdir(dirname(path), { recursive: true }); + await writeFile(path, JSON.stringify(manifest.toJSON(), null, 2), "utf-8"); +} diff --git a/cli/src/contexts/framework/infrastructure/manifest-repository-adapter.ts b/cli/src/contexts/framework/infrastructure/manifest-repository-adapter.ts new file mode 100644 index 000000000..878d7899c --- /dev/null +++ b/cli/src/contexts/framework/infrastructure/manifest-repository-adapter.ts @@ -0,0 +1,47 @@ +import { readdir, rm, rmdir } from "node:fs/promises"; +import { join } from "node:path"; +import { AIDD_DIR, MANIFEST_FILENAME } from "../../../kernel/paths.js"; +import type { Manifest } from "../domain/manifest.js"; +import type { ManifestRepository } from "../domain/ports/manifest-repository.js"; +import { readManifestFile, writeManifestFile } from "./manifest-file-io.js"; + +export class ManifestRepositoryAdapter implements ManifestRepository { + constructor(private readonly projectRoot: string) {} + + get path(): string { + return join(this.projectRoot, AIDD_DIR, MANIFEST_FILENAME); + } + + private get aiddDir(): string { + return join(this.projectRoot, AIDD_DIR); + } + + async load(): Promise { + return readManifestFile({ + path: this.path, + location: "in this project", + reinstallCommand: "aidd setup", + }); + } + + async save(manifest: Manifest): Promise { + await writeManifestFile(this.path, manifest); + } + + async delete(): Promise { + try { + await rm(this.path, { force: true }); + } catch { + // No error if missing + } + + try { + const entries = await readdir(this.aiddDir); + if (entries.length === 0) { + await rmdir(this.aiddDir); + } + } catch { + // No error if dir missing + } + } +} diff --git a/cli/src/infrastructure/adapters/plugin-distribution-reader-adapter.ts b/cli/src/contexts/framework/infrastructure/plugin-distribution-reader-adapter.ts similarity index 88% rename from cli/src/infrastructure/adapters/plugin-distribution-reader-adapter.ts rename to cli/src/contexts/framework/infrastructure/plugin-distribution-reader-adapter.ts index 8e22329fb..fc14ffece 100644 --- a/cli/src/infrastructure/adapters/plugin-distribution-reader-adapter.ts +++ b/cli/src/contexts/framework/infrastructure/plugin-distribution-reader-adapter.ts @@ -3,19 +3,19 @@ import { InvalidPluginManifestError, InvalidPluginNameError, InvalidPluginVersionError, -} from "../../domain/errors.js"; -import { PLUGIN_NAME_REGEX } from "../../domain/models/plugin.js"; +} from "../../../kernel/errors.js"; +import type { FileReader } from "../../../kernel/ports/file-reader.js"; +import { isSemver } from "../../../kernel/semver.js"; import { type PluginComponentFile, type PluginComponents, PluginDistribution, type PluginManifestFields, -} from "../../domain/models/plugin-distribution.js"; -import type { PluginFormat } from "../../domain/models/plugin-format.js"; -import { PLUGIN_MANIFEST_PROBES } from "../../domain/models/plugin-format.js"; -import { isSemver } from "../../domain/models/semver.js"; -import type { FileReader } from "../../domain/ports/file-reader.js"; -import type { PluginDistributionReader } from "../../domain/ports/plugin-distribution-reader.js"; +} from "../../translate/domain/plugin-distribution.js"; +import type { PluginFormat } from "../../translate/domain/plugin-format.js"; +import { pluginManifestProbes } from "../../translate/domain/plugin-format.js"; +import { PLUGIN_NAME_REGEX } from "../domain/plugins/installed-plugin.js"; +import type { PluginDistributionReader } from "../domain/ports/plugin-distribution-reader.js"; const README_FILENAME = "README.md"; @@ -33,7 +33,7 @@ export class PluginDistributionReaderAdapter implements PluginDistributionReader private async probeManifest( pluginRoot: string ): Promise<{ format: PluginFormat; manifestPath: string; manifestRelativePath: string }> { - for (const probe of PLUGIN_MANIFEST_PROBES) { + for (const probe of pluginManifestProbes()) { const fullPath = join(pluginRoot, probe.relativePath); if (await this.fs.fileExists(fullPath)) { return { @@ -113,7 +113,6 @@ function validateManifest(raw: unknown): PluginManifestFields { const result: PluginManifestFields = { name, version }; if (typeof obj.description === "string") result.description = obj.description; - if (typeof obj.strict === "boolean") result.strict = obj.strict; if (obj.author !== null && typeof obj.author === "object" && !Array.isArray(obj.author)) { const a = obj.author as Record; if (typeof a.name === "string") { diff --git a/cli/src/contexts/framework/infrastructure/user-manifest-repository-adapter.ts b/cli/src/contexts/framework/infrastructure/user-manifest-repository-adapter.ts new file mode 100644 index 000000000..b1240fd9e --- /dev/null +++ b/cli/src/contexts/framework/infrastructure/user-manifest-repository-adapter.ts @@ -0,0 +1,38 @@ +import { rm } from "node:fs/promises"; +import { userManifestPath } from "../../../kernel/paths.js"; +import type { Manifest } from "../domain/manifest.js"; +import type { ManifestRepository } from "../domain/ports/manifest-repository.js"; +import { readManifestFile, writeManifestFile } from "./manifest-file-io.js"; + +/** + * The user-scope counterpart of `ManifestRepositoryAdapter` — same schema, same version and refusal + * rule, same file I/O. Only the path differs, and what a version-refusal message names to fix it. + * + * `delete()` removes only `manifest.json` itself, never its parent directory: `userConfigDir()` + * also holds `auth.json`, `marketplaces.json`, `references.json` and `telemetry/`, none of which + * this repository owns. Pruning it once empty, as the project adapter does with `.aidd/`, would be + * a live bug here. + */ +export class UserManifestRepositoryAdapter implements ManifestRepository { + constructor(private readonly userConfigDir: () => string) {} + + get path(): string { + return userManifestPath(this.userConfigDir()); + } + + async load(): Promise { + return readManifestFile({ + path: this.path, + location: "for this machine", + reinstallCommand: "aidd setup --scope user", + }); + } + + async save(manifest: Manifest): Promise { + await writeManifestFile(this.path, manifest); + } + + async delete(): Promise { + await rm(this.path, { force: true }); + } +} diff --git a/cli/src/contexts/framework/infrastructure/user-source-references-adapter.ts b/cli/src/contexts/framework/infrastructure/user-source-references-adapter.ts new file mode 100644 index 000000000..b41428b5a --- /dev/null +++ b/cli/src/contexts/framework/infrastructure/user-source-references-adapter.ts @@ -0,0 +1,149 @@ +import { dirname, join } from "node:path"; +import { UnreadableUserSourceReferencesError } from "../../../kernel/errors.js"; +import { + dedupePathSegments, + samePathSegment, + USER_SOURCE_REFERENCES_FILENAME, +} from "../../../kernel/paths.js"; +import type { FileReader } from "../../../kernel/ports/file-reader.js"; +import type { FileWriter } from "../../../kernel/ports/file-writer.js"; +import type { UserSourceReferences } from "../domain/ports/user-source-references.js"; + +type ReferencesFile = Record; + +/** The version key `projectRoot` is recorded under, and that key's full roots list — `undefined` + * when it is recorded nowhere. Path equality goes through `samePathSegment`, never `===`, so a + * case-insensitive platform still matches a root a real `realpath` returns with different casing. */ +function findVersionFor( + references: ReferencesFile, + projectRoot: string +): [string, readonly string[]] | undefined { + for (const [version, roots] of Object.entries(references)) { + if (roots.some((root) => samePathSegment(root, projectRoot))) return [version, roots]; + } + return undefined; +} + +export class UserSourceReferencesAdapter implements UserSourceReferences { + constructor( + private readonly fs: FileReader & FileWriter, + private readonly userConfigDir: () => string + ) {} + + async addReference(version: string, projectRoot: string): Promise { + const references = await this.readAll(); + const alreadyOnlyHere = + (references[version] ?? []).some((root) => samePathSegment(root, projectRoot)) && + Object.entries(references).every( + ([key, roots]) => + key === version || !roots.some((root) => samePathSegment(root, projectRoot)) + ); + if (alreadyOnlyHere) return; + const next: Record = {}; + for (const [key, roots] of Object.entries(references)) { + if (key === version) continue; + const filtered = roots.filter((root) => !samePathSegment(root, projectRoot)); + if (filtered.length > 0) next[key] = filtered; + } + const existingAtVersion = (references[version] ?? []).filter( + (root) => !samePathSegment(root, projectRoot) + ); + next[version] = [...existingAtVersion, projectRoot]; + // `projectRoot` is protected from the prune below: a project vanishing between being recorded + // and this very write finishing would otherwise drop the claim `addReference` was just asked + // to add. + await this.write(next, projectRoot); + } + + async removeReference(projectRoot: string): Promise { + const references = await this.readAll(); + const found = findVersionFor(references, projectRoot); + if (found === undefined) return; + const [version, roots] = found; + const remaining = roots.filter((root) => !samePathSegment(root, projectRoot)); + const next = { ...references }; + if (remaining.length > 0) next[version] = remaining; + else delete next[version]; + await this.write(next); + } + + async listAllReferencingProjects(): Promise { + const references = await this.readAll(); + const allRoots: string[] = []; + for (const roots of Object.values(references)) allRoots.push(...roots); + const deduped = dedupePathSegments(allRoots); + const existing: string[] = []; + for (const root of deduped) { + if (await this.fs.fileExists(root)) existing.push(root); + } + return existing; + } + + private get path(): string { + return join(this.userConfigDir(), USER_SOURCE_REFERENCES_FILENAME); + } + + private async readAll(): Promise { + const path = this.path; + if (!(await this.fs.fileExists(path))) return {}; + const raw = await this.fs.readFile(path); + return parseReferencesFile(raw, path); + } + + /** Every write is the one place a `projectRoot` that has stopped existing is purged from the file + * entirely, not merely ignored at read time — until this the file only ever grew, and every read + * kept `stat`-ing dead paths. `protectedRoot` is never pruned regardless of its own existence, so + * a write can never drop the very claim it exists to record. */ + private async write(references: ReferencesFile, protectedRoot?: string): Promise { + const pruned = await this.pruneVanishedRoots(references, protectedRoot); + await this.fs.createDirectory(dirname(this.path)); + await this.fs.writeFile(this.path, JSON.stringify(pruned, null, 2)); + } + + private async pruneVanishedRoots( + references: ReferencesFile, + protectedRoot: string | undefined + ): Promise { + const result: ReferencesFile = {}; + for (const [version, roots] of Object.entries(references)) { + const existing: string[] = []; + for (const root of roots) { + const protectedFromPrune = + protectedRoot !== undefined && samePathSegment(root, protectedRoot); + if (protectedFromPrune || (await this.fs.fileExists(root))) existing.push(root); + } + if (existing.length > 0) result[version] = existing; + } + return result; + } +} + +/** Validates the file's shape into a typed value at the adapter boundary. A version key whose value + * is not a list of strings is exactly as unreadable as JSON that fails to parse at all: + * half-trusting a corrupted shape is how a later write would silently drop what it could not + * validate. */ +function parseReferencesFile(raw: string, path: string): ReferencesFile { + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch (error) { + throw new UnreadableUserSourceReferencesError( + path, + error instanceof Error ? error.message : "it is not valid JSON" + ); + } + if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) { + throw new UnreadableUserSourceReferencesError(path, "it is not a version-keyed object"); + } + const result: Record = {}; + for (const [version, projectRoots] of Object.entries(parsed as Record)) { + if (!Array.isArray(projectRoots) || !projectRoots.every((root) => typeof root === "string")) { + throw new UnreadableUserSourceReferencesError( + path, + `its "${version}" entry is not a list of project paths` + ); + } + result[version] = projectRoots; + } + return result; +} diff --git a/cli/src/contexts/telemetry/application/diagnose-telemetry-use-case.ts b/cli/src/contexts/telemetry/application/diagnose-telemetry-use-case.ts new file mode 100644 index 000000000..2a1758423 --- /dev/null +++ b/cli/src/contexts/telemetry/application/diagnose-telemetry-use-case.ts @@ -0,0 +1,319 @@ +import { describeError } from "../../../kernel/describe-error.js"; +import type { VersionReader } from "../../../kernel/ports/version-reader.js"; +import { AI_TOOL_IDS, type AiToolId } from "../../../kernel/tool.js"; +import { buildHostRegistration } from "../../tools/domain/host-plugin-registration.js"; +import type { HostPluginRegistryReader } from "../../tools/domain/ports/host-plugin-registry-reader.js"; +import { getAiToolConfig, resolvePluginsCapability } from "../../tools/domain/registry.js"; +import { + SESSION_TRAILER_DELEGATE_FILE, + SESSION_TRAILER_TOKEN, +} from "../domain/formats/commit-session-trailer.js"; +import type { HookTrustReader } from "../domain/ports/hook-trust-reader.js"; +import type { InstalledPluginsReader } from "../domain/ports/installed-plugins-reader.js"; +import type { PersonIdentityStore } from "../domain/ports/person-identity-store.js"; +import type { RunJournal, RunJournalReader } from "../domain/ports/run-journal-reader.js"; +import type { SessionCostReader } from "../domain/ports/session-cost-reader.js"; +import type { TelemetryEvidenceReader } from "../domain/ports/telemetry-evidence-reader.js"; +import type { TelemetrySink } from "../domain/ports/telemetry-sink.js"; +import type { VersionControl } from "../domain/ports/version-control.js"; +import { resolveSessionAnchor } from "../domain/session-anchor.js"; +import { + attributeMoment, + buildStepIntervals, + type StepAttributionSource, +} from "../domain/step-attribution.js"; +import { + diagnoseTelemetryClaims, + type TelemetryClaim, + type TelemetryClaimJournal, + type TelemetryClaimToolRead, + type TelemetryCodexHookTrust, + type TelemetryEvidence, +} from "../domain/telemetry-claim.js"; +import type { TelemetryExportLeftover } from "../domain/telemetry-export-leftover.js"; +import { + buildTelemetryAllowedSetup, + type TelemetryHostRegistrationSetup, + type TelemetryIdentitySetup, + type TelemetryPluginVersionSetup, + type TelemetryRecorderDeclarationSetup, + type TelemetrySetup, +} from "../domain/telemetry-setup.js"; + +const DEFAULT_RUNS_DIR_LABEL = "aidd_docs/runs"; + +export interface DiagnoseTelemetryUncoveredTool { + readonly tool: AiToolId; + readonly reason: string; +} + +/** What `aidd telemetry check` answers with. `gate` is mutually exclusive with `claims`: a gated + * run judges nothing. `leftoverExportConfig` and `setup` are gathered either side of the gate — a + * stale export lives in a tool's own settings file, whatever this project's switch says. */ +export type DiagnoseTelemetryResult = + | { + readonly gate: string; + readonly setup: TelemetrySetup; + readonly leftoverExportConfig: readonly TelemetryExportLeftover[]; + } + | { + readonly gate?: undefined; + readonly setup: TelemetrySetup; + readonly claims: readonly TelemetryClaim[]; + readonly uncovered: readonly DiagnoseTelemetryUncoveredTool[]; + readonly leftoverExportConfig: readonly TelemetryExportLeftover[]; + }; + +/** How far back the trailer count looks. A count rather than a date: the cost is the same on any + * repository, and enough to show whether recent commits are being stamped. */ +const COMMITS_EXAMINED_FOR_TRAILER = 20; + +export interface DiagnoseTelemetryOptions { + readonly projectRoot: string; + readonly env: NodeJS.ProcessEnv; +} + +function toClaimJournal(journal: RunJournal): TelemetryClaimJournal { + return { + vendorId: journal.session?.vendor_id, + sessionStartAt: journal.session?.at, + turnClosed: journal.boundaries.length > 0, + }; +} + +function isCovered(tool: AiToolId): boolean { + return getAiToolConfig(tool).telemetryLocalRead.kind === "declared"; +} + +function coveredTools(): readonly AiToolId[] { + return AI_TOOL_IDS.filter(isCovered); +} + +function uncoveredTools(): readonly DiagnoseTelemetryUncoveredTool[] { + return AI_TOOL_IDS.filter((tool) => !isCovered(tool)).map((tool) => { + const localRead = getAiToolConfig(tool).telemetryLocalRead; + return { + tool, + reason: localRead.kind === "unsupported" ? localRead.reason : "no reader wired yet", + }; + }); +} + +/** The plugin version the hook stamped, from the most recently opened session carrying one: an + * upgrade mid-period leaves older lines naming the older build. A session carrying none is + * skipped, never counted as an absence. */ +function pluginVersionFrom(journals: readonly RunJournal[]): TelemetryPluginVersionSetup { + const sessions = journals.map((journal) => journal.session).filter(isPresent); + if (sessions.length === 0) return { kind: "nothing-journalled" }; + const withVersion = sessions + .filter((session) => session.plugin_version !== undefined) + .sort((a, b) => Date.parse(b.at) - Date.parse(a.at)); + const newest = withVersion[0]; + return newest?.plugin_version === undefined + ? { kind: "unrecorded" } + : { kind: "recorded", version: newest.plugin_version }; +} + +function isPresent(value: T | undefined): value is T { + return value !== undefined; +} + +/** + * Gathers every claim's evidence, then hands it to the pure judge in `domain/telemetry-claim.ts`. + * Never writes: the question is only ever "would a read of this session's figures work". + */ +export class DiagnoseTelemetryUseCase { + constructor( + private readonly evidence: TelemetryEvidenceReader, + private readonly git: VersionControl, + private readonly runJournalReader: RunJournalReader, + private readonly readers: ReadonlyMap, + private readonly hookTrustReader: HookTrustReader, + private readonly personIdentityStore: PersonIdentityStore, + private readonly telemetrySink: TelemetrySink, + private readonly currentVersion: VersionReader, + private readonly installedPlugins: InstalledPluginsReader, + private readonly hostRegistries: ReadonlyMap + ) {} + + async execute(options: DiagnoseTelemetryOptions): Promise { + // Gathered before the gate and regardless of it: a stale export exports whether or not this + // project's switch is on, and setup is what a person switched off still needs to see. + const journals = await this.runJournalReader.list(); + const leftoverExportConfig = await this.evidence.findLeftoverExportConfig(options.projectRoot); + const setup = await this.gatherSetup(options, journals); + const gate = await this.gateReason(options); + if (gate !== null) return { gate, setup, leftoverExportConfig }; + const evidence = await this.gatherEvidence(options, setup.recorderDeclaration, journals); + const claims = diagnoseTelemetryClaims(evidence); + return { setup, claims, uncovered: uncoveredTools(), leftoverExportConfig }; + } + + private async gatherSetup( + options: DiagnoseTelemetryOptions, + journals: readonly RunJournal[] + ): Promise { + const [switchSetup, recorderDeclaration] = await Promise.all([ + this.evidence.readSwitchSetup(options.projectRoot), + this.evidence.readRecorderDeclaration(options.projectRoot), + ]); + return { + allowed: buildTelemetryAllowedSetup(switchSetup, options.env), + identity: await this.readIdentitySetup(), + recordsLocation: { path: this.telemetrySink.rootDir }, + recorderDeclaration, + hostRegistration: await this.readHostRegistration(options.projectRoot), + commitTrailer: await this.git.readCommitTrailerSetup( + options.projectRoot, + SESSION_TRAILER_DELEGATE_FILE, + SESSION_TRAILER_TOKEN, + COMMITS_EXAMINED_FOR_TRAILER + ), + versions: { + cli: this.currentVersion.get(), + plugin: pluginVersionFrom(journals), + }, + }; + } + + /** Every plugin the manifest records, against what each host's registry says. Driven from the + * manifest, never from a settings file: a plugin whose marketplace does not resolve is skipped + * silently there, so a settings-first comparison would read absence as agreement. */ + private async readHostRegistration(projectRoot: string): Promise { + let recorded: Awaited>; + try { + recorded = await this.installedPlugins.read(); + } catch (error) { + // A damaged manifest throws rather than returning null: reported, never fatal, and named — + // the `recorder declared` row scans the same file's raw JSON while this goes through the + // manifest's own validation, so the two rows can disagree about one file. + return { + ...buildHostRegistration([]), + manifestUnreadable: `${this.installedPlugins.path} — ${describeError(error)}`, + }; + } + if (recorded === null) return buildHostRegistration([]); + const evidence = await Promise.all( + // Filtered before the read, never after: a tool with no plugin recorded would otherwise pay + // a home-directory read whose result is thrown away on every `check`. + AI_TOOL_IDS.filter((tool) => (recorded.get(tool) ?? []).length > 0).map(async (tool) => ({ + tool, + plugins: (recorded.get(tool) ?? []).map((plugin) => ({ + name: plugin.name, + marketplace: plugin.marketplace, + })), + reading: await this.hostRegistries.get(tool)?.read(projectRoot), + declaresNativeActivation: resolvePluginsCapability(tool)?.nativeActivation != null, + })) + ); + return buildHostRegistration(evidence); + } + + // `readStrict()` throws on a damaged file, unlike the `read()` every other consumer uses: this + // caller must tell "nobody chose" apart from "could not be read", so it catches. + private async readIdentitySetup(): Promise { + const path = this.personIdentityStore.filePath; + try { + const identity = await this.personIdentityStore.readStrict(); + return { attached: identity !== null, path, readable: true }; + } catch { + return { attached: false, path, readable: false }; + } + } + + private async gatherEvidence( + options: DiagnoseTelemetryOptions, + recorderDeclaration: TelemetryRecorderDeclarationSetup, + journals: readonly RunJournal[] + ): Promise { + const currentSessionId = resolveSessionAnchor(options.env); + const unrecognisedPayload = await this.evidence.readUnrecognisedPayload(options.projectRoot); + const hookTrust = await this.resolveHookTrust(options.env, currentSessionId); + const toolReads = await this.gatherToolReads(journals); + return { + journals: journals.map(toClaimJournal), + toolReads, + runsDirLabel: DEFAULT_RUNS_DIR_LABEL, + currentSessionId, + unrecognisedPayloadAt: unrecognisedPayload?.at, + hookTrust, + recorderDeclared: recorderDeclaration.declared, + recorderDeclarationReadable: recorderDeclaration.unreadable.length === 0, + foreignSchemaVersions: await this.runJournalReader.listForeignSchemas(), + }; + } + + // Stops the run before any claim is evaluated: neither fact is evidence about the hook, + // both are facts about whether there is anything here for it to have written. + private async gateReason(options: DiagnoseTelemetryOptions): Promise { + if (!(await this.evidence.isTelemetryEnabled(options.projectRoot, options.env))) { + return "measurement is off — nothing to check until it is turned on"; + } + if (!(await this.git.isRepository(options.projectRoot))) { + return ( + "not a git repository — the hook has nowhere to write here, not a hook that failed " + + "to fire" + ); + } + return null; + } + + // Only Codex gates a hook behind a trust grant it can decline in silence: a session + // running under any other tool has nothing to read here, and asks nothing of it. + private async resolveHookTrust( + env: NodeJS.ProcessEnv, + currentSessionId: string | undefined + ): Promise { + if (env.CODEX_THREAD_ID === undefined || currentSessionId === undefined) return undefined; + return this.hookTrustReader.read(); + } + + private async gatherToolReads( + journals: readonly RunJournal[] + ): Promise { + const covered = coveredTools(); + const reads: TelemetryClaimToolRead[] = []; + for (const journal of journals) { + const sessionId = journal.session?.vendor_id; + if (sessionId === undefined) continue; + const intervals = buildStepIntervals(journal); + for (const tool of covered) { + reads.push(await this.readOneTool(tool, sessionId, intervals)); + } + } + return reads; + } + + // A reader's contract promises never to throw, and this catches anyway: a diagnostic that + // crashed on one unreadable file would answer nothing about every other claim. + private async readOneTool( + tool: AiToolId, + sessionId: string, + intervals: ReturnType + ): Promise { + const reader = this.readers.get(tool); + const hasIntervals = intervals.length > 0; + if (!reader) return { tool, sessionFound: false, hasIntervals, records: [] }; + try { + const result = await reader.read(sessionId); + const records = result.records.map((record) => stampAttribution(record, intervals)); + return { tool, sessionFound: result.sessionFound, hasIntervals, records }; + } catch (error) { + return { + tool, + sessionFound: false, + hasIntervals, + records: [], + error: error instanceof Error ? error.message : String(error), + }; + } + } +} + +function stampAttribution( + record: { readonly step?: string; readonly event_timestamp?: string }, + intervals: ReturnType +): { readonly stepAttribution: StepAttributionSource } { + if (record.step !== undefined) return { stepAttribution: "tool-stated" }; + return { stepAttribution: attributeMoment(intervals, record.event_timestamp).source }; +} diff --git a/cli/src/contexts/telemetry/application/forget-telemetry-use-case.ts b/cli/src/contexts/telemetry/application/forget-telemetry-use-case.ts new file mode 100644 index 000000000..7042d1f8f --- /dev/null +++ b/cli/src/contexts/telemetry/application/forget-telemetry-use-case.ts @@ -0,0 +1,152 @@ +import { errorMessage } from "../../../kernel/describe-error.js"; +import { RUNS_ENTRY } from "../../../kernel/paths.js"; +import type { PersonIdentityStore } from "../domain/ports/person-identity-store.js"; +import type { RunJournalStore } from "../domain/ports/run-journal-reader.js"; +import type { TelemetrySink } from "../domain/ports/telemetry-sink.js"; +import type { VersionControl } from "../domain/ports/version-control.js"; +import type { + TelemetryHistoryReading, + TelemetryMachineIdentityRemoval, + TelemetryMachineSinkRemoval, + TelemetryProjectJournalRemoval, + TelemetryRemovalPreview, +} from "../domain/telemetry-removal.js"; + +export interface ForgetTelemetryOptions { + readonly projectRoot: string; +} + +export interface TelemetryRemovalFailure { + readonly path: string; + readonly reason: string; +} + +export interface TelemetryRemovalOutcome { + readonly removed: number; + readonly failed: readonly TelemetryRemovalFailure[]; +} + +export interface TelemetryRemovalResult { + readonly journal: TelemetryRemovalOutcome; + readonly sink: TelemetryRemovalOutcome; + readonly identity: TelemetryRemovalOutcome; + /** Repeated from the preview: history does not become reachable by having removed the rest, + * so this is the exact same reading, not a fresh one. */ + readonly history: TelemetryHistoryReading; +} + +/** + * Shows, then removes, what this tool measured about one person, never both in the same call. + * `preview()` alone resolves every location; `remove()` takes exactly that value and resolves + * none of its own, so deleting something nobody was shown is inexpressible, not merely untested. + * Whether to call `remove()` at all is the command layer's decision; a refusal is never a throw. + */ +export class ForgetTelemetryUseCase { + constructor( + private readonly sink: TelemetrySink, + private readonly runJournalReader: RunJournalStore, + private readonly identity: PersonIdentityStore, + private readonly git: VersionControl + ) {} + + /** Resolves every location once, and touches nothing — a person sees exactly this value + * before anything is asked to go. */ + async preview(options: ForgetTelemetryOptions): Promise { + const [dayFileNames, runFileNames, isRepo, tracked, hasHistory, identityState] = + await Promise.all([ + this.sink.listDayFiles(), + this.runJournalReader.listRunFiles(), + this.git.isRepository(options.projectRoot), + this.git.listTrackedFiles(options.projectRoot, RUNS_ENTRY), + this.git.hasHistoryFor(options.projectRoot, RUNS_ENTRY), + this.identityState(), + ]); + return { + journal: { scope: "project", path: this.runJournalReader.runsDir, runFileNames }, + sink: { scope: "machine", path: this.sink.rootDir, dayFileNames }, + identity: { scope: "machine", path: this.identity.filePath, ...identityState }, + history: this.historyReading(isRepo, tracked, hasHistory), + }; + } + + private historyReading( + isRepo: boolean, + tracked: readonly string[], + hasHistory: boolean + ): TelemetryHistoryReading { + if (!isRepo) return { certainty: "none" }; + if (tracked.length === 0) return { certainty: "possible" }; + return hasHistory + ? { certainty: "committed", files: tracked } + : { certainty: "staged", files: tracked }; + } + + /** Removes exactly what `preview` resolved, never a location of its own. Every location is + * attempted whatever the others did: one failure never spares or stops the rest. */ + async remove(preview: TelemetryRemovalPreview): Promise { + const [journal, sink, identity] = await Promise.all([ + this.removeJournal(preview.journal), + this.removeSink(preview.sink), + this.removeIdentity(preview.identity), + ]); + return { journal, sink, identity, history: preview.history }; + } + + // `readStrict()` throwing is the file existing but being unreadable - exactly the file a + // person most needs named as present. `null` is the ordinary "nobody opted in" case. + private async identityState(): Promise<{ present: boolean; unreadable: boolean }> { + try { + return { present: (await this.identity.readStrict()) !== null, unreadable: false }; + } catch { + return { present: true, unreadable: true }; + } + } + + // `journal.path`, never `this.runJournalReader.runsDir` re-read here: this must act on the + // same value a person was shown. + private async removeJournal( + journal: TelemetryProjectJournalRemoval + ): Promise { + const failed: TelemetryRemovalFailure[] = []; + let removed = 0; + for (const fileName of journal.runFileNames) { + try { + await this.runJournalReader.deleteRunFile(journal.path, fileName); + removed++; + } catch (error) { + failed.push({ path: fileName, reason: errorMessage(error) }); + } + } + return { removed, failed }; + } + + // `sink.path`, for the same reason `removeJournal` uses `journal.path`: the two agree today, + // but a second computation here would be free to disagree tomorrow. + private async removeSink(sink: TelemetryMachineSinkRemoval): Promise { + const failed: TelemetryRemovalFailure[] = []; + let removed = 0; + for (const fileName of sink.dayFileNames) { + try { + await this.sink.deleteDayFile(sink.path, fileName); + removed++; + } catch (error) { + failed.push({ path: fileName, reason: errorMessage(error) }); + } + } + return { removed, failed }; + } + + // Gated on the preview's own `identity.present`, never the filesystem at removal time: a file + // that appeared after a preview said "nothing to remove" must not be deleted and counted. + private async removeIdentity( + identity: TelemetryMachineIdentityRemoval + ): Promise { + if (!identity.present) return { removed: 0, failed: [] }; + try { + const wasThere = await this.identity.forget(identity.path); + return { removed: wasThere ? 1 : 0, failed: [] }; + } catch (error) { + return { removed: 0, failed: [{ path: identity.path, reason: errorMessage(error) }] }; + } + } +} diff --git a/cli/src/contexts/telemetry/application/person-identity-use-case.ts b/cli/src/contexts/telemetry/application/person-identity-use-case.ts new file mode 100644 index 000000000..4971d903e --- /dev/null +++ b/cli/src/contexts/telemetry/application/person-identity-use-case.ts @@ -0,0 +1,168 @@ +import { + EmptyDisplayNameError, + EmptyIdentifierError, + IdentityRequiredToLinkError, + UnreadableIdentityFileError, +} from "../../../kernel/errors.js"; +import type { PersonIdentity } from "../domain/ports/person-identity-reader.js"; +import type { PersonIdentityStore } from "../domain/ports/person-identity-store.js"; + +export interface PersonIdentityStatusResult { + readonly filePath: string; + readonly identity: PersonIdentity | null; +} + +export interface PersonIdentityUseResult { + readonly filePath: string; + readonly identity: PersonIdentity; + /** How this machine came to carry the identifier it now carries. Three values, not two + * booleans: one this machine minted is not the same fact as one carried here from another. */ + readonly outcome: "minted" | "adopted" | "unchanged"; + /** The identifier this replaced — absent both when nothing was declared yet and when the same + * one was already in effect. Records already written keep the identifier they carry. */ + readonly replacedPersonId?: string; + /** The display name this call attached, when one was asked for. Absent when none was — + * never `""`, which would read as a name someone chose to be empty. */ + readonly displayNameSet?: string; +} + +export interface PersonIdentityOffResult { + readonly filePath: string; + /** `false` when there was nothing to withdraw. */ + readonly removed: boolean; + /** `true` when the file existed but could not be read back, and was removed anyway — `off` + * must work exactly when a damaged file would otherwise leave a person unable to withdraw. */ + readonly discardedDamaged: boolean; + /** How many identifiers `alsoMe` carried at withdrawal — `off` removes the whole declaration. + * `0` both when none were added and when a damaged file hid how many there were. */ + readonly addedIdentifiersRemoved: number; +} + +export interface PersonIdentityLinkResult { + readonly filePath: string; + readonly personId: string; + readonly identity: string; + /** `true` when `identity` already resolved to this same person before this call - a caller + * that always links first, then reports, must tell a no-op apart from a fresh write. */ + readonly alreadyListed: boolean; +} + +export interface PersonIdentityUnlinkResult { + readonly filePath: string; + readonly identity: string; + /** `false` when `identity` was never listed at all - reported as nothing to remove, + * never as a failure. */ + readonly removed: boolean; +} + +/** + * Every verb acts on the one file declaring who this machine's user is: `use` mints an + * identifier or takes one minted elsewhere, so a person reads as one across machines; + * `link`/`unlink` carry identifiers chosen elsewhere on `alsoMe`; `off` withdraws the whole + * file. Neither `use` nor `link` can verify that the person running it is who they claim. + */ +export class PersonIdentityUseCase { + constructor(private readonly store: PersonIdentityStore) {} + + async status(): Promise { + const filePath = this.store.filePath; + const identity = await this.store.readStrict(); + return { + filePath, + identity, + }; + } + + /** The one door to "which identifier am I": `identifier` absent mints, present adopts — the + * same question with and without an answer in hand, kept apart on disk by `origin`. */ + async use(options: { + identifier?: string; + displayName?: string; + }): Promise { + if (options.identifier !== undefined && options.identifier.trim() === "") { + throw new EmptyIdentifierError("use"); + } + if (options.displayName !== undefined && options.displayName.trim() === "") { + throw new EmptyDisplayNameError(); + } + const settled = await this.settleIdentifier(options.identifier); + const identity = + options.displayName === undefined + ? settled.identity + : await this.store.setDisplayName(settled.identity, options.displayName); + return { + filePath: this.store.filePath, + identity, + outcome: settled.outcome, + ...(settled.replacedPersonId === undefined + ? {} + : { replacedPersonId: settled.replacedPersonId }), + ...(options.displayName === undefined ? {} : { displayNameSet: options.displayName }), + }; + } + + /** Split from the display name because that is an independent decision: folding both into one + * body would make a rename look like a change of identity. */ + private async settleIdentifier(identifier?: string): Promise<{ + identity: PersonIdentity; + outcome: PersonIdentityUseResult["outcome"]; + replacedPersonId?: string; + }> { + const current = await this.store.readStrict(); + if (identifier === undefined) { + if (current !== null) return { identity: current, outcome: "unchanged" }; + return { identity: await this.store.mint(), outcome: "minted" }; + } + if (current !== null && current.personId === identifier) { + return { identity: current, outcome: "unchanged" }; + } + const identity = await this.store.adopt(identifier); + return { + identity, + outcome: "adopted", + ...(current === null ? {} : { replacedPersonId: current.personId }), + }; + } + + async link(identity: string): Promise { + if (identity.trim() === "") throw new EmptyIdentifierError("link"); + const person = await this.store.readStrict(); + if (person === null) throw new IdentityRequiredToLinkError(); + const alreadyListed = identity === person.personId || person.alsoMe.includes(identity); + if (!alreadyListed) await this.store.addAlsoMe(identity); + return { filePath: this.store.filePath, personId: person.personId, identity, alreadyListed }; + } + + async unlink(identity: string): Promise { + const person = await this.store.readStrict(); + const removed = person?.alsoMe.includes(identity) ?? false; + if (removed) await this.store.removeAlsoMe(identity); + return { filePath: this.store.filePath, identity, removed }; + } + + /** The one verb allowed to swallow `readStrict()`'s throw: `off` is how a person gets out, and + * a file too damaged to parse is exactly the moment withdrawing must still work. */ + async off(): Promise { + const filePath = this.store.filePath; + const { existing, discardedDamaged } = await this.readForWithdrawal(); + const addedIdentifiersRemoved = existing?.alsoMe.length ?? 0; + // Always asks the store, never decides from the read above: a file holding an empty + // `person_id` reads as "nobody chose" and would be left on disk. + const removed = await this.store.forget(filePath); + return { filePath, removed, discardedDamaged, addedIdentifiersRemoved }; + } + + private async readForWithdrawal(): Promise<{ + existing: PersonIdentity | null; + discardedDamaged: boolean; + }> { + try { + return { existing: await this.store.readStrict(), discardedDamaged: false }; + } catch (error) { + if (error instanceof UnreadableIdentityFileError) { + return { existing: null, discardedDamaged: true }; + } + throw error; + } + } +} diff --git a/cli/src/contexts/telemetry/application/read-local-cost-use-case.ts b/cli/src/contexts/telemetry/application/read-local-cost-use-case.ts new file mode 100644 index 000000000..10f655a3c --- /dev/null +++ b/cli/src/contexts/telemetry/application/read-local-cost-use-case.ts @@ -0,0 +1,502 @@ +import { errorMessage } from "../../../kernel/describe-error.js"; +import type { + TelemetryLocalReadDeclared, + TelemetryLocalReadUnsupported, +} from "../../../kernel/measurement.js"; +import type { Logger } from "../../../kernel/ports/logger.js"; +import type { VersionReader } from "../../../kernel/ports/version-reader.js"; +import { AI_TOOL_IDS, type AiToolId } from "../../../kernel/tool.js"; +import { getAiToolConfig, journalHostToAiToolId } from "../../tools/domain/registry.js"; +import type { + PersonIdentity, + PersonIdentityReader, +} from "../domain/ports/person-identity-reader.js"; +import type { RunJournalReader } from "../domain/ports/run-journal-reader.js"; +import type { + LocalCostCandidateRecord, + LocalCostReadResult, + SessionCostReader, +} from "../domain/ports/session-cost-reader.js"; +import type { TelemetryEvidenceReader } from "../domain/ports/telemetry-evidence-reader.js"; +import type { TelemetrySink } from "../domain/ports/telemetry-sink.js"; +import { resolveSessionProject, type SessionProject } from "../domain/session-project.js"; +import { + attributeMoment, + buildStepIntervals, + type StepInterval, +} from "../domain/step-attribution.js"; +import { SINK_SCHEMA_VERSION, type TelemetrySinkRecord } from "../domain/telemetry-sink-record.js"; +import { + DEFAULT_TELEMETRY_SINK_RETENTION_DAYS, + decideTelemetrySinkRetention, +} from "../domain/telemetry-sink-retention.js"; + +/** Six answers, and only `empty` may ever be printed as a zero — there the zero is the + * measurement. `not-found` is an observation, `not-asked` a decision not to look, `unreadable` a + * failure: collapsing any of them into `empty` is how a session nobody measured reads as free. */ +export type LocalCostToolStatus = + | "found" + | "empty" + | "not-found" + | "unreadable" + | "not-covered" + | "not-asked"; + +export interface LocalCostToolReport { + readonly tool: AiToolId; + readonly status: LocalCostToolStatus; + /** Records the reader returned, before dedup — this is what makes "found" and "empty" + * distinguishable from each other, independent of how many were new. */ + readonly recordsFound: number; + /** Records newly appended to the sink; a re-read of an already-stored session can be + * `status: "found"` with `recordsStored: 0`. */ + readonly recordsStored: number; + /** Why this tool is not covered, or what a covered one's figures cannot yet be used for — both + * from the declaration. On `unreadable`, what the reader itself said. */ + readonly reason?: string; + /** Sessions this tool's reader threw on. Separate from `status` because a sweep can read + * nineteen and fail the twentieth: the figures are real, so the status stays `found`. */ + readonly sessionsFailed: number; + /** What the last failed session's reader said, when any failed. */ + readonly failureReason?: string; +} + +export interface ReadLocalCostOptions { + /** One session by name. Absent reads every session the run journal knows about — the + * only route a person has, since nothing tells them a session identifier. */ + readonly sessionId?: string; + readonly at?: Date; + /** Where to look for `.aidd/config.json` when asking whether the project switch is on. + * Required, not defaulted: this is the one route left that writes the sink. */ + readonly projectRoot: string; + /** Passed through to the same refusal check the switch itself honours + * (`AIDD_TELEMETRY=0`), rather than read from `process.env` here. */ + readonly env: NodeJS.ProcessEnv; +} + +/** What every candidate gets stamped with: facts about where a record came from, never about the + * record itself. `intervals` and `project` are per-session, `person` per-sweep. */ +interface LocalReadAttribution { + readonly intervals: readonly StepInterval[]; + readonly project: SessionProject | null; + readonly person: PersonIdentity | null; +} + +/** What one session's read produced. `sessionId` is on the report because a sweep answers + * about several and a caller has to be able to tell them apart. */ +export interface LocalCostSessionReport { + readonly sessionId: string; + readonly toolReports: readonly LocalCostToolReport[]; +} + +export interface ReadLocalCostResult { + readonly sessions: readonly LocalCostSessionReport[]; + /** Every tool's answer across every session read, so a caller sees one line per tool + * rather than one per tool per session. */ + readonly toolReports: readonly LocalCostToolReport[]; + /** Present only when the sweep refused to run at all. `sessions` and `toolReports` are empty + * then, and an empty sweep must never be told apart from a refusal by inference. */ + readonly refusedReason?: string; +} + +function isPresent(value: string | undefined): value is string { + return value !== undefined; +} + +/** Already-stored records for this session, keyed on `turn_id`; one carrying none is never + * indexed. Mutable on purpose, so two candidates for one turn in a batch match each other. */ +function groupByTurnId( + records: readonly TelemetrySinkRecord[] +): Map { + const groups = new Map(); + for (const record of records) { + if (record.turn_id === undefined) continue; + const bucket = groups.get(record.turn_id); + if (bucket) bucket.push(record); + else groups.set(record.turn_id, [record]); + } + return groups; +} + +function indexStoredRecord( + groups: Map, + record: TelemetrySinkRecord +): void { + if (record.turn_id === undefined) return; + const bucket = groups.get(record.turn_id); + if (bucket) bucket.push(record); + else groups.set(record.turn_id, [record]); +} + +const LOCAL_READ_TURN_COUNTER_KEYS = [ + "input_tokens", + "output_tokens", + "cache_read_tokens", + "cache_creation_tokens", +] as const; + +/** How much of a turn a record accounts for — used only to find the largest of several + * still-open readings of it, never stored, never itself summed into a total. */ +function counterWeight(record: TelemetrySinkRecord): number { + return LOCAL_READ_TURN_COUNTER_KEYS.reduce((sum, key) => sum + (record[key] ?? 0), 0); +} + +/** Whether `candidate` improves on `stored`: every counter at least as large, one strictly + * larger. The sink keeps the larger reading rather than let a figure fall back silently. */ +function strictlyImprovesOn( + stored: TelemetrySinkRecord, + candidate: LocalCostCandidateRecord +): boolean { + let improved = false; + for (const key of LOCAL_READ_TURN_COUNTER_KEYS) { + const before = stored[key]; + const after = candidate[key]; + if (before === undefined) { + if (after !== undefined) improved = true; + continue; + } + if (after === undefined || after < before) return false; + if (after > before) improved = true; + } + return improved; +} + +/** The strongest answer a tool gave anywhere in the sweep. One session read and another failed + * reports `found` — those figures are real — and `sessionsFailed` carries the failure. */ +const STATUS_RANK: readonly LocalCostToolStatus[] = [ + "found", + "unreadable", + "empty", + "not-found", + "not-covered", + // Weakest on purpose: one session where this tool was never asked must never outrank + // another where it actually answered. + "not-asked", +]; + +function strongestOf(tool: AiToolId, reports: readonly LocalCostToolReport[]): LocalCostToolReport { + // The seed for a tool with no report at all: `not-asked` is what "nothing looked at it" means, + // where `not-found` would report an observation never made. + const nothingKnown: LocalCostToolReport = { + tool, + status: "not-asked", + recordsFound: 0, + recordsStored: 0, + sessionsFailed: 0, + }; + return reports.reduce( + (strongest, report) => + STATUS_RANK.indexOf(report.status) < STATUS_RANK.indexOf(strongest.status) + ? report + : strongest, + reports[0] ?? nothingKnown + ); +} + +function mergeOneTool( + tool: AiToolId, + sessions: readonly LocalCostSessionReport[] +): LocalCostToolReport { + const reports = sessions.flatMap((session) => + session.toolReports.filter((report) => report.tool === tool) + ); + const failures = reports + .map((report) => report.failureReason) + .filter((reason): reason is string => reason !== undefined); + return { + ...strongestOf(tool, reports), + recordsFound: reports.reduce((sum, report) => sum + report.recordsFound, 0), + recordsStored: reports.reduce((sum, report) => sum + report.recordsStored, 0), + sessionsFailed: failures.length, + ...(failures.length === 0 ? {} : { failureReason: failures[failures.length - 1] }), + }; +} + +/** Nothing here can read this tool at all, with the reason its declaration gives. */ +function notCovered(tool: AiToolId, localRead: TelemetryLocalReadUnsupported): LocalCostToolReport { + return { + tool, + status: "not-covered", + recordsFound: 0, + recordsStored: 0, + sessionsFailed: 0, + reason: localRead.reason, + }; +} + +/** This tool's reader was never run, because the journal named another tool. Carries no + * `reason`: nothing is wrong and nothing was measured, so the status is the whole fact. */ +function notAsked(tool: AiToolId): LocalCostToolReport { + return { tool, status: "not-asked", recordsFound: 0, recordsStored: 0, sessionsFailed: 0 }; +} + +/** Its reader failed, so nothing is known about it and something is wrong — distinct from + * `not-found`, where nothing is known and nothing is wrong. */ +function unreadable(tool: AiToolId, failure: string): LocalCostToolReport { + return { + tool, + status: "unreadable", + recordsFound: 0, + recordsStored: 0, + sessionsFailed: 1, + reason: failure, + failureReason: failure, + }; +} + +function mergeToolReports( + sessions: readonly LocalCostSessionReport[] +): readonly LocalCostToolReport[] { + return AI_TOOL_IDS.map((tool) => mergeOneTool(tool, sessions)); +} + +const REFUSED_REASON = + "measurement is refused — AIDD_TELEMETRY=0 or the project switch is off; nothing read, " + + "nothing stored"; + +/** Reads what every locally-readable tool's own files hold for one session, normalises it into + * the stored shape, and appends what is not already there. Which tools are readable is each + * tool's own declaration, read through the registry — this class names no tool. */ +export class ReadLocalCostUseCase { + constructor( + private readonly sink: TelemetrySink, + private readonly readers: ReadonlyMap, + private readonly runJournalReader: RunJournalReader, + private readonly personIdentityReader: PersonIdentityReader, + private readonly telemetryEvidenceReader: TelemetryEvidenceReader, + /** The CLI's own version, stamped on every record this sweep stores. Optional only so a + * caller exercising another concern need not invent one - absent, the field is omitted + * from what gets stored, never guessed at. */ + private readonly versionReader?: VersionReader, + /** Only the retention prune below writes here, and only to warn. Optional so a caller that + * does not care about housekeeping warnings need not invent a logger. */ + private readonly logger: Logger = { debug() {}, info() {}, warn() {} }, + private readonly retentionDays: number = DEFAULT_TELEMETRY_SINK_RETENTION_DAYS + ) {} + + async execute(options: ReadLocalCostOptions): Promise { + // Re-checked here, since this is the sink's one remaining writer: a refusal enforced only by + // the hook never writing a journal does not hold against `--session `, which never reads + // one. Before any reader runs, so a refused sweep touches neither the sink nor a tool's files. + if ( + !(await this.telemetryEvidenceReader.isTelemetryEnabled(options.projectRoot, options.env)) + ) { + // Told once, by the caller's own display layer, not here too: `this.logger` exists for + // housekeeping the figures themselves never surface. + return { sessions: [], toolReports: mergeToolReports([]), refusedReason: REFUSED_REASON }; + } + const at = options.at ?? new Date(); + const sessionIds = + options.sessionId === undefined ? await this.journalledSessionIds() : [options.sessionId]; + // Resolved once for the whole sweep, not per session: this is a fact about the machine + // this process is running on, not about any one session it reads. + const person = await this.personIdentityReader.read(); + const sessions: LocalCostSessionReport[] = []; + for (const sessionId of sessionIds) { + sessions.push({ sessionId, toolReports: await this.readOneSession(sessionId, at, person) }); + } + // A sweep prunes; a single named session does not: `report` catches sessions up one at a + // time, and a command asked a question must not delete day files as a side effect. + if (options.sessionId === undefined) await this.pruneOldDayFiles(); + return { sessions, toolReports: mergeToolReports(sessions) }; + } + + /** + * Keeps the sink inside its retention window, once per sweep — the unit a person invokes. + * Every failure warns per file: housekeeping must not cost the figures this sweep just + * stored, and one undeletable file must not spare every older one behind it. + */ + private async pruneOldDayFiles(): Promise { + let prune: readonly string[]; + try { + prune = decideTelemetrySinkRetention( + await this.sink.listDayFiles(), + this.retentionDays + ).prune; + } catch (error) { + this.logger.warn(`telemetry read: retention prune failed - ${errorMessage(error)}`); + return; + } + for (const fileName of prune) { + try { + await this.sink.deleteDayFile(this.sink.rootDir, fileName); + } catch (error) { + this.logger.warn(`telemetry read: could not delete ${fileName} - ${errorMessage(error)}`); + } + } + } + + /** Every session the journal names, oldest file first. A person has no other way to + * learn a session identifier, and the journal has recorded every one of them. */ + private async journalledSessionIds(): Promise { + const journals = await this.runJournalReader.list(); + const ids = journals.map((journal) => journal.session?.vendor_id).filter(isPresent); + return [...new Set(ids)]; + } + + private async readOneSession( + sessionId: string, + at: Date, + person: PersonIdentity | null + ): Promise { + // Read once per session, never per tool: every reader's candidates join the same journal. No + // journal yields empty intervals and a `null` project, so candidates fall through to + // unattributed rather than the read failing, and the project is never re-derived from cwd. + const journal = await this.runJournalReader.read(sessionId); + const attribution: LocalReadAttribution = { + intervals: journal ? buildStepIntervals(journal) : [], + project: resolveSessionProject(journal), + person, + }; + // Only the tool whose session this is: the journal's `session_start` names the host that + // wrote it, so asking the others is useless work one of them pays for in process spawns — the + // OpenCode reader shells out to its binary and waits. Fan out only when the journal names no + // tool, where the tool is genuinely unknown. + const host = journal?.session?.tool; + const namedTool = host === undefined ? null : journalHostToAiToolId(host); + const toolReports: LocalCostToolReport[] = []; + for (const tool of AI_TOOL_IDS) { + toolReports.push(await this.answerFor(tool, namedTool, sessionId, at, attribution)); + } + return toolReports; + } + + /** What this tool has to say about this session. Coverage first, always: "nothing here can read + * this tool" is true of every session, and it carries the declaration's own reason. */ + private async answerFor( + tool: AiToolId, + namedTool: AiToolId | null, + sessionId: string, + at: Date, + attribution: LocalReadAttribution + ): Promise { + const localRead = getAiToolConfig(tool).telemetryLocalRead; + if (localRead.kind !== "declared") return notCovered(tool, localRead); + if (namedTool !== null && tool !== namedTool) return notAsked(tool); + return this.readOneTool(tool, localRead, sessionId, at, attribution); + } + + private async readOneTool( + tool: AiToolId, + localRead: TelemetryLocalReadDeclared, + sessionId: string, + at: Date, + attribution: LocalReadAttribution + ): Promise { + const attempt = await this.attemptRead(tool, sessionId); + if ("failure" in attempt) return unreadable(tool, attempt.failure); + const candidates = attempt.records; + const recordsStored = await this.storeNewCandidates( + tool, + sessionId, + candidates, + at, + attribution + ); + return { + tool, + status: candidates.length > 0 ? "found" : attempt.sessionFound ? "empty" : "not-found", + recordsFound: candidates.length, + recordsStored, + sessionsFailed: 0, + ...(localRead.limitation !== undefined ? { reason: localRead.limitation } : {}), + }; + } + + /** The one place this use case catches: a fan-out over independent sources, where one reader + * failing must not cost every other tool's figures, nor every other session's. */ + private async attemptRead( + tool: AiToolId, + sessionId: string + ): Promise { + const reader = this.readers.get(tool); + if (!reader) return { records: [], sessionFound: false }; + try { + return await reader.read(sessionId); + } catch (error) { + return { failure: error instanceof Error ? error.message : String(error) }; + } + } + + /** Matches on `turn_id` alone, never a hash of the line: the tool's own file keeps growing as + * the same record is read again. A stored turn is dropped unless a `request` local-read record + * strictly improves on it, which appends a second line for `collapseSupersededTurns` to + * reconcile. A correction is a larger counter, never a field the stored record lacks. */ + private async storeNewCandidates( + tool: AiToolId, + sessionId: string, + candidates: readonly LocalCostCandidateRecord[], + at: Date, + attribution: LocalReadAttribution + ): Promise { + if (candidates.length === 0) return 0; + const byTurnId = groupByTurnId(await this.sink.readRecordsForVendor(sessionId)); + let stored = 0; + for (const candidate of candidates) { + const prior = candidate.turn_id === undefined ? undefined : byTurnId.get(candidate.turn_id); + if (prior && !this.isLocalReadTurnCorrection(candidate, prior)) continue; + const record = this.stampProvenanceAndTool(tool, candidate, attribution); + await this.sink.appendRecord(record, at); + indexStoredRecord(byTurnId, record); + stored++; + } + return stored; + } + + /** Never for a `kind: "session"` record: Copilot's shutdown total shares this match but is a + * one-shot cumulative figure, which a re-read would start doubling. Otherwise only a strict + * improvement on the largest stored, never gated on `turn_end` — a larger candidate is itself + * proof the stored reading was not final. */ + private isLocalReadTurnCorrection( + candidate: LocalCostCandidateRecord, + prior: readonly TelemetrySinkRecord[] + ): boolean { + if (candidate.kind !== "request") return false; + const priorReads = prior.filter((r) => r.kind === "request" && r.provenance === "local-read"); + if (priorReads.length === 0) return false; + const largest = priorReads.reduce((best, r) => + counterWeight(r) > counterWeight(best) ? r : best + ); + return strictlyImprovesOn(largest, candidate); + } + + // The caller asked this tool's reader by name — that is the fact this stamps, never + // inferred from the candidate itself, which the reader's contract forbids it naming. + private stampProvenanceAndTool( + tool: AiToolId, + candidate: LocalCostCandidateRecord, + { intervals, project, person }: LocalReadAttribution + ): TelemetrySinkRecord { + return { + ...candidate, + sink_schema_version: SINK_SCHEMA_VERSION, + provenance: "local-read", + tool, + ...this.resolveStepAttribution(candidate, intervals), + ...(project === null + ? {} + : { project_id: project.projectId, project_field: project.projectField }), + ...(person === null ? {} : { person_id: person.personId }), + ...(person?.displayName === undefined ? {} : { person_display_name: person.displayName }), + ...(this.versionReader === undefined ? {} : { cli_version: this.versionReader.get() }), + }; + } + + // Where the candidate carries `step`, the tool stated it directly — exact, never second-guessed + // by an interval, which is only an inference. A candidate with no moment, or one earlier than + // every interval, comes back unattributed rather than folded into the nearest step. + private resolveStepAttribution( + candidate: LocalCostCandidateRecord, + intervals: readonly StepInterval[] + ): Pick { + if (candidate.step !== undefined) { + return { + step_attribution: "tool-stated", + step: candidate.step, + step_plugin: candidate.step_plugin, + }; + } + const attribution = attributeMoment(intervals, candidate.event_timestamp); + return { step_attribution: attribution.source, step: attribution.step, step_plugin: undefined }; + } +} diff --git a/cli/src/contexts/telemetry/application/report-cost-use-case.ts b/cli/src/contexts/telemetry/application/report-cost-use-case.ts new file mode 100644 index 000000000..f21caef86 --- /dev/null +++ b/cli/src/contexts/telemetry/application/report-cost-use-case.ts @@ -0,0 +1,447 @@ +import { UnreadableIdentityFileError } from "../../../kernel/errors.js"; +import type { Logger } from "../../../kernel/ports/logger.js"; +import { AI_TOOL_IDS } from "../../../kernel/tool.js"; +import { getAiToolConfig } from "../../tools/domain/registry.js"; +import { + buildCostReport, + type CostReport, + type CostReportFilters, + type CostReportInput, + type CostReportSessionJournal, + type CostReportToolCapability, + type CostReportToolDeclaration, + type PersonIdentityUnusableCause, +} from "../domain/cost-report.js"; +import { buildFlowIntervals } from "../domain/flow-attribution.js"; +import type { PersonIdentity } from "../domain/ports/person-identity-reader.js"; +import type { PersonIdentityStore } from "../domain/ports/person-identity-store.js"; +import type { RunJournal, RunJournalReader } from "../domain/ports/run-journal-reader.js"; +import type { TaskBacklogReader } from "../domain/ports/task-backlog-reader.js"; +import type { TelemetryEvidenceReader } from "../domain/ports/telemetry-evidence-reader.js"; +import type { TelemetrySink, TelemetrySinkPeriodRead } from "../domain/ports/telemetry-sink.js"; +import type { ResolvedReportPeriod } from "../domain/report-period.js"; +import { + attributeMoment, + buildStepIntervals, + type StepInterval, +} from "../domain/step-attribution.js"; +import { buildTaskIntervals } from "../domain/task-attribution.js"; +import { + type TaskBacklogDeclaration, + taskFolderPathFromIdentity, +} from "../domain/task-backlog-link.js"; +import { type TaskIdentity, taskIdentityFromWrittenPath } from "../domain/task-identity.js"; +import type { TelemetrySinkRecord } from "../domain/telemetry-sink-record.js"; +import type { ReadLocalCostResult, ReadLocalCostUseCase } from "./read-local-cost-use-case.js"; + +export interface ReportCostOptions { + /** Already two absolute days, resolved once at the edge — so nothing from here down reads a + * clock, and the same options answer the same twice. */ + readonly period: ResolvedReportPeriod; + /** Restrict to the sessions that wrote into this task. Absent reports the whole period. */ + readonly task?: TaskIdentity; + /** Any of `project`, `step`, `model` and `tool` - each optional, composing with `task` + * and each other by `and`. */ + readonly filters?: CostReportFilters; + /** Where to look for `.aidd/config.json` when asking whether the project switch is on. */ + readonly projectRoot: string; + /** Passed through to the same refusal check the switch itself honours (`AIDD_TELEMETRY=0`), + * rather than read from `process.env` down in an adapter. */ + readonly env: NodeJS.ProcessEnv; +} + +/** What each tool declares about being read at all, as data the pure report consumes — so a + * report prints the declaration's own reason rather than a zero, `limitation` included. */ +function declaredTools(): readonly CostReportToolDeclaration[] { + return AI_TOOL_IDS.map((tool) => { + const config = getAiToolConfig(tool); + const localRead = config.telemetryLocalRead; + const capability: CostReportToolCapability = { + localRead: localRead.kind === "declared" ? localRead.supplies : null, + // No tool declares an export route any more, so nothing could ever supply this. Kept as + // `null` rather than a type change rippling through the `--json` contract. + export: null, + journalAttributable: config.telemetryJournalHost !== undefined, + taskAttributable: config.telemetryTaskAttributable, + }; + if (localRead.kind === "declared") { + return { + tool, + coverage: "covered" as const, + ...(localRead.limitation === undefined ? {} : { reason: localRead.limitation }), + capability, + }; + } + return { + tool, + coverage: "not-covered" as const, + ...(localRead.kind === "unsupported" ? { reason: localRead.reason } : {}), + capability, + }; + }); +} + +/** The first and last moment a journal's own lines carry — every line kind, since the question is + * "was this journal open then". Not capped at the period's end: the sink returns no record past + * it, so a clock-skewed line can widen the span but never pull a record in. */ +const LAST_MILLISECOND_OF_A_SECOND = 999; + +function witnessedSpan(journal: RunJournal): { fromMs: number; toMs: number } | undefined { + const moments = [ + ...journal.boundaries, + ...journal.taskDeclarations, + ...journal.filesWritten, + ...(journal.session ? [journal.session] : []), + ] + .map((line) => Date.parse(line.at)) + .filter((atMs) => !Number.isNaN(atMs)); + if (moments.length === 0) return undefined; + // The end is the end of the second the last line names: the writing hook strips milliseconds + // from a journal moment while a record carries them, so comparing the two as instants would + // refuse a record that landed inside the very second the journal last wrote. + return { + fromMs: Math.min(...moments), + toMs: Math.max(...moments) + LAST_MILLISECOND_OF_A_SECOND, + }; +} + +function toSessionJournal( + journal: RunJournal, + periodEndMs: number +): CostReportSessionJournal | null { + if (!journal.session) return null; + const span = witnessedSpan(journal); + return { + vendorId: journal.session.vendor_id, + tool: journal.session.tool, + ...(journal.session.project_id === undefined ? {} : { projectId: journal.session.project_id }), + writtenPaths: journal.filesWritten.map((written) => written.path), + taskIntervals: buildTaskIntervals(journal, periodEndMs), + flowIntervals: buildFlowIntervals(journal, periodEndMs), + ...(span === undefined ? {} : { witnessed: span }), + }; +} + +/** Every distinct task identity this period's journals could ever key `by_task` on. Each is + * resolved to its folder's declaration exactly once, never once per record. */ +function distinctTaskIdentities( + journals: readonly RunJournal[], + periodEndMs: number +): readonly TaskIdentity[] { + const seen = new Set(); + const identities: TaskIdentity[] = []; + const remember = (identity: TaskIdentity | null): void => { + if (identity === null || seen.has(identity)) return; + seen.add(identity); + identities.push(identity); + }; + for (const journal of journals) { + for (const interval of buildTaskIntervals(journal, periodEndMs)) { + remember(taskIdentityFromWrittenPath(interval.path)); + } + // Written paths too, not declared intervals alone: that folder can declare a backlog item, + // and declarations alone would claim it declares none, from a lookup that never ran. + for (const written of journal.filesWritten) { + remember(taskIdentityFromWrittenPath(written.path)); + } + } + return identities; +} + +/** One read per distinct task identity, through the port, never re-read per record. A reader + * that throws is not this function's to catch: `TaskBacklogReader.read` promises it never does. */ +async function taskBacklogDeclarationsOf( + reader: TaskBacklogReader, + journals: readonly RunJournal[], + periodEndMs: number +): Promise> { + const declarations = new Map(); + for (const identity of distinctTaskIdentities(journals, periodEndMs)) { + declarations.set(identity, await reader.read(taskFolderPathFromIdentity(identity))); + } + return declarations; +} + +const MILLISECONDS_PER_DAY = 24 * 60 * 60 * 1000; + +/** The first moment no record in the period can reach: `toDay` itself runs through 23:59:59.999 + * UTC, so this is the *start* of the day after, never `toDay`'s own start — which would cut off + * a record legitimately timestamped later that day. */ +function periodEndMsOf(toDay: string): number { + return Date.parse(`${toDay}T00:00:00Z`) + MILLISECONDS_PER_DAY; +} + +interface PersonIdentityFields { + readonly identity: PersonIdentity | null; + readonly identityUnusableCause?: PersonIdentityUnusableCause; +} + +/** + * Never aborts the report over a damaged identity file: that is one dependency's own trouble, + * and the figures must still come back whole. Names which of the two causes fired rather than + * folding both into a boolean, and re-throws anything else rather than mislabel an unexpected + * failure as a familiar-looking caveat. + */ +async function personIdentityFields(store: PersonIdentityStore): Promise { + try { + const identity = await store.readStrict(); + return identity === null ? { identity: null, identityUnusableCause: "absent" } : { identity }; + } catch (error) { + if (error instanceof UnreadableIdentityFileError) { + return { identity: null, identityUnusableCause: "unreadable" }; + } + throw error; + } +} + +/** `identity` and `identityUnusableCause` together, as `buildCostReport` wants them - pulled out + * so `execute` reads as one shape assembled from its own reads. */ +function identityInputFields( + fields: PersonIdentityFields +): Pick { + return { + identity: fields.identity, + ...(fields.identityUnusableCause === undefined + ? {} + : { identityUnusableCause: fields.identityUnusableCause }), + }; +} + +/** Which skill each prompt opened, from the journal's own `step_start` lines. First wins: + * several steps can open under one prompt, and a prompt names the step its work *began* in — + * taking the last would answer for the reasoning that produced an earlier step's output. */ +function promptToSkill(journal: RunJournal): ReadonlyMap { + const byPrompt = new Map(); + for (const boundary of journal.boundaries) { + if (boundary.type !== "step_start" || boundary.turn_id === undefined) continue; + if (!byPrompt.has(boundary.turn_id)) byPrompt.set(boundary.turn_id, boundary.skill); + } + return byPrompt; +} + +/** The step a record's own prompt opened. Outranks the interval, and says so: `prompt-matched` + * is an identifier two sources agree on, where `journal-interval` infers from moments, so it + * stays true when two tasks advance at once. Two tasks inside *one* prompt stay indivisible: a + * billed amount cannot be split without inventing a ratio. */ +function matchOnPrompt( + record: TelemetrySinkRecord, + byPrompt: ReadonlyMap | undefined +): { readonly source: "prompt-matched"; readonly step: string } | null { + const step = journalNamedStep(record, byPrompt) ?? record.prompt_skill; + return step === undefined ? null : { source: "prompt-matched", step }; +} + +/** What the run journal says the record's own prompt opened, asked first: the journal was + * written by a hook the host itself fired, while `prompt_skill` is read back off a transcript + * afterwards, so the reading with a witness wins. A session the journal never saw at all falls + * through to the record's own. */ +function journalNamedStep( + record: TelemetrySinkRecord, + byPrompt: ReadonlyMap | undefined +): string | undefined { + if (record.prompt_id === undefined || byPrompt === undefined) return undefined; + return byPrompt.get(record.prompt_id); +} + +/** Every record's step, derived rather than trusted from disk: `step_attribution` is stored when + * the record is read, frozen at whatever the rule answered then. `tool-stated` is left alone, + * being witnessed rather than inferred, and so is a session this period's journals say nothing + * about — no interval to judge it against, and a blanker answer is worse than a stale one. */ +function withDerivedStep( + records: readonly TelemetrySinkRecord[], + journals: readonly RunJournal[] +): readonly TelemetrySinkRecord[] { + const bySession = new Map(); + const skillByPrompt = new Map>(); + for (const journal of journals) { + if (!journal.session) continue; + bySession.set(journal.session.vendor_id, buildStepIntervals(journal)); + skillByPrompt.set(journal.session.vendor_id, promptToSkill(journal)); + } + + return records.map((record) => { + if (record.step_attribution === "tool-stated") return record; + const intervals = bySession.get(record.vendor_id); + if (intervals === undefined) return record; + + const matched = matchOnPrompt(record, skillByPrompt.get(record.vendor_id)); + const derived = matched ?? attributeMoment(intervals, record.event_timestamp); + // Rebuilt rather than spread over: a record that carried a step from an earlier reading + // must lose it when the journal no longer names one, and a spread would keep it. + const { step: _step, step_plugin: _plugin, ...rest } = record; + return { + ...rest, + step_attribution: derived.source, + ...(derived.step === undefined ? {} : { step: derived.step }), + }; + }); +} + +/** Every gathered read, folded into the one shape `buildCostReport` wants - kept on its own + * so `execute` reads as "gather, then assemble," not a wall of field assignments. */ +function toReportInput( + options: ReportCostOptions, + read: Awaited>, + journals: readonly RunJournal[], + identity: PersonIdentityFields, + measurementEnabled: boolean, + taskBacklogDeclarations: ReadonlyMap +): CostReportInput { + const { fromDay, toDay } = options.period; + const periodEndMs = periodEndMsOf(toDay); + return { + fromDay, + toDay, + records: withDerivedStep(read.records, journals), + journals: journals + .map((journal) => toSessionJournal(journal, periodEndMs)) + .filter((journal) => journal !== null), + declaredTools: declaredTools(), + undatedRecords: read.undated.length, + unreadableLines: read.skippedLines, + ...(options.task === undefined ? {} : { task: options.task }), + ...(options.filters === undefined ? {} : { filters: options.filters }), + knownValues: read.knownValues, + measurementEnabled, + taskBacklogDeclarations, + ...identityInputFields(identity), + }; +} + +/** Sessions holding at least one stored record a re-read could never be matched against: a + * re-read reconciles on `turn_id`, and a record carrying none is never indexed, so re-reading + * such a session would append its records a second time. A host that writes no such identifier + * must not be silently doubled. */ +function sessionsWithAnUnmatchableRecord( + stored: readonly TelemetrySinkRecord[] +): ReadonlySet { + const sessions = new Set(); + for (const record of stored) { + if (record.turn_id === undefined) sessions.add(record.vendor_id); + } + return sessions; +} + +/** Every session the journal names whose own `session_start` falls inside the period — never + * "the ones the sink has never seen", which freezes a session still running the moment its first + * turn is stored. Re-reading is safe, the local read dedupes per `turn_id`; the period bound is + * what stops a one-week report re-reading every session ever journalled. */ +function sessionsToCatchUp( + stored: readonly TelemetrySinkRecord[], + journals: readonly RunJournal[], + fromMs: number, + periodEndMs: number +): readonly string[] { + const unmatchable = sessionsWithAnUnmatchableRecord(stored); + const missing: string[] = []; + for (const journal of journals) { + const session = journal.session; + if (session === undefined || unmatchable.has(session.vendor_id)) continue; + const atMs = Date.parse(session.at); + // `periodEndMs` is the first instant *after* the period, which is why this is `>=` and not + // `>`. Taken from `periodEndMsOf`, never recomputed here: an end computed from `toDay`'s own + // start excludes the whole of `toDay`, which `--days N` always makes today. + if (Number.isNaN(atMs) || atMs < fromMs || atMs >= periodEndMs) continue; + missing.push(session.vendor_id); + } + return missing; +} + +/** + * Answers what a period, or one task inside it, cost. Orchestration only: the rules belong to + * `domain/cost-report.ts`. It names no tool and computes no figure - in particular no amount, + * since the rates live outside this repository and an amount is only ever reported where a + * tool's own files already carried one. + */ +export class ReportCostUseCase { + constructor( + private readonly sink: TelemetrySink, + private readonly runJournalReader: RunJournalReader, + private readonly personIdentityStore: PersonIdentityStore, + private readonly telemetryEvidenceReader: TelemetryEvidenceReader, + private readonly taskBacklogReader: TaskBacklogReader, + /** Where `warnAboutFailures` says what a reader could not answer. */ + private readonly logger: Logger, + /** Reads the sessions the sink has not caught up with yet, before the report is built. + * Optional so a caller exercising the report's own rules need not wire it; absent, this + * reports exactly what the sink already holds. */ + private readonly readLocalCost?: ReadLocalCostUseCase + ) {} + + /** Whether the project switch is on right now - independent of the sink and the journal, + * so gathered on its own rather than folded into either of their reads. */ + private async measurementEnabled(options: ReportCostOptions): Promise { + return this.telemetryEvidenceReader.isTelemetryEnabled(options.projectRoot, options.env); + } + + async execute(options: ReportCostOptions): Promise { + const { fromDay, toDay } = options.period; + const from = new Date(`${fromDay}T00:00:00Z`); + const to = new Date(`${toDay}T00:00:00Z`); + const periodEndMs = periodEndMsOf(toDay); + // Every journal, not only the period's: a journal carries no date in its file name, and the + // records it is joined to were already selected by their own moments. + const journals = await this.runJournalReader.list(); + const read = await this.catchUp( + await this.sink.readRecordsInPeriod(from, to), + journals, + options, + { from, to, periodEndMs } + ); + const identity = await personIdentityFields(this.personIdentityStore); + const measurementEnabled = await this.measurementEnabled(options); + const taskBacklogDeclarations = await taskBacklogDeclarationsOf( + this.taskBacklogReader, + journals, + periodEndMsOf(toDay) + ); + + return buildCostReport( + toReportInput(options, read, journals, identity, measurementEnabled, taskBacklogDeclarations) + ); + } + + /** Reads whatever the sink has not caught up with, then asks it again. `ReadLocalCostUseCase` + * refuses on its own when measurement is off, so this needs no second gate: a refusal stores + * nothing and the report describes what was already there. */ + private async catchUp( + read: TelemetrySinkPeriodRead, + journals: readonly RunJournal[], + options: ReportCostOptions, + period: { from: Date; to: Date; periodEndMs: number } + ): Promise { + if (this.readLocalCost === undefined) return read; + const missing = sessionsToCatchUp( + read.records, + journals, + period.from.getTime(), + period.periodEndMs + ); + if (missing.length === 0) return read; + for (const sessionId of missing) { + this.warnAboutFailures( + sessionId, + await this.readLocalCost.execute({ + sessionId, + projectRoot: options.projectRoot, + env: options.env, + }) + ); + } + return this.sink.readRecordsInPeriod(period.from, period.to); + } + + /** Says what a reader could not answer, since behind a report nobody sees the read's own + * output: a period where every reader threw would otherwise print what a period with no spend + * prints. Warnings go to stderr, so a `--json` caller's stdout stays one parseable object. */ + private warnAboutFailures(sessionId: string, result: ReadLocalCostResult): void { + const say = this.logger.warn.bind(this.logger); + for (const report of result.toolReports) { + if (report.status !== "unreadable") continue; + say( + `telemetry report: ${report.tool} could not be read for session ${sessionId}` + + `${report.failureReason === undefined ? "" : ` - ${report.failureReason}`}` + ); + } + } +} diff --git a/cli/src/contexts/telemetry/application/telemetry-off-use-case.ts b/cli/src/contexts/telemetry/application/telemetry-off-use-case.ts new file mode 100644 index 000000000..8e119a314 --- /dev/null +++ b/cli/src/contexts/telemetry/application/telemetry-off-use-case.ts @@ -0,0 +1,103 @@ +import type { FileReader } from "../../../kernel/ports/file-reader.js"; +import type { FileWriter } from "../../../kernel/ports/file-writer.js"; +import type { Logger } from "../../../kernel/ports/logger.js"; +import { + SESSION_TRAILER_DELEGATE_FILE, + SESSION_TRAILER_TOKEN, + sessionTrailerManagerSnippet, +} from "../domain/formats/commit-session-trailer.js"; +import type { TelemetryEvidenceReader } from "../domain/ports/telemetry-evidence-reader.js"; +import type { VersionControl } from "../domain/ports/version-control.js"; +import { + buildTelemetrySwitchFile, + parseTelemetrySwitchFile, + telemetryConfigPath, +} from "../domain/telemetry-switch.js"; + +export interface TelemetryOffOptions { + readonly projectRoot: string; +} + +export interface TelemetryOffResult { + readonly switchPath: string; + readonly switchChanged: boolean; +} + +/** Sets the switch off, preserving any `endpoint` the file already carries. Never edits a tool's + * own settings file: nothing here wrote one, so editing it could erase somebody's real setup. */ +export class TelemetryOffUseCase { + constructor( + private readonly fs: FileReader & FileWriter, + private readonly logger: Logger, + private readonly telemetryEvidenceReader: TelemetryEvidenceReader, + private readonly git: VersionControl + ) {} + + async execute(options: TelemetryOffOptions): Promise { + const switchPath = telemetryConfigPath(options.projectRoot); + this.logger.info(`AIDD telemetry switch -> ${switchPath}`); + const switchChanged = await this.turnSwitchOff(switchPath); + await this.stopTrailingCommits(options.projectRoot); + await this.warnLeftoverExportConfig(options.projectRoot); + return { switchPath, switchChanged }; + } + + /** `on` wrote the hook line and the delegate, so `off` takes both back — whatever the switch's + * previous state was: already off with the hook installed is what a second `off` must fix. */ + private async stopTrailingCommits(projectRoot: string): Promise { + const result = await this.git.removeCommitMessageDelegate( + projectRoot, + SESSION_TRAILER_DELEGATE_FILE + ); + if (result.removed) { + this.logger.info( + `New commits will carry no ${SESSION_TRAILER_TOKEN} trailer. Commits already made ` + + "keep theirs — nothing here rewrites history." + ); + } + // A manager's own config is committed, shared config this CLI never writes, so the job + // calling the removed delegate stays — its own `[ -f ]` guard now finds nothing to run. + if (result.hookManager !== undefined && result.managerCallsDelegate === true) { + const { targetFile } = sessionTrailerManagerSnippet( + result.hookManager, + SESSION_TRAILER_DELEGATE_FILE + ); + this.logger.info( + `${targetFile} still calls the delegate this just removed — that file is not this ` + + "CLI's to edit, so the job is left in place. Its own `[ -f ]` guard now finds " + + "nothing there, so it runs nothing; delete it from " + + `${targetFile} by hand if you want it gone too.` + ); + } + } + + /** Names what `off` cannot touch: a tool's own settings file still carrying an export key. + * Silence is the failure this closes — nothing else tells a person their machine exports. */ + private async warnLeftoverExportConfig(projectRoot: string): Promise { + const leftovers = await this.telemetryEvidenceReader.findLeftoverExportConfig(projectRoot); + for (const leftover of leftovers) { + this.logger.warn( + `${leftover.path} still sets ${leftover.keys.join(", ")} — this switch cannot ` + + "touch a tool's own settings file. Delete these keys from its `env` block by " + + "hand to stop that export." + ); + } + } + + private async turnSwitchOff(switchPath: string): Promise { + if (!(await this.fs.fileExists(switchPath))) { + this.logger.info("AIDD telemetry: already off, unchanged."); + return false; + } + const raw = await this.fs.readFile(switchPath); + const current = parseTelemetrySwitchFile(raw); + if (current?.enabled !== true) { + this.logger.info("AIDD telemetry: already off, unchanged."); + return false; + } + const next = buildTelemetrySwitchFile(raw, { enabled: false, endpoint: current.endpoint }); + await this.fs.writeFile(switchPath, next); + this.logger.info("AIDD telemetry: off."); + return true; + } +} diff --git a/cli/src/contexts/telemetry/application/telemetry-on-use-case.ts b/cli/src/contexts/telemetry/application/telemetry-on-use-case.ts new file mode 100644 index 000000000..36c53e10a --- /dev/null +++ b/cli/src/contexts/telemetry/application/telemetry-on-use-case.ts @@ -0,0 +1,151 @@ +import { TelemetryProjectScopeRequiresYesError } from "../../../kernel/errors.js"; +import { RUNS_ENTRY } from "../../../kernel/paths.js"; +import type { FileReader } from "../../../kernel/ports/file-reader.js"; +import type { FileWriter } from "../../../kernel/ports/file-writer.js"; +import type { Logger } from "../../../kernel/ports/logger.js"; +import { + SESSION_TRAILER_DELEGATE_FILE, + SESSION_TRAILER_TOKEN, + sessionTrailerDelegateScript, + sessionTrailerManagerInstruction, + sessionTrailerManagerSnippet, +} from "../domain/formats/commit-session-trailer.js"; +import type { IgnoreEntries } from "../domain/ports/ignore-entries.js"; +import type { TelemetrySink } from "../domain/ports/telemetry-sink.js"; +import type { VersionControl } from "../domain/ports/version-control.js"; +import type { HookManager } from "../domain/telemetry-setup.js"; +import { + buildTelemetrySwitchFile, + parseTelemetrySwitchFile, + type TelemetrySwitch, + telemetryConfigPath, +} from "../domain/telemetry-switch.js"; + +export interface TelemetryOnOptions { + readonly projectRoot: string; + /** `.aidd/config.json` is git-tracked, so a fresh clone inherits the project's decision — + * which is why anyone is asked at all. */ + readonly confirmed: boolean; +} + +export interface TelemetryOnResult { + readonly switchPath: string; + readonly switchChanged: boolean; +} + +/** Owns the AIDD telemetry switch alone: flips `.aidd/config.json`'s `telemetry.enabled` and + * git-ignores the run journal. Never touches a tool's own settings file — arming a tool to + * export and recording locally are two different promises. Any `endpoint` there is preserved. */ +export class TelemetryOnUseCase { + constructor( + private readonly fs: FileReader & FileWriter, + private readonly logger: Logger, + private readonly gitignore: IgnoreEntries, + private readonly git: VersionControl, + private readonly sink: TelemetrySink + ) {} + + async execute(options: TelemetryOnOptions): Promise { + const switchPath = telemetryConfigPath(options.projectRoot); + this.guardConfirmed(options); + // Before the switch, not after: `appendRecord` creates the directory itself, so without this + // the first failure lands at the first record, on whoever reads rather than whoever turned on. + await this.sink.ensureWritable(); + this.logger.info(`AIDD telemetry switch -> ${switchPath}`); + // The switch is written last, once everything it promises is in place: a failure partway + // through must never leave `enabled: true` describing a setup that stopped short of it. + await this.protectRunsDir(options.projectRoot); + await this.makeCommitsJoinable(options.projectRoot); + const switchChanged = await this.writeSwitch(switchPath); + return { switchPath, switchChanged }; + } + + // `.aidd/config.json` is git-tracked, the consequence `endpoint --scope project` already + // refuses without `--yes`. Fires unconditionally, whatever the switch's current state. + private guardConfirmed(options: TelemetryOnOptions): void { + if (options.confirmed) return; + throw new TelemetryProjectScopeRequiresYesError( + "aidd telemetry on", + telemetryConfigPath(options.projectRoot) + ); + } + + // Re-checked on every successful `on`, switch newly written or not: a project turned on before + // this existed still gets the journal ignored, without turning telemetry off and on again. + private async protectRunsDir(projectRoot: string): Promise { + const added = await this.gitignore.execute(projectRoot, [RUNS_ENTRY]); + if (added) { + this.logger.info( + `Added ${RUNS_ENTRY} to .gitignore — the journal names no person, only the ` + + "repository, the task folders written into, the skills run, and their timings. " + + "Delete that line to commit it instead." + ); + } + const tracked = await this.git.listTrackedFiles(projectRoot, RUNS_ENTRY); + if (tracked.length === 0) return; + this.logger.warn( + "Already tracked by git — the repository, the task folders written into, the skills " + + `run, and their timings:\n${tracked.map((file) => ` ${file}`).join("\n")}\n` + + "Nothing removed or rewritten — your call." + ); + } + + /** Re-run on every successful `on`, switch newly written or not, for the same reason + * `protectRunsDir` is. Announced rather than done quietly: it writes into commit messages a + * team will read, so the sentence saying so and the command undoing it must be findable. */ + private async makeCommitsJoinable(projectRoot: string): Promise { + const install = await this.git.installCommitMessageDelegate( + projectRoot, + SESSION_TRAILER_DELEGATE_FILE, + sessionTrailerDelegateScript() + ); + if (install.hookManager !== undefined) { + this.reportManagedHook(install.hookManager, install.managerCallsDelegate === true); + return; + } + if (!install.lineAdded) return; + this.logger.info( + `Commits made by an AI session will carry an ${SESSION_TRAILER_TOKEN} trailer, so what ` + + "a session cost can be read per commit. A commit no session made carries nothing. " + + "`aidd telemetry off` removes it." + ); + } + + /** `prepare-commit-msg` under a manager is committed, shared config this CLI may not append + * to — an append would reach every clone through a commit nobody reviewed — and lefthook + * regenerates it from that config anyway. A chain already wired prints nothing: the ordinary + * trailer promise above already covers it. */ + private reportManagedHook(manager: HookManager, wired: boolean): void { + if (wired) return; + const { targetFile, snippet } = sessionTrailerManagerSnippet( + manager, + SESSION_TRAILER_DELEGATE_FILE + ); + this.logger.info( + `${manager} owns prepare-commit-msg here, so nothing was appended to it. Commits will ` + + `not carry an ${SESSION_TRAILER_TOKEN} trailer until you ` + + `${sessionTrailerManagerInstruction(manager, targetFile)}:\n\n${snippet}\n` + ); + } + + private async readIfExists(path: string): Promise { + return (await this.fs.fileExists(path)) ? await this.fs.readFile(path) : null; + } + + private async writeSwitch(switchPath: string): Promise { + const existingRaw = await this.readIfExists(switchPath); + const existing: TelemetrySwitch | null = + existingRaw !== null ? parseTelemetrySwitchFile(existingRaw) : null; + if (existing?.enabled === true) { + this.logger.info("AIDD telemetry: already on, unchanged."); + return false; + } + const next = buildTelemetrySwitchFile(existingRaw, { + enabled: true, + endpoint: existing?.endpoint, + }); + await this.fs.writeFile(switchPath, next); + this.logger.info("AIDD telemetry: on."); + return true; + } +} diff --git a/cli/src/contexts/telemetry/domain/cost-report-envelope.ts b/cli/src/contexts/telemetry/domain/cost-report-envelope.ts new file mode 100644 index 000000000..4e26961da --- /dev/null +++ b/cli/src/contexts/telemetry/domain/cost-report-envelope.ts @@ -0,0 +1,424 @@ +import type { TelemetryRouteSupply } from "../../../kernel/measurement.js"; +import type { AiToolId } from "../../../kernel/tool.js"; +import type { + AgentAttributionSource, + CostReport, + CostReportEmptySelection, + CostReportFilters, + CostReportToolCoverage, + CostTotals, + PersonIdentityUnusableCause, +} from "./cost-report.js"; +import type { FlowAttributionSource } from "./flow-attribution.js"; +import type { PersonResolution } from "./person-resolution.js"; +import type { StepAttributionSource } from "./step-attribution.js"; +import type { TaskAttributionSource, TaskUnattributedReason } from "./task-attribution.js"; + +/** Bumped when a consumer that understood the previous shape would misread this one, so it + * can refuse rather than guess - the same reason `sink_schema_version` exists on a stored + * line. Adding a field a consumer may ignore is not a bump; changing what an existing field + * means, or adding a value to a union it switches on, is. Each bump's reason is in + * `aidd_docs/product/cost-report-contract.md`. */ +export const COST_REPORT_ENVELOPE_VERSION = 15; + +/** Money as whole micro-dollars, so a consumer summing several reports gets the same answer + * this one did. Divide by 1,000,000 for dollars, and only at the moment of display. */ +export interface CostReportEnvelopeTotals { + readonly requests: number; + readonly cost_micro_usd?: number; + readonly input_tokens?: number; + readonly output_tokens?: number; + readonly cache_read_tokens?: number; + readonly cache_creation_tokens?: number; +} + +export interface CostReportEnvelopeStepRow { + readonly step?: string; + readonly attribution: StepAttributionSource; + readonly totals: CostReportEnvelopeTotals; +} + +/** Largest first, plus one row for what named none - `model` absent there. */ +export interface CostReportEnvelopeModelRow { + readonly model?: string; + readonly totals: CostReportEnvelopeTotals; +} + +/** What a route was measured to supply. `null` where the tool declares no such route at all + * - a different fact from a declared route that supplies nothing. */ +export interface CostReportEnvelopeRouteSupply { + readonly token_counters: boolean; + readonly amount: boolean; + readonly tool_stated_step: boolean; + /** The route names the agent a record belongs to, and so also says when one is the main + * thread's own. Without it `by_agent` reads this tool's records as stating no agent. */ + readonly agent_name: boolean; +} + +export interface CostReportEnvelopeCapability { + readonly local_read: CostReportEnvelopeRouteSupply | null; + readonly export: CostReportEnvelopeRouteSupply | null; + /** False means the run journal never names this tool's sessions: no step can be derived + * from an interval, and a journal sweep never reaches one of them. A consumer seeing a + * readable tool with no figures looks here before concluding it did no work. */ + readonly journal_attributable: boolean; + readonly task_attributable: boolean; +} + +export interface CostReportEnvelopeToolRow { + readonly tool: AiToolId; + readonly coverage: CostReportToolCoverage; + readonly reason?: string; + /** Read this rather than inferring from whether a figure is present. A tool that cannot + * supply an amount and a session that cost nothing look identical in the numbers. */ + readonly capability: CostReportEnvelopeCapability; + readonly totals: CostReportEnvelopeTotals; + /** A local-read `kind: "session"` total, present only for a tool whose own file yields one + * already-complete session figure rather than per-request records - today, only Copilot. + * Never folded into `totals`, which counts billed requests alone. */ + readonly session_totals?: CostReportEnvelopeTotals; +} + +export interface CostReportEnvelopeAttributionRow { + readonly attribution: StepAttributionSource; + readonly totals: CostReportEnvelopeTotals; +} + +/** The same idea, one axis over: how much of a `--task` report's total came from a + * declared interval versus a written file. */ +export interface CostReportEnvelopeTaskAttributionRow { + readonly attribution: TaskAttributionSource; + readonly totals: CostReportEnvelopeTotals; +} + +/** Largest first, plus one row for what named none - `project` absent there. */ +export interface CostReportEnvelopeProjectRow { + readonly project?: string; + readonly totals: CostReportEnvelopeTotals; +} + +/** `attribution` is present only alongside `task` and says which route named it: + * `"declared"` where a `task_declared` interval covers the record, `"inferred"` where the + * session wrote into exactly one task folder, so one task can carry two rows. A row for what + * fell in no declared interval carries `reason` instead - never both, never neither. */ +export interface CostReportEnvelopeTaskRow { + readonly task?: string; + readonly attribution?: TaskAttributionSource; + readonly reason?: TaskUnattributedReason; + readonly totals: CostReportEnvelopeTotals; +} + +/** On a row with no `backlog`, `declaration` and `reason` are never both present and never + * neither. */ +export interface CostReportEnvelopeBacklogRow { + readonly backlog?: string; + readonly declaration?: "none" | "unreadable"; + readonly reason?: TaskUnattributedReason; + readonly totals: CostReportEnvelopeTotals; +} + +/** `agent` is present exactly when `attribution` is `tool-stated`. The two rows naming none + * are different facts, never merged: `main-thread` is a tool that names agents saying this + * record belongs to none, `not-stated` a tool whose route never names one. */ +export interface CostReportEnvelopeAgentRow { + readonly agent?: string; + readonly attribution: AgentAttributionSource; + readonly totals: CostReportEnvelopeTotals; +} + +/** `started_at` is the earliest moment in the prompt, and only a named prompt carries one: + * the unnamed row is drawn from many turns, so a start moment would assert a unit that never + * existed. */ +export interface CostReportEnvelopePromptRow { + readonly prompt?: string; + readonly started_at?: string; + readonly totals: CostReportEnvelopeTotals; +} + +/** `attribution` says how this flow came to be known: `journal-interval` for one the journal + * opened and closed, `tool-stated` for one only a record's own tool named, `unattributed` + * for work that joined neither. A `tool-stated` row carries no `started_at` - it is a bucket + * drawn from however many runs of that skill the tool named, and a name is not a run. */ +export interface CostReportEnvelopeFlowRow { + readonly flow?: string; + readonly attribution: FlowAttributionSource; + readonly started_at?: string; + readonly totals: CostReportEnvelopeTotals; +} + +/** Every day the period spans, in order, whether or not a record landed on it - a day with + * nothing is a row of zeros, never an omitted row. */ +export interface CostReportEnvelopeDayRow { + readonly day: string; + readonly totals: CostReportEnvelopeTotals; +} + +/** `person` is the canonical identifier, present only where `resolution` is `"mapped"`; an + * unresolved row's raw identifier lives in `identities` instead, which always carries what + * produced the row so a person line is traceable back to its evidence. */ +export interface CostReportEnvelopePersonRow { + readonly resolution: PersonResolution; + readonly person?: string; + readonly display_name?: string; + readonly identities: readonly string[]; + readonly totals: CostReportEnvelopeTotals; +} + +/** What the read could not do, travelling with what it did: a total assembled from a partial + * read is indistinguishable from a complete one unless these come with it. */ +export interface CostReportEnvelopeRead { + readonly undated_records: number; + readonly unreadable_lines: number; + /** `"unreadable"` for a declared identity file that could not be read back, `"absent"` for + * none declared at all. Absent entirely when the identity was read back fine. */ + readonly identity_unusable?: PersonIdentityUnusableCause; +} + +/** One period's report in the shape a program reads, field names snake_case to match the + * stored record a consumer may already parse. Every counter is optional for the same reason + * it is optional there: absent means never observed, a different fact from zero. */ +export interface CostReportEnvelope { + readonly cost_report_version: number; + /** The period as it resolved, absolutely — never as it was asked for. */ + readonly period: { readonly from_day: string; readonly to_day: string }; + /** Whether the project switch is on right now, carried so `--json` and `--axis` say what + * the terminal rendering does. A switch off and a genuinely empty period read identically + * in every count below, and only this field tells them apart. */ + readonly measurement_enabled: boolean; + readonly task?: string; + /** Only the generic filters actually given - `task` above keeps its own field. Absent for + * an unfiltered period. */ + readonly filters?: CostReportFilters; + /** Present only when a filter, never the period itself, emptied this selection - naming + * which one, and whether its value was ever known at all. */ + readonly empty_selection?: CostReportEmptySelection; + readonly sessions: number; + readonly totals: CostReportEnvelopeTotals; + /** Per session, and never broken down by step: no active-time measure on any tool + * carries a step attribute. Absent when no record carried it. */ + readonly active_time_s?: number; + readonly by_step: readonly CostReportEnvelopeStepRow[]; + readonly by_model: readonly CostReportEnvelopeModelRow[]; + readonly by_tool: readonly CostReportEnvelopeToolRow[]; + readonly by_project: readonly CostReportEnvelopeProjectRow[]; + readonly by_task: readonly CostReportEnvelopeTaskRow[]; + readonly by_backlog: readonly CostReportEnvelopeBacklogRow[]; + readonly by_flow: readonly CostReportEnvelopeFlowRow[]; + /** One row per agent that ran, `agent` absent on the main thread's own row. */ + readonly by_agent: readonly CostReportEnvelopeAgentRow[]; + readonly by_prompt: readonly CostReportEnvelopePromptRow[]; + /** Every day the period spans, always - a long period stays readable by how the text + * rendering chooses to show it, never by what this envelope omits. */ + readonly by_day: readonly CostReportEnvelopeDayRow[]; + /** Mapped people first, then every unplaced identity, then the one row for records + * carrying none at all. */ + readonly by_person: readonly CostReportEnvelopePersonRow[]; + /** All four strengths, always, strongest first. */ + readonly attribution: readonly CostReportEnvelopeAttributionRow[]; + /** Present only alongside `task`: an unfiltered period carries no per-record task + * identity to break down. */ + readonly task_attribution?: readonly CostReportEnvelopeTaskAttributionRow[]; + readonly read: CostReportEnvelopeRead; +} + +function supply(from: TelemetryRouteSupply | null): CostReportEnvelopeRouteSupply | null { + return from === null + ? null + : { + token_counters: from.tokenCounters, + amount: from.amount, + tool_stated_step: from.toolStatedStep, + agent_name: from.agentName, + }; +} + +function capability(from: CostReport["byTools"][number]["capability"]) { + return { + local_read: supply(from.localRead), + export: supply(from.export), + journal_attributable: from.journalAttributable, + task_attributable: from.taskAttributable, + }; +} + +function toolRow(row: CostReport["byTools"][number]): CostReportEnvelopeToolRow { + return { + tool: row.tool, + coverage: row.coverage, + ...(row.reason === undefined ? {} : { reason: row.reason }), + capability: capability(row.capability), + totals: totals(row.totals), + ...(row.sessionTotals === undefined ? {} : { session_totals: totals(row.sessionTotals) }), + }; +} + +function stepRow(row: CostReport["bySteps"][number]): CostReportEnvelopeStepRow { + return { + ...(row.step === undefined ? {} : { step: row.step }), + attribution: row.attribution, + totals: totals(row.totals), + }; +} + +function totals(from: CostTotals): CostReportEnvelopeTotals { + return { + requests: from.requests, + ...(from.costMicroUsd === undefined ? {} : { cost_micro_usd: from.costMicroUsd }), + ...(from.inputTokens === undefined ? {} : { input_tokens: from.inputTokens }), + ...(from.outputTokens === undefined ? {} : { output_tokens: from.outputTokens }), + ...(from.cacheReadTokens === undefined ? {} : { cache_read_tokens: from.cacheReadTokens }), + ...(from.cacheCreationTokens === undefined + ? {} + : { cache_creation_tokens: from.cacheCreationTokens }), + }; +} + +function projectRow(row: CostReport["byProjects"][number]): CostReportEnvelopeProjectRow { + return { + ...(row.project === undefined ? {} : { project: row.project }), + totals: totals(row.totals), + }; +} + +function modelRow(row: CostReport["byModels"][number]): CostReportEnvelopeModelRow { + return { + ...(row.model === undefined ? {} : { model: row.model }), + totals: totals(row.totals), + }; +} + +function taskRow(row: CostReport["byTasks"][number]): CostReportEnvelopeTaskRow { + return { + ...(row.task === undefined ? {} : { task: row.task }), + ...(row.attribution === undefined ? {} : { attribution: row.attribution }), + ...(row.reason === undefined ? {} : { reason: row.reason }), + totals: totals(row.totals), + }; +} + +function backlogRow(row: CostReport["byBacklog"][number]): CostReportEnvelopeBacklogRow { + return { + ...(row.backlog === undefined ? {} : { backlog: row.backlog }), + ...(row.declaration === undefined ? {} : { declaration: row.declaration }), + ...(row.reason === undefined ? {} : { reason: row.reason }), + totals: totals(row.totals), + }; +} + +function agentRow(row: CostReport["byAgents"][number]): CostReportEnvelopeAgentRow { + return { + ...(row.agent === undefined ? {} : { agent: row.agent }), + attribution: row.attribution, + totals: totals(row.totals), + }; +} + +function promptRow(row: CostReport["byPrompts"][number]): CostReportEnvelopePromptRow { + return { + ...(row.prompt === undefined ? {} : { prompt: row.prompt }), + ...(row.startedAt === undefined ? {} : { started_at: row.startedAt }), + totals: totals(row.totals), + }; +} + +function flowRow(row: CostReport["byFlows"][number]): CostReportEnvelopeFlowRow { + return { + ...(row.flow === undefined ? {} : { flow: row.flow }), + attribution: row.attribution, + ...(row.startedAt === undefined ? {} : { started_at: row.startedAt }), + totals: totals(row.totals), + }; +} + +function personRow(row: CostReport["byPeople"][number]): CostReportEnvelopePersonRow { + return { + resolution: row.resolution, + ...(row.person === undefined ? {} : { person: row.person }), + ...(row.displayName === undefined ? {} : { display_name: row.displayName }), + identities: row.identities, + totals: totals(row.totals), + }; +} + +function attributionRow( + row: CostReport["attributionMix"][number] +): CostReportEnvelopeAttributionRow { + return { attribution: row.attribution, totals: totals(row.totals) }; +} + +/** Present only alongside `task`: an unfiltered period carries no per-record task identity + * to break down. */ +function taskAttribution( + taskAttributionMix: CostReport["taskAttributionMix"] +): Pick { + if (taskAttributionMix === undefined) return {}; + return { + task_attribution: taskAttributionMix.map((row) => ({ + attribution: row.attribution, + totals: totals(row.totals), + })), + }; +} + +function readSummary(report: CostReport): CostReportEnvelopeRead { + return { + undated_records: report.undatedRecords, + unreadable_lines: report.unreadableLines, + ...(report.identityUnusableCause === undefined + ? {} + : { identity_unusable: report.identityUnusableCause }), + }; +} + +/** Every `by_*` breakdown together. */ +function breakdownFields( + report: CostReport +): Pick< + CostReportEnvelope, + | "by_step" + | "by_model" + | "by_tool" + | "by_project" + | "by_task" + | "by_backlog" + | "by_flow" + | "by_agent" + | "by_prompt" + | "by_day" + | "by_person" +> { + return { + by_step: report.bySteps.map(stepRow), + by_model: report.byModels.map(modelRow), + by_tool: report.byTools.map(toolRow), + by_project: report.byProjects.map(projectRow), + by_task: report.byTasks.map(taskRow), + by_backlog: report.byBacklog.map(backlogRow), + by_flow: report.byFlows.map(flowRow), + by_agent: report.byAgents.map(agentRow), + by_prompt: report.byPrompts.map(promptRow), + by_day: report.byDays.map((row) => ({ day: row.day, totals: totals(row.totals) })), + by_person: report.byPeople.map(personRow), + }; +} + +/** A rendering, never a second computation: every figure comes from the `CostReport` handed + * in and nothing is derived on the way through, since two ways of computing one number is + * how they start disagreeing. Pure - no clock, no filesystem, no printing. */ +export function toCostReportEnvelope(report: CostReport): CostReportEnvelope { + return { + cost_report_version: COST_REPORT_ENVELOPE_VERSION, + period: { from_day: report.fromDay, to_day: report.toDay }, + measurement_enabled: report.measurementEnabled, + ...(report.task === undefined ? {} : { task: report.task }), + ...(report.filters === undefined ? {} : { filters: report.filters }), + ...(report.emptySelection === undefined ? {} : { empty_selection: report.emptySelection }), + sessions: report.sessions, + totals: totals(report.totals), + ...(report.activeTimeSeconds === undefined ? {} : { active_time_s: report.activeTimeSeconds }), + ...breakdownFields(report), + attribution: report.attributionMix.map(attributionRow), + ...taskAttribution(report.taskAttributionMix), + read: readSummary(report), + }; +} diff --git a/cli/src/contexts/telemetry/domain/cost-report.ts b/cli/src/contexts/telemetry/domain/cost-report.ts new file mode 100644 index 000000000..20bce9055 --- /dev/null +++ b/cli/src/contexts/telemetry/domain/cost-report.ts @@ -0,0 +1,760 @@ +/** An axis owns its key, sentinels, group shape and order under `report/axes/`; this file + * owns the accumulators and the single pass that fills them. The edge back from `report/**` + * is `import type` only - a value import there recreates the cycle this split avoids. */ + +import type { TelemetryRouteSupply } from "../../../kernel/measurement.js"; +import type { AiToolId } from "../../../kernel/tool.js"; +import type { FlowAttributionSource, FlowInterval } from "./flow-attribution.js"; +import { type PersonResolution, type ResolvedPerson, resolvePerson } from "./person-resolution.js"; +import type { PersonIdentity } from "./ports/person-identity-reader.js"; +import { addToDayGroup, dayRange, dayRows } from "./report/axes/day-rows.js"; +import { + allFlowIntervalsByVendorId, + type FlowRowKey, + flowKeyOf, + flowRows, +} from "./report/axes/flow-rows.js"; +import { + type PersonGroup, + type PersonRowKey, + personGroupKey, + personRawIdOf, + personRows, +} from "./report/axes/person-rows.js"; +import { + type AgentKey, + agentKeyOf, + agentNamingTools, + agentRows, + type ModelKey, + modelKeyOf, + modelRows, + type ProjectKey, + type PromptGroup, + type PromptKey, + projectKeyOf, + projectRows, + promptKeyOf, + promptRows, +} from "./report/axes/record-stated-rows.js"; +import { attributionRows, type StepGroup, stepRowKey, stepRows } from "./report/axes/step-rows.js"; +import { + allTaskIntervalsByVendorId, + type BacklogRowKey, + backlogKeyOf, + backlogRows, + type TaskGroup, + type TaskRow, + taskAttributionRows, + taskRowKeyOf, + taskRowOf, + taskRows, +} from "./report/axes/task-rows.js"; +import { buildToolRows, declaredToolsInScope } from "./report/axes/tool-rows.js"; +import { COUNTER_FIELDS, COUNTER_SOURCE, type CounterField } from "./report/record-counters.js"; +import { collapseBilledRequests, collapseSupersededTurns } from "./report/record-reconciliation.js"; +import { + activeFilters, + emptySelectionOf, + selectionStages, + type TaskMembership, + taskAttributionOf, + taskMembership, +} from "./report/report-selection.js"; +import type { StepAttributionSource } from "./step-attribution.js"; +import type { + TaskAttributionSource, + TaskInterval, + TaskUnattributedReason, +} from "./task-attribution.js"; +import type { TaskBacklogDeclaration } from "./task-backlog-link.js"; +import type { TaskIdentity } from "./task-identity.js"; +import type { TelemetrySinkRecord } from "./telemetry-sink-record.js"; + +/** Money is carried as whole micro-dollars, never as the floating amount a record stores: + * the same amounts summed in two groupings differ in the last bits, so no report over + * floats reconciles exactly. Rounding once, on the way in, makes every sum after it exact. */ +const MICRO_USD_PER_USD = 1e6; + +export function toMicroUsd(costUsd: number): number { + return Math.round(costUsd * MICRO_USD_PER_USD); +} + +export function fromMicroUsd(microUsd: number): number { + return microUsd / MICRO_USD_PER_USD; +} + +/** Every counter is optional and an absent one means *never observed*, a different fact + * from zero: a tool whose files carry no amount has an unknown cost, not a free one. + * `requests` alone is never absent - a group exists because records are in it. */ +export interface CostTotals { + readonly requests: number; + readonly costMicroUsd?: number; + readonly inputTokens?: number; + readonly outputTokens?: number; + readonly cacheReadTokens?: number; + readonly cacheCreationTokens?: number; +} + +/** Keyed by the step *and* the strength of its attribution, never the step alone: the same + * skill reached from the tool's own statement and from a journal interval is two claims, and + * merging them presents an inference as a measurement. `step` is absent exactly when + * `attribution` is `"unattributed"`, which names what nothing could say. */ +export interface CostReportStepRow { + readonly step?: string; + readonly attribution: StepAttributionSource; + readonly totals: CostTotals; +} + +/** Largest first, plus one row for what named none - `model` absent there. Codex and + * OpenCode both permit a request record with no model, so that row is what keeps `byModels` + * reconciling to the total. */ +export interface CostReportModelRow { + readonly model?: string; + readonly totals: CostTotals; +} + +/** `main-thread` is a measurement - the tool names agents and said this record belongs to + * none of them; `not-stated` is a tool whose route never names an agent at all, where + * reading a main thread would assert a fact nothing observed. */ +export type AgentAttributionSource = "tool-stated" | "main-thread" | "not-stated"; + +/** One agent's own share of a period, `agent` absent unless `attribution` is `tool-stated`. + * The limit it lives with: a subagent line carrying no agent name reads as the main thread, + * since nothing on the stored record separates the two, and no record already stored could + * gain the field that would (see `storeNewCandidates`). */ +export interface CostReportAgentRow { + readonly agent?: string; + readonly attribution: AgentAttributionSource; + readonly totals: CostTotals; +} + +/** `prompt` absent on the row for records that named none, and a record stored without one + * stays unnamed however often the sink is re-read, since `storeNewCandidates` fixes a + * record's field set the first time it sees the turn. Only a named prompt gets `startedAt`: + * the unnamed row is drawn from many turns, and was never one unit with a start. */ +export interface CostReportPromptRow { + readonly prompt?: string; + readonly startedAt?: string; + readonly totals: CostTotals; +} + +/** `covered` with no records is a tool that could have been read and did nothing this + * period; `not-covered` is a tool nothing here can read at all. A consumer prints the second + * as its reason, never as a zero. */ +export type CostReportToolCoverage = "covered" | "not-covered"; + +/** Travels beside the figures so a consumer branches on a declared capability rather than + * on whether a number happened to be present - the inference that turns a limit into a zero. + * `null` means the route is not declared at all, not a declared route supplying nothing. */ +export interface CostReportToolCapability { + readonly localRead: TelemetryRouteSupply | null; + readonly export: TelemetryRouteSupply | null; + /** False means two things at once: no step can be derived from an interval, and a read + * sweeping the journal never reaches one of this tool's sessions - so a perfectly readable + * tool can report nothing, a limit otherwise indistinguishable from doing no work. */ + readonly journalAttributable: boolean; + readonly taskAttributable: boolean; +} + +export interface CostReportToolDeclaration { + readonly tool: AiToolId; + readonly coverage: CostReportToolCoverage; + /** Why it is not covered, or what a covered tool's figures cannot be used for. Comes from + * the tool's own declaration; this module never writes one. */ + readonly reason?: string; + readonly capability: CostReportToolCapability; +} + +export interface CostReportToolRow { + readonly tool: AiToolId; + readonly coverage: CostReportToolCoverage; + readonly reason?: string; + readonly capability: CostReportToolCapability; + readonly totals: CostTotals; + /** A local-read `kind: "session"` total, present only for a tool whose own file yields an + * already-complete session figure rather than per-request records - today, only Copilot. + * Never folded into `totals`: the two-kinds rule forbids treating one as the other. */ + readonly sessionTotals?: CostTotals; +} + +/** How much of the broken-down total each strength accounts for - four figures that sum to + * the total rather than a sentence saying attribution is approximate, since only the four + * can be asserted. */ +export interface CostReportAttributionRow { + readonly attribution: StepAttributionSource; + readonly totals: CostTotals; +} + +/** The same idea as `CostReportAttributionRow`, one axis over: how much of a `--task` + * report's total came from a declared interval versus a written file. */ +export interface CostReportTaskAttributionRow { + readonly attribution: TaskAttributionSource; + readonly totals: CostTotals; +} + +/** Largest first, plus one row for what named none - `project` absent there. Never folded + * into a neighbour: that would place a figure that was never placed. */ +export interface CostReportProjectRow { + readonly project?: string; + readonly totals: CostTotals; +} + +/** Keyed on the declared interval a record's own moment falls in - never on the whole-session + * written-path inference the `--task` filter reads, which would let one session's records + * land in more than one row. A record in no declared interval carries `reason` instead, one + * row per `TASK_UNATTRIBUTED_REASONS` entry present: two different gaps are not one gap. */ +export interface CostReportTaskRow { + readonly task?: TaskIdentity; + readonly attribution?: TaskAttributionSource; + readonly reason?: TaskUnattributedReason; + readonly totals: CostTotals; +} + +/** Grouped one level above `CostReportTaskRow`, on the same per-record task membership + * `byTasks` computes. Where `backlog` is absent, exactly one of `declaration` (the task + * declares no item, or its declaration could not be parsed - counted either way) and + * `reason` (the record belongs to no task) says why: never both, never neither. */ +export interface CostReportBacklogRow { + readonly backlog?: string; + readonly declaration?: "none" | "unreadable"; + readonly reason?: TaskUnattributedReason; + readonly totals: CostTotals; +} + +/** Keyed on the closed `FlowInterval` a record's moment falls inside, never on `flow` (the + * orchestrating skill's name) alone: one session running that skill twice must stay two + * rows, told apart by `startedAt`. The limit it lives with: a skill run by hand while a flow + * is open still counts inside it, since the journal writes the same `step_start` for both. */ +export interface CostReportFlowRow { + readonly flow?: string; + readonly attribution: FlowAttributionSource; + readonly startedAt?: string; + readonly totals: CostTotals; +} + +/** `person` is the canonical `personId`, present only when `resolution` is `"mapped"`; an + * `"unresolved"` row's raw identifier lives in `identities` instead, nobody having claimed a + * canonical form for it. `identities` always carries what produced the row, so a line naming + * a person is traceable back to its evidence without a second lookup. */ +export interface CostReportPersonRow { + readonly resolution: PersonResolution; + readonly person?: string; + readonly displayName?: string; + readonly identities: readonly string[]; + readonly totals: CostTotals; +} + +/** Every day the period spans in order, whether or not a record landed on it. A day with + * nothing is a row of zeros - the one place here a zero is the measurement, because an + * omitted row would read as continuity a gap is not. */ +export interface CostReportDayRow { + readonly day: string; + readonly totals: CostTotals; +} + +/** One session's journal, reduced to what a report needs. Assembling it is the caller's + * job; this module never opens a file, and `taskIntervals` arrives already built once per + * session rather than re-derived per record. */ +export interface CostReportSessionJournal { + readonly vendorId: string; + readonly tool: string; + readonly projectId?: string; + readonly writtenPaths: readonly string[]; + readonly taskIntervals: readonly TaskInterval[]; + readonly flowIntervals: readonly FlowInterval[]; + /** The first and last moment this journal's own lines witnessed - the bound the + * written-file route infers inside and never outside, since a journal lost and recreated + * mid-session witnesses far less time than its session produced records for. Absent when + * no line carried a moment this reader could parse: nothing witnessed, nothing inferred. */ + readonly witnessed?: { readonly fromMs: number; readonly toMs: number }; +} + +/** The four dimensions that narrow on an equal record field - `task` keeps its own route and + * its own top-level field. Every one composes with the others, and with `task`, by `and`: + * two given narrow to their intersection, never their union. */ +export interface CostReportFilters { + readonly project?: string; + readonly step?: string; + readonly model?: string; + readonly tool?: string; +} + +export type CostReportFilterName = keyof CostReportFilters | "task"; + +/** Every value a filterable field has carried anywhere the caller looked, not only in this + * period - what lets an empty selection tell a value nobody ever recorded apart from one + * that simply had no work here. */ +export interface CostReportKnownValues { + readonly projects: ReadonlySet; + readonly steps: ReadonlySet; + readonly models: ReadonlySet; +} + +/** The filter that narrowed a non-empty selection to nothing - never the period itself, + * which is an honest zero. `combination` is present only when the value matched something + * before any generic filter ran, so the emptiness comes from an intersection. */ +export interface CostReportEmptySelection { + readonly filter: CostReportFilterName; + readonly value: string; + readonly known: boolean; + readonly combination?: boolean; +} + +/** Why this machine's own identity could not be used to resolve records - two causes rather + * than one boolean, so "the file exists but could not be read" stays apart from "nobody + * declared one at all". */ +export type PersonIdentityUnusableCause = "unreadable" | "absent"; + +export interface CostReportInput { + readonly fromDay: string; + readonly toDay: string; + readonly records: readonly TelemetrySinkRecord[]; + readonly journals: readonly CostReportSessionJournal[]; + readonly declaredTools: readonly CostReportToolDeclaration[]; + /** Records carrying no moment at all - counted and named, never placed in the period. */ + readonly undatedRecords: number; + /** Lines the read could not parse. A report built from a partial read looks exactly like + * one built from a whole read unless this travels with it. */ + readonly unreadableLines: number; + /** Restrict to the sessions that wrote into this task. Absent means the whole period: a + * task is a filter over one, and work touching no task folder is still fully reportable. */ + readonly task?: TaskIdentity; + /** Any of `project`, `step`, `model` and `tool`, each optional and composing with `task` + * and each other by `and`. */ + readonly filters?: CostReportFilters; + /** Every distinct task identity this period's records could fall inside, resolved once + * each to its folder's declaration - gathered by the caller so the domain stays free of a + * filesystem, and never re-resolved per record. A task this map cannot name reads as + * `{ kind: "none" }`, so a missing entry never drops a record. */ + readonly taskBacklogDeclarations?: ReadonlyMap; + /** Where a generic filter's value has ever been seen - absent when the caller has none + * to offer, which reads the same as a filter never matching it elsewhere. */ + readonly knownValues?: CostReportKnownValues; + /** This machine's own identity, arriving as data so the domain stays free of where the + * identity file lives. Absent or `null` both mean none was declared, which resolves every + * identifier as `unresolved` rather than failing the report. */ + readonly identity?: PersonIdentity | null; + /** `"unreadable"` for a declared identity file that could not be read back, `"absent"` for + * none declared. Either way costs the resolution alone: every record is still counted, + * every identifier reported `unresolved`. Absent when the identity was read back fine. */ + readonly identityUnusableCause?: PersonIdentityUnusableCause; + /** Whether the project switch is on right now, as data rather than a read this pure + * function performs. Required, never defaulted: a default would be the silent "on" this + * field exists to rule out. */ + readonly measurementEnabled: boolean; +} + +export interface CostReport { + readonly fromDay: string; + readonly toDay: string; + readonly task?: TaskIdentity; + /** Only the generic filters actually given, in a fixed order - `task` keeps its own + * field above, unchanged. Absent for an unfiltered period. */ + readonly filters?: CostReportFilters; + /** Present only when a filter - never the period itself - is what emptied this + * selection. */ + readonly emptySelection?: CostReportEmptySelection; + readonly sessions: number; + readonly totals: CostTotals; + /** From `kind: "session"` records alone and never broken down by step: no active-time + * measure on any tool carries a step attribute. Absent when no record carried it. */ + readonly activeTimeSeconds?: number; + readonly bySteps: readonly CostReportStepRow[]; + readonly byModels: readonly CostReportModelRow[]; + readonly byAgents: readonly CostReportAgentRow[]; + readonly byPrompts: readonly CostReportPromptRow[]; + readonly byTools: readonly CostReportToolRow[]; + readonly byProjects: readonly CostReportProjectRow[]; + readonly byTasks: readonly CostReportTaskRow[]; + readonly byBacklog: readonly CostReportBacklogRow[]; + readonly byFlows: readonly CostReportFlowRow[]; + readonly byDays: readonly CostReportDayRow[]; + /** Mapped people first, then every unplaced identity, then the one row for records + * carrying none. Largest first within each group; never merged across the three. */ + readonly byPeople: readonly CostReportPersonRow[]; + readonly attributionMix: readonly CostReportAttributionRow[]; + /** Present only alongside `task`: an unfiltered period carries no per-record task identity + * to break down. */ + readonly taskAttributionMix?: readonly CostReportTaskAttributionRow[]; + readonly undatedRecords: number; + readonly unreadableLines: number; + /** Which cause made this machine's own identity unusable for resolving records. Absent + * when the identity was read back fine. */ + readonly identityUnusableCause?: PersonIdentityUnusableCause; + /** Whether the project switch is on right now - never inferred from whether any record was + * found, since an empty period and a switched-off one are different facts. Always concrete + * here, unlike the optional input field it is resolved from. */ + readonly measurementEnabled: boolean; +} + +/** Accumulates a group while keeping "never observed" distinct from "observed as zero". + * A field stays absent until some record in the group carries it. */ +export class TotalsAccumulator { + private requests = 0; + private costMicroUsd: number | undefined; + private readonly counters = new Map(); + + add(record: TelemetrySinkRecord): void { + this.requests += 1; + // `typeof`, not `!== undefined`: a record read off disk need not hold the type its field + // declares, and `JSON.stringify(NaN)` is `null`, which would read as a known, free cost. + if (typeof record.cost_usd === "number") { + this.costMicroUsd = (this.costMicroUsd ?? 0) + toMicroUsd(record.cost_usd); + } + this.addTokensOnly(record); + } + + /** Never touches `requests` or `cost_usd`: a `kind: "session"` local-read total is not a + * billed request, and the tool never states a cost for one. */ + addTokensOnly(record: TelemetrySinkRecord): void { + for (const field of COUNTER_FIELDS) { + const value = record[COUNTER_SOURCE[field]]; + if (typeof value === "number") { + this.counters.set(field, (this.counters.get(field) ?? 0) + value); + } + } + } + + build(): CostTotals { + const counters: Partial> = {}; + for (const field of COUNTER_FIELDS) { + const value = this.counters.get(field); + if (value !== undefined) counters[field] = value; + } + return { + requests: this.requests, + ...(this.costMicroUsd === undefined ? {} : { costMicroUsd: this.costMicroUsd }), + ...counters, + }; + } +} + +function accumulateInto( + groups: Map, + key: K, + record: TelemetrySinkRecord, + apply: (accumulator: TotalsAccumulator) => void = (accumulator) => accumulator.add(record) +): void { + const existing = groups.get(key); + if (existing) { + apply(existing); + return; + } + const created = new TotalsAccumulator(); + apply(created); + groups.set(key, created); +} + +function addToStepGroup(groups: Map, record: TelemetrySinkRecord): void { + const key = stepRowKey(record); + const existing = groups.get(key); + if (existing) { + existing.totals.add(record); + return; + } + const created: StepGroup = { + attribution: record.step_attribution, + ...(record.step === undefined ? {} : { step: record.step }), + totals: new TotalsAccumulator(), + }; + created.totals.add(record); + groups.set(key, created); +} + +/** Mirrors `addToStepGroup`, which folds that axis' own pairs the same way. */ +function addToTaskGroup( + groups: Map, + row: TaskRow, + record: TelemetrySinkRecord +): void { + const key = taskRowKeyOf(row); + const existing = groups.get(key); + if (existing) { + existing.totals.add(record); + return; + } + const created: TaskGroup = + typeof row === "string" + ? { reason: row, totals: new TotalsAccumulator() } + : { task: row.task, attribution: row.attribution, totals: new TotalsAccumulator() }; + created.totals.add(record); + groups.set(key, created); +} + +function addToPersonGroup( + groups: Map, + record: TelemetrySinkRecord, + resolved: ResolvedPerson +): void { + const key = personGroupKey(resolved); + const existing = groups.get(key); + if (existing) { + existing.totals.add(record); + return; + } + const created: PersonGroup = { resolved, totals: new TotalsAccumulator() }; + created.totals.add(record); + groups.set(key, created); +} + +function addToPromptGroup(groups: Map, record: TelemetrySinkRecord): void { + const key = promptKeyOf(record); + const group = groups.get(key) ?? { totals: new TotalsAccumulator() }; + group.totals.add(record); + const atMs = + record.event_timestamp === undefined ? Number.NaN : Date.parse(record.event_timestamp); + if (!Number.isNaN(atMs) && (group.earliestMs === undefined || atMs < group.earliestMs)) { + group.earliestMs = atMs; + } + groups.set(key, group); +} + +/** Every group one pass over the records fills, kept together so the pass reads as one + * decision per record rather than parallel loops over the same list. */ +interface Groups { + readonly totals: TotalsAccumulator; + readonly steps: Map; + readonly models: Map; + readonly agents: Map; + readonly prompts: Map; + readonly tools: Map; + readonly toolSessionTotals: Map; + readonly attributions: Map; + readonly taskAttributions: Map; + readonly projects: Map; + readonly tasks: Map; + readonly backlog: Map; + readonly flows: Map; + readonly people: Map; + readonly days: Map; + activeTimeSeconds?: number; +} + +function emptyGroups(fromDay: string, toDay: string): Groups { + const days = new Map(); + for (const day of dayRange(fromDay, toDay)) days.set(day, new TotalsAccumulator()); + return { + totals: new TotalsAccumulator(), + steps: new Map(), + models: new Map(), + agents: new Map(), + prompts: new Map(), + tools: new Map(), + toolSessionTotals: new Map(), + attributions: new Map(), + taskAttributions: new Map(), + projects: new Map(), + tasks: new Map(), + backlog: new Map(), + flows: new Map(), + people: new Map(), + days, + }; +} + +/** Active time is the one quantity taken from a `"session"` record: no `"request"` record on + * any tool measured so far carries it, and a `"session"` record's money and tokens are a + * flush window's own delta of quantities the request records already report in full, so they + * are kept off `totals`, `bySteps` and `byDays` whatever the route. */ +function accumulateSessionRecord(groups: Groups, record: TelemetrySinkRecord): void { + // `typeof`, not `!== undefined`: `parseTelemetrySinkLine` casts everything but the schema + // version, so `null` would read as an observed zero and a string would concatenate into + // the running total and reach the terminal as `NaN` minutes. + if (typeof record.active_time_s === "number") { + groups.activeTimeSeconds = (groups.activeTimeSeconds ?? 0) + record.active_time_s; + } + if (record.provenance === "local-read") { + accumulateInto(groups.toolSessionTotals, record.tool, record, (accumulator) => + accumulator.addTokensOnly(record) + ); + } +} + +/** Everything one record needs to be placed on every axis, resolved once per report rather + * than once per record. */ +interface RecordContext { + readonly membership: TaskMembership | null; + readonly taskIntervalsByVendorId: ReadonlyMap; + readonly flowIntervalsByVendorId: ReadonlyMap; + readonly journalsByVendorId: ReadonlyMap; + readonly identity: PersonIdentity | null; + readonly taskBacklogDeclarations: ReadonlyMap | undefined; + readonly namesAgents: (tool: AiToolId) => boolean; +} + +function accumulateRequestRecord( + groups: Groups, + record: TelemetrySinkRecord, + context: RecordContext +): void { + groups.totals.add(record); + addToStepGroup(groups.steps, record); + accumulateInto(groups.attributions, record.step_attribution, record); + accumulateInto(groups.tools, record.tool, record); + accumulateInto(groups.models, modelKeyOf(record), record); + accumulateInto(groups.agents, agentKeyOf(record, context.namesAgents), record); + addToPromptGroup(groups.prompts, record); + accumulateInto(groups.projects, projectKeyOf(record), record); + const taskRow = taskRowOf(record, context.taskIntervalsByVendorId, context.journalsByVendorId); + addToTaskGroup(groups.tasks, taskRow, record); + accumulateInto(groups.backlog, backlogKeyOf(taskRow, context.taskBacklogDeclarations), record); + accumulateInto(groups.flows, flowKeyOf(record, context.flowIntervalsByVendorId), record); + addToPersonGroup(groups.people, record, resolvePerson(context.identity, personRawIdOf(record))); + addToDayGroup(groups.days, record); + const { membership } = context; + const attribution = membership === null ? undefined : taskAttributionOf(record, membership); + if (attribution !== undefined) accumulateInto(groups.taskAttributions, attribution, record); +} + +function accumulate( + records: readonly TelemetrySinkRecord[], + fromDay: string, + toDay: string, + membership: TaskMembership | null, + journals: readonly CostReportSessionJournal[], + identity: PersonIdentity | null, + taskBacklogDeclarations: ReadonlyMap | undefined, + declaredTools: readonly CostReportToolDeclaration[] +): Groups { + const groups = emptyGroups(fromDay, toDay); + const context: RecordContext = { + membership, + taskIntervalsByVendorId: allTaskIntervalsByVendorId(journals), + flowIntervalsByVendorId: allFlowIntervalsByVendorId(journals), + journalsByVendorId: new Map(journals.map((journal) => [journal.vendorId, journal])), + identity, + taskBacklogDeclarations, + namesAgents: agentNamingTools(declaredTools), + }; + for (const record of records) { + if (record.kind === "session") accumulateSessionRecord(groups, record); + else accumulateRequestRecord(groups, record, context); + } + return groups; +} + +/** `task`, `filters` and `emptySelection` together - the selection this report answered, as + * opposed to the figures it answered with. */ +function selectionFields( + input: CostReportInput, + emptySelection: CostReportEmptySelection | undefined +): Pick { + const filters = activeFilters(input.filters); + return { + ...(input.task === undefined ? {} : { task: input.task }), + ...(filters === undefined ? {} : { filters }), + ...(emptySelection === undefined ? {} : { emptySelection }), + }; +} + +function toolRowsInScope(input: CostReportInput, groups: Groups): readonly CostReportToolRow[] { + return buildToolRows( + declaredToolsInScope(input.declaredTools, input.filters), + groups.tools, + groups.toolSessionTotals + ); +} + +/** `undatedRecords`, `unreadableLines` and `identityUnusableCause` together - what the read + * could not do. */ +function readFields( + input: CostReportInput +): Pick< + CostReport, + "undatedRecords" | "unreadableLines" | "identityUnusableCause" | "measurementEnabled" +> { + return { + undatedRecords: input.undatedRecords, + unreadableLines: input.unreadableLines, + measurementEnabled: input.measurementEnabled, + ...(input.identityUnusableCause === undefined + ? {} + : { identityUnusableCause: input.identityUnusableCause }), + }; +} + +/** Every `by*` breakdown together. */ +function breakdownFields( + input: CostReportInput, + groups: Groups +): Pick< + CostReport, + | "bySteps" + | "byModels" + | "byAgents" + | "byPrompts" + | "byTools" + | "byProjects" + | "byTasks" + | "byBacklog" + | "byFlows" + | "byDays" + | "byPeople" +> { + return { + bySteps: stepRows(groups.steps), + byModels: modelRows(groups.models), + byAgents: agentRows(groups.agents), + byPrompts: promptRows(groups.prompts), + byTools: toolRowsInScope(input, groups), + byProjects: projectRows(groups.projects), + byTasks: taskRows(groups.tasks), + byBacklog: backlogRows(groups.backlog), + byFlows: flowRows(groups.flows), + byDays: dayRows(groups.days), + byPeople: personRows(groups.people), + }; +} + +function assembleCostReport( + input: CostReportInput, + inScope: readonly TelemetrySinkRecord[], + groups: Groups, + membership: TaskMembership | null, + emptySelection: CostReportEmptySelection | undefined +): CostReport { + return { + fromDay: input.fromDay, + toDay: input.toDay, + ...selectionFields(input, emptySelection), + sessions: new Set(inScope.map((record) => record.vendor_id)).size, + totals: groups.totals.build(), + ...(groups.activeTimeSeconds === undefined + ? {} + : { activeTimeSeconds: groups.activeTimeSeconds }), + ...breakdownFields(input, groups), + attributionMix: attributionRows(groups.attributions), + ...(membership === null + ? {} + : { taskAttributionMix: taskAttributionRows(groups.taskAttributions) }), + ...readFields(input), + }; +} + +/** One period's records and journals, reduced to a report whose every breakdown sums to the + * total it belongs to. Money and the four token counters come from `kind: "request"` records + * alone, active time from `kind: "session"` records alone: summing across the two kinds + * counts the same tokens twice and produces a total that looks right. */ +export function buildCostReport(input: CostReportInput): CostReport { + // Turn-supersede first: a still-open Codex turn is down to one record before a billed-call + // group is formed. Order is otherwise inert - the two key on disjoint fields. + const records = collapseBilledRequests(collapseSupersededTurns(input.records)); + const membership = input.task === undefined ? null : taskMembership(input.journals, input.task); + const stages = selectionStages(records, input, membership); + const emptySelection = emptySelectionOf(stages, input, membership); + const inScope = stages[stages.length - 1]?.records ?? []; + const identity = input.identity ?? null; + const groups = accumulate( + inScope, + input.fromDay, + input.toDay, + membership, + input.journals, + identity, + input.taskBacklogDeclarations, + input.declaredTools + ); + + return assembleCostReport(input, inScope, groups, membership, emptySelection); +} diff --git a/cli/src/contexts/telemetry/domain/flow-attribution.ts b/cli/src/contexts/telemetry/domain/flow-attribution.ts new file mode 100644 index 000000000..a4b214e61 --- /dev/null +++ b/cli/src/contexts/telemetry/domain/flow-attribution.ts @@ -0,0 +1,74 @@ +import { + buildClosedIntervals, + type ClosedInterval, + type IntervalClosure, +} from "./journal-intervals.js"; +import type { + RunJournal, + RunJournalBoundary, + RunJournalFileWritten, + RunJournalStepStart, + RunJournalTaskDeclared, +} from "./ports/run-journal-reader.js"; +import { namesTheSameSkill } from "./skill-name.js"; + +/** How a record's flow came to be known - a flow an interval placed a record inside and one + * the record's own tool named are different claims. Narrower than `StepAttributionSource` + * deliberately: `prompt-matched` names a step and never a flow, so nothing here produces it. */ +export type FlowAttributionSource = "journal-interval" | "tool-stated" | "unattributed"; + +/** Which skills open a flow when their own `step_start` fires - declared, never matched from a + * plugin name. Each is named twice because `skill-detection.cjs`'s two capture routes write + * `aidd-orchestrator:01-sdlc` (Claude Code, Copilot) and the bare `SKILL.md` directory name + * (Cursor, Codex); the prefixed form alone opens no flow there, and a bare one can collide. */ +export const ORCHESTRATING_SKILLS: ReadonlySet = new Set([ + "aidd-orchestrator:00-async-dev", + "00-async-dev", + "aidd-orchestrator:01-sdlc", + "01-sdlc", + "aidd-orchestrator:02-backlog", + "02-backlog", +]); + +/** The unqualified spellings among them - the ones a reader's own project can collide with, so + * the ones `flowLimits` names. Derived rather than listed by hand, which would go stale the + * moment one is added. Sorted so the sentence reads the same on every run. */ +export function bareOrchestratingSkillNames( + skills: ReadonlySet = ORCHESTRATING_SKILLS +): readonly string[] { + return [...skills].filter((skill) => !skill.includes(":")).sort(); +} + +/** One closed flow interval: from an orchestrating skill's own `step_start` to whichever of a + * `step_end` naming that same skill or the next orchestrating `step_start` comes first, or - + * unclosed - the journal's own last witnessed moment. A `turn_end` is a pause and never closes + * one; a non-orchestrating `step_start` neither opens nor closes one. */ +export interface FlowInterval extends ClosedInterval { + readonly skill: string; + /** Whether `endMs` is a moment this journal witnessed or the cap standing in for one it + * never did - carried because `buildStepIntervals` reads it in the step axis. */ + readonly closedBy: IntervalClosure; +} + +/** Journal lines in, closed flow intervals out - the same merge and cap `buildTaskIntervals` + * uses, through the one shared walk. A `step_end` naming a *different* skill is never a closer: + * a step finishing inside the orchestration is not the orchestration finishing, and since only + * `aidd-dev:01-plan` emits that marker, most flows close on the journal's end instead. */ +export function buildFlowIntervals( + journal: RunJournal, + periodEndMs?: number +): readonly FlowInterval[] { + return buildClosedIntervals< + RunJournalBoundary | RunJournalTaskDeclared | RunJournalFileWritten, + RunJournalStepStart, + FlowInterval + >( + [...journal.boundaries, ...journal.taskDeclarations, ...journal.filesWritten], + periodEndMs, + (boundary): boundary is RunJournalStepStart => + boundary.type === "step_start" && ORCHESTRATING_SKILLS.has(boundary.skill), + (boundary, opener) => + boundary.type === "step_end" && namesTheSameSkill(boundary.skill, opener.skill), + (opener, startMs, endMs, closedBy) => ({ skill: opener.skill, startMs, endMs, closedBy }) + ); +} diff --git a/cli/src/contexts/telemetry/domain/formats/claude-code-transcript.ts b/cli/src/contexts/telemetry/domain/formats/claude-code-transcript.ts new file mode 100644 index 000000000..461ffc244 --- /dev/null +++ b/cli/src/contexts/telemetry/domain/formats/claude-code-transcript.ts @@ -0,0 +1,300 @@ +import type { + LocalCostCandidateRecord, + TranscriptLineAccumulator, +} from "../ports/session-cost-reader.js"; + +// Field names measured against real transcripts: the unit test turns red against its fixture if +// Claude Code moves one, before a zero could be stored in the moved field's place. A subagent's +// messages are never inline in the main transcript but in their own `subagents/agent-*.jsonl`, +// which is why `CLAUDE_CODE_TRANSCRIPT_LOCATION` matches both layouts. +const VENDOR_FIELD = "sessionId"; +const TURN_FIELD = "requestId"; + +// Claude Code writes its own fabricated assistant messages with this literal in +// `message.model` — a session-limit or error notice the tool composed, billed to nobody, so +// they yield no record. The marker is the filter, never all-counters-zero: a genuinely billed +// call reading zero on every counter is still an observation, and still yields its record. +const SYNTHETIC_MODEL = ""; + +interface ClaudeUsage { + readonly input_tokens?: unknown; + readonly cache_creation_input_tokens?: unknown; + readonly cache_read_input_tokens?: unknown; + readonly output_tokens?: unknown; +} + +interface ClaudeTranscriptLine { + readonly type?: unknown; + readonly sessionId?: unknown; + readonly uuid?: unknown; + readonly parentUuid?: unknown; + readonly promptId?: unknown; + readonly requestId?: unknown; + readonly isSidechain?: unknown; + readonly timestamp?: unknown; + readonly effort?: unknown; + readonly attributionAgent?: unknown; + readonly attributionSkill?: unknown; + readonly attributionPlugin?: unknown; + readonly message?: { + readonly model?: unknown; + readonly id?: unknown; + readonly usage?: ClaudeUsage; + readonly content?: unknown; + }; +} + +interface ClaudeCounters { + readonly input_tokens: number; + readonly cache_creation_input_tokens: number; + readonly cache_read_input_tokens: number; + readonly output_tokens: number; +} + +function asNumber(value: unknown): number | undefined { + return typeof value === "number" ? value : undefined; +} + +function asString(value: unknown): string | undefined { + return typeof value === "string" ? value : undefined; +} + +/** All four or none: a partial `usage` yields no record rather than one whose missing counter + * reads as zero. */ +function readCounters(usage: ClaudeUsage | undefined): ClaudeCounters | null { + const input = asNumber(usage?.input_tokens); + const cacheCreation = asNumber(usage?.cache_creation_input_tokens); + const cacheRead = asNumber(usage?.cache_read_input_tokens); + const output = asNumber(usage?.output_tokens); + if (input === undefined || cacheCreation === undefined) return null; + if (cacheRead === undefined || output === undefined) return null; + return { + input_tokens: input, + cache_creation_input_tokens: cacheCreation, + cache_read_input_tokens: cacheRead, + output_tokens: output, + }; +} + +function buildIdentity( + line: ClaudeTranscriptLine, + vendorId: string +): Pick< + LocalCostCandidateRecord, + "vendor_id" | "vendor_field" | "turn_id" | "turn_field" | "billed_request_id" +> { + const turnId = asString(line.requestId); + return { + vendor_id: vendorId, + vendor_field: VENDOR_FIELD, + ...(turnId !== undefined ? { turn_id: turnId, turn_field: TURN_FIELD } : {}), + // Stated separately rather than derived from `turn_id` downstream: `turn_id` is not unique + // per billed request on every route, and a consumer collapsing two records into one must + // never key on a field carrying that caveat. + ...(turnId !== undefined ? { billed_request_id: turnId } : {}), + }; +} + +// `agent_name` matches what the export path sets, so a local-read subagent record differs from +// an exported one by `provenance` alone. `attributionSkill` is omitted, never nulled, both when +// no skill runs and on a version predating it, so its absence yields no `step` at all rather +// than asserting "no skill ran"; `attributionPlugin` is read only alongside it. +function buildOptionalFields( + line: ClaudeTranscriptLine +): Pick< + LocalCostCandidateRecord, + "model" | "effort" | "event_timestamp" | "agent_name" | "step" | "step_plugin" +> { + const model = asString(line.message?.model); + const effort = asString(line.effort); + const timestamp = asString(line.timestamp); + const agentName = line.isSidechain === true ? asString(line.attributionAgent) : undefined; + const step = asString(line.attributionSkill); + const stepPlugin = step !== undefined ? asString(line.attributionPlugin) : undefined; + return { + ...(model !== undefined ? { model } : {}), + ...(effort !== undefined ? { effort } : {}), + ...(timestamp !== undefined ? { event_timestamp: timestamp } : {}), + ...(agentName !== undefined ? { agent_name: agentName } : {}), + ...(step !== undefined ? { step } : {}), + ...(stepPlugin !== undefined ? { step_plugin: stepPlugin } : {}), + }; +} + +function buildRecord( + line: ClaudeTranscriptLine, + vendorId: string, + counters: ClaudeCounters +): LocalCostCandidateRecord { + return { + kind: "request", + ...buildIdentity(line, vendorId), + ...buildOptionalFields(line), + input_tokens: counters.input_tokens, + output_tokens: counters.output_tokens, + cache_read_tokens: counters.cache_read_input_tokens, + cache_creation_tokens: counters.cache_creation_input_tokens, + }; +} + +/** One JSONL line as an object, or `null` for a blank or unparseable one. Shared by the + * billed-turn parser and the link walk, so a line either reaches both or neither. */ +function parseLine(line: string): ClaudeTranscriptLine | null { + const trimmed = line.trim(); + if (!trimmed) return null; + try { + return JSON.parse(trimmed) as ClaudeTranscriptLine; + } catch { + return null; + } +} + +function uuidOf(line: string): string | undefined { + const parsed = parseLine(line); + return parsed === null ? undefined : asString(parsed.uuid); +} + +/** Only a `Skill` call names a step: every other tool call is work done inside the step already + * running, and reading one as a start would name a skill for a prompt that invoked none. + * `input.skill` is the same field `skill-detection.cjs` reads out of the hook payload, so + * transcript and run journal name a step identically. */ +function skillInvokedOn(line: ClaudeTranscriptLine): string | undefined { + const content = line.message?.content; + if (!Array.isArray(content)) return undefined; + for (const part of content) { + if (typeof part !== "object" || part === null) continue; + const call = part as { type?: unknown; name?: unknown; input?: { skill?: unknown } }; + if (call.type !== "tool_use" || call.name !== "Skill") continue; + const skill = asString(call.input?.skill); + if (skill !== undefined) return skill; + } + return undefined; +} + +/** The prompt a line belongs to, walking `parentUuid` upward — a billed call and the prompt + * that caused it never share a line. `seen` bounds the walk rather than a hop count: a live + * transcript truncated mid-write can point at a parent that never arrived, or leave a cycle, + * while a hop cap would silently stop answering for a legitimately deep chain. */ +function resolvePromptId( + startUuid: string | undefined, + parents: ReadonlyMap, + prompts: ReadonlyMap +): string | undefined { + const seen = new Set(); + let current = startUuid; + while (current !== undefined && !seen.has(current)) { + const prompt = prompts.get(current); + if (prompt !== undefined) return prompt; + seen.add(current); + current = parents.get(current); + } + return undefined; +} + +/** Keyed by `message.id`: Claude Code writes a line when a message starts and another when it + * completes, so one record per line would count the same call twice. The last wins, carrying the + * complete `output_tokens` where the first holds a placeholder; the figures are never summed, + * the lines being one call restated with identical input and cache-read counters. */ +function parseAssistantLine( + line: string +): { readonly dedupeKey: string; readonly record: LocalCostCandidateRecord } | null { + const trimmed = line.trim(); + if (!trimmed) return null; + let parsed: ClaudeTranscriptLine; + try { + parsed = JSON.parse(trimmed) as ClaudeTranscriptLine; + } catch { + return null; + } + if (parsed.type !== "assistant") return null; + // Before the dedupe key is computed: a line that is not a request must not consume a + // key either, or the first real call sharing it would be dropped as a duplicate. + if (parsed.message?.model === SYNTHETIC_MODEL) return null; + const vendorId = asString(parsed.sessionId); + if (vendorId === undefined) return null; + const counters = readCounters(parsed.message?.usage); + if (!counters) return null; + const dedupeKey = asString(parsed.message?.id) ?? asString(parsed.requestId) ?? trimmed; + return { dedupeKey, record: buildRecord(parsed, vendorId, counters) }; +} + +class ClaudeCodeTranscriptAccumulator implements TranscriptLineAccumulator { + // Insertion-ordered, value replaced rather than skipped: the record keeps the position the + // call first appeared at, so a reader sees the order the calls happened. + private readonly byKey = new Map(); + // Which line each record came from, so prompts are resolved once every line has been seen: + // nothing in the format promises a parent appears earlier, and a walk run mid-stream would + // answer from a half-built map. + private readonly uuidByKey = new Map(); + // Gathered from *all* lines, not only billed ones: the chain from a call to its prompt runs + // through lines carrying no counters at all. + private readonly parents = new Map(); + private readonly prompts = new Map(); + /** In the order the transcript holds them, resolved to prompts in `build()` for the same + * reason prompts are: a walk run mid-stream reads a half-built chain. */ + private readonly skillCalls: { readonly uuid: string; readonly skill: string }[] = []; + + push(line: string): void { + this.rememberLinks(line); + const parsed = parseAssistantLine(line); + if (!parsed) return; + this.byKey.set(parsed.dedupeKey, parsed.record); + const uuid = uuidOf(line); + if (uuid !== undefined) this.uuidByKey.set(parsed.dedupeKey, uuid); + } + + /** Parsed a second time: `parseAssistantLine` answers `null` for every line that is not a + * billed assistant turn, and those are exactly the lines this walk needs. */ + private rememberLinks(line: string): void { + const parsed = parseLine(line); + if (parsed === null) return; + const uuid = asString(parsed.uuid); + if (uuid === undefined) return; + const parent = asString(parsed.parentUuid); + if (parent !== undefined) this.parents.set(uuid, parent); + const prompt = asString(parsed.promptId); + if (prompt !== undefined) this.prompts.set(uuid, prompt); + const skill = skillInvokedOn(parsed); + if (skill !== undefined) this.skillCalls.push({ uuid, skill }); + } + + /** First call wins, not the last: a prompt invoking two skills invoked the second from + * inside the first, and the prompt is named for the work it began — the same rule + * `promptToSkill` follows over the journal's own lines, so the two cannot disagree. */ + private skillByPrompt(): ReadonlyMap { + const byPrompt = new Map(); + for (const { uuid, skill } of this.skillCalls) { + const prompt = resolvePromptId(uuid, this.parents, this.prompts); + if (prompt !== undefined && !byPrompt.has(prompt)) byPrompt.set(prompt, skill); + } + return byPrompt; + } + + build(): readonly LocalCostCandidateRecord[] { + const skillByPrompt = this.skillByPrompt(); + return [...this.byKey.entries()].map(([key, record]) => { + const promptId = resolvePromptId(this.uuidByKey.get(key), this.parents, this.prompts); + if (promptId === undefined) return record; + const promptSkill = skillByPrompt.get(promptId); + return { + ...record, + prompt_id: promptId, + ...(promptSkill === undefined ? {} : { prompt_skill: promptSkill }), + }; + }); + } +} + +export function createClaudeCodeTranscriptAccumulator(): TranscriptLineAccumulator { + return new ClaudeCodeTranscriptAccumulator(); +} + +/** A wrapper around the same per-line logic, for a fixture-driven test to target directly: + * the adapter streams the accumulator instead, so a large transcript is never held whole. */ +export function mapClaudeCodeTranscriptToSinkRecords( + content: string +): readonly LocalCostCandidateRecord[] { + const accumulator = createClaudeCodeTranscriptAccumulator(); + for (const line of content.split("\n")) accumulator.push(line); + return accumulator.build(); +} diff --git a/cli/src/contexts/telemetry/domain/formats/codex-rollout.ts b/cli/src/contexts/telemetry/domain/formats/codex-rollout.ts new file mode 100644 index 000000000..7f1204cae --- /dev/null +++ b/cli/src/contexts/telemetry/domain/formats/codex-rollout.ts @@ -0,0 +1,204 @@ +import type { + LocalCostCandidateRecord, + TranscriptLineAccumulator, +} from "../ports/session-cost-reader.js"; + +// A `token_count` event carries a cumulative `total_token_usage` beside this call's own +// `last_token_usage`, so summing the totals double-counts every call after the first; it names +// no model and no request id, which live on the `turn_context` opening the turn. Its +// `input_tokens` is *inclusive* of `cached_input_tokens`, unlike Claude Code's exclusive figure, +// so cached is subtracted; `reasoning_output_tokens` is a subset of `output_tokens`, never added. +const VENDOR_FIELD = "session_meta.id"; +const TURN_FIELD = "turn_id"; + +interface CodexTokenUsage { + readonly input_tokens?: unknown; + readonly cached_input_tokens?: unknown; + readonly cache_write_input_tokens?: unknown; + readonly output_tokens?: unknown; +} + +interface CodexLine { + readonly type?: unknown; + readonly timestamp?: unknown; + readonly payload?: { + readonly id?: unknown; + readonly turn_id?: unknown; + readonly model?: unknown; + readonly effort?: unknown; + readonly type?: unknown; + readonly info?: { + readonly last_token_usage?: CodexTokenUsage; + readonly total_token_usage?: CodexTokenUsage; + }; + }; +} + +interface PendingTurn { + readonly turnId: string; + readonly model?: string; + readonly effort?: string; + readonly at?: string; + inputTokens?: number; + outputTokens?: number; + cacheReadTokens?: number; + cacheCreationTokens?: number; +} + +function asNumber(value: unknown): number | undefined { + return typeof value === "number" ? value : undefined; +} + +function asString(value: unknown): string | undefined { + return typeof value === "string" ? value : undefined; +} + +function parseLine(line: string): CodexLine | null { + const trimmed = line.trim(); + if (!trimmed) return null; + try { + return JSON.parse(trimmed) as CodexLine; + } catch { + return null; + } +} + +// `at` is the turn's own start, off the `turn_context` line rather than a counted event: a +// record covers a whole turn, so a moment inside it would claim a precision it does not have. +function startTurn( + payload: NonNullable, + at: string | undefined +): PendingTurn | null { + const turnId = asString(payload.turn_id); + if (turnId === undefined) return null; + return { turnId, model: asString(payload.model), effort: asString(payload.effort), at }; +} + +/** The event's own increment, never the cumulative `total_token_usage`. A metric absent from + * every event of the turn — Codex omits `cache_write_input_tokens` rather than sending zero — + * stays unset rather than summed into a fabricated zero. */ +function addUsage(pending: PendingTurn, usage: CodexTokenUsage): void { + const rawInput = asNumber(usage.input_tokens); + const cached = asNumber(usage.cached_input_tokens); + const cacheWrite = asNumber(usage.cache_write_input_tokens); + const output = asNumber(usage.output_tokens); + if (rawInput !== undefined) { + pending.inputTokens = (pending.inputTokens ?? 0) + (rawInput - (cached ?? 0)); + } + if (cached !== undefined) pending.cacheReadTokens = (pending.cacheReadTokens ?? 0) + cached; + if (cacheWrite !== undefined) { + pending.cacheCreationTokens = (pending.cacheCreationTokens ?? 0) + cacheWrite; + } + if (output !== undefined) pending.outputTokens = (pending.outputTokens ?? 0) + output; +} + +/** Codex re-emits a turn's last `token_count` verbatim, the increment arriving twice while the + * cumulative does not move — and a cumulative that has not moved cannot carry billed consumption. + * `null` when the event states none: the increment is then counted, an absent figure being no + * evidence that nothing happened. */ +function cumulativeKey(usage: CodexTokenUsage | undefined): string | null { + if (!usage) return null; + const parts = [ + asNumber(usage.input_tokens), + asNumber(usage.cached_input_tokens), + asNumber(usage.cache_write_input_tokens), + asNumber(usage.output_tokens), + ]; + if (parts.every((part) => part === undefined)) return null; + return parts.map((part) => (part === undefined ? "" : String(part))).join("/"); +} + +function hasCounters(pending: PendingTurn): boolean { + return ( + pending.inputTokens !== undefined || + pending.outputTokens !== undefined || + pending.cacheReadTokens !== undefined || + pending.cacheCreationTokens !== undefined + ); +} + +function buildRecord(vendorId: string, pending: PendingTurn): LocalCostCandidateRecord { + return { + kind: "request", + vendor_id: vendorId, + vendor_field: VENDOR_FIELD, + turn_id: pending.turnId, + turn_field: TURN_FIELD, + ...(pending.model !== undefined ? { model: pending.model } : {}), + ...(pending.effort !== undefined ? { effort: pending.effort } : {}), + ...(pending.at !== undefined ? { event_timestamp: pending.at } : {}), + ...(pending.inputTokens !== undefined ? { input_tokens: pending.inputTokens } : {}), + ...(pending.outputTokens !== undefined ? { output_tokens: pending.outputTokens } : {}), + ...(pending.cacheReadTokens !== undefined + ? { cache_read_tokens: pending.cacheReadTokens } + : {}), + ...(pending.cacheCreationTokens !== undefined + ? { cache_creation_tokens: pending.cacheCreationTokens } + : {}), + }; +} + +/** One record per turn, never per line. A turn is closed by the *next* `turn_context`, and a + * rollout has no line saying the session is finished, so the final flush cannot tell an ended + * session from a running one: it emits what the counters sum to so far, and whether a later + * read's record for the same `turn_id` corrects it is decided downstream. */ +class CodexRolloutAccumulator implements TranscriptLineAccumulator { + private vendorId: string | undefined; + private pending: PendingTurn | undefined; + /** The cumulative last counted, so a re-emitted final `token_count` is not added twice. */ + private lastCumulative: string | undefined; + private readonly records: LocalCostCandidateRecord[] = []; + + push(line: string): void { + const parsed = parseLine(line); + if (!parsed?.payload) return; + if (parsed.type === "session_meta") this.vendorId = asString(parsed.payload.id); + else if (parsed.type === "turn_context") this.startNewTurn(parsed.payload, parsed.timestamp); + else if (parsed.type === "event_msg" && parsed.payload.type === "token_count") { + this.applyTokenCount( + parsed.payload.info?.last_token_usage, + parsed.payload.info?.total_token_usage + ); + } + } + + build(): readonly LocalCostCandidateRecord[] { + this.flush(); + return this.records; + } + + private startNewTurn(payload: NonNullable, timestamp: unknown): void { + this.flush(); + this.pending = startTurn(payload, asString(timestamp)) ?? undefined; + } + + private applyTokenCount( + usage: CodexTokenUsage | undefined, + cumulative: CodexTokenUsage | undefined + ): void { + if (!this.pending || !usage) return; + const key = cumulativeKey(cumulative); + if (key !== null && key === this.lastCumulative) return; + if (key !== null) this.lastCumulative = key; + addUsage(this.pending, usage); + } + + private flush(): void { + if (this.pending && this.vendorId !== undefined && hasCounters(this.pending)) { + this.records.push(buildRecord(this.vendorId, this.pending)); + } + this.pending = undefined; + } +} + +export function createCodexRolloutAccumulator(): TranscriptLineAccumulator { + return new CodexRolloutAccumulator(); +} + +/** For a fixture-driven test to target directly: the adapter streams the accumulator one line + * at a time, so a large rollout is never held whole in memory. */ +export function mapCodexRolloutToSinkRecords(content: string): readonly LocalCostCandidateRecord[] { + const accumulator = createCodexRolloutAccumulator(); + for (const line of content.split("\n")) accumulator.push(line); + return accumulator.build(); +} diff --git a/cli/src/contexts/telemetry/domain/formats/commit-session-trailer.ts b/cli/src/contexts/telemetry/domain/formats/commit-session-trailer.ts new file mode 100644 index 000000000..8ba70a9d5 --- /dev/null +++ b/cli/src/contexts/telemetry/domain/formats/commit-session-trailer.ts @@ -0,0 +1,110 @@ +import type { HookManager } from "../telemetry-setup.js"; + +/** The one link between a commit and the session that produced it: every other hop exists, so a + * trailer carrying whatever `session-anchor.ts` resolves — never a second identifier minted here + * — makes "this commit cost X" answerable. Both hosts' anchors equal the `vendor_id` records join + * on, except inside a Codex subagent, where the anchor may name the delegating thread instead. */ + +/** Capitalised the way `Co-authored-by` and `Signed-off-by` are: `git interpret-trailers` + * matches a token case-insensitively but writes back what it was given, and a history spelling + * one trailer three ways is one nobody can grep. */ +export const SESSION_TRAILER_TOKEN = "AIDD-Session-Id"; + +/** The delegate lives beside the hook that calls it rather than inside it, so a repository + * already running a `prepare-commit-msg` hook keeps it and gains one line calling this. */ +export const SESSION_TRAILER_DELEGATE_FILE = "aidd-session-trailer.sh"; + +/** What a `prepare-commit-msg` written from scratch starts with, and — read back — the one + * line that does not count as somebody else's content. One spelling for both sides, or a + * freshly installed hook reports as somebody else's. */ +export const SESSION_TRAILER_HOOK_HEADER = "#!/bin/sh"; + +/** Both the line appended to `prepare-commit-msg` and the marker read back to detect it, so the + * two sides cannot spell it differently; `"$@"` forwards git's own arguments. Separators are + * forced to `/`: the `sh` Git for Windows ships treats a backslash inside double quotes as an + * ordinary character, so a path Node resolved there would arrive literally and name nothing. */ +export function sessionTrailerHookLine(delegatePath: string): string { + return `sh "${delegatePath.replace(/\\/gu, "/")}" "$@"`; +} + +/** Resolved at run time rather than baked in at write time, so a hand-added line survives + * being carried into a checkout living somewhere else. The `[ -f "$delegate" ]` guard keeps a + * later `aidd telemetry off`, which deletes only the script, from leaving every commit calling + * a file that no longer exists. */ +function delegateLookup(delegateFile: string): string { + return `delegate="$(git rev-parse --git-common-dir)/hooks/${delegateFile}"`; +} + +/** `{1}` and `{2}` are lefthook's own placeholders for the message-file and source arguments + * git passes a `prepare-commit-msg` hook — the counterpart of `"$@"` in a plain shell hook. */ +export function sessionTrailerLefthookJob(delegateFile: string): string { + return `prepare-commit-msg: + commands: + aidd-session-trailer: + run: | + ${delegateLookup(delegateFile)} + if [ -f "$delegate" ]; then sh "$delegate" {1} {2}; fi`; +} + +/** Husky's own hook is a plain shell script, so `"$@"` forwards git's arguments the way it + * does for a hook this CLI owns outright. */ +export function sessionTrailerHuskyLine(delegateFile: string): string { + return `${delegateLookup(delegateFile)} +[ -f "$delegate" ] && sh "$delegate" "$@"`; +} + +/** Lefthook's snippet goes under a top-level `prepare-commit-msg:` key, so "add this job to + * lefthook.yml" followed literally would give a file that already has one a duplicate YAML + * key. Husky's hook has no such structure, and a line is still a line there. */ +export function sessionTrailerManagerInstruction(manager: HookManager, targetFile: string): string { + return manager === "lefthook" + ? `add this command under \`prepare-commit-msg:\` in ${targetFile}` + : `add this line to ${targetFile}`; +} + +/** The one place both `telemetry on` and `telemetry check` read this from, so the file named + * and the snippet printed cannot drift apart. */ +export function sessionTrailerManagerSnippet( + manager: HookManager, + delegateFile: string +): { readonly manager: HookManager; readonly targetFile: string; readonly snippet: string } { + if (manager === "lefthook") { + return { + manager, + targetFile: "lefthook.yml", + snippet: sessionTrailerLefthookJob(delegateFile), + }; + } + return { + manager, + targetFile: ".husky/prepare-commit-msg", + snippet: sessionTrailerHuskyLine(delegateFile), + }; +} + +/** POSIX `sh`, no Node, no dependency on this CLI still being installed. A hook that fails is a + * commit that fails, so every path ends in `exit 0`. Variable precedence is `session-anchor.ts`'s; + * neither set means no AI session made the commit and it gets no trailer. No commit is skipped by + * `message_source`: a merge a session resolved by hand cost as much as any other change. */ +export function sessionTrailerDelegateScript(): string { + return `#!/bin/sh +# Installed by \`aidd telemetry on\`, removed by \`aidd telemetry off\`. +# +# Names the AI session that authored this commit, so what a session cost can be read +# per commit. Writes nothing when no session made the commit. +set -u + +message_file="\${1:-}" + +[ -n "$message_file" ] || exit 0 + +session_id="\${CODEX_THREAD_ID:-\${CLAUDE_CODE_SESSION_ID:-}}" +[ -n "$session_id" ] || exit 0 + +# --if-exists doNothing keeps an amend, or a second run of this hook, from writing it twice. +git interpret-trailers --in-place --if-exists doNothing \\ + --trailer "${SESSION_TRAILER_TOKEN}=$session_id" "$message_file" || exit 0 + +exit 0 +`; +} diff --git a/cli/src/contexts/telemetry/domain/formats/copilot-events.ts b/cli/src/contexts/telemetry/domain/formats/copilot-events.ts new file mode 100644 index 000000000..4bad2e571 --- /dev/null +++ b/cli/src/contexts/telemetry/domain/formats/copilot-events.ts @@ -0,0 +1,106 @@ +import type { LocalCostCandidateRecord } from "../ports/session-cost-reader.js"; + +// `session.shutdown` fires once at the end of a session, never per turn, and its `tokenDetails` +// is exclusive of the cache figures where the sibling `usage` object is inclusive of them — +// which is why this reader takes the first. `requests.cost` and `totalPremiumRequests` are a +// count times a per-model multiplier, invariant to consumption, so neither is read as `cost_usd`; +// no `model` is stamped either, `currentModel` naming only the last one a session used. +const VENDOR_FIELD = "sessionId"; +const TURN_FIELD = "id"; + +interface CopilotTokenCount { + readonly tokenCount?: unknown; +} + +interface CopilotShutdownData { + readonly tokenDetails?: { + readonly input?: CopilotTokenCount; + readonly output?: CopilotTokenCount; + readonly cache_read?: CopilotTokenCount; + readonly cache_write?: CopilotTokenCount; + }; +} + +interface CopilotEventLine { + readonly type?: unknown; + readonly id?: unknown; + readonly timestamp?: unknown; + readonly data?: CopilotShutdownData; +} + +interface CopilotCounters { + readonly input_tokens: number; + readonly output_tokens: number; + readonly cache_read_tokens: number; + readonly cache_creation_tokens: number; +} + +function asNumber(value: unknown): number | undefined { + return typeof value === "number" ? value : undefined; +} + +function asString(value: unknown): string | undefined { + return typeof value === "string" ? value : undefined; +} + +/** All four or none: a shape this file has not been taught — a renamed field, an empty + * `tokenDetails` — yields no record rather than one silently missing every counter. */ +function readCounters(details: CopilotShutdownData["tokenDetails"]): CopilotCounters | null { + const input = asNumber(details?.input?.tokenCount); + const output = asNumber(details?.output?.tokenCount); + const cacheRead = asNumber(details?.cache_read?.tokenCount); + const cacheWrite = asNumber(details?.cache_write?.tokenCount); + if (input === undefined || output === undefined) return null; + if (cacheRead === undefined || cacheWrite === undefined) return null; + return { + input_tokens: input, + output_tokens: output, + cache_read_tokens: cacheRead, + cache_creation_tokens: cacheWrite, + }; +} + +function buildRecord( + line: CopilotEventLine, + vendorId: string, + counters: CopilotCounters +): LocalCostCandidateRecord { + const turnId = asString(line.id); + const timestamp = asString(line.timestamp); + return { + kind: "session", + vendor_id: vendorId, + vendor_field: VENDOR_FIELD, + ...(turnId !== undefined ? { turn_id: turnId, turn_field: TURN_FIELD } : {}), + ...(timestamp !== undefined ? { event_timestamp: timestamp } : {}), + ...counters, + }; +} + +function parseLine(line: string): CopilotEventLine | null { + const trimmed = line.trim(); + if (!trimmed) return null; + try { + return JSON.parse(trimmed) as CopilotEventLine; + } catch { + return null; + } +} + +/** One record at most: no per-request figure exists on this tool's file at all, and a session + * that never shut down or shut down unbilled yields nothing rather than a record of zeros. + * `vendorId` is the caller's own — the directory already names the session, where a truncated + * copy of the file would not — and this stays pure; the adapter is what opens a file. */ +export function mapCopilotEventsToSinkRecords( + content: string, + vendorId: string +): readonly LocalCostCandidateRecord[] { + for (const raw of content.split("\n")) { + const parsed = parseLine(raw); + if (parsed?.type !== "session.shutdown") continue; + const counters = readCounters(parsed.data?.tokenDetails); + if (counters === null) continue; + return [buildRecord(parsed, vendorId, counters)]; + } + return []; +} diff --git a/cli/src/contexts/telemetry/domain/formats/opencode-export.ts b/cli/src/contexts/telemetry/domain/formats/opencode-export.ts new file mode 100644 index 000000000..a96d770b3 --- /dev/null +++ b/cli/src/contexts/telemetry/domain/formats/opencode-export.ts @@ -0,0 +1,120 @@ +import type { LocalCostCandidateRecord } from "../ports/session-cost-reader.js"; + +// The counters are disjoint — `total == input + output + reasoning + cache.read + cache.write` +// held for every provider captured, with `input` shrinking as `cache.read` climbed — so nothing +// cached is subtracted here. `info.cost` is never read: it reads `0` throughout and its +// denomination is unestablished, a figure whose meaning is unknown being worse than an absent +// one. `info.providerID` is never read either, the stored record carrying no provider field, so +// the residual limit lives in `profiles/opencode/profile.ts`'s `telemetryLocalRead.limitation`. +const VENDOR_FIELD = "sessionID"; +const TURN_FIELD = "id"; + +interface OpencodeTokenCounts { + readonly total?: unknown; + readonly input?: unknown; + readonly output?: unknown; + readonly cache?: { readonly read?: unknown; readonly write?: unknown }; +} + +interface OpencodeMessageInfo { + readonly id?: unknown; + readonly modelID?: unknown; + readonly tokens?: OpencodeTokenCounts; + readonly time?: { readonly created?: unknown; readonly completed?: unknown }; +} + +interface OpencodeExportPayload { + readonly messages?: readonly { readonly info?: OpencodeMessageInfo }[]; +} + +function asNumber(value: unknown): number | undefined { + return typeof value === "number" ? value : undefined; +} + +function asString(value: unknown): string | undefined { + return typeof value === "string" ? value : undefined; +} + +// Epoch milliseconds. `time.created`, not `time.completed`: completed is absent on some +// counted messages, and a field meaning "started" on one record and "finished" on the next is +// worse than one that always means the same thing. +function isoFromEpochMillis(value: unknown): string | undefined { + const millis = asNumber(value); + if (millis === undefined || millis <= 0) return undefined; + const at = new Date(millis); + return Number.isNaN(at.getTime()) ? undefined : at.toISOString(); +} + +function buildIdentity( + info: OpencodeMessageInfo, + sessionId: string +): Pick { + const turnId = asString(info.id); + return { + vendor_id: sessionId, + vendor_field: VENDOR_FIELD, + ...(turnId !== undefined ? { turn_id: turnId, turn_field: TURN_FIELD } : {}), + }; +} + +// `cache.read`/`cache.write` are the same quantities the other tools already call +// cache-read and cache-creation — mapped onto those field names, not OpenCode's own. +function buildCounters( + tokens: OpencodeTokenCounts +): Pick< + LocalCostCandidateRecord, + "input_tokens" | "output_tokens" | "cache_read_tokens" | "cache_creation_tokens" +> { + const input = asNumber(tokens.input); + const output = asNumber(tokens.output); + const cacheRead = asNumber(tokens.cache?.read); + const cacheWrite = asNumber(tokens.cache?.write); + return { + ...(input !== undefined ? { input_tokens: input } : {}), + ...(output !== undefined ? { output_tokens: output } : {}), + ...(cacheRead !== undefined ? { cache_read_tokens: cacheRead } : {}), + ...(cacheWrite !== undefined ? { cache_creation_tokens: cacheWrite } : {}), + }; +} + +// A message OpenCode created but never billed — an interrupted response — carries `tokens` +// with no `total` key at all, and is not reliably given a `finish` either, so `total`'s absence +// is the signal. A message that completed with genuinely zero usage still carries `total: 0` +// and still yields a record: that is an observation, not a call that never happened. +function wasBilled(tokens: OpencodeTokenCounts): boolean { + return asNumber(tokens.total) !== undefined; +} + +function buildRecord( + info: OpencodeMessageInfo, + sessionId: string +): LocalCostCandidateRecord | null { + if (info.tokens === undefined) return null; + if (!wasBilled(info.tokens)) return null; + const model = asString(info.modelID); + const at = isoFromEpochMillis(info.time?.created); + return { + kind: "request", + ...buildIdentity(info, sessionId), + ...(model !== undefined ? { model } : {}), + ...(at !== undefined ? { event_timestamp: at } : {}), + ...buildCounters(info.tokens), + }; +} + +/** A message with no `info.tokens` — every user turn, and any turn OpenCode never measured — + * yields no record rather than an invented zero, and so does one never billed (`wasBilled`), + * which would otherwise inflate the request count. `sessionId` is trusted as given, matching + * every other local reader's contract. */ +export function mapOpencodeExportToSinkRecords( + payload: unknown, + sessionId: string +): readonly LocalCostCandidateRecord[] { + const messages = (payload as OpencodeExportPayload)?.messages ?? []; + const records: LocalCostCandidateRecord[] = []; + for (const message of messages) { + const record = buildRecord(message?.info ?? {}, sessionId); + if (record) records.push(record); + } + return records; +} diff --git a/cli/src/contexts/telemetry/domain/journal-intervals.ts b/cli/src/contexts/telemetry/domain/journal-intervals.ts new file mode 100644 index 000000000..a3334f94b --- /dev/null +++ b/cli/src/contexts/telemetry/domain/journal-intervals.ts @@ -0,0 +1,112 @@ +/** The one walk that turns run-journal lines into closed intervals, shared by + * `task-attribution.ts` and `flow-attribution.ts`: they differ only in opener, closer and + * payload. */ + +/** One boundary-like value, paired with the millisecond moment its own `at` parses to. */ +export interface TimedBoundary { + readonly atMs: number; + readonly boundary: T; +} + +/** Every `at`-bearing value, timed and sorted, dropping one whose own `at` cannot be parsed. + * Left in, it would occupy a list index while showing nothing, widening the interval before it. */ +export function timed( + boundaries: readonly T[] +): readonly TimedBoundary[] { + return boundaries + .map((boundary) => ({ atMs: Date.parse(boundary.at), boundary })) + .filter(({ atMs }) => !Number.isNaN(atMs)) + .sort((left, right) => left.atMs - right.atMs); +} + +/** The journal's own last recorded moment, capped at `periodEndMs` when one is given, so a + * clock-skewed far-future moment never widens an unclosed interval past what a report could + * place a record in. `timed()` refuses only what it cannot parse; this refuses the absurd. */ +function cappedLastMoment(witnessedLastMs: number, periodEndMs: number | undefined): number { + return periodEndMs === undefined ? witnessedLastMs : Math.min(witnessedLastMs, periodEndMs); +} + +/** What ended an interval: a moment the journal witnessed, or the `journal-end` cap standing in + * for one it never did. The distinction is a caller's to act on - a capped end is a bound, not + * a measured extent. Two values and not three: no caller separates "closed by its own + * `step_end`" from "closed by a later opener", both being moments the journal witnessed. */ +export type IntervalClosure = "boundary" | "journal-end"; + +/** The two facts every closed interval this module builds needs — `path` (`TaskInterval`) or + * `skill` (`FlowInterval`) rides beside these, never inside this shape itself. */ +export interface ClosedInterval { + readonly startMs: number; + readonly endMs: number; +} + +/** Whether a record's own moment falls inside one of `intervals` - never true for a record with + * no moment, or one earlier than every interval, which keeps an interval from being read + * backward onto work that happened before it ever opened. */ +export function momentFallsWithin( + intervals: readonly ClosedInterval[], + momentIso: string | undefined +): boolean { + if (momentIso === undefined) return false; + const momentMs = Date.parse(momentIso); + if (Number.isNaN(momentMs)) return false; + return intervals.some((interval) => momentMs >= interval.startMs && momentMs < interval.endMs); +} + +/** Journal lines in, closed intervals out - the one walk `buildTaskIntervals` and + * `buildFlowIntervals` both run. `isCloser` is asked about the opener as well as the candidate, + * and an interval ends at the first later boundary either predicate accepts, never at the first + * `isCloser`. `toInterval` may answer `null` to close an interval without emitting a row. */ +export function buildClosedIntervals< + TBoundary extends { readonly at: string }, + TOpener extends TBoundary, + TInterval, +>( + boundaryLike: readonly TBoundary[], + periodEndMs: number | undefined, + isOpener: (boundary: TBoundary) => boundary is TOpener, + isCloser: (boundary: TBoundary, opener: TOpener) => boolean, + toInterval: ( + opener: TOpener, + startMs: number, + endMs: number, + closedBy: IntervalClosure + ) => TInterval | null +): readonly TInterval[] { + const everyWitnessedMoment = timed(boundaryLike); + // Not one readable moment in the whole journal: no interval either. Returning here is also + // what makes `lastMs` a moment rather than a maybe-moment for the rest of this function. + if (everyWitnessedMoment.length === 0) return []; + const lastMs = cappedLastMoment( + everyWitnessedMoment[everyWitnessedMoment.length - 1].atMs, + periodEndMs + ); + const intervals: TInterval[] = []; + for (let i = 0; i < everyWitnessedMoment.length; i++) { + const { atMs: startMs, boundary } = everyWitnessedMoment[i]; + if (!isOpener(boundary)) continue; + const closerMs = firstCloserAfter(everyWitnessedMoment, i, boundary, isOpener, isCloser); + const interval = + closerMs === undefined + ? toInterval(boundary, startMs, lastMs, "journal-end") + : toInterval(boundary, startMs, closerMs, "boundary"); + if (interval !== null) intervals.push(interval); + } + return intervals; +} + +/** The moment the interval opened at `from` ends, or `undefined` when nothing closes it. + * Scanned forward rather than pre-filtered because `isCloser` is asked about the pair: a + * `step_end` closes the flow whose skill it names and no other. */ +function firstCloserAfter( + everyWitnessedMoment: readonly TimedBoundary[], + from: number, + opener: TOpener, + isOpener: (boundary: TBoundary) => boundary is TOpener, + isCloser: (boundary: TBoundary, opener: TOpener) => boolean +): number | undefined { + for (let i = from + 1; i < everyWitnessedMoment.length; i++) { + const { atMs, boundary } = everyWitnessedMoment[i]; + if (isOpener(boundary) || isCloser(boundary, opener)) return atMs; + } + return undefined; +} diff --git a/cli/src/contexts/telemetry/domain/person-resolution.ts b/cli/src/contexts/telemetry/domain/person-resolution.ts new file mode 100644 index 000000000..1267486f8 --- /dev/null +++ b/cli/src/contexts/telemetry/domain/person-resolution.ts @@ -0,0 +1,85 @@ +import type { PersonIdentity } from "./ports/person-identity-reader.js"; + +/** How a record's person was resolved. `person_id` is stamped at store time, so its absence + * tracks the identity, not the work; `"this-machine"` holds only while the sink has one writer. + * + * - `"mapped"` — this machine's own `personId`, or one of the identifiers in `alsoMe`. + * - `"unresolved"` — a real identifier nobody's identity covers. + * - `"this-machine"` — the record carried no identifier and this machine declared an identity. + * - `"none"` — no identifier **and** no identity: nobody opted in, which unresolved never says. */ +export type PersonResolution = "mapped" | "unresolved" | "none" | "this-machine"; + +/** What resolving one raw identifier against an identity answers. `identities` always carries + * what produced the row — the canonical `personId` when mapped, the raw identifier when + * unresolved — so a caller shows a row's evidence without re-reading the identity. */ +export interface ResolvedPerson { + readonly resolution: PersonResolution; + readonly personId?: string; + readonly displayName?: string; + readonly identities: readonly string[]; +} + +function matches(identity: PersonIdentity, rawId: string): boolean { + return identity.personId === rawId || identity.alsoMe.includes(rawId); +} + +/** One identity, as the person a row names and the evidence behind it — shared by the two routes + * that end at this machine's own person, so they cannot describe them differently. `alsoMe` + * cannot contain `personId`: `PersonIdentityUseCase.link` refuses it, so no check belongs here. */ +function claimedBy(identity: PersonIdentity): Omit { + return { + personId: identity.personId, + ...(identity.displayName === undefined ? {} : { displayName: identity.displayName }), + identities: [identity.personId, ...identity.alsoMe], + }; +} + +/** Resolves one raw identifier against this machine's own identity. A `null` `identity` resolves + * every identifier as `unresolved`, exactly as one that does not cover it would: a report shows + * both gaps the same way. An empty `rawId` answers `"this-machine"` or `"none"`, and never + * overrules an identifier the record did carry. */ +export function resolvePerson( + identity: PersonIdentity | null, + rawId: string | undefined +): ResolvedPerson { + if (rawId === undefined || rawId === "") { + return identity === null + ? { resolution: "none", identities: [] } + : { ...claimedBy(identity), resolution: "this-machine" }; + } + if (identity === null || !matches(identity, rawId)) { + return { resolution: "unresolved", identities: [rawId] }; + } + return { ...claimedBy(identity), resolution: "mapped" }; +} + +/** `identity` with `value` added to `alsoMe`, deduplicated, and never the person's own + * `personId` — the one place this rule is written, so the adapter and its test double share it. + * A person's own identifier is not an identifier *added onto* them. */ +export function withAlsoMeAdded(identity: PersonIdentity, value: string): PersonIdentity { + return identity.alsoMe.includes(value) || value === identity.personId + ? identity + : { ...identity, alsoMe: [...identity.alsoMe, value] }; +} + +/** `current` re-anchored on `personId`, taken from another machine — keeping what was declared, + * minus `personId` itself. That subtraction is why this exists instead of a literal in the + * adapter: `link X` then `use X` is an ordinary sequence, and without it the person's own + * identifier reads as one added onto themselves. */ +export function withPersonIdAdopted( + current: PersonIdentity | null, + personId: string +): PersonIdentity { + return { + personId, + origin: "adopted", + alsoMe: (current?.alsoMe ?? []).filter((raw) => raw !== personId), + ...(current?.displayName === undefined ? {} : { displayName: current.displayName }), + }; +} + +/** `identity` with `value` withdrawn from `alsoMe`, wherever it is — an identifier not + * listed leaves `alsoMe` unchanged rather than failing. */ +export function withAlsoMeRemoved(identity: PersonIdentity, value: string): PersonIdentity { + return { ...identity, alsoMe: identity.alsoMe.filter((raw) => raw !== value) }; +} diff --git a/cli/src/contexts/telemetry/domain/ports/hook-trust-reader.ts b/cli/src/contexts/telemetry/domain/ports/hook-trust-reader.ts new file mode 100644 index 000000000..fafd6edec --- /dev/null +++ b/cli/src/contexts/telemetry/domain/ports/hook-trust-reader.ts @@ -0,0 +1,10 @@ +import type { TelemetryCodexHookTrust } from "../telemetry-claim.js"; + +/** + * Whether Codex trusts this plugin's hook, keyed in `~/.codex/config.toml` exactly on the + * event name: a hook approved under a renamed event inherits no approval. `readable: false` + * covers every fs failure and licenses no guess at trust in either direction. + */ +export interface HookTrustReader { + read(): Promise; +} diff --git a/cli/src/contexts/telemetry/domain/ports/ignore-entries.ts b/cli/src/contexts/telemetry/domain/ports/ignore-entries.ts new file mode 100644 index 000000000..927b010bf --- /dev/null +++ b/cli/src/contexts/telemetry/domain/ports/ignore-entries.ts @@ -0,0 +1,9 @@ +/** + * Adding lines to whatever this project uses to keep files out of version control. A port + * telemetry owns rather than a call into the context that manages project files: the need is + * "these entries must be ignored", not "run that context's use case". + */ +export interface IgnoreEntries { + /** Adds every entry not already present, answering whether anything was added. */ + execute(projectRoot: string, entries: string[]): Promise; +} diff --git a/cli/src/contexts/telemetry/domain/ports/installed-plugins-reader.ts b/cli/src/contexts/telemetry/domain/ports/installed-plugins-reader.ts new file mode 100644 index 000000000..b4fed7257 --- /dev/null +++ b/cli/src/contexts/telemetry/domain/ports/installed-plugins-reader.ts @@ -0,0 +1,26 @@ +import type { AiToolId } from "../../../../kernel/tool.js"; + +/** One plugin, as measurement needs it: a name to report and the marketplace a host's own + * registry is keyed by. Nothing else is a fact `telemetry check` states. */ +export interface InstalledPluginRef { + readonly name: string; + readonly marketplace: string | undefined; +} + +/** + * What this project recorded as installed, per tool. A port telemetry owns, so measurement + * never reaches into the context that keeps the record. + */ +export interface InstalledPluginsReader { + /** The file the record lives in, so an unreadable one can be reported by name. */ + readonly path: string; + + /** + * Every plugin recorded, keyed by the tool it was installed for. `null` when this project + * has no record at all — which is not an error, only a project nothing was installed into. + * + * Throws when a record exists and cannot be read: `telemetry check` is the command a person + * runs when something is wrong, so a damaged file must say so rather than read as empty. + */ + read(): Promise | null>; +} diff --git a/cli/src/contexts/telemetry/domain/ports/person-identity-reader.ts b/cli/src/contexts/telemetry/domain/ports/person-identity-reader.ts new file mode 100644 index 000000000..8b2db4c86 --- /dev/null +++ b/cli/src/contexts/telemetry/domain/ports/person-identity-reader.ts @@ -0,0 +1,23 @@ +/** What a person chose to attach to this machine's records — never derived from a git + * author, an email or a hostname. One file, one person: nothing here can express a second. + * `origin` says how the identity came to be, `"adopted"` being a declaration the tool cannot + * check, which is why no third value is reserved for a verification nothing can perform. + * `alsoMe` is required and reads back empty rather than absent — only `displayName` uses + * absence for "not set". */ +export interface PersonIdentity { + readonly personId: string; + readonly origin: "minted" | "adopted"; + readonly alsoMe: readonly string[]; + readonly displayName?: string; +} + +/** + * The identifier this machine's own user chose, or `null` when nobody did — a missing file, a + * damaged one and a default installation all answer the same way. Never throws: an unreadable + * identity file costs the identity, not the local-read sweep around it. Reads only the OS + * user's own profile, never `AIDD_USER_CONFIG_DIR` and never a project's `.aidd/config.json`, + * since a repository or a CI job can set those and this choice is not theirs to make. + */ +export interface PersonIdentityReader { + read(): Promise; +} diff --git a/cli/src/contexts/telemetry/domain/ports/person-identity-store.ts b/cli/src/contexts/telemetry/domain/ports/person-identity-store.ts new file mode 100644 index 000000000..6dd2dc8f3 --- /dev/null +++ b/cli/src/contexts/telemetry/domain/ports/person-identity-store.ts @@ -0,0 +1,44 @@ +import type { PersonIdentity, PersonIdentityReader } from "./person-identity-reader.js"; + +/** + * What the `aidd telemetry identity` verbs need beyond `PersonIdentityReader.read()`, which + * never throws so a damaged identity file cannot cost a local-read sweep its figures. A + * person asking about their own state is the opposite question, so `readStrict()` throws + * there instead of folding into "nobody chose". Resolved from the OS user's own profile only, + * like the reader. + */ +export interface PersonIdentityStore extends PersonIdentityReader { + /** Where the identity file lives, for messages that name it. */ + readonly filePath: string; + + /** Like `read()`, but surfaces a damaged or unreadable file as a throw instead of `null`. */ + readStrict(): Promise; + + /** Generates a fresh identifier and writes it unconditionally, `origin: "minted"` — whether + * one is needed at all is the caller's call, never this store's. */ + mint(): Promise; + + /** Writes `personId` as this machine's own identifier, `origin: "adopted"`, keeping the + * `alsoMe` and `displayName` already declared. Whether adopting is the right move at all is + * the caller's call; this store only writes what it is told. */ + adopt(personId: string): Promise; + + /** Adds `identity` to the current identity's `alsoMe`, unconditionally — the caller + * decides whether a person exists to add onto at all; this store assumes one does. + * A no-op, not a duplicate, when `identity` is already listed. */ + addAlsoMe(identity: string): Promise; + + /** Withdraws `identity` from the current identity's `alsoMe`, wherever it is. An + * identifier not listed is nothing to remove, never a failure. */ + removeAlsoMe(identity: string): Promise; + + /** Writes `identity` back with `displayName` attached, replacing any previous one. */ + setDisplayName(identity: PersonIdentity, displayName: string): Promise; + + /** Removes the identity file at `path`, answering whether one was actually there — a no-op, + * not a failure, when there was none. `path` is never resolved here: the caller supplies + * the exact value a person was already shown, so a removal can never reach a file the + * preview never named. Answers from the filesystem rather than from a parse, since a file + * holding an empty `person_id` parses as "nobody chose" while still existing on disk. */ + forget(path: string): Promise; +} diff --git a/cli/src/contexts/telemetry/domain/ports/run-journal-reader.ts b/cli/src/contexts/telemetry/domain/ports/run-journal-reader.ts new file mode 100644 index 000000000..d261d61bf --- /dev/null +++ b/cli/src/contexts/telemetry/domain/ports/run-journal-reader.ts @@ -0,0 +1,139 @@ +/** One `step_start` line from a session's run journal: a step's own start and the skill + * recorded for it. No end is carried — no tool measured so far exposes when a skill's work + * finishes, so an interval's end is the reader's derivation, not a fact on this line. */ +export interface RunJournalStepStart { + readonly type: "step_start"; + readonly at: string; + readonly skill: string; + /** The host's own identifier for the prompt this step opened under, where it hands one to a + * hook. Named `turn_id` on the line, but it is a prompt: several steps opened under one + * share it. Matched against a record's `prompt_id` it attributes a step exactly, the only + * reading that survives two tasks advancing at once. Absent for a host that hands its hooks + * no such identifier. */ + readonly turn_id?: string; +} + +/** One `turn_end` line: closes whatever step was open, even where no further step opens + * before the turn itself ends. */ +export interface RunJournalTurnEnd { + readonly type: "turn_end"; + readonly at: string; +} + +/** One `step_end` line: the moment a skill said its own work was over. No host emits this, + * which is why the skill declares it. It closes only its own skill's open interval: closing + * "whatever is open" would close the wrong one the moment a skill invokes another, and an end + * naming a skill this session never started closes nothing rather than truncating. */ +export interface RunJournalStepEnd { + readonly type: "step_end"; + readonly at: string; + readonly skill: string; +} + +export type RunJournalBoundary = RunJournalStepStart | RunJournalTurnEnd | RunJournalStepEnd; + +/** The `session_start` line: the one line naming what a session was. `tool` holds the journal + * hook's own host identifier ("claude-code", "codex", "copilot", "cursor"), which is not an + * `AiToolId` — `journalHostToAiToolId` is the only place the two are related, and it reads a + * declaration rather than a table. */ +export interface RunJournalSessionStart { + readonly type: "session_start"; + readonly at: string; + /** The schema the hook stamped this journal with, absent for one written before the field + * existed. Read and carried, never derived: a reader inferring a schema from the shapes it + * happens to recognise is the silent misreading this field exists to prevent. */ + readonly schema_version?: number; + readonly run_id: string; + readonly tool: string; + readonly vendor_id: string; + readonly project_id?: string; + /** The git remote this session's repository resolved to, absent for one with none. Carried + * beside `project_id` rather than replacing it. */ + readonly project_remote?: string; + /** Git's own name for the linked worktree this session ran in, so two worktrees of one + * repository are distinguishable. Absent — never `""` — for a plain checkout. */ + readonly worktree_id?: string; + /** The repository those worktrees share, named from `--git-common-dir`. Recorded beside + * `worktree_id` rather than left to `project_id`, which falls back to the worktree's own + * directory name when a clone has no remote. Absent whenever `worktree_id` is. */ + readonly worktree_repo_id?: string; + /** The plugin's own version at the moment this line was written - never the framework's, + * and never the CLI's, which stamps only the record it stores. Absent reads as an unknown + * version, never as a default or a guess. */ + readonly plugin_version?: string; +} + +/** A `file_written` line: a repository-relative, "/"-separated path a session wrote inside a + * task folder, and when. Carries no task identity — the hook refuses to store a derivation as + * a fact, so deriving the task is the reader's job. */ +export interface RunJournalFileWritten { + readonly type: "file_written"; + readonly at: string; + readonly path: string; +} + +/** A `task_declared` line: a tool call named a file under a task folder, so this session is on + * that task from here on — told rather than inferred. Carries no task identity for the reason + * `file_written` does not. Deliberately outside `RunJournalBoundary`: an interval walk merges + * it in as a moment the journal witnessed, never as a boundary that ends something, and the + * type keeps the two apart so a later reader cannot confuse them. */ +export interface RunJournalTaskDeclared { + readonly type: "task_declared"; + readonly at: string; + readonly path: string; +} + +/** What the journal side promises a reader, for one session's run file, in file order — lines + * read, nothing derived. Deriving intervals from `boundaries`, or a task from `filesWritten`, + * is the reader's job. `session` is optional because a file whose first line is torn is still + * worth its boundaries. */ +export interface RunJournal { + readonly boundaries: readonly RunJournalBoundary[]; + readonly session?: RunJournalSessionStart; + readonly filesWritten: readonly RunJournalFileWritten[]; + readonly taskDeclarations: readonly RunJournalTaskDeclared[]; +} + +/** + * The boundaries recorded for one session, or `null` when nothing can be said about it — no + * run file, an unreadable runs directory, telemetry never enabled. Never throws: a missing, + * unreadable or truncated journal costs attribution, not the read itself. Read-only on + * purpose, so a caller that only reads cannot reach the verb that removes; one adapter + * implements this and `RunJournalStore` below both. + */ +export interface RunJournalReader { + read(sessionId: string): Promise; + /** Every session the journal holds, for a caller with no identifier to ask about. Filtering + * to a period is the caller's, from each journal's own `session.at`: the run file's name + * carries no date. Never throws; an unreadable runs directory answers an empty list. */ + list(): Promise; + /** Every run file's own name, directly from the directory — never opened, never parsed. + * Distinct from `list()`, which can silently drop a file it cannot parse: a caller counting + * what removing this journal would touch needs a name a damaged file still has. Never + * throws, the same failure direction as `list()`. */ + listRunFiles(): Promise; + /** The schema stated by every journal this reader refused to read, one entry per file. + * `list()` drops such a journal outright, and a caller shown only that emptiness would + * report a torn file about one whose header it parsed perfectly well. Empty is the ordinary + * answer. Never throws, like everything else here. */ + listForeignSchemas(): Promise; +} + +/** + * What removing a journal needs beyond a plain read — extends `RunJournalReader` rather than + * sitting beside it, so the one adapter that resolves the runs directory implements one port. + */ +export interface RunJournalStore extends RunJournalReader { + /** Where this project's run journal lives — the same directory `read`/`list` and + * `listRunFiles` resolve, exposed so a caller naming the location never re-derives the + * `AIDD_RUNS_DIR`-aware resolution itself. `deleteRunFile` below is handed back this value + * rather than deriving its own. */ + readonly runsDir: string; + /** Removes one run file, by the name `listRunFiles()` named it with, from `dir` — mirrors + * `TelemetrySink.deleteDayFile`. `dir` is never resolved here: the caller passes the exact + * directory a person was already shown, so a removal can never reach one the preview never + * named. `fileName` must name exactly one entry directly inside `dir`; anything else, + * including a relative walk out of it, is refused rather than deleted. A no-op, not a + * failure, when the name is already gone. */ + deleteRunFile(dir: string, fileName: string): Promise; +} diff --git a/cli/src/contexts/telemetry/domain/ports/session-cost-reader.ts b/cli/src/contexts/telemetry/domain/ports/session-cost-reader.ts new file mode 100644 index 000000000..0c464bed7 --- /dev/null +++ b/cli/src/contexts/telemetry/domain/ports/session-cost-reader.ts @@ -0,0 +1,45 @@ +import type { TelemetrySinkRecord } from "../telemetry-sink-record.js"; + +/** What a per-tool local reader returns: the stored shape minus the four fields the caller + * stamps uniformly. A reader able to set `provenance`, `tool` or `step_attribution` could + * claim to be an export it is not, name another tool, or state a derivation only the caller + * — which alone reads the run journal — can perform. A reader may still set `step` and + * `step_plugin`: that presence *is* the tool-stated fact the caller resolves + * `step_attribution: "tool-stated"` from. */ +export type LocalCostCandidateRecord = Omit< + TelemetrySinkRecord, + "sink_schema_version" | "provenance" | "tool" | "step_attribution" +>; + +/** What a reader answers with. `sessionFound` separates the two silences a bare empty list + * conflates: a tool that held this session and recorded nothing billable, and one that held + * no trace of it at all. Printed as one zero, a session never found reads as free. */ +export interface LocalCostReadResult { + readonly records: readonly LocalCostCandidateRecord[]; + readonly sessionFound: boolean; +} + +/** + * Given the session identity a run-journal entry already carries, the records that tool's own + * file holds for it — nothing joined in. A tool that wrote no file answers `sessionFound: + * false` with no records rather than throwing. Every returned record's `vendor_id` equals the + * `sessionId` passed in, so a caller never resolves identity twice. `turn_id` is how a re-read + * is matched against what is already stored, and a reader whose tool carries no stable + * per-record identifier leaves it unset: an unstable synthesised key is worse than an absent + * one, since records unmatched by one are appended again rather than deduplicated. + */ +export interface SessionCostReader { + read(sessionId: string): Promise; +} + +/** + * What a per-line transcript format hands the streaming adapter: `push` for every line in file + * order, `build` once the file is exhausted. Stateful because not every tool's format maps one + * line to one record — Codex's spans a `turn_context` line and the `token_count` lines that + * follow it. Declared in the port so a domain format module can implement it without importing + * the infrastructure adapter that drives it. + */ +export interface TranscriptLineAccumulator { + push(line: string): void; + build(): readonly LocalCostCandidateRecord[]; +} diff --git a/cli/src/contexts/telemetry/domain/ports/task-backlog-reader.ts b/cli/src/contexts/telemetry/domain/ports/task-backlog-reader.ts new file mode 100644 index 000000000..676e5f348 --- /dev/null +++ b/cli/src/contexts/telemetry/domain/ports/task-backlog-reader.ts @@ -0,0 +1,15 @@ +import type { TaskBacklogDeclaration } from "../task-backlog-link.js"; + +/** + * One task folder's declaration, never a throw. A missing file answers `{ kind: "none" }` and + * an unparseable one `{ kind: "unreadable" }`, so a report tells "this task delivers nothing + * on the backlog" from "this task's declaration is damaged" without either costing the period + * its figures. **Never writes**, as an invariant an implementation must hold rather than + * today's behaviour: a read must never introduce the drift it was asked to measure. + */ +export interface TaskBacklogReader { + /** `taskFolderPath` is project-relative, in the shape `taskFolderPathFromIdentity` + * produces (`aidd_docs/tasks///`). Resolving it against a project root, and + * every other filesystem concern, is the adapter's job. */ + read(taskFolderPath: string): Promise; +} diff --git a/cli/src/contexts/telemetry/domain/ports/telemetry-evidence-reader.ts b/cli/src/contexts/telemetry/domain/ports/telemetry-evidence-reader.ts new file mode 100644 index 000000000..e96c2060f --- /dev/null +++ b/cli/src/contexts/telemetry/domain/ports/telemetry-evidence-reader.ts @@ -0,0 +1,54 @@ +import type { TelemetryExportLeftover } from "../telemetry-export-leftover.js"; +import type { TelemetryRecorderDeclarationSetup } from "../telemetry-setup.js"; + +/** The marker the journal hook writes when a payload arrived and matched no host this build + * declares — read by name, never through the run journal reader, whose parser would leave it + * indistinguishable from a torn run file. */ +export interface TelemetryUnrecognisedPayload { + readonly at: string; +} + +/** The project's own switch file, read for what `isTelemetryEnabled`'s plain boolean cannot + * carry: the file's own `enabled` value, and whether it could be read at all. Never folds in + * the person's own refusal, which is a non-file fact the caller reads from `env` directly. */ +export interface TelemetrySwitchSetupRead { + readonly path: string; + /** The file's own `enabled` value. Meaningless when `readable` is `false` — always `false` + * there, the same "damaged reads as off" direction the gate itself takes. */ + readonly enabled: boolean; + /** `true` for a file that is absent (nothing here is a person's choice yet) or that + * parses as a valid switch. `false` only for a file that exists but could not be read or + * parsed — a damaged file, not a choice. */ + readonly readable: boolean; +} + +/** + * The evidence `aidd telemetry check` and `aidd telemetry off` need beyond the run journal, + * each tool's own local reader and Codex's hook trust, each of which has its own port: + * whether the project switch is on, the unrecognised-payload marker, and whether a settings + * file still carries a stale export configuration. A read that fails answers with the evidence + * that says so (`false`/`null`/`[]`) and never throws, so one damaged file cannot cost every + * other claim its verdict. + */ +export interface TelemetryEvidenceReader { + /** `.aidd/config.json`'s `telemetry.enabled`, read the way the hook reads it — strict + * `=== true`, so a half-written config counts as off. The person's own refusal + * (`AIDD_TELEMETRY=0`) overrides it unconditionally. */ + isTelemetryEnabled(projectRoot: string, env: NodeJS.ProcessEnv): Promise; + + readUnrecognisedPayload(projectRoot: string): Promise; + + /** Every settings file this build knows how to check that still carries a key an export + * endpoint used to write — detection only. Empty is not proof one was never configured: + * only the locations this build knows to look at are checked. */ + findLeftoverExportConfig(projectRoot: string): Promise; + + /** The project's switch file itself — see `TelemetrySwitchSetupRead` for why this is + * separate from `isTelemetryEnabled`. */ + readSwitchSetup(projectRoot: string): Promise; + + /** Whether the recorder is declared anywhere this build knows to check — the AIDD manifest + * and a tool's own settings file. Never throws: a manifest or settings file that cannot be + * parsed reads as "not declared there". */ + readRecorderDeclaration(projectRoot: string): Promise; +} diff --git a/cli/src/contexts/telemetry/domain/ports/telemetry-sink.ts b/cli/src/contexts/telemetry/domain/ports/telemetry-sink.ts new file mode 100644 index 000000000..4fe721910 --- /dev/null +++ b/cli/src/contexts/telemetry/domain/ports/telemetry-sink.ts @@ -0,0 +1,59 @@ +import type { TelemetrySinkRecord } from "../telemetry-sink-record.js"; + +export interface TelemetrySinkAppendResult { + readonly filePath: string; + readonly dayFileIsNew: boolean; +} + +/** Every value a filterable field has carried, anywhere this sweep looked - not only in the + * period returned. Telling a filter naming something that never existed apart from one that + * simply had no work in this period only stays cheap because these are gathered from the same + * bytes `records` already comes from, never a second read. */ +export interface TelemetrySinkKnownValues { + readonly projects: ReadonlySet; + readonly steps: ReadonlySet; + readonly models: ReadonlySet; +} + +/** `records` are those whose `event_timestamp` falls inside the period, never those the day + * file's own name covers: a session read locally days later lands in the file for the day it + * was stored. `undated` carry no moment at all and are handed back rather than folded in, and + * `skippedLines` travels with them because a total quietly omitting lines is indistinguishable + * from a complete one. */ +export interface TelemetrySinkPeriodRead { + readonly records: readonly TelemetrySinkRecord[]; + readonly undated: readonly TelemetrySinkRecord[]; + readonly skippedLines: number; + readonly knownValues: TelemetrySinkKnownValues; +} + +/** Separate from `FileWriter`/`FileReader`: a day file is append-only for its whole life, + * never rewritten in place. `readRecordsForVendor` is the one read: a local re-read needs + * to know what is already stored for a session before it appends, or every read would + * double what came before. */ +export interface TelemetrySink { + readonly rootDir: string; + /** How `rootDir` was decided. `"user-config-dir"` is the one a caller has to react to: it + * means this person set `AIDD_USER_CONFIG_DIR`, which also relocates `auth.json`, so sharing + * this directory shares a GitHub token. Named on the port because only a command knows where + * a person is looking when it warns. */ + readonly locatedBy: "telemetry-dir" | "user-config-dir" | "default"; + ensureWritable(): Promise; + appendRecord(record: TelemetrySinkRecord, at: Date): Promise; + listDayFiles(): Promise; + /** Removes one day file, by the name `listDayFiles()` named it with, from `dir`. `dir` is + * never resolved here: the caller supplies the exact directory it already named. `fileName` + * must name exactly one entry directly inside `dir`; anything else, including a relative + * walk out of it, is refused rather than deleted. A no-op, not a failure, when the name is + * already gone. */ + deleteDayFile(dir: string, fileName: string): Promise; + /** Every stored record whose `vendor_id` matches, across every day file. A line that + * cannot be parsed is skipped rather than failing the whole scan — a torn final line + * from a concurrent write must not block reading an unrelated session. */ + readRecordsForVendor(vendorId: string): Promise; + /** Every stored record whose own moment falls in an inclusive range of UTC days, whatever + * session it belongs to. Every day file is read: a record's moment and the file it landed in + * are different days whenever a session is read after the fact. Skips a line it cannot read, + * and counts what it skipped. */ + readRecordsInPeriod(fromDay: Date, toDay: Date): Promise; +} diff --git a/cli/src/contexts/telemetry/domain/ports/version-control.ts b/cli/src/contexts/telemetry/domain/ports/version-control.ts new file mode 100644 index 000000000..f1bbf46bf --- /dev/null +++ b/cli/src/contexts/telemetry/domain/ports/version-control.ts @@ -0,0 +1,85 @@ +import type { HookManager, TelemetryCommitTrailerSetup } from "../telemetry-setup.js"; + +/** What installing the delegate answered: whether a line was appended to a hook this CLI + * owns, and — when lefthook or husky owns it instead — which one, so `TelemetryOnUseCase` + * can stop promising a trailer that a hook the CLI never touched cannot deliver. */ +export interface CommitMessageDelegateInstall { + /** Whether a line was newly appended to `prepare-commit-msg`. Always `false` when + * `hookManager` is set: a hook lefthook or husky regenerates would wipe it silently. */ + readonly lineAdded: boolean; + /** Which manager owns `prepare-commit-msg` here, when one does — see `detectHookManager`. + * `undefined` is the ordinary case: this CLI still owns the hook. */ + readonly hookManager?: HookManager; + /** Whether that manager's own config already calls the delegate. Present only when + * `hookManager` is. */ + readonly managerCallsDelegate?: boolean; +} + +/** What undoing the delegate answered — the removal counterpart of + * `CommitMessageDelegateInstall`, carrying the same two manager facts so `off` can report a + * manager's own job is left behind (harmless, inert) rather than staying silent about it. */ +export interface CommitMessageDelegateRemoval { + /** Whether the line, the delegate file, or both were there to take back. `false` is the + * ordinary no-op: nothing was ever installed. */ + readonly removed: boolean; + /** Which manager owns `prepare-commit-msg` here, when one does — see `detectHookManager`. + * Present independently of `removed`: a manager's own hand-added job can outlive the + * delegate it called, and a caller needs to know that regardless of whether this run found + * anything to delete. */ + readonly hookManager?: HookManager; + /** Whether that manager's own config still calls the delegate this just removed. Present + * only when `hookManager` is. */ + readonly managerCallsDelegate?: boolean; +} + +export interface VersionControl { + /** Installs `delegateFile` beside the repository's hooks and, only when no manager owns + * `prepare-commit-msg`, adds one line to it calling the delegate. A hook lefthook or husky + * owns is regenerated from that manager's config, which would wipe an appended line + * silently, so `hookManager` tells the caller instead — the delegate script is still + * written, where the printed job resolves it at commit time. `lineAdded: false` also covers + * the no-op cases: the line is already there, or there is no repository. The hooks directory + * comes from git itself, never from an assumed `.git/hooks`: a `core.hooksPath` pointing + * elsewhere is exactly the configuration under which a hook is never run and never says so. */ + installCommitMessageDelegate( + projectRoot: string, + delegateFile: string, + script: string + ): Promise; + + /** Undoes it: drops the line from `prepare-commit-msg` and deletes the delegate, from + * whichever directory the install actually wrote to — the same manager-aware decision, so a + * removal can never look elsewhere and report nothing removed. Leaves a hook file holding + * other lines exactly as it found it, minus the one line. */ + removeCommitMessageDelegate( + projectRoot: string, + delegateFile: string + ): Promise; + /** Every tracked path matching `pathspec`, relative to `repoRoot` — empty, never a throw, + * when there is no repository or nothing matches: a project outside git still has to turn + * telemetry on quietly, so this can never be the reason that fails. */ + listTrackedFiles(repoRoot: string, pathspec: string): Promise; + + /** Whether `cwd` sits inside a git repository at all — read the way the hook reads it + * (`git rev-parse --show-toplevel`), never a throw. The journal writes nowhere without a + * repository, which is what tells that apart from a hook that fired and left no trace. */ + isRepository(cwd: string): Promise; + + /** Whether git's *history* — not the index `listTrackedFiles` reads — holds at least one + * commit touching `pathspec`. The two disagree for a file `git add`ed and never committed. + * Never a throw: no commits, no repository, or no git at all read as "no history", the same + * failure direction as `listTrackedFiles`. */ + hasHistoryFor(repoRoot: string, pathspec: string): Promise; + + /** Everything a check says about the commit trailer, gathered in one place because every + * part of it is a git question. `limit` is a count of commits rather than a date, so the + * answer costs the same on a repository of ten commits and one of a million. Never a throw: + * no repository, no commits, or no git leaves the fields that need one absent rather than + * failing the diagnostic that exists to describe them. */ + readCommitTrailerSetup( + projectRoot: string, + delegateFile: string, + trailerToken: string, + limit: number + ): Promise; +} diff --git a/cli/src/contexts/telemetry/domain/report-period.ts b/cli/src/contexts/telemetry/domain/report-period.ts new file mode 100644 index 000000000..b7cf9c05e --- /dev/null +++ b/cli/src/contexts/telemetry/domain/report-period.ts @@ -0,0 +1,61 @@ +import { InvalidReportDayError, InvalidReportSpanError } from "../../../kernel/errors.js"; + +/** The two UTC days a report covers, inclusive, as they resolved. Stored beside a figure: + * "the last seven days" names two different measurements on two different days. */ +export interface ResolvedReportPeriod { + readonly fromDay: string; + readonly toDay: string; +} + +export interface ReportPeriodRequest { + readonly from?: string; + readonly to?: string; + readonly days?: string; +} + +const DAY_PATTERN = /^\d{4}-\d{2}-\d{2}$/u; +const DAY_KEY_LENGTH = "YYYY-MM-DD".length; +const MILLISECONDS_PER_DAY = 24 * 60 * 60 * 1000; + +/** A week: short enough that a first run answers instead of scanning a year of day files. */ +export const DEFAULT_REPORT_DAYS = 7; +const MAX_REPORT_DAYS = 3650; + +function parseDay(flag: string, value: string): string { + // Shape first, then the calendar: a well-shaped string can still name a day no month has, + // and `Date.parse` alone accepts a great deal that is not a day at all. + if (!DAY_PATTERN.test(value)) throw new InvalidReportDayError(flag, value); + const parsed = new Date(`${value}T00:00:00Z`); + if (Number.isNaN(parsed.getTime())) throw new InvalidReportDayError(flag, value); + if (dayKey(parsed) !== value) throw new InvalidReportDayError(flag, value); + return value; +} + +function parseSpan(value: string): number { + const days = Number(value); + if (!Number.isInteger(days) || days < 1 || days > MAX_REPORT_DAYS) { + throw new InvalidReportSpanError(value, MAX_REPORT_DAYS); + } + return days; +} + +function dayKey(at: Date): string { + return at.toISOString().slice(0, DAY_KEY_LENGTH); +} + +function daysBefore(day: string, count: number): string { + return dayKey(new Date(Date.parse(`${day}T00:00:00Z`) - count * MILLISECONDS_PER_DAY)); +} + +/** Never reads a clock: `today` is the caller's, so one request resolves the same way twice. + * The two days come back in order however they were given. */ +export function resolveReportPeriod( + request: ReportPeriodRequest, + today: Date +): ResolvedReportPeriod { + const span = request.days === undefined ? DEFAULT_REPORT_DAYS : parseSpan(request.days); + const toDay = request.to === undefined ? dayKey(today) : parseDay("--to", request.to); + const fromDay = + request.from === undefined ? daysBefore(toDay, span - 1) : parseDay("--from", request.from); + return fromDay <= toDay ? { fromDay, toDay } : { fromDay: toDay, toDay: fromDay }; +} diff --git a/cli/src/contexts/telemetry/domain/report/axes/day-rows.ts b/cli/src/contexts/telemetry/domain/report/axes/day-rows.ts new file mode 100644 index 000000000..f21c9cee7 --- /dev/null +++ b/cli/src/contexts/telemetry/domain/report/axes/day-rows.ts @@ -0,0 +1,37 @@ +/** The day axis: every UTC day the period spans, whether or not a record landed on it, so + * a gap in the series is a printed zero rather than a missing row. */ + +import type { CostReportDayRow, TotalsAccumulator } from "../../cost-report.js"; +import { + type TelemetrySinkRecord, + telemetrySinkRecordDayKey, +} from "../../telemetry-sink-record.js"; + +const MS_PER_DAY = 24 * 60 * 60 * 1000; + +/** Every UTC day from `fromDay` to `toDay`, inclusive. A day with nothing is still a row: a + * gap in a series reads as continuity, so the row has to exist to be a zero. */ +export function dayRange(fromDay: string, toDay: string): readonly string[] { + const days: string[] = []; + const end = Date.parse(`${toDay}T00:00:00Z`); + for (let at = Date.parse(`${fromDay}T00:00:00Z`); at <= end; at += MS_PER_DAY) { + days.push(new Date(at).toISOString().slice(0, 10)); + } + return days; +} + +/** Only a day the period itself spans, every one of them already seeded, so a record dated + * outside the period joins nothing rather than adding a day the report never covered. */ +export function addToDayGroup( + days: Map, + record: TelemetrySinkRecord +): void { + const day = telemetrySinkRecordDayKey(record); + if (day !== undefined && days.has(day)) days.get(day)?.add(record); +} + +/** Every day in the period, in order — never sorted by size, unlike every other breakdown + * here. A series read out of order is not a series. */ +export function dayRows(days: ReadonlyMap): readonly CostReportDayRow[] { + return [...days].map(([day, accumulator]) => ({ day, totals: accumulator.build() })); +} diff --git a/cli/src/contexts/telemetry/domain/report/axes/flow-rows.ts b/cli/src/contexts/telemetry/domain/report/axes/flow-rows.ts new file mode 100644 index 000000000..e5f2f44b6 --- /dev/null +++ b/cli/src/contexts/telemetry/domain/report/axes/flow-rows.ts @@ -0,0 +1,100 @@ +/** The flow axis: keyed on the closed `FlowInterval` object a record's own moment falls + * inside, by reference, so two orchestrated runs of the same skill stay two rows. */ + +import type { + CostReportFlowRow, + CostReportSessionJournal, + TotalsAccumulator, +} from "../../cost-report.js"; +import { type FlowInterval, ORCHESTRATING_SKILLS } from "../../flow-attribution.js"; +import { momentFallsWithin } from "../../journal-intervals.js"; +import type { TelemetrySinkRecord } from "../../telemetry-sink-record.js"; +import { bySize, isoSecondsFromMs } from "../row-ordering.js"; + +// A record falling in no flow interval at all is its own group, keyed on a symbol no real +// interval or skill name can ever equal. +const OUTSIDE_EVERY_FLOW = Symbol("record falls outside every flow interval"); + +// Keyed on the closed `FlowInterval` object itself, by reference, never on `skill` alone: two +// orchestrated runs of the same skill in one session are two distinct interval objects, and a +// `Map` keyed on object identity keeps them two rows without a synthesized composite key. +export type FlowRowKey = FlowInterval | string | typeof OUTSIDE_EVERY_FLOW; + +/** Every session's own closed flow intervals, keyed by vendor id - the same shape + * `allTaskIntervalsByVendorId` gives task intervals, one layer wider. */ +export function allFlowIntervalsByVendorId( + journals: readonly CostReportSessionJournal[] +): ReadonlyMap { + const byVendorId = new Map(); + for (const journal of journals) { + if (journal.flowIntervals.length > 0) byVendorId.set(journal.vendorId, journal.flowIntervals); + } + return byVendorId; +} + +/** Which flow interval a record's own moment falls inside, among all of its session's + * orchestrated runs - `OUTSIDE_EVERY_FLOW` for a record whose moment falls in none, with no + * taxonomy of why: a flow is read from the same sequence either way. Intervals within one + * session are closed and never overlap, so at most one ever matches. */ +export function flowKeyOf( + record: TelemetrySinkRecord, + intervalsByVendorId: ReadonlyMap +): FlowRowKey { + const intervals = intervalsByVendorId.get(record.vendor_id) ?? []; + const interval = intervals.find((candidate) => + momentFallsWithin([candidate], record.event_timestamp) + ); + return interval ?? flowTheToolNamed(record) ?? OUTSIDE_EVERY_FLOW; +} + +/** The orchestrating skill a record's own tool named, for a record no interval covers - the + * skill name itself as the key, which no `FlowInterval` object and no symbol can equal. + * + * Only `tool-stated`: a `journal-interval` step is inferred from the very intervals just + * checked, and a `prompt-matched` one names a step, not an orchestration. This capture exists + * because a session resumed after its context was compacted invokes nothing again, so no + * `step_start` fires and its journal opens no flow while the transcript goes on stating the + * step on every record it produces. */ +function flowTheToolNamed(record: TelemetrySinkRecord): string | undefined { + if (record.step_attribution !== "tool-stated") return undefined; + return record.step !== undefined && ORCHESTRATING_SKILLS.has(record.step) + ? record.step + : undefined; +} + +/** Every orchestrated run the period's journals name, largest first, then the one row for work + * that fell in no flow interval at all. No reason taxonomy, unlike `by_task`'s remainder: there + * is one fact to state about falling outside every flow, never several. That remainder is + * pinned last rather than sorted among the named rows, the same tail convention `taskRows` and + * `backlogRows` keep, since a breakdown ordering itself differently from its neighbours reads + * as a different kind of answer. */ +export function flowRows( + flows: ReadonlyMap +): readonly CostReportFlowRow[] { + const named: CostReportFlowRow[] = []; + let outsideEveryFlow: CostReportFlowRow | undefined; + for (const [key, accumulator] of flows) { + if (key === OUTSIDE_EVERY_FLOW) { + outsideEveryFlow = { attribution: "unattributed", totals: accumulator.build() }; + continue; + } + // A name is not a run: the tool-stated row is a bucket drawn from however many runs of + // that skill the tool named, so it carries no `startedAt`. + if (typeof key === "string") { + named.push({ flow: key, attribution: "tool-stated", totals: accumulator.build() }); + continue; + } + named.push({ + flow: key.skill, + attribution: "journal-interval", + startedAt: isoSecondsFromMs(key.startMs), + totals: accumulator.build(), + }); + } + const sorted = bySize( + named, + (row) => row.totals, + (row) => `${row.flow ?? ""}@${row.attribution}@${row.startedAt ?? ""}` + ); + return outsideEveryFlow === undefined ? sorted : [...sorted, outsideEveryFlow]; +} diff --git a/cli/src/contexts/telemetry/domain/report/axes/person-rows.ts b/cli/src/contexts/telemetry/domain/report/axes/person-rows.ts new file mode 100644 index 000000000..b28ad45ea --- /dev/null +++ b/cli/src/contexts/telemetry/domain/report/axes/person-rows.ts @@ -0,0 +1,83 @@ +/** The person axis: keyed on whichever field makes two records the same row - a mapped + * canonical id, an unresolved raw identifier, or the shared row for records naming none. */ + +import type { CostReportPersonRow, TotalsAccumulator } from "../../cost-report.js"; +import type { PersonResolution, ResolvedPerson } from "../../person-resolution.js"; +import type { TelemetrySinkRecord } from "../../telemetry-sink-record.js"; +import { bySize } from "../row-ordering.js"; + +// A record with no identifier is its own row, keyed on a symbol - never folded into an +// unresolved row, which `PersonResolution`'s three-way shape requires stay distinct. +const NO_KNOWN_PERSON = Symbol("no known person"); +export type PersonRowKey = string | typeof NO_KNOWN_PERSON; + +// An empty string reads the same as absent: a tool writing `person_id: ""` has stated +// nothing, not named an identity nobody could ever claim. +export function personRawIdOf(record: TelemetrySinkRecord): string | undefined { + return typeof record.person_id === "string" && record.person_id !== "" + ? record.person_id + : undefined; +} + +/** One resolved person's group, keyed on whichever field makes two records the same row: a + * mapped record's canonical `personId`, so two raw identities one person declared merge; an + * unresolved record's own raw identifier, so two unplaced identities never merge into each + * other; or `NO_KNOWN_PERSON` for a record with none. */ +export interface PersonGroup { + readonly resolved: ResolvedPerson; + readonly totals: TotalsAccumulator; +} + +export function personGroupKey(resolved: ResolvedPerson): PersonRowKey { + if (resolved.resolution === "mapped" && resolved.personId !== undefined) { + return resolved.personId; + } + if (resolved.resolution === "unresolved") { + const [rawId] = resolved.identities; + if (rawId !== undefined) return rawId; + } + return NO_KNOWN_PERSON; +} + +function personRowOf(group: PersonGroup): CostReportPersonRow { + const { resolved } = group; + return { + resolution: resolved.resolution, + ...(resolved.personId === undefined ? {} : { person: resolved.personId }), + ...(resolved.displayName === undefined ? {} : { displayName: resolved.displayName }), + identities: resolved.identities, + totals: group.totals.build(), + }; +} + +/** The order every `by_person` breakdown is read in, strongest claim first: a person the + * record itself named, then the one this machine's identity claims, then every unplaced + * identity, then the one no-identifier row. A `Record` over the whole union rather than a + * filter per group: a filter silently drops whatever a future resolution does not name, and + * this shape makes that a compile error instead. */ +const PERSON_ROW_ORDER: Record = { + mapped: 0, + "this-machine": 1, + unresolved: 2, + none: 3, +}; + +/** Grouped in `PERSON_ROW_ORDER`, largest first within each group - `bySize` alone sorts + * purely on weight, so a large unresolved row would outrank a small mapped one. */ +export function personRows( + people: ReadonlyMap +): readonly CostReportPersonRow[] { + const rows = [...people.values()].map(personRowOf); + const keyOf = (row: CostReportPersonRow) => row.person ?? row.identities[0] ?? ""; + return Object.keys(PERSON_ROW_ORDER) + .sort( + (a, b) => PERSON_ROW_ORDER[a as PersonResolution] - PERSON_ROW_ORDER[b as PersonResolution] + ) + .flatMap((resolution) => + bySize( + rows.filter((row) => row.resolution === resolution), + (row) => row.totals, + keyOf + ) + ); +} diff --git a/cli/src/contexts/telemetry/domain/report/axes/record-stated-rows.ts b/cli/src/contexts/telemetry/domain/report/axes/record-stated-rows.ts new file mode 100644 index 000000000..2c58f96e0 --- /dev/null +++ b/cli/src/contexts/telemetry/domain/report/axes/record-stated-rows.ts @@ -0,0 +1,162 @@ +/** The four axes keyed on a value the record itself states directly - project, model, + * agent and prompt - each with its own sentinel for what named none. */ + +import type { AiToolId } from "../../../../../kernel/tool.js"; +import type { + CostReportAgentRow, + CostReportModelRow, + CostReportProjectRow, + CostReportPromptRow, + CostReportToolDeclaration, + TotalsAccumulator, +} from "../../cost-report.js"; +import type { TelemetrySinkRecord } from "../../telemetry-sink-record.js"; +import { bySize, isoSecondsFromMs } from "../row-ordering.js"; + +// A record with no project is its own group, never folded into one that was placed. A symbol +// can never equal a real `project_id`, so it is a safe Map key for "unknown". +const NO_KNOWN_PROJECT = Symbol("no known project"); +export type ProjectKey = string | typeof NO_KNOWN_PROJECT; + +// An empty string is not a name - it is what a tool writes when it has none, and its own row +// would be one a person cannot act on. The `typeof` guard is separate: a record read off disk +// carries whatever its line held, not what this field's type declares. +export function projectKeyOf(record: TelemetrySinkRecord): ProjectKey { + return typeof record.project_id === "string" && record.project_id !== "" + ? record.project_id + : NO_KNOWN_PROJECT; +} + +// The same idea one dimension over: the Codex and OpenCode readers both permit a request +// record with no model, so without this row `byModels` would stop reconciling to its own +// total with nothing naming the gap. Narrower than `projectKeyOf` on purpose - nothing +// measured so far writes an empty-string `model`, so this stays an `undefined` check. +const NO_KNOWN_MODEL = Symbol("no known model"); +export type ModelKey = string | typeof NO_KNOWN_MODEL; + +// The main thread's own row, and the row for a tool that could never have named one. Symbols +// because an agent can be named anything, so no string is safe to reserve. +const MAIN_THREAD = Symbol("the main thread"); +const AGENT_NOT_STATED = Symbol("a tool whose route never names an agent"); +export type AgentKey = string | typeof MAIN_THREAD | typeof AGENT_NOT_STATED; + +/** Which of the three rows a record joins. `agent_name` present is the tool's own statement; + * absent means one of two different things, and only the tool's declaration tells them apart. + * The declaration is read rather than the record because the record cannot carry the absence: + * a tool that never names an agent writes exactly what a main-thread line writes. */ +export function agentKeyOf( + record: TelemetrySinkRecord, + namesAgents: (tool: AiToolId) => boolean +): AgentKey { + if (record.agent_name !== undefined) return record.agent_name; + return namesAgents(record.tool) ? MAIN_THREAD : AGENT_NOT_STATED; +} + +/** Whether a tool's own declared route names agents, answered from `declaredTools` alone. A + * tool with no declared local read supplies nothing, so it names no agent either. */ +export function agentNamingTools( + declaredTools: readonly CostReportToolDeclaration[] +): (tool: AiToolId) => boolean { + const naming = new Set( + declaredTools + .filter((declaration) => declaration.capability.localRead?.agentName === true) + .map((declaration) => declaration.tool) + ); + return (tool) => naming.has(tool); +} + +// The row for what named no prompt. A symbol because a prompt id is opaque and host-assigned, +// so no string is safe to reserve against it. +const NO_PROMPT = Symbol("no prompt named"); +export type PromptKey = string | typeof NO_PROMPT; + +export function promptKeyOf(record: TelemetrySinkRecord): PromptKey { + return record.prompt_id === undefined ? NO_PROMPT : record.prompt_id; +} + +export function modelKeyOf(record: TelemetrySinkRecord): ModelKey { + return record.model === undefined ? NO_KNOWN_MODEL : record.model; +} + +/** A prompt's running totals plus the earliest moment seen in it, tracked here rather than + * read back off the records: the pass over them happens once, and a sink is append-ordered by + * when a record was read, never by when a turn began. */ +export interface PromptGroup { + readonly totals: TotalsAccumulator; + earliestMs?: number; +} + +/** Every project a record named, largest first, plus one row for what named none. */ +export function projectRows( + projects: ReadonlyMap +): readonly CostReportProjectRow[] { + const rows: CostReportProjectRow[] = [...projects].map(([key, accumulator]) => ({ + ...(key === NO_KNOWN_PROJECT ? {} : { project: key }), + totals: accumulator.build(), + })); + return bySize( + rows, + (row) => row.totals, + (row) => row.project ?? "" + ); +} + +/** Every agent that ran, largest first, plus one row for the main thread. */ +export function agentRows( + agents: ReadonlyMap +): readonly CostReportAgentRow[] { + const rows: CostReportAgentRow[] = [...agents].map(([key, accumulator]) => { + if (key === MAIN_THREAD) return { attribution: "main-thread", totals: accumulator.build() }; + if (key === AGENT_NOT_STATED) return { attribution: "not-stated", totals: accumulator.build() }; + return { agent: key, attribution: "tool-stated", totals: accumulator.build() }; + }); + return bySize( + rows, + (row) => row.totals, + (row) => `${row.agent ?? ""}@${row.attribution}` + ); +} + +/** Every prompt that caused work, largest first, plus one row for what named none. Not + * chronological: unlike `by_day` this is a ranking, with no continuity to break. The row for + * what named none is pinned last rather than ranked - a remainder drawn from many turns is + * not a turn, so its size is not comparable to theirs. */ +export function promptRows( + prompts: ReadonlyMap +): readonly CostReportPromptRow[] { + const named: CostReportPromptRow[] = []; + let namedNone: CostReportPromptRow | undefined; + for (const [key, group] of prompts) { + const totals = group.totals.build(); + if (key === NO_PROMPT) { + namedNone = { totals }; + continue; + } + named.push({ + prompt: key, + ...(group.earliestMs === undefined ? {} : { startedAt: isoSecondsFromMs(group.earliestMs) }), + totals, + }); + } + const sorted = bySize( + named, + (row) => row.totals, + (row) => row.prompt ?? "" + ); + return namedNone === undefined ? sorted : [...sorted, namedNone]; +} + +/** Every model a record named, largest first, plus one row for what named none. */ +export function modelRows( + models: ReadonlyMap +): readonly CostReportModelRow[] { + const rows: CostReportModelRow[] = [...models].map(([key, accumulator]) => ({ + ...(key === NO_KNOWN_MODEL ? {} : { model: key }), + totals: accumulator.build(), + })); + return bySize( + rows, + (row) => row.totals, + (row) => row.model ?? "" + ); +} diff --git a/cli/src/contexts/telemetry/domain/report/axes/step-rows.ts b/cli/src/contexts/telemetry/domain/report/axes/step-rows.ts new file mode 100644 index 000000000..6c7f06517 --- /dev/null +++ b/cli/src/contexts/telemetry/domain/report/axes/step-rows.ts @@ -0,0 +1,53 @@ +/** The step axis: keyed by the step and the strength of its own attribution, never by the + * step alone, so a tool-stated reach and a journal-inferred one stay two rows. */ + +import type { + CostReportAttributionRow, + CostReportStepRow, + TotalsAccumulator, +} from "../../cost-report.js"; +import { STEP_ATTRIBUTION_SOURCES, type StepAttributionSource } from "../../step-attribution.js"; +import type { TelemetrySinkRecord } from "../../telemetry-sink-record.js"; +import { bySize } from "../row-ordering.js"; + +// A single space cannot occur in a `step_attribution` value, so it separates the two parts of +// the key unambiguously even though a skill name could contain almost anything. The group +// keeps both parts beside its counters rather than parsing them back out of the key, which +// would mean asserting a type back out of a string. +const STEP_ROW_SEPARATOR = " "; + +export interface StepGroup { + readonly attribution: StepAttributionSource; + readonly step?: string; + readonly totals: TotalsAccumulator; +} + +export function stepRowKey(record: TelemetrySinkRecord): string { + return `${record.step_attribution}${STEP_ROW_SEPARATOR}${record.step ?? ""}`; +} + +/** All four, always, in the declared order. A strength that accounted for nothing is the one + * place in this report where a zero is the measurement rather than an absence, and dropping + * the row would leave a consumer unable to tell "no records were attributed this way" from + * "this report does not carry that field". */ +export function attributionRows( + attributions: ReadonlyMap +): readonly CostReportAttributionRow[] { + return STEP_ATTRIBUTION_SOURCES.map((attribution) => ({ + attribution, + totals: attributions.get(attribution)?.build() ?? { requests: 0 }, + })); +} + +export function stepRows(steps: ReadonlyMap): readonly CostReportStepRow[] { + const rows: CostReportStepRow[] = [...steps.values()].map((group) => ({ + attribution: group.attribution, + ...(group.step === undefined ? {} : { step: group.step }), + totals: group.totals.build(), + })); + return bySize( + rows, + (row) => row.totals, + (row) => `${row.step ?? ""}/${row.attribution}` + ); +} diff --git a/cli/src/contexts/telemetry/domain/report/axes/task-rows.ts b/cli/src/contexts/telemetry/domain/report/axes/task-rows.ts new file mode 100644 index 000000000..461da284b --- /dev/null +++ b/cli/src/contexts/telemetry/domain/report/axes/task-rows.ts @@ -0,0 +1,255 @@ +/** The task and backlog axes: keyed on the declared interval a record's own moment falls + * in, falling back to a session's written files, never merging the two attribution routes. */ + +import type { + CostReportBacklogRow, + CostReportSessionJournal, + CostReportTaskAttributionRow, + CostReportTaskRow, + TotalsAccumulator, +} from "../../cost-report.js"; +import { momentFallsWithin } from "../../journal-intervals.js"; +import { + TASK_ATTRIBUTION_SOURCES, + TASK_UNATTRIBUTED_REASONS, + type TaskAttributionSource, + type TaskInterval, + type TaskUnattributedReason, + taskUnattributedReason, +} from "../../task-attribution.js"; +import type { TaskBacklogDeclaration } from "../../task-backlog-link.js"; +import { + type TaskIdentity, + taskIdentitiesFromWrittenPaths, + taskIdentityFromWrittenPath, +} from "../../task-identity.js"; +import type { TelemetrySinkRecord } from "../../telemetry-sink-record.js"; +import { bySize } from "../row-ordering.js"; + +/** How a record came to belong to a task, or why it belongs to none - the value every task + * axis keys on, computed once per record. A named membership carries `attribution` beside the + * identity because one task holds records from both routes, and a single row carrying the + * weaker attribution would state something false about the records that were declared. */ +export interface TaskGroup { + readonly task?: TaskIdentity; + readonly attribution?: TaskAttributionSource; + readonly reason?: TaskUnattributedReason; + readonly totals: TotalsAccumulator; +} + +interface TaskMembershipRow { + readonly task: TaskIdentity; + readonly attribution: TaskAttributionSource; +} + +export type TaskRow = TaskMembershipRow | TaskUnattributedReason; + +const TASK_ROW_SEPARATOR = " "; + +/** Mirrors `stepRowKey`, which keys its own `(name x attribution)` pairs the same way. A + * `TaskIdentity` is always `${month}/${name}` and an attribution is never one, so a named key + * can never collide with a reason key. */ +export function taskRowKeyOf(row: TaskRow): string { + return typeof row === "string" ? row : `${row.attribution}${TASK_ROW_SEPARATOR}${row.task}`; +} + +/** The one task a session's written files name, when they name exactly one. Two written + * folders infer nothing: two candidates and no reason to choose between them. That refusal is + * the bound that makes this route sound, since attributing a whole session otherwise places + * one session under two task rows at once. */ +function soleWrittenTaskOf(journal: CostReportSessionJournal | undefined): TaskIdentity | null { + if (journal === undefined) return null; + const identities = new Set(taskIdentitiesFromWrittenPaths(journal.writtenPaths)); + if (identities.size !== 1) return null; + const [only] = identities; + return only ?? null; +} + +/** Whether this journal witnessed `momentIso` at all - never an unbounded yes for a journal + * that carries no readable moment. */ +function witnessed( + journal: CostReportSessionJournal | undefined, + momentIso: string | undefined +): boolean { + const span = journal?.witnessed; + if (span === undefined || momentIso === undefined) return false; + const momentMs = Date.parse(momentIso); + if (Number.isNaN(momentMs)) return false; + return momentMs >= span.fromMs && momentMs <= span.toMs; +} + +// One level above a task: a task whose folder declares no backlog item, and one whose +// declaration could not be read, are two groups, never folded into each other or into a named +// item. Symbols because a backlog item is a free-form string on either support, so no string +// sentinel could be ruled out colliding with a real one. +const NO_BACKLOG_DECLARED = Symbol("task declares no backlog item"); +const UNREADABLE_BACKLOG_DECLARATION = Symbol("task's backlog declaration could not be read"); +export type BacklogRowKey = + | string + | typeof NO_BACKLOG_DECLARED + | typeof UNREADABLE_BACKLOG_DECLARATION + | TaskUnattributedReason; + +/** Every session's own closed intervals, keyed by vendor id - never a second notion of when a + * task was running. Unlike `declaredIntervalsForTask` this keeps every task a session declared, + * since `byTasks` groups by whichever task a record's moment falls in. Every journal gets an + * entry, including one that declared nothing: the empty list and the absent key are two + * different facts, and `taskRowOf` reads them as two. */ +export function allTaskIntervalsByVendorId( + journals: readonly CostReportSessionJournal[] +): ReadonlyMap { + const byVendorId = new Map(); + for (const journal of journals) byVendorId.set(journal.vendorId, journal.taskIntervals); + return byVendorId; +} + +/** Which task a record's own moment falls inside, among *all* of its session's declared + * intervals - `taskUnattributedReason` for a record whose moment falls in none. Intervals + * within one session are closed and never overlap, so at most one ever matches. + * + * `interval.path` failing to resolve is unreachable through this codebase's own wiring but not + * through the type: `taskIntervals` is a plain input field, so a caller can still hand this an + * interval whose path resolves to nothing, which is why the fallback stays. Reading such a + * moment as no interval covering it is deliberate - a path this layer cannot turn into an + * identity names no task a person could act on either. */ +export function taskRowOf( + record: TelemetrySinkRecord, + intervalsByVendorId: ReadonlyMap, + journalsByVendorId: ReadonlyMap +): TaskRow { + const intervals = intervalsByVendorId.get(record.vendor_id); + // No entry at all means no journal was read for this session - never that it declared + // nothing. `allTaskIntervalsByVendorId` gives every journal it read an entry, so the two + // cases are distinguishable here and nowhere else. + if (intervals === undefined) return "no-journal"; + const interval = intervals.find((candidate) => + momentFallsWithin([candidate], record.event_timestamp) + ); + const declared = interval && taskIdentityFromWrittenPath(interval.path); + if (declared) return { task: declared, attribution: "declared" }; + // Only now the weaker route, and only inside what this journal witnessed: a declaration + // that covers the record always wins, so this never overrides a stated fact with an + // inferred one. + const journal = journalsByVendorId.get(record.vendor_id); + const inferred = soleWrittenTaskOf(journal); + if (inferred !== null && witnessed(journal, record.event_timestamp)) { + return { task: inferred, attribution: "inferred" }; + } + // The journal's own earliest witnessed moment, so a record older than everything this + // session saw is named for that rather than for declaring late. Absent for a journal with no + // readable moment, which then makes no coverage claim at all. + return taskUnattributedReason(intervals, record.event_timestamp, journal?.witnessed?.fromMs); +} + +/** Which `byBacklog` row a record's own task-row key belongs in - built from `taskRowOf`'s + * output, never a second notion of which task a record fell inside. A reason passes straight + * through unchanged; a named task looks up its folder's declaration once, in the map the + * caller already resolved. A named task missing from `declarations` is unreachable through the + * one production caller but reads as `{ kind: "none" }` rather than throwing or dropping the + * record, so no gap in wiring this module cannot see can lose a record's figures. */ +export function backlogKeyOf( + taskRow: TaskRow, + declarations: ReadonlyMap | undefined +): BacklogRowKey { + if (typeof taskRow === "string") return taskRow; + const declaration = declarations?.get(taskRow.task) ?? { kind: "none" as const }; + if (declaration.kind === "none") return NO_BACKLOG_DECLARED; + if (declaration.kind === "unreadable") return UNREADABLE_BACKLOG_DECLARATION; + return declaration.link.backlog; +} + +/** Both sources, always, the same as `attributionRows`: a source that accounted for nothing is + * still a fact about this task, not an absent field. */ +export function taskAttributionRows( + taskAttributions: ReadonlyMap +): readonly CostReportTaskAttributionRow[] { + return TASK_ATTRIBUTION_SOURCES.map((attribution) => ({ + attribution, + totals: taskAttributions.get(attribution)?.build() ?? { requests: 0 }, + })); +} + +// Typed over `string | symbol`, wider than `BacklogRowKey`, since a `backlog` map's key can +// also be a symbol - safe because every reason is a plain string, which no symbol equals. +function isTaskUnattributedReason(key: string | symbol): key is TaskUnattributedReason { + return typeof key === "string" && (TASK_UNATTRIBUTED_REASONS as readonly string[]).includes(key); +} + +/** Every task a record's own moment fell inside, largest first, then one row per reason + * actually present for what fell in none - `TASK_UNATTRIBUTED_REASONS`' fixed order, always + * after every named task regardless of size. Never fewer rows than the reasons present: two + * different gaps collapsed into one row is the fault this breakdown exists to avoid. */ +export function taskRows(tasks: ReadonlyMap): readonly CostReportTaskRow[] { + const named: CostReportTaskRow[] = []; + const byReason = new Map(); + for (const group of tasks.values()) { + const totals = group.totals.build(); + if (group.reason !== undefined) { + byReason.set(group.reason, { reason: group.reason, totals }); + continue; + } + if (group.task === undefined || group.attribution === undefined) continue; + named.push({ task: group.task, attribution: group.attribution, totals }); + } + // Tie-broken on the pair, not on the task alone: one task can hold both a declared row and + // an inferred one, and a tie-break blind to the attribution would order them arbitrarily. + const sorted = bySize( + named, + (row) => row.totals, + (row) => `${row.task ?? ""}/${row.attribution ?? ""}` + ); + const reasonRows = TASK_UNATTRIBUTED_REASONS.map((reason) => byReason.get(reason)).filter( + (row): row is CostReportTaskRow => row !== undefined + ); + return [...sorted, ...reasonRows]; +} + +interface BacklogGroups { + readonly named: readonly CostReportBacklogRow[]; + readonly byReason: ReadonlyMap; + readonly none: CostReportBacklogRow | undefined; + readonly unreadable: CostReportBacklogRow | undefined; +} + +// One pass classifying every backlog key into the four shapes a row can be - named, +// unattributed-by-reason, declared none, or unreadable - nothing sorted yet. +function classifyBacklogGroups( + backlog: ReadonlyMap +): BacklogGroups { + const named: CostReportBacklogRow[] = []; + const byReason = new Map(); + let none: CostReportBacklogRow | undefined; + let unreadable: CostReportBacklogRow | undefined; + for (const [key, accumulator] of backlog) { + if (isTaskUnattributedReason(key)) { + byReason.set(key, { reason: key, totals: accumulator.build() }); + } else if (key === NO_BACKLOG_DECLARED) { + none = { declaration: "none", totals: accumulator.build() }; + } else if (key === UNREADABLE_BACKLOG_DECLARATION) { + unreadable = { declaration: "unreadable", totals: accumulator.build() }; + } else { + named.push({ backlog: key, totals: accumulator.build() }); + } + } + return { named, byReason, none, unreadable }; +} + +/** Every backlog item a task declared, largest first, then the two rows for a known task that + * named none or could not be read, then one row per reason a record fell in no task at all - + * the same tail convention `taskRows` uses. Two tasks declaring the same item merge by + * construction, on the identical `backlog` key, never in a second merge step that could + * disagree with how every other axis reconciles. */ +export function backlogRows( + backlog: ReadonlyMap +): readonly CostReportBacklogRow[] { + const { named, byReason, none, unreadable } = classifyBacklogGroups(backlog); + const sorted = bySize( + named, + (row) => row.totals, + (row) => row.backlog ?? "" + ); + const reasonRows = TASK_UNATTRIBUTED_REASONS.map((reason) => byReason.get(reason)).filter( + (row): row is CostReportBacklogRow => row !== undefined + ); + return [...sorted, ...(none ? [none] : []), ...(unreadable ? [unreadable] : []), ...reasonRows]; +} diff --git a/cli/src/contexts/telemetry/domain/report/axes/tool-rows.ts b/cli/src/contexts/telemetry/domain/report/axes/tool-rows.ts new file mode 100644 index 000000000..e16181741 --- /dev/null +++ b/cli/src/contexts/telemetry/domain/report/axes/tool-rows.ts @@ -0,0 +1,45 @@ +/** The tool axis: one row per declared tool, whether or not it contributed, so an + * unreadable one shows its own reason instead of a false zero. */ + +import type { AiToolId } from "../../../../../kernel/tool.js"; +import type { + CostReportFilters, + CostReportToolDeclaration, + CostReportToolRow, + TotalsAccumulator, +} from "../../cost-report.js"; + +/** `by_tool` is a breakdown of every *declared* tool, not only the ones a record touched, so + * an unreadable one shows its reason instead of a false zero. A `--tool` filter narrows that + * same list, or every tool it excluded would still print "nothing in this period" - + * indistinguishable from one genuinely measured idle. */ +export function declaredToolsInScope( + declaredTools: readonly CostReportToolDeclaration[], + filters: CostReportFilters | undefined +): readonly CostReportToolDeclaration[] { + const wanted = filters?.tool; + return wanted === undefined + ? declaredTools + : declaredTools.filter((tool) => tool.tool === wanted); +} + +/** Every declared tool gets a row, in the declared order, whether or not it contributed: a + * tool absent from the output is one a reader assumes did nothing, which for an unreadable + * tool is the false zero this layer exists to prevent. */ +export function buildToolRows( + declaredTools: readonly CostReportToolDeclaration[], + measured: ReadonlyMap, + sessionTotals: ReadonlyMap +): readonly CostReportToolRow[] { + return declaredTools.map((declaration) => { + const session = sessionTotals.get(declaration.tool); + return { + tool: declaration.tool, + coverage: declaration.coverage, + ...(declaration.reason === undefined ? {} : { reason: declaration.reason }), + capability: declaration.capability, + totals: measured.get(declaration.tool)?.build() ?? { requests: 0 }, + ...(session === undefined ? {} : { sessionTotals: session.build() }), + }; + }); +} diff --git a/cli/src/contexts/telemetry/domain/report/record-counters.ts b/cli/src/contexts/telemetry/domain/report/record-counters.ts new file mode 100644 index 000000000..882e1060c --- /dev/null +++ b/cli/src/contexts/telemetry/domain/report/record-counters.ts @@ -0,0 +1,21 @@ +/** The four token counters a record can carry, and the record field each one is read from. */ + +import type { TelemetrySinkRecord } from "../telemetry-sink-record.js"; + +// The list first, the type derived from it: reading the keys back off the table would have to +// assert their type, and an assertion stops holding the day the two disagree. +export const COUNTER_FIELDS = [ + "inputTokens", + "outputTokens", + "cacheReadTokens", + "cacheCreationTokens", +] as const; + +export type CounterField = (typeof COUNTER_FIELDS)[number]; + +export const COUNTER_SOURCE: Readonly> = { + inputTokens: "input_tokens", + outputTokens: "output_tokens", + cacheReadTokens: "cache_read_tokens", + cacheCreationTokens: "cache_creation_tokens", +}; diff --git a/cli/src/contexts/telemetry/domain/report/record-reconciliation.ts b/cli/src/contexts/telemetry/domain/report/record-reconciliation.ts new file mode 100644 index 000000000..cba689d79 --- /dev/null +++ b/cli/src/contexts/telemetry/domain/report/record-reconciliation.ts @@ -0,0 +1,169 @@ +/** Reconciles records read more than once - a still-open turn re-read locally, and one + * billed call seen by two live routes - into the one record a report may safely sum. */ + +import type { TelemetrySinkRecord } from "../telemetry-sink-record.js"; +import { COUNTER_FIELDS, COUNTER_SOURCE } from "./record-counters.js"; + +/** A group key only for a `kind: "request"`, `provenance: "local-read"` record carrying a + * `turn_id` — the shape a local re-read of a still-running turn produces more than one of. A + * `kind: "session"` record can carry a `turn_id` too, but it is a one-shot cumulative figure + * with no provisional reading to collapse; on the export route the same field is a prompt id + * several billed calls share, so the identical key there would merge distinct calls. */ +function localReadTurnKey(record: TelemetrySinkRecord): string | null { + if (record.kind !== "request" || record.provenance !== "local-read") return null; + return record.turn_id === undefined + ? null + : `${record.tool} ${record.vendor_id} ${record.turn_id}`; +} + +/** How much of a group a record accounts for, used only to pick the largest of several + * readings of one still-growing turn — never stored, never itself summed into a total. */ +function counterWeight(record: TelemetrySinkRecord): number { + return COUNTER_FIELDS.reduce((sum, field) => { + const value = record[COUNTER_SOURCE[field]]; + return sum + (typeof value === "number" ? value : 0); + }, 0); +} + +/** How many of the four counters a record states at all, whether zero or not — the tie-break + * beyond `counterWeight` alone, since an observed zero and a counter never mentioned both add + * zero to the weight. Preferring the record that states more never risks preferring a shrink: + * the write-time guard already refused a candidate dropping a counter the stored one had. */ +function definedCounterCount(record: TelemetrySinkRecord): number { + return COUNTER_FIELDS.reduce( + (count, field) => count + (typeof record[COUNTER_SOURCE[field]] === "number" ? 1 : 0), + 0 + ); +} + +/** + * One turn read more than once while it was still open, collapsed to the record carrying the + * most complete counters. Never done at write time: the sink is append-only, so a partial + * earlier reading is reconciled by whatever reads it back. Every record here came from the + * same route reading the same file at different moments, so the survivor is whichever carries + * the largest counters — never a blend of two, which would state a combination the tool's own + * file never reported together, and never a shrink over the larger reading. */ +function mergeSupersededTurnGroup(group: readonly TelemetrySinkRecord[]): TelemetrySinkRecord { + if (group.length === 1) return group[0]; + const heaviest = Math.max(...group.map(counterWeight)); + const largest = group.filter((record) => counterWeight(record) === heaviest); + const mostDefined = Math.max(...largest.map(definedCounterCount)); + return pickDeterministically( + largest.filter((record) => definedCounterCount(record) === mostDefined) + ); +} + +/** Every other kind and route passes through untouched — see `localReadTurnKey`. */ +export function collapseSupersededTurns( + records: readonly TelemetrySinkRecord[] +): readonly TelemetrySinkRecord[] { + const groups = new Map(); + const rest: TelemetrySinkRecord[] = []; + for (const record of records) { + const key = localReadTurnKey(record); + if (key === null) { + rest.push(record); + continue; + } + const bucket = groups.get(key); + if (bucket) bucket.push(record); + else groups.set(key, [record]); + } + return [...rest, ...[...groups.values()].map(mergeSupersededTurnGroup)]; +} + +/** A group key only where `billed_request_id` is present — the one stable, cross-route + * identifier for a single billed call, unlike `turn_id`, which a main-agent request and its + * subagent share. A record with none joins nothing and is left exactly as it arrived. */ +function billedRequestKey(record: TelemetrySinkRecord): string | null { + return record.billed_request_id === undefined + ? null + : `${record.tool}\0${record.vendor_id}\0${record.billed_request_id}`; +} + +/** The same group, from any starting order, always answers the same record. A group's own + * order is never guaranteed — redelivery can duplicate an export record, and a re-read joins + * a session's stored records in whatever order the day files listed them — so picking + * `group[0]` would make the survivor depend on that accident; sorting on each candidate's own + * serialized content does not. */ +function pickDeterministically(candidates: readonly TelemetrySinkRecord[]): TelemetrySinkRecord { + return [...candidates].sort((a, b) => JSON.stringify(a).localeCompare(JSON.stringify(b)))[0]; +} + +/** Borrows `step_attribution`/`step`/`step_plugin` from a sibling that resolved one, when + * `base`'s own is `"unattributed"` — the export route never states a step at all, so + * leaving it as the survivor by default would throw away the one thing the local-read + * route in the same group did know, preferring a tool-stated step over a journal-interval + * one where both exist. */ +function withStepBackfill( + base: TelemetrySinkRecord, + group: readonly TelemetrySinkRecord[] +): TelemetrySinkRecord { + if (base.step_attribution !== "unattributed") return base; + const stepDonors = group.filter( + (record) => record !== base && record.step_attribution !== "unattributed" + ); + if (stepDonors.length === 0) return base; + const toolStated = stepDonors.filter((record) => record.step_attribution === "tool-stated"); + const donor = pickDeterministically(toolStated.length > 0 ? toolStated : stepDonors); + return { + ...base, + step_attribution: donor.step_attribution, + step: donor.step, + step_plugin: donor.step_plugin, + }; +} + +/** `person_id` and `person_display_name`, backfilled onto `base` as a pair, never one field + * from each: only the local-read side of a billed call carries a person, so keeping the export + * sibling instead would silently report a mapped person's work as `"none"`. Independent of + * `withStepBackfill`, never chained after it, which returns early once a step is resolved + * while person still has to be checked. */ +function withPersonBackfill( + base: TelemetrySinkRecord, + group: readonly TelemetrySinkRecord[] +): TelemetrySinkRecord { + if (base.person_id !== undefined) return base; + const donors = group.filter((record) => record.person_id !== undefined); + if (donors.length === 0) return base; + const donor = pickDeterministically(donors); + return { + ...base, + person_id: donor.person_id, + ...(donor.person_display_name === undefined + ? {} + : { person_display_name: donor.person_display_name }), + }; +} + +/** One billed call, seen once by each of two live routes, collapsed to the one record a report + * may safely sum. Never done at write time: the sink is append-only, so a stored record is + * only ever reconciled by whatever reads it back. The survivor keeps whichever record carries + * `cost_usd`, which on every tool measured so far is also the one whose token counters are + * complete, so the group's money is never summed from more than one record. */ +function mergeBilledRequestGroup(group: readonly TelemetrySinkRecord[]): TelemetrySinkRecord { + if (group.length === 1) return group[0]; + const costBearing = group.filter((record) => record.cost_usd !== undefined); + const base = pickDeterministically(costBearing.length > 0 ? costBearing : group); + return withPersonBackfill(withStepBackfill(base, group), group); +} + +/** `kind: "session"` records are never part of a billed-call group — no metric datapoint + * measured so far carries `billed_request_id` — so only `kind: "request"` records join one. */ +export function collapseBilledRequests( + records: readonly TelemetrySinkRecord[] +): readonly TelemetrySinkRecord[] { + const groups = new Map(); + const rest: TelemetrySinkRecord[] = []; + for (const record of records) { + const key = record.kind === "request" ? billedRequestKey(record) : null; + if (key === null) { + rest.push(record); + continue; + } + const bucket = groups.get(key); + if (bucket) bucket.push(record); + else groups.set(key, [record]); + } + return [...rest, ...[...groups.values()].map(mergeBilledRequestGroup)]; +} diff --git a/cli/src/contexts/telemetry/domain/report/report-selection.ts b/cli/src/contexts/telemetry/domain/report/report-selection.ts new file mode 100644 index 000000000..99b88383d --- /dev/null +++ b/cli/src/contexts/telemetry/domain/report/report-selection.ts @@ -0,0 +1,192 @@ +/** Narrows a period's records to what a `--task` and a set of generic filters both admit, + * one composing stage at a time, and says which filter emptied the selection when one did. */ + +import type { + CostReportEmptySelection, + CostReportFilterName, + CostReportFilters, + CostReportInput, + CostReportSessionJournal, +} from "../cost-report.js"; +import { momentFallsWithin } from "../journal-intervals.js"; +import type { TaskAttributionSource, TaskInterval } from "../task-attribution.js"; +import { + type TaskIdentity, + taskIdentitiesFromWrittenPaths, + taskIdentityFromWrittenPath, +} from "../task-identity.js"; +import type { TelemetrySinkRecord } from "../telemetry-sink-record.js"; + +/** The vendor ids whose sessions wrote into `task` at some point - deliberately + * whole-session, unlike the per-record precision a declared interval gives. */ +function inferredVendorIdsForTask( + journals: readonly CostReportSessionJournal[], + task: TaskIdentity +): ReadonlySet { + const vendorIds = new Set(); + for (const journal of journals) { + if (taskIdentitiesFromWrittenPaths(journal.writtenPaths).includes(task)) { + vendorIds.add(journal.vendorId); + } + } + return vendorIds; +} + +/** Every session's own declared intervals that name `task`, keyed by vendor id so a record's + * session is a lookup rather than a walk of every journal again. A session that never declared + * this task carries no entry, so it reads as belonging to none, never to the last one seen. */ +function declaredIntervalsForTask( + journals: readonly CostReportSessionJournal[], + task: TaskIdentity +): ReadonlyMap { + const byVendorId = new Map(); + for (const journal of journals) { + const intervals = journal.taskIntervals.filter( + (interval) => taskIdentityFromWrittenPath(interval.path) === task + ); + if (intervals.length > 0) byVendorId.set(journal.vendorId, intervals); + } + return byVendorId; +} + +/** Both routes to `task`, kept apart rather than merged into one vendor-id set: a declared + * interval decides per record, a written file for a session's records as a whole. Merging them + * would let a zero-width or long-closed declaration - real, but covering no record - drag in + * records a written file never touched either. */ +export interface TaskMembership { + readonly declaredIntervalsByVendorId: ReadonlyMap; + readonly inferredVendorIds: ReadonlySet; +} + +export function taskMembership( + journals: readonly CostReportSessionJournal[], + task: TaskIdentity +): TaskMembership { + return { + declaredIntervalsByVendorId: declaredIntervalsForTask(journals, task), + inferredVendorIds: inferredVendorIdsForTask(journals, task), + }; +} + +/** How, if at all, one record belongs to the task `membership` was built for - `undefined` for + * neither route, which excludes it from a `--task` report entirely. Only a record a + * declaration does not cover falls back to whether its whole session wrote into the folder. */ +export function taskAttributionOf( + record: TelemetrySinkRecord, + membership: TaskMembership +): TaskAttributionSource | undefined { + const intervals = membership.declaredIntervalsByVendorId.get(record.vendor_id); + if (intervals && momentFallsWithin(intervals, record.event_timestamp)) return "declared"; + return membership.inferredVendorIds.has(record.vendor_id) ? "inferred" : undefined; +} + +const GENERIC_FILTER_FIELDS: Readonly> = + { + project: "project_id", + step: "step", + model: "model", + tool: "tool", + }; +const GENERIC_FILTER_ORDER: readonly (keyof CostReportFilters)[] = [ + "project", + "step", + "model", + "tool", +]; + +interface SelectionStage { + readonly name: CostReportFilterName | undefined; + readonly value: string | undefined; + readonly records: readonly TelemetrySinkRecord[]; +} + +/** One stage per active filter, each narrowing what the stage before it kept. Filters + * compose by `and` and nothing else: every stage only ever removes records the one before + * it was already going to keep, never adds one back. */ +export function selectionStages( + records: readonly TelemetrySinkRecord[], + input: CostReportInput, + membership: TaskMembership | null +): readonly SelectionStage[] { + const stages: SelectionStage[] = [{ name: undefined, value: undefined, records }]; + if (membership !== null) { + const kept = records.filter((r) => taskAttributionOf(r, membership) !== undefined); + stages.push({ name: "task", value: input.task, records: kept }); + } + for (const name of GENERIC_FILTER_ORDER) { + const value = input.filters?.[name]; + if (value === undefined) continue; + const field = GENERIC_FILTER_FIELDS[name]; + const previous = stages[stages.length - 1]?.records ?? []; + stages.push({ name, value, records: previous.filter((r) => r[field] === value) }); + } + return stages; +} + +/** Whether a filter's own value is known at all - anywhere this call can see, not only in this + * selection. `tool` reads the declared list, a closed set; the rest read `knownValues`, + * gathered across every day file the caller looked at, not only the period's own records. */ +function isKnownFilterValue( + name: CostReportFilterName, + value: string, + input: CostReportInput, + membership: TaskMembership | null +): boolean { + if (name === "task") { + return ( + (membership?.declaredIntervalsByVendorId.size ?? 0) > 0 || + (membership?.inferredVendorIds.size ?? 0) > 0 + ); + } + if (name === "tool") return input.declaredTools.some((tool) => tool.tool === value); + const known = input.knownValues ?? { projects: new Set(), steps: new Set(), models: new Set() }; + const set = { project: known.projects, step: known.steps, model: known.models }[name]; + return set?.has(value) ?? false; +} + +/** True when the culprit filter's own value matched something before any generic filter ran, + * so the emptiness comes from an intersection rather than from this value alone. `task` has no + * "alone" reading - it is the only route to a task, not one composed equality check. */ +function isCombinationCulprit( + stages: readonly SelectionStage[], + membership: TaskMembership | null, + culprit: SelectionStage +): boolean { + if (culprit.name === undefined || culprit.name === "task") return false; + const field = GENERIC_FILTER_FIELDS[culprit.name]; + const baseline = stages[membership === null ? 0 : 1]?.records ?? []; + return baseline.some((r) => r[field] === culprit.value); +} + +/** The first filter that narrowed a non-empty selection down to nothing - never the + * period itself, which is an honest zero rather than a filter's doing. Stages only ever + * shrink, so the first empty one is the whole answer to "which filter emptied it". */ +export function emptySelectionOf( + stages: readonly SelectionStage[], + input: CostReportInput, + membership: TaskMembership | null +): CostReportEmptySelection | undefined { + if ((stages[0]?.records.length ?? 0) === 0) return undefined; + const culprit = stages.find((stage) => stage.records.length === 0); + if (!culprit || culprit.name === undefined || culprit.value === undefined) return undefined; + const known = isKnownFilterValue(culprit.name, culprit.value, input, membership); + const combination = isCombinationCulprit(stages, membership, culprit); + return { + filter: culprit.name, + value: culprit.value, + known, + ...(combination ? { combination: true } : {}), + }; +} + +/** Which of the four generic filters were actually given, in the same fixed order - never + * `task`, which keeps its own top-level field unchanged. `undefined` when none were, so + * an unfiltered period carries no empty object. */ +export function activeFilters( + filters: CostReportFilters | undefined +): CostReportFilters | undefined { + if (!filters) return undefined; + const given = GENERIC_FILTER_ORDER.filter((name) => filters[name] !== undefined); + if (given.length === 0) return undefined; + return Object.fromEntries(given.map((name) => [name, filters[name]])); +} diff --git a/cli/src/contexts/telemetry/domain/report/row-ordering.ts b/cli/src/contexts/telemetry/domain/report/row-ordering.ts new file mode 100644 index 000000000..9dbc0bcc2 --- /dev/null +++ b/cli/src/contexts/telemetry/domain/report/row-ordering.ts @@ -0,0 +1,42 @@ +/** How a breakdown orders its rows: largest amount first, tokens as the fallback weight + * when a row is costless, and a moment rendered to second precision. */ + +import type { CostTotals } from "../cost-report.js"; + +/** Every token a row counted, across all four disjoint counters - the weight `bySize` falls + * back to for a costless row. Never `inputTokens + outputTokens` alone: tools run mostly on + * cache, so a weight blind to the cache counters would invert the order, and this is also the + * same sum the report prints beside a costless row. */ +function tokensOf(totals: CostTotals): number { + return ( + (totals.inputTokens ?? 0) + + (totals.outputTokens ?? 0) + + (totals.cacheReadTokens ?? 0) + + (totals.cacheCreationTokens ?? 0) + ); +} + +/** Largest first, so the biggest thing is the first thing read. Weighted by amount where + * one exists and by tokens where none does, since a tool with no amount would otherwise + * sort as if it had cost nothing. Ties fall back to the row's own key, so the same records + * always produce the same report. */ +export function bySize( + rows: readonly T[], + totalsOf: (row: T) => CostTotals, + keyOf: (row: T) => string +): T[] { + const weight = (row: T): number => { + const totals = totalsOf(row); + return totals.costMicroUsd ?? tokensOf(totals); + }; + return [...rows].sort( + (left, right) => weight(right) - weight(left) || keyOf(left).localeCompare(keyOf(right)) + ); +} + +// Second precision, no milliseconds - the same spelling the journal hook writes to a line's +// `at` field, so a row's `startedAt` string-matches the line it opened on. `startMs` is parsed +// from one such value, so this only strips the ".000" `toISOString` would append. +export function isoSecondsFromMs(ms: number): string { + return new Date(ms).toISOString().replace(/\.\d{3}Z$/u, "Z"); +} diff --git a/cli/src/contexts/telemetry/domain/session-anchor.ts b/cli/src/contexts/telemetry/domain/session-anchor.ts new file mode 100644 index 000000000..3647905d7 --- /dev/null +++ b/cli/src/contexts/telemetry/domain/session-anchor.ts @@ -0,0 +1,7 @@ +/** Which environment variable names the session running this process. Codex first: a Codex + * process nested inside a Claude Code session inherits `CLAUDE_CODE_SESSION_ID`, a false + * anchor naming the enclosing session. The order is what the plugin's own `session-anchor.cjs` + * measured before it was deleted; no other host was probed, so no other host reads an anchor. */ +export function resolveSessionAnchor(env: NodeJS.ProcessEnv): string | undefined { + return env.CODEX_THREAD_ID || env.CLAUDE_CODE_SESSION_ID; +} diff --git a/cli/src/contexts/telemetry/domain/session-project.ts b/cli/src/contexts/telemetry/domain/session-project.ts new file mode 100644 index 000000000..530399cc1 --- /dev/null +++ b/cli/src/contexts/telemetry/domain/session-project.ts @@ -0,0 +1,24 @@ +import type { RunJournal } from "./ports/run-journal-reader.js"; + +/** Which of `session_start`'s two fields named the project: `project_id` is a directory name + * that collides across machines, so a consumer has to be told which one it got. */ +export type ProjectField = "project_id" | "project_remote"; + +export interface SessionProject { + readonly projectId: string; + readonly projectField: ProjectField; +} + +/** `project_remote` wins when present: one value for every checkout of a repository, where + * `project_id` carries no such guarantee. Neither field named answers `null`, never a guess. */ +export function resolveSessionProject(journal: RunJournal | null): SessionProject | null { + const session = journal?.session; + if (!session) return null; + if (session.project_remote !== undefined && session.project_remote !== "") { + return { projectId: session.project_remote, projectField: "project_remote" }; + } + if (session.project_id !== undefined && session.project_id !== "") { + return { projectId: session.project_id, projectField: "project_id" }; + } + return null; +} diff --git a/cli/src/contexts/telemetry/domain/skill-name.ts b/cli/src/contexts/telemetry/domain/skill-name.ts new file mode 100644 index 000000000..c7b5c875c --- /dev/null +++ b/cli/src/contexts/telemetry/domain/skill-name.ts @@ -0,0 +1,17 @@ +/** Two hosts spell one skill differently — `aidd-dev:01-plan` where the argument is passed, the + * bare `01-plan` from a `SKILL.md` path — so an exact comparison would leave the interval open. + * Two qualified spellings that disagree never fold; a bare one closes whichever skill is open. */ +export function namesTheSameSkill(one: string, other: string): boolean { + if (one === other) return true; + const oneQualified = one.includes(":"); + const otherQualified = other.includes(":"); + if (oneQualified && otherQualified) return false; + return bareSkillName(one) === bareSkillName(other); +} + +/** The name with any `plugin:` prefix dropped. The separator is the first colon, the one + * shape this domain has: `plugin:skill`, or a bare `skill`. */ +function bareSkillName(skill: string): string { + const separator = skill.indexOf(":"); + return separator === -1 ? skill : skill.slice(separator + 1); +} diff --git a/cli/src/contexts/telemetry/domain/step-attribution.ts b/cli/src/contexts/telemetry/domain/step-attribution.ts new file mode 100644 index 000000000..839b04cbc --- /dev/null +++ b/cli/src/contexts/telemetry/domain/step-attribution.ts @@ -0,0 +1,147 @@ +import { buildFlowIntervals, ORCHESTRATING_SKILLS } from "./flow-attribution.js"; +import { + buildClosedIntervals, + type ClosedInterval, + type IntervalClosure, +} from "./journal-intervals.js"; +import type { + RunJournal, + RunJournalBoundary, + RunJournalFileWritten, + RunJournalStepStart, + RunJournalTaskDeclared, +} from "./ports/run-journal-reader.js"; +import { namesTheSameSkill } from "./skill-name.js"; + +/** How a record's step came to be known: a name the tool stated and one taken from an interval + * are different claims. `unattributed` is a value, never an absent field, which would read as + * "no step ran" — an assertion no transcript or journal supports. */ +export type StepAttributionSource = + | "tool-stated" + | "prompt-matched" + | "journal-interval" + | "unattributed"; + +/** Strongest first, and fixed: ordering them by how much of a period each accounted for + * would make the order itself a measurement, which a stable contract must not do. */ +export const STEP_ATTRIBUTION_SOURCES: readonly StepAttributionSource[] = [ + "tool-stated", + "prompt-matched", + "journal-interval", + "unattributed", +]; + +export interface StepAttribution { + readonly source: StepAttributionSource; + readonly step?: string; +} + +const UNATTRIBUTED: StepAttribution = { source: "unattributed" }; + +/** One `step_start`, closed by a `step_end` naming that same skill or by the next + * `step_start`, and - unclosed - capped at the journal's own last witnessed moment; `endMs` + * is exclusive. A `turn_end` is a pause, not the end of a step, and never closes one. Capped + * rather than left open because one session's `vendor_id` can span weeks of unrelated work. */ +export interface StepInterval extends ClosedInterval { + readonly skill: string; + /** Whether `endMs` is a moment the journal witnessed or the cap standing in for one it + * never did - `answersFor` reads it, and it is why the cap above is safe to apply. */ + readonly closedBy: IntervalClosure; +} + +/** Journal lines in, closed intervals out. An orchestrating skill's `step_start` is + * `buildFlowIntervals`'s to open, not this walk's; a `step_end` matches its opener through + * `namesTheSameSkill` and never `===`, since a host may write the skill's bare directory name + * where the end the skill echoes carries its plugin. */ +function buildInvokedStepIntervals( + journal: RunJournal, + periodEndMs: number | undefined +): readonly StepInterval[] { + return buildClosedIntervals< + RunJournalBoundary | RunJournalTaskDeclared | RunJournalFileWritten, + RunJournalStepStart, + StepInterval + >( + [...journal.boundaries, ...journal.taskDeclarations, ...journal.filesWritten], + periodEndMs, + (boundary): boundary is RunJournalStepStart => + boundary.type === "step_start" && !ORCHESTRATING_SKILLS.has(boundary.skill), + // Any `step_start` closes one of these, an orchestrating one included: a session that + // starts orchestrating is no longer running the plain skill it was running before. + (boundary, opener) => + boundary.type === "step_start" || + (boundary.type === "step_end" && namesTheSameSkill(boundary.skill, opener.skill)), + (opener, startMs, endMs, closedBy) => ({ skill: opener.skill, startMs, endMs, closedBy }) + ); +} + +/** Two walks over the same lines: the orchestrating half **is** `buildFlowIntervals`, so an + * invoked step never closes the orchestration that invoked it. Which skills orchestrate is + * `ORCHESTRATING_SKILLS`'s declaration - nesting and sequence produce the identical journal, + * so nothing read off the boundaries alone can separate them. */ +export function buildStepIntervals( + journal: RunJournal, + periodEndMs?: number +): readonly StepInterval[] { + return [ + ...buildFlowIntervals(journal, periodEndMs), + ...buildInvokedStepIntervals(journal, periodEndMs), + ]; +} + +/** The most specific interval a moment falls in: the latest to have opened, and among equals + * the first to close. Order in the array decides nothing - the two walks that build these run + * separately, so reading the first match would answer differently per run order. */ +function innermostOf(intervals: readonly StepInterval[]): StepInterval | undefined { + let best: StepInterval | undefined; + for (const interval of intervals) { + if ( + best === undefined || + interval.startMs > best.startMs || + (interval.startMs === best.startMs && interval.endMs < best.endMs) + ) { + best = interval; + } + } + return best; +} + +/** Whether an interval nothing closed sits inside another that nothing closed either. Both end + * at the same capped moment, so containment reduces to which opened first; the enclosing one + * wins because the inner one's extent rests on no evidence at all. */ +function enclosedByAnotherUnclosed( + covering: readonly StepInterval[], + interval: StepInterval +): boolean { + if (interval.closedBy !== "journal-end") return false; + return covering.some( + (other) => other.closedBy === "journal-end" && other.startMs < interval.startMs + ); +} + +/** The interval that answers for a moment: the innermost one covering it, *except* that an + * interval nothing ever closed yields to one that encloses it and was never closed either. An + * unclosed extent is a bound, not a measurement, so a step opened shortly before a long + * session goes on working would otherwise be credited with all of it. */ +function answersFor( + intervals: readonly StepInterval[], + momentMs: number +): StepInterval | undefined { + const covering = intervals.filter( + (interval) => momentMs >= interval.startMs && momentMs < interval.endMs + ); + return innermostOf(covering.filter((interval) => !enclosedByAnotherUnclosed(covering, interval))); +} + +/** A record's moment inside one interval takes that interval's skill. One with no moment, or + * earlier than every interval, is unattributed — never folded into the first step. */ +export function attributeMoment( + intervals: readonly StepInterval[], + momentIso: string | undefined +): StepAttribution { + if (momentIso === undefined) return UNATTRIBUTED; + const momentMs = Date.parse(momentIso); + if (Number.isNaN(momentMs)) return UNATTRIBUTED; + const hit = answersFor(intervals, momentMs); + return hit ? { source: "journal-interval", step: hit.skill } : UNATTRIBUTED; +} diff --git a/cli/src/contexts/telemetry/domain/task-attribution.ts b/cli/src/contexts/telemetry/domain/task-attribution.ts new file mode 100644 index 000000000..0415d5c65 --- /dev/null +++ b/cli/src/contexts/telemetry/domain/task-attribution.ts @@ -0,0 +1,95 @@ +import { buildClosedIntervals, type ClosedInterval } from "./journal-intervals.js"; +import type { + RunJournal, + RunJournalBoundary, + RunJournalFileWritten, + RunJournalTaskDeclared, +} from "./ports/run-journal-reader.js"; +import { taskIdentityFromWrittenPath } from "./task-identity.js"; + +/** How a record's task came to be known: a flow declared it, or this layer inferred it from a + * written file. No "unattributed": a record matching neither route `taskMembershipFor` names + * is not in the report at all. */ +export type TaskAttributionSource = "declared" | "inferred"; + +export const TASK_ATTRIBUTION_SOURCES: readonly TaskAttributionSource[] = ["declared", "inferred"]; + +/** One declared interval, closed by a later declaration - or, unclosed, capped at the + * journal's own last recorded moment. A `turn_end` is a pause, not a change of subject: it + * witnesses that moment but never closes. Never open-ended: no tool exposes when a flow leaves + * a ticket, so a boundless interval would keep crediting the first ticket ever named. */ +export interface TaskInterval extends ClosedInterval { + readonly path: string; +} + +/** Journal lines in, bounded intervals out; each `task_declared` closes at the next. Unclosed, + * it is capped at the merged list's last moment, a written file included, never `[t, t)`, and + * `periodEndMs` caps what a damaged clock could widen that to. A declaration whose `path` + * resolves to no identity still closes the previous one, which would otherwise widen. */ +export function buildTaskIntervals( + journal: RunJournal, + periodEndMs?: number +): readonly TaskInterval[] { + return buildClosedIntervals< + RunJournalBoundary | RunJournalTaskDeclared | RunJournalFileWritten, + RunJournalTaskDeclared, + TaskInterval + >( + [...journal.boundaries, ...journal.taskDeclarations, ...journal.filesWritten], + periodEndMs, + (boundary): boundary is RunJournalTaskDeclared => boundary.type === "task_declared", + // Only a later declaration closes one: a `turn_end` witnesses its moment, it never ends + // the subject. + () => false, + (opener, startMs, endMs) => + taskIdentityFromWrittenPath(opener.path) === null + ? null + : { path: opener.path, startMs, endMs } + ); +} + +/** Why a record belongs to no task - one distinct fact per reason, each acted on differently. + * `"no-journal"` is `taskRowOf`'s to answer, from its own map; the four below are this one's. + * + * - `"no-journal"`: no *usable* journal reached this session - none read, or one unusable. + * - `"no-declaration"`: no usable declared interval - none written, or none placeable in time. + * - `"precedes-journal"`: older than the journal's earliest moment, so nothing could cover it. + * - `"precedes-declaration"`: a record before the session's very first declaration. + * - `"journal-silent"`: declared coverage ran out before this moment; an undated record too. */ +export type TaskUnattributedReason = + | "no-journal" + | "precedes-journal" + | "no-declaration" + | "precedes-declaration" + | "journal-silent"; + +/** Fixed and always in this order, never ordered by how much of a period each accounted for, + * so a reader comparing two periods finds the same list. */ +export const TASK_UNATTRIBUTED_REASONS: readonly TaskUnattributedReason[] = [ + "no-journal", + "precedes-journal", + "no-declaration", + "precedes-declaration", + "journal-silent", +]; + +/** `journalFirstWitnessedMs` is the earliest moment this session's journal witnessed, absent + * for one carrying no readable moment: absent is no coverage claim, never a `0`. */ +export function taskUnattributedReason( + intervals: readonly TaskInterval[], + momentIso: string | undefined, + journalFirstWitnessedMs?: number +): TaskUnattributedReason { + const momentMs = momentIso === undefined ? Number.NaN : Date.parse(momentIso); + if ( + !Number.isNaN(momentMs) && + journalFirstWitnessedMs !== undefined && + momentMs < journalFirstWitnessedMs + ) { + return "precedes-journal"; + } + if (intervals.length === 0) return "no-declaration"; + if (Number.isNaN(momentMs)) return "journal-silent"; + const somethingDeclaredAfter = intervals.some((interval) => interval.startMs > momentMs); + return somethingDeclaredAfter ? "precedes-declaration" : "journal-silent"; +} diff --git a/cli/src/contexts/telemetry/domain/task-backlog-link.ts b/cli/src/contexts/telemetry/domain/task-backlog-link.ts new file mode 100644 index 000000000..9920cc750 --- /dev/null +++ b/cli/src/contexts/telemetry/domain/task-backlog-link.ts @@ -0,0 +1,31 @@ +import type { TaskIdentity } from "./task-identity.js"; + +/** `backlog-link.json`, a task folder's declaration of which backlog item it delivers. It + * carries the one fact nothing else in the repository holds, and nothing the backlog artefact + * itself already holds — no type, no originating ticket — so the two cannot disagree. */ +export interface TaskBacklogLink { + /** The backlog item, on whatever support it lives: a forge reference where a ticket provider + * holds the backlog, a project-relative path where Markdown does. Never resolved to a title + * or a state here; that is a destination's work. */ + readonly backlog: string; + /** ISO 8601. Provenance, not status: judging a wrong link means knowing which act made it. */ + readonly writtenAt: string; + /** A skill's own name (`"aidd-pm:04-spec"`), or `"hand"` for a person who corrected it + * directly — beside `writtenAt`, what traces a wrong link back to the act that made it. */ + readonly writtenBy: string; +} + +/** Three states, never two. `"declared"` and `"none"` are both normal states of a task; + * `"unreadable"` — the file exists and could not be parsed — differs in kind and must never + * print as `"none"`, so one bad folder's damage stays visible on its own row. */ +export type TaskBacklogDeclaration = + | { readonly kind: "declared"; readonly link: TaskBacklogLink } + | { readonly kind: "none" } + | { readonly kind: "unreadable" }; + +/** The project-relative folder an identity resolves to. A single-file task has no folder to + * hold a declaration, so a reader asking that path finds none — the same answer an ordinary + * folder without one gives, never a special case. */ +export function taskFolderPathFromIdentity(identity: TaskIdentity): string { + return `aidd_docs/tasks/${identity}/`; +} diff --git a/cli/src/contexts/telemetry/domain/task-identity.ts b/cli/src/contexts/telemetry/domain/task-identity.ts new file mode 100644 index 000000000..f929caee5 --- /dev/null +++ b/cli/src/contexts/telemetry/domain/task-identity.ts @@ -0,0 +1,39 @@ +/** A task's identity is derived here rather than journalled, so it can be revised over every + * past session. Two shapes are both real tasks — a folder of files and a single `.md` file — + * and matching only the folder would leave half of them unattributable. The anchoring mirrors + * `file-writes.cjs`'s own gate: a path refused here never produced a line to read. */ +const TASK_FOLDER_PATTERN = /^aidd_docs\/tasks\/(\d{4}_\d{2})\/([^/]+)\//u; +const TASK_FILE_PATTERN = /^aidd_docs\/tasks\/(\d{4}_\d{2})\/([^/]+)\.md$/u; + +/** A task's month and its own name. A folder task and a single-file task of the same name + * resolve to one identity, so a task that grew into a folder does not read as two. */ +export type TaskIdentity = string; + +/** The task a written path belongs to, answering what the path says and never what is on + * disk. The path must be repository-relative and `/`-separated, as the journal writes it on + * every platform. A segment check, not a substring one: the hooks allow `..` inside a name, + * so only a segment that is exactly `..` climbs out and is refused. */ +export function taskIdentityFromWrittenPath(writtenPath: string): TaskIdentity | null { + if (writtenPath.split("/").includes("..")) return null; + const match = TASK_FOLDER_PATTERN.exec(writtenPath) ?? TASK_FILE_PATTERN.exec(writtenPath); + if (!match) return null; + const [, month, name] = match; + return month !== undefined && name !== undefined ? `${month}/${name}` : null; +} + +/** In first-seen order, without repeats: a session that wrote into two tasks belongs to both, + * and one that wrote into none is still fully reportable by period. */ +export function taskIdentitiesFromWrittenPaths( + writtenPaths: readonly string[] +): readonly TaskIdentity[] { + const seen = new Set(); + const identities: TaskIdentity[] = []; + for (const writtenPath of writtenPaths) { + const identity = taskIdentityFromWrittenPath(writtenPath); + if (identity !== null && !seen.has(identity)) { + seen.add(identity); + identities.push(identity); + } + } + return identities; +} diff --git a/cli/src/contexts/telemetry/domain/telemetry-claim.ts b/cli/src/contexts/telemetry/domain/telemetry-claim.ts new file mode 100644 index 000000000..9dec40064 --- /dev/null +++ b/cli/src/contexts/telemetry/domain/telemetry-claim.ts @@ -0,0 +1,461 @@ +import type { AiToolId } from "../../../kernel/tool.js"; +import type { StepAttributionSource } from "./step-attribution.js"; + +/** Four independently verifiable claims about the measurement chain, each answered from what was + * actually read, never inferred from the others. Ported from the plugin's own `diagnose.cjs`, + * whose route is named here rather than left behind a pointer to a file no reader can open: + * `hook fired` -> `session journalled` -> `tool files readable` -> `records join`. */ +export type TelemetryClaimId = + | "hook-fired" + | "session-journalled" + | "tool-files-readable" + | "records-join"; + +export type TelemetryClaimVerdict = "ok" | "fail" | "unknown"; + +/** The reasons `noRunFileClaim` can land the first claim on — one absence, told apart by what is + * known about it, never guessed. Its own union: `TelemetryClaimReason` composes it in, so a + * sixth member forces every exhaustive `Record` keyed on it to name one or fail to compile. */ +export type NoRunFileReason = + | "recorder-declared-nowhere" + | "recorder-declared-not-yet-fired" + | "recorder-declaration-unreadable" + | "anchorless-run-file" + | "journal-in-another-schema"; + +/** The closed set of reasons a claim can land on a verdict. One claim's `fail` has several + * distinct causes — "no run file" and "untrusted hook" both fail `hook-fired` — so a caller + * switches on the reason, never on the verdict alone. */ +export type TelemetryClaimReason = + | "session-anchored" + | "untrusted-codex-hook" + | NoRunFileReason + | "unrecognised-payload" + | "session-left-no-run-file" + | "no-session-anchor" + | "turn-closed" + | "only-session-start" + | "no-run-file-to-read" + | "session-found" + | "no-session-found-for-any-tool" + | "no-session-named" + | "records-joined" + | "all-unattributed" + | "no-record-to-join" + | "no-join-material"; + +export interface TelemetryClaim { + readonly claim: TelemetryClaimId; + readonly verdict: TelemetryClaimVerdict; + readonly reason: TelemetryClaimReason; + readonly detail: string; +} + +/** One journalled session, the shape `claimHookFired`/`claimSessionJournalled` need from it — + * a fuller run journal carries nothing these claims read, so it stays out of this shape. */ +export interface TelemetryClaimJournal { + readonly vendorId?: string; + readonly sessionStartAt?: string; + readonly turnClosed: boolean; +} + +/** One covered tool's attempt to read one journalled session's files. `records` carries only the + * step attribution each resolved to, never counters this diagnostic would be repeating. */ +export interface TelemetryClaimToolRead { + readonly tool: AiToolId; + readonly sessionFound: boolean; + readonly hasIntervals: boolean; + readonly records: readonly { + readonly stepAttribution: StepAttributionSource; + }[]; + readonly error?: string; +} + +/** Whether Codex has trusted this plugin's hook — `undefined` when there is no trust gate to + * consult (every host but Codex, or a Codex session whose anchor was never resolved). + * `readable: false` covers everything short of the config file opening as text. */ +export interface TelemetryCodexHookTrust { + readonly readable: boolean; + readonly trusted?: boolean; + readonly configPath?: string; + readonly reason?: string; +} + +export interface TelemetryEvidence { + readonly journals: readonly TelemetryClaimJournal[]; + readonly toolReads: readonly TelemetryClaimToolRead[]; + readonly runsDirLabel: string; + readonly currentSessionId?: string; + readonly unrecognisedPayloadAt?: string; + readonly hookTrust?: TelemetryCodexHookTrust; + /** Whether the recorder is declared anywhere this build checks — read the same way + * `TelemetrySetup`'s own `recorderDeclaration` is, so the two cannot disagree. Never proof + * the hook will fire: a declaration can be silently dropped. */ + readonly recorderDeclared: boolean; + /** Whether every location `readRecorderDeclaration` checked was readable — `false` only when + * `recorderDeclared` is `false` *and* a checked location exists but could not be read. A + * damaged declaring file is not the same absence as one declaring nothing: collapsing them + * grades a healthy install `FAIL` for a cause it does not have. */ + readonly recorderDeclarationReadable: boolean; + /** The schema stated by every run file the journal reader refused. Carried separately from + * `journals` because a refused file is absent there while present on disk, and without it the + * claim below falls through to a branch that is false about it. */ + readonly foreignSchemaVersions: readonly number[]; +} + +function sessionJournalsOf( + journals: readonly TelemetryClaimJournal[] +): readonly TelemetryClaimJournal[] { + return journals.filter((journal) => journal.vendorId !== undefined); +} + +function latestSessionStart(journals: readonly TelemetryClaimJournal[]): string { + const starts = journals + .map((journal) => journal.sessionStartAt) + .filter((at): at is string => at !== undefined) + .sort(); + return starts[starts.length - 1] ?? "an unreadable session_start"; +} + +function firedForSession(journals: readonly TelemetryClaimJournal[], sessionId: string): boolean { + return journals.some((journal) => journal.vendorId === sessionId); +} + +function trustExplainsAbsence(hookTrust: TelemetryCodexHookTrust | undefined): boolean { + return Boolean(hookTrust?.readable && !hookTrust.trusted); +} + +function untrustedHookClaim(hookTrust: TelemetryCodexHookTrust): TelemetryClaim { + return { + claim: "hook-fired", + verdict: "fail", + reason: "untrusted-codex-hook", + detail: + "Codex has not trusted this plugin's hook — no trusted_hash for " + + `hooks/hooks.json:session_start in ${hookTrust.configPath}. Approve it interactively ` + + "once, or pass --dangerously-bypass-hook-trust to codex exec for a headless run.", + }; +} + +function unreadableTrustSuffix(hookTrust: TelemetryCodexHookTrust | undefined): string { + if (!hookTrust || hookTrust.readable) return ""; + return ` — Codex's own hook trust state could not be read either (${hookTrust.reason}), so this may be the same cause`; +} + +// The one absence, two causes this claim exists to tell apart: a recorder never declared +// anywhere this build reads has nothing that could have written a run file, which is a +// failure worth naming; a recorder that IS declared may simply not have run yet — nothing to +// evaluate, and its detail says so without promising the declaration will actually fire. +function recorderDeclaredNotYetFiredClaim(runsDirLabel: string): TelemetryClaim { + return { + claim: "hook-fired", + verdict: "unknown", + reason: "recorder-declared-not-yet-fired", + detail: + `no run file in ${runsDirLabel} yet — nothing to evaluate. The recorder is declared, ` + + "but a declaration is not proof it will fire: a headless run can silently drop it " + + "without ever registering the plugin (see claude-cli-adapter.ts).", + }; +} + +function recorderNotDeclaredClaim( + runsDirLabel: string, + hookTrust: TelemetryCodexHookTrust | undefined +): TelemetryClaim { + return { + claim: "hook-fired", + verdict: "fail", + reason: "recorder-declared-nowhere", + detail: + `no run file in ${runsDirLabel} — the hook has never been observed firing, and the ` + + `recorder is declared nowhere this build checks${unreadableTrustSuffix(hookTrust)}`, + }; +} + +// A location that cannot be read says so and costs only itself: this is not proof the +// recorder is missing, only that whether it is declared could not be determined. Grading +// `FAIL` off an unreadable file tells a healthy install it is broken. +function recorderDeclarationUnreadableClaim(runsDirLabel: string): TelemetryClaim { + return { + claim: "hook-fired", + verdict: "unknown", + reason: "recorder-declaration-unreadable", + detail: + `no run file in ${runsDirLabel} yet, and whether the recorder is declared could not ` + + "be read — see recorder declared, above, for which location. A damaged declaring " + + "file is not the same absence as one that never declared the recorder.", + }; +} + +// A run file existing at all is direct evidence the recorder did something, whatever the +// declaration says. Unconditional on `recorderDeclared`: a hooks block registering PostToolUse +// but never SessionStart produces exactly this file while reading "declared nowhere", so gating +// on it would print "no run file" about a file that demonstrably exists. +function anchorlessRunFileClaim(runsDirLabel: string, fileCount: number): TelemetryClaim { + return { + claim: "hook-fired", + verdict: "fail", + reason: "anchorless-run-file", + detail: + `${fileCount} run file(s) in ${runsDirLabel}, but none carry a readable session_start ` + + "to anchor them — a torn write, or a hooks block that registers another event " + + "without SessionStart, never a hook that has not fired", + }; +} + +// The schema a journal states is the writer's own statement about its shape, so a build that +// does not read that schema knows exactly one thing about the file: not what its lines mean. +// Ahead of `anchorlessRunFileClaim`, whose "none carry a readable session_start" is a claim +// about contents this build has just said it cannot make. +function foreignSchemaClaim(runsDirLabel: string, stated: readonly number[]): TelemetryClaim { + const versions = [...new Set(stated)].sort((left, right) => left - right).join(", "); + return { + claim: "hook-fired", + verdict: "fail", + reason: "journal-in-another-schema", + detail: + `${stated.length} run file(s) in ${runsDirLabel} written under a schema this build does ` + + `not read (${versions}) — a journal from another version of the plugin, never a hook ` + + "that did not fire", + }; +} + +function noRunFileClaim( + runsDirLabel: string, + hookTrust: TelemetryCodexHookTrust | undefined, + recorderDeclared: boolean, + recorderDeclarationReadable: boolean, + anchorlessFileCount: number, + foreignSchemaVersions: readonly number[] +): TelemetryClaim { + if (hookTrust && trustExplainsAbsence(hookTrust)) return untrustedHookClaim(hookTrust); + if (foreignSchemaVersions.length > 0) { + return foreignSchemaClaim(runsDirLabel, foreignSchemaVersions); + } + if (anchorlessFileCount > 0) return anchorlessRunFileClaim(runsDirLabel, anchorlessFileCount); + if (!recorderDeclarationReadable) return recorderDeclarationUnreadableClaim(runsDirLabel); + if (recorderDeclared) return recorderDeclaredNotYetFiredClaim(runsDirLabel); + return recorderNotDeclaredClaim(runsDirLabel, hookTrust); +} + +function unrecognisedPayloadClaim(at: string): TelemetryClaim { + return { + claim: "hook-fired", + verdict: "fail", + reason: "unrecognised-payload", + detail: `a payload arrived and matched no known host at ${at} — this tool is not recognised, not a hook that never ran`, + }; +} + +function noAnchorClaim(journals: readonly TelemetryClaimJournal[], latest: string): TelemetryClaim { + return { + claim: "hook-fired", + verdict: "unknown", + reason: "no-session-anchor", + detail: `${journals.length} run file(s), most recent session_start ${latest} — no session anchor available to tell whether this session's hook fired`, + }; +} + +function sessionAnchoredClaim( + journals: readonly TelemetryClaimJournal[], + latest: string, + currentSessionId: string, + hookTrust: TelemetryCodexHookTrust | undefined +): TelemetryClaim { + if (!firedForSession(journals, currentSessionId)) { + if (hookTrust && trustExplainsAbsence(hookTrust)) return untrustedHookClaim(hookTrust); + return { + claim: "hook-fired", + verdict: "fail", + reason: "session-left-no-run-file", + detail: `this session left no run file — the newest one is from ${latest}${unreadableTrustSuffix(hookTrust)}`, + }; + } + return { + claim: "hook-fired", + verdict: "ok", + reason: "session-anchored", + detail: `${journals.length} run file(s), most recent session_start ${latest}`, + }; +} + +function noSessionJournalClaim(evidence: TelemetryEvidence): TelemetryClaim { + const { journals, runsDirLabel, unrecognisedPayloadAt, hookTrust } = evidence; + if (unrecognisedPayloadAt !== undefined) return unrecognisedPayloadClaim(unrecognisedPayloadAt); + return noRunFileClaim( + runsDirLabel, + hookTrust, + evidence.recorderDeclared, + evidence.recorderDeclarationReadable, + journals.length, + evidence.foreignSchemaVersions + ); +} + +function claimHookFired(evidence: TelemetryEvidence): TelemetryClaim { + const sessionJournals = sessionJournalsOf(evidence.journals); + if (sessionJournals.length === 0) return noSessionJournalClaim(evidence); + const latest = latestSessionStart(sessionJournals); + if (evidence.currentSessionId === undefined) return noAnchorClaim(sessionJournals, latest); + return sessionAnchoredClaim( + sessionJournals, + latest, + evidence.currentSessionId, + evidence.hookTrust + ); +} + +function claimSessionJournalled(journals: readonly TelemetryClaimJournal[]): TelemetryClaim { + const sessionJournals = sessionJournalsOf(journals); + if (sessionJournals.length === 0) { + return { + claim: "session-journalled", + verdict: "unknown", + reason: "no-run-file-to-read", + detail: "no run file to read", + }; + } + const closed = sessionJournals.filter((journal) => journal.turnClosed); + if (closed.length === 0) { + return { + claim: "session-journalled", + verdict: "fail", + reason: "only-session-start", + detail: `${sessionJournals.length} run file(s), all carrying only session_start — nothing closed the turn`, + }; + } + return { + claim: "session-journalled", + verdict: "ok", + reason: "turn-closed", + detail: `${closed.length} of ${sessionJournals.length} run file(s) carry more than session_start`, + }; +} + +interface ToolTally { + attempted: number; + found: number; + errors: string[]; +} + +function tallyByTool(toolReads: readonly TelemetryClaimToolRead[]): Map { + const byTool = new Map(); + for (const read of toolReads) { + const entry = byTool.get(read.tool) ?? { attempted: 0, found: 0, errors: [] }; + entry.attempted += 1; + entry.found += read.sessionFound ? 1 : 0; + if (read.error !== undefined) entry.errors.push(read.error); + byTool.set(read.tool, entry); + } + return byTool; +} + +function readableSummary(toolReads: readonly TelemetryClaimToolRead[]): string { + return [...tallyByTool(toolReads).entries()] + .map(([tool, tally]) => { + const failed = tally.errors.length > 0 ? `, ${tally.errors.length} could not be read` : ""; + return `${tool}: ${tally.found} of ${tally.attempted} session(s) read${failed}`; + }) + .join("; "); +} + +function errorNote(toolReads: readonly TelemetryClaimToolRead[]): string { + const errors = toolReads + .map((read) => read.error) + .filter((error): error is string => error !== undefined); + return errors.length === 0 + ? "" + : ` — ${errors.length} read attempt(s) failed: ${errors[errors.length - 1]}`; +} + +function claimToolsReadable( + journals: readonly TelemetryClaimJournal[], + toolReads: readonly TelemetryClaimToolRead[] +): TelemetryClaim { + const sessionIds = [...new Set(sessionJournalsOf(journals).map((journal) => journal.vendorId))]; + if (sessionIds.length === 0) { + return { + claim: "tool-files-readable", + verdict: "unknown", + reason: "no-session-named", + detail: "no session named by the journal", + }; + } + if (!toolReads.some((read) => read.sessionFound)) { + const tools = [...new Set(toolReads.map((read) => read.tool))].join(", "); + return { + claim: "tool-files-readable", + verdict: "fail", + reason: "no-session-found-for-any-tool", + detail: `no session found for any journalled session, across every covered tool (${tools}) — while the journal names ${sessionIds.join(", ")}${errorNote(toolReads)}`, + }; + } + return { + claim: "tool-files-readable", + verdict: "ok", + reason: "session-found", + detail: readableSummary(toolReads), + }; +} + +function hasJoinMaterial( + toolReads: readonly TelemetryClaimToolRead[], + records: readonly { readonly stepAttribution: StepAttributionSource }[] +): boolean { + return ( + toolReads.some((read) => read.hasIntervals) || + records.some((record) => record.stepAttribution === "tool-stated") + ); +} + +function joinedVerdict( + records: readonly { readonly stepAttribution: StepAttributionSource }[] +): TelemetryClaim { + const joined = records.filter((record) => record.stepAttribution !== "unattributed"); + if (joined.length === 0) { + return { + claim: "records-join", + verdict: "fail", + reason: "all-unattributed", + detail: `${records.length} record(s) found, joined: 0 — every record unattributed`, + }; + } + const rest = records.length - joined.length; + return { + claim: "records-join", + verdict: "ok", + reason: "records-joined", + detail: `${joined.length} of ${records.length} record(s) joined a step, ${rest} unattributed`, + }; +} + +function claimRecordsJoin(toolReads: readonly TelemetryClaimToolRead[]): TelemetryClaim { + const records = toolReads.flatMap((read) => read.records); + if (records.length === 0) { + return { + claim: "records-join", + verdict: "unknown", + reason: "no-record-to-join", + detail: "no record read to join", + }; + } + if (!hasJoinMaterial(toolReads, records)) { + return { + claim: "records-join", + verdict: "unknown", + reason: "no-join-material", + detail: "no step interval and no tool-stated step — see session journalled", + }; + } + return joinedVerdict(records); +} + +/** The four claims, always in this order, and never a fifth line that summarises them. */ +export function diagnoseTelemetryClaims(evidence: TelemetryEvidence): readonly TelemetryClaim[] { + return [ + claimHookFired(evidence), + claimSessionJournalled(evidence.journals), + claimToolsReadable(evidence.journals, evidence.toolReads), + claimRecordsJoin(evidence.toolReads), + ]; +} diff --git a/cli/src/contexts/telemetry/domain/telemetry-export-leftover.ts b/cli/src/contexts/telemetry/domain/telemetry-export-leftover.ts new file mode 100644 index 000000000..88e9673df --- /dev/null +++ b/cli/src/contexts/telemetry/domain/telemetry-export-leftover.ts @@ -0,0 +1,36 @@ +import { asPlainObject } from "../../../kernel/reading/plain-object.js"; + +/** The `env` keys a since-removed `telemetry endpoint` command wrote into a Claude Code + * settings file. Detection only: nothing in this system writes them any more, and a settings + * file still carrying them keeps exporting, so naming them is the only remedy left. */ +export const CLAUDE_TELEMETRY_EXPORT_ENV_KEYS = [ + "CLAUDE_CODE_ENABLE_TELEMETRY", + "OTEL_METRICS_EXPORTER", + "OTEL_LOGS_EXPORTER", + "OTEL_EXPORTER_OTLP_PROTOCOL", + "OTEL_EXPORTER_OTLP_ENDPOINT", + "OTEL_METRIC_EXPORT_INTERVAL", + "OTEL_RESOURCE_ATTRIBUTES", +] as const; + +/** One settings file and which of the keys it carries, so exactly those can be removed rather + * than the whole `env` block. */ +export interface TelemetryExportLeftover { + readonly path: string; + readonly keys: readonly string[]; +} + +/** Never throws: an absent or malformed file has no keys this can find, which is not the same + * claim as "clean" but is the only one available here. */ +export function findLeftoverExportKeys(content: string | null): readonly string[] { + if (content === null) return []; + let parsed: unknown; + try { + parsed = JSON.parse(content); + } catch { + return []; + } + const env = asPlainObject(asPlainObject(parsed)?.env); + if (env === null) return []; + return CLAUDE_TELEMETRY_EXPORT_ENV_KEYS.filter((key) => key in env); +} diff --git a/cli/src/contexts/telemetry/domain/telemetry-removal.ts b/cli/src/contexts/telemetry/domain/telemetry-removal.ts new file mode 100644 index 000000000..47f74c69b --- /dev/null +++ b/cli/src/contexts/telemetry/domain/telemetry-removal.ts @@ -0,0 +1,61 @@ +/** Every location `aidd telemetry forget` would remove from, resolved exactly once and then + * handed to the removal step rather than re-resolved there: a second resolution could disagree + * with the first, and would delete something the person confirming was never shown. `scope` + * separates one project's journal from the machine-wide sink and identity on the type itself. */ + +export interface TelemetryProjectJournalRemoval { + readonly scope: "project"; + /** The run journal's own directory, as `RunJournalReader.runsDir` resolved it. */ + readonly path: string; + /** By name, never derived from parsing: a run file too damaged to parse still has a name + * `readdir` can see, so it is still listed and still removed. */ + readonly runFileNames: readonly string[]; +} + +export interface TelemetryMachineSinkRemoval { + readonly scope: "machine"; + /** As `TelemetrySink.rootDir` resolved it: every project measured on this machine, never + * one project alone. */ + readonly path: string; + /** By name — a day file's content is never opened, so a damaged one is named like any other. */ + readonly dayFileNames: readonly string[]; +} + +export interface TelemetryMachineIdentityRemoval { + readonly scope: "machine"; + /** This machine's identity file, as `PersonIdentityStore.filePath` resolved it. */ + readonly path: string; + /** True even for a file that exists and cannot be parsed — the file most needing removal. */ + readonly present: boolean; + /** Beside `present` rather than folded into it: a damaged file and an absent one both show + * nothing, but only one is still sitting there. */ + readonly unreadable: boolean; +} + +/** The index and history answer different questions: a file added and never committed is tracked + * while history holds nothing for it, so `git log` separates `"committed"` from `"staged"` — + * whose blob survives this removal and returns on the next commit. `"possible"` is never an + * all-clear; only `"none"`, outside a repository, may say so plainly. */ +export type TelemetryHistoryReading = + | { readonly certainty: "committed"; readonly files: readonly string[] } + | { readonly certainty: "staged"; readonly files: readonly string[] } + | { readonly certainty: "possible" } + | { readonly certainty: "none" }; + +/** Every location a removal would touch and what no removal can touch, resolved together so a + * caller cannot render one without the other. */ +export interface TelemetryRemovalPreview { + readonly journal: TelemetryProjectJournalRemoval; + readonly sink: TelemetryMachineSinkRemoval; + readonly identity: TelemetryMachineIdentityRemoval; + readonly history: TelemetryHistoryReading; +} + +/** Whether to offer removal at all, rather than asking to confirm removing nothing. */ +export function telemetryRemovalIsEmpty(preview: TelemetryRemovalPreview): boolean { + return ( + preview.journal.runFileNames.length === 0 && + preview.sink.dayFileNames.length === 0 && + !preview.identity.present + ); +} diff --git a/cli/src/contexts/telemetry/domain/telemetry-setup.ts b/cli/src/contexts/telemetry/domain/telemetry-setup.ts new file mode 100644 index 000000000..55427d39c --- /dev/null +++ b/cli/src/contexts/telemetry/domain/telemetry-setup.ts @@ -0,0 +1,202 @@ +import type { HostRegistrationEntry } from "../../tools/domain/host-plugin-registration.js"; +import { personRefusesTelemetry, TELEMETRY_REFUSAL_VARIABLE } from "./telemetry-switch.js"; + +/** What is already in place before `aidd telemetry check` grades whether anything recorded, + * printed whether or not measurement is on. Every fact names the location it came from and + * carries no count and no figure: the report owns those, and a diagnostic repeating + * quantities can disagree with it. */ +export interface TelemetrySetup { + readonly allowed: TelemetryAllowedSetup; + readonly identity: TelemetryIdentitySetup; + readonly recordsLocation: TelemetryRecordsLocationSetup; + readonly recorderDeclaration: TelemetryRecorderDeclarationSetup; + readonly hostRegistration: TelemetryHostRegistrationSetup; + readonly commitTrailer: TelemetryCommitTrailerSetup; + readonly versions: TelemetryVersionsSetup; +} + +/** Two producers, and only one can be asked directly: the CLI's version is this process's + * own, while the plugin's is a fact about a different program, so it is read back out of the + * journal rather than re-derived by a process that never ran the hook. */ +export interface TelemetryVersionsSetup { + readonly cli: string; + readonly plugin: TelemetryPluginVersionSetup; +} + +/** `"unrecorded"` is a hook that ran and could not name its own build - a plugin copied in + * by hand; `"nothing-journalled"` is a project where nothing has been measured yet and says + * nothing about the plugin. Collapsing them lets "not measured yet" read as "damaged". */ +export type TelemetryPluginVersionSetup = + | { readonly kind: "recorded"; readonly version: string } + | { readonly kind: "unrecorded" } + | { readonly kind: "nothing-journalled" }; + +/** Whether AIDD is allowed to measure this project, and whose decision that is. Mirrors + * `resolveTelemetryEnabled`'s precedence exactly so the two can never disagree: a person's + * own refusal wins unconditionally over whatever the project file says. */ +export interface TelemetryAllowedSetup { + readonly allowed: boolean; + /** `"person-refusal"`: `AIDD_TELEMETRY=0` decided it, whatever the project file holds. + * `"project-switch"`: on, off, absent or unreadable are all that file's decision. */ + readonly decidedBy: "person-refusal" | "project-switch"; + /** The env var name for `"person-refusal"`; the switch file's path for + * `"project-switch"` — where a person would go to change this. */ + readonly location: string; + /** Always `true` for `"person-refusal"`, since reading an env var never fails. `false` + * only for a switch file that exists but could not be read: a damaged file is not the + * same choice as an absent or an explicit one. */ + readonly readable: boolean; +} + +/** The primitives `buildTelemetryAllowedSetup` needs from the project's switch file, never + * the port's own read type: importing that back here would draw a domain model into the + * layer the port exists to abstract away from. */ +export interface TelemetrySwitchFileFacts { + readonly path: string; + readonly enabled: boolean; + readonly readable: boolean; +} + +/** Mirrors `resolveTelemetryEnabled`'s precedence exactly: a person's own refusal wins + * unconditionally, whatever the project file says. Pure, so the precedence itself is + * testable apart from the adapter that reads the switch file. */ +export function buildTelemetryAllowedSetup( + switchFile: TelemetrySwitchFileFacts, + env: NodeJS.ProcessEnv +): TelemetryAllowedSetup { + if (personRefusesTelemetry(env)) { + return { + allowed: false, + decidedBy: "person-refusal", + location: TELEMETRY_REFUSAL_VARIABLE, + readable: true, + }; + } + return { + allowed: switchFile.enabled, + decidedBy: "project-switch", + location: switchFile.path, + readable: switchFile.readable, + }; +} + +/** Whether this person attached their own identifier to what gets read locally, and where + * that file lives. `attached: false, readable: true` is the ordinary "nobody chose" case; + * `readable` is `false` only for a file that exists but is damaged. */ +export interface TelemetryIdentitySetup { + readonly attached: boolean; + readonly path: string; + readonly readable: boolean; +} + +/** The sink's own root directory, resolved by nothing but the adapter that owns it. No + * `readable`: naming a directory never fails, written into or not. */ +export interface TelemetryRecordsLocationSetup { + readonly path: string; +} + +/** Whether the `aidd-telemetry` plugin is declared anywhere this build knows to check. A + * declaration is not proof the hook will fire - a host that never registered the plugin + * drops the entry as orphaned - so whether the host acts on it is + * `TelemetryHostRegistrationSetup` below. */ +export interface TelemetryRecorderDeclarationSetup { + readonly declared: boolean; + /** Where it was found declared — non-empty exactly when `declared` is `true`. */ + readonly declaredAt: readonly string[]; + /** Every location this build knows to check, so a person can go add it there when + * `declared` is `false` — the same set regardless of the outcome. */ + readonly locationsChecked: readonly string[]; + /** Every checked location that exists but could not be read or parsed - the same "a + * damaged file is not a choice" distinction `readable` carries elsewhere, as a list since + * several locations are checked at once. Only meaningful alongside `declared: false`: a + * declaration found at one readable location is real whatever else failed to read. */ + readonly unreadable: readonly string[]; +} + +/** Whether the host will actually load what AIDD installed. Read from AIDD's own manifest, + * never `enabledPlugins`: `mergeEnabledPlugins` skips silently for a plugin recording no + * marketplace and for one that does not resolve, so comparing settings against a registry + * finds both sides absent and reads it as agreement. */ +export interface TelemetryHostRegistrationSetup { + /** One per plugin AIDD installed for a tool whose registration can be asked about at all. + * Empty when the manifest records no plugin, a normal state rather than a fault. */ + readonly entries: readonly HostRegistrationEntry[]; + /** Why AIDD's own manifest could not be read, when it could not - its own field rather + * than an absent-entries silence, since `Manifest`'s parser reads `files.map(...)` + * unguarded and a truncated `.aidd/manifest.json` throws rather than returning null. A + * damaged manifest is exactly when a person runs `check`, so `check` must survive it. */ + readonly manifestUnreadable?: string; +} + +/** Whether a commit made by a session will carry the trailer that closes "this commit cost + * X", and whether any actually has. It is one line in `prepare-commit-msg`, erased whenever + * another tool regenerates that file - a loss with no symptom, since commits keep succeeding + * and records keep being written. */ +export interface TelemetryCommitTrailerSetup { + /** Where git says it runs hooks from - `git rev-parse --git-path hooks`, never `.git/hooks` + * assumed, since `core.hooksPath` elsewhere makes every other fact here describe the + * wrong directory. */ + readonly hooksDir?: string; + /** Which tool regenerates `prepare-commit-msg` and wipes anything appended to it, read + * from marker files at the repository root - never from the hook's own contents, which a + * regeneration has already overwritten. `undefined` means the CLI still owns the hook. */ + readonly hookManager?: HookManager; + /** Whether that manager's own config already calls the delegate. Present only when + * `hookManager` is. `false` is not a fault: it is the ordinary state of a repository + * nobody has wired up yet, which is what the printed job fixes. */ + readonly managerCallsDelegate?: boolean; + /** Why there is no `hooksDir`. Two causes, never one: a project outside git has no hook to + * carry anything, while a repository whose git could not answer is a reading that failed, + * and saying "no repository" about it would be false. */ + readonly hooksDirMissing?: "no-repository" | "unresolved"; + /** Whether the delegate script is there and executable. Present but not executable is its + * own state: git will not run it, and "installed" would be a lie nobody could act on. */ + readonly delegate: "executable" | "not-executable" | "absent"; + /** Whether `prepare-commit-msg` carries the line that calls the delegate. */ + readonly callSite: "present" | "missing" | "no-hook-file"; + /** Whether that hook is executable, when there is one. Git refuses to run a hook without + * the bit, so a regeneration that drops it leaves an install that looks perfect and writes + * nothing. Absent when there is no hook to ask about - a third state, not a `false`. */ + readonly hookExecutable?: boolean; + /** Whether that hook holds anything besides our own line. Said, never named: which tool it + * is changes nothing a person does, and naming one would be a guess from its contents. */ + readonly hookHasOtherContent: boolean; + /** How many of the commits looked at carry the trailer, and how many were looked at. A + * count, never a pass: "some of your recent commits carry it" is not something a person + * can check. Absent when history could not be read at all. */ + readonly recentlyCarrying?: { readonly carrying: number; readonly examined: number }; +} + +/** The two tools this build knows to regenerate `prepare-commit-msg` out from under + * whatever the CLI appended to it. */ +export type HookManager = "lefthook" | "husky"; + +/** Every spelling lefthook accepts for its own config file, in the order it looks for + * them. */ +export const LEFTHOOK_MARKER_NAMES = [ + "lefthook.yml", + "lefthook.yaml", + ".lefthook.yml", + ".lefthook.yaml", +] as const; + +/** The root marker that means husky owns this repository's hooks. */ +export const HUSKY_MARKER_NAME = ".husky"; + +/** The set a caller probes for existence before calling `detectHookManager`, in one place so + * the two never drift apart. */ +export const HOOK_MANAGER_MARKER_NAMES: readonly string[] = [ + ...LEFTHOOK_MARKER_NAMES, + HUSKY_MARKER_NAME, +]; + +/** Decided from root marker files alone, never from the hook's own contents: a manager + * regenerates the hook on every install, so the append this CLI made is already gone by the + * time anything reads it. Lefthook wins the tie when both are present. */ +export function detectHookManager(rootEntryNames: readonly string[]): HookManager | undefined { + if (rootEntryNames.some((name) => (LEFTHOOK_MARKER_NAMES as readonly string[]).includes(name))) { + return "lefthook"; + } + if (rootEntryNames.includes(HUSKY_MARKER_NAME)) return "husky"; + return undefined; +} diff --git a/cli/src/contexts/telemetry/domain/telemetry-sink-record.ts b/cli/src/contexts/telemetry/domain/telemetry-sink-record.ts new file mode 100644 index 000000000..0a7ce0384 --- /dev/null +++ b/cli/src/contexts/telemetry/domain/telemetry-sink-record.ts @@ -0,0 +1,124 @@ +import { UnknownTelemetrySinkSchemaVersionError } from "../../../kernel/errors.js"; +import type { AiToolId } from "../../../kernel/tool.js"; +import type { StepAttributionSource } from "./step-attribution.js"; + +// v2 adds `provenance`, required rather than defaulted: a default meaning "the old route" +// is exactly the ambiguity the field exists to remove. +export const SINK_SCHEMA_VERSION = 2; + +/** `turn_id` is the key a re-read deduplicates on - the tool's own identifier, never a hash + * of the line, which changes the moment the tool appends to the record. No field says a + * record was provisional when stored: nothing outliving the moment of a read could confirm + * it closed, so what is stored is every reading strictly larger than the last. */ +export type TelemetrySinkRecordKind = "request" | "session"; + +/** Which route produced this line. Never optional: a default meaning "the old route" would + * make the field unreadable the day a third route appears. `"export"` can no longer be + * *produced* by this system, but stays in the union and every reader keeps honouring it, + * because a stored line outlives the code that wrote it. */ +export type TelemetrySinkRecordProvenance = "export" | "local-read"; + +/** The tool-neutral stored line, and the complete allowlist of what a session may leave + * behind: no identity of any kind on a *stored* export-provenance record, a person being + * named only via `person_id`, opted into on the local-read route. `vendor_field` and + * `turn_field` name the export-side attribute a value came from, which differs per tool. */ +export interface TelemetrySinkRecord { + readonly sink_schema_version: number; + readonly kind: TelemetrySinkRecordKind; + readonly provenance: TelemetrySinkRecordProvenance; + readonly tool: AiToolId; + readonly vendor_id: string; + readonly vendor_field: string; + readonly turn_id?: string; + readonly turn_field?: string; + /** The tool's own identifier for one billed call, not one turn - present only where a + * route can name it and, unlike `turn_id`, unique per billed request wherever present. It + * exists so a report can collapse two records describing one call into one, and is never + * used for the local-read re-read match `turn_id` exists for. */ + readonly billed_request_id?: string; + /** The prompt this billed call belongs to. A billed call and the prompt that caused it + * never share a transcript line, so the reader follows `parentUuid` back to one; the run + * journal writes the same identifier on `step_start`, so matching the two joins a step to + * a record exactly rather than inferring it from overlapping intervals. */ + readonly prompt_id?: string; + /** The skill a `Skill` call invoked inside this record's own prompt, stored because the + * report never re-reads a transcript. Scoped to the transcript the record sits in: a + * subagent that invoked its own skill did that work under it, so merging a prompt's + * several files first would pick one name for both. */ + readonly prompt_skill?: string; + /** How `step` came to be known. Never optional, for the same reason `provenance` is not: + * an absent field would read as "no step ran", the one assertion nothing on a transcript + * or a journal can support. */ + readonly step_attribution: StepAttributionSource; + /** Present only where `step_attribution` names a source that found one; absent, never a + * placeholder, when `step_attribution` is `"unattributed"`. */ + readonly step?: string; + /** The plugin a tool-stated `step` came bundled with, when the tool reports one + * alongside the skill name. Never set from a journal interval, which carries no plugin + * at all. */ + readonly step_plugin?: string; + readonly project_id?: string; + /** Which field on the run journal's `session_start` line `project_id` came from, present + * only on a record joined from a journal. Absent on an export-provenance record, whose + * `project_id` is set directly from an OTLP attribute with no join to name a source for. */ + readonly project_field?: string; + /** The identifier a person chose to attach to records this machine reads locally - never + * derived from `user_id`, a tool's own attribute, and never written onto an + * export-provenance record. Absent whenever nobody opted in, which is the default. */ + readonly person_id?: string; + /** A separate, later choice from `person_id` - present only once asked for, and never + * derived from it or from anything else. */ + readonly person_display_name?: string; + /** The CLI's own version, stamped only on what the CLI itself stored - a `provenance: + * "local-read"` record, never an `"export"` one. Never the framework's version and never + * the plugin's, which stamps the journal line beside this record instead. Absent on a + * record written before this field existed, which reads as an unknown version. */ + readonly cli_version?: string; + readonly cost_usd?: number; + readonly input_tokens?: number; + readonly output_tokens?: number; + readonly cache_read_tokens?: number; + readonly cache_creation_tokens?: number; + readonly model?: string; + readonly effort?: string; + readonly speed?: string; + readonly query_source?: string; + readonly agent_name?: string; + readonly duration_ms?: number; + readonly active_time_s?: number; + readonly event_timestamp?: string; + readonly event_sequence?: number; +} + +const DAY_KEY_LENGTH = "YYYY-MM-DD".length; + +/** The UTC day a record's own moment falls on, or `undefined` when it carries none. Lives + * here rather than in the sink adapter because the adapter and every double standing in for + * it must agree, and two implementations of "which day is this" diverge on exactly the + * inputs nobody writes a fixture for. */ +export function telemetrySinkRecordDayKey(record: TelemetrySinkRecord): string | undefined { + const at = record.event_timestamp; + // `typeof`, not `!== undefined`: `parseTelemetrySinkLine` checks the schema version and + // casts the rest, so a number here would parse as epoch milliseconds, land outside every + // real period and go missing from the read without being counted as undated. + if (typeof at !== "string") return undefined; + // The parse is checked first, always: the slice below is a faster way to read a moment + // already known to parse, never a substitute for checking it does. Slicing first lets a + // string merely shaped like a moment ("not-a-momentZ") answer a calendar fragment. + const parsed = new Date(at); + if (Number.isNaN(parsed.getTime())) return undefined; + if (at.length >= DAY_KEY_LENGTH && at.endsWith("Z")) return at.slice(0, DAY_KEY_LENGTH); + return parsed.toISOString().slice(0, DAY_KEY_LENGTH); +} + +export function serializeTelemetrySinkRecord(record: TelemetrySinkRecord): string { + return JSON.stringify(record); +} + +export function parseTelemetrySinkLine(line: string): TelemetrySinkRecord { + const parsed = JSON.parse(line) as { sink_schema_version?: unknown }; + if (parsed.sink_schema_version !== SINK_SCHEMA_VERSION) { + throw new UnknownTelemetrySinkSchemaVersionError(parsed.sink_schema_version); + } + return parsed as TelemetrySinkRecord; +} diff --git a/cli/src/contexts/telemetry/domain/telemetry-sink-retention.ts b/cli/src/contexts/telemetry/domain/telemetry-sink-retention.ts new file mode 100644 index 000000000..5b311ade8 --- /dev/null +++ b/cli/src/contexts/telemetry/domain/telemetry-sink-retention.ts @@ -0,0 +1,21 @@ +/** At ~576 bytes per mapped `request` line, about 25 MB over the window on a busy machine. */ +export const DEFAULT_TELEMETRY_SINK_RETENTION_DAYS = 90; + +export interface TelemetrySinkRetentionDecision { + readonly keep: readonly string[]; + readonly prune: readonly string[]; +} + +/** `windowDays` is clamped to at least 1: the newest day file is never a prune candidate. */ +export function decideTelemetrySinkRetention( + dayFileNames: readonly string[], + windowDays: number +): TelemetrySinkRetentionDecision { + const window = Math.max(1, Math.floor(windowDays)); + const sorted = [...dayFileNames].sort(); + if (sorted.length <= window) return { keep: sorted, prune: [] }; + return { + keep: sorted.slice(sorted.length - window), + prune: sorted.slice(0, sorted.length - window), + }; +} diff --git a/cli/src/contexts/telemetry/domain/telemetry-switch.ts b/cli/src/contexts/telemetry/domain/telemetry-switch.ts new file mode 100644 index 000000000..8c3d67d90 --- /dev/null +++ b/cli/src/contexts/telemetry/domain/telemetry-switch.ts @@ -0,0 +1,73 @@ +import { join } from "node:path"; +import { AIDD_CONFIG_FILENAME, AIDD_DIR } from "../../../kernel/paths.js"; + +/** `.aidd/config.json`'s `telemetry` key, the one answer to "is AIDD allowed to measure this + * project", read fresh at every call. Absent or unparseable means off, the same failure + * direction the journal hook's own read takes. */ +export interface TelemetrySwitch { + readonly enabled: boolean; + /** A destination a since-removed `telemetry endpoint` command wrote here. Nothing reads it + * as a destination any more; `on` and `off` preserve it verbatim so neither drops a key it + * never wrote. A live export a tool still reads is `telemetry-export-leftover.ts`'s fact. */ + readonly endpoint?: string; +} + +export function telemetryConfigPath(projectRoot: string): string { + return join(projectRoot, AIDD_DIR, AIDD_CONFIG_FILENAME); +} + +/** The refusal at a person's own scope: an environment variable, refusable per shell and per + * machine, rather than a second file holding the same fact. Mirrors `repo.cjs`'s own predicate + * so hook and CLI cannot disagree — only the literal `"0"` refuses, and unset is not a choice + * this variable can express, so it never turns measurement on by itself. */ +export const TELEMETRY_REFUSAL_VARIABLE = "AIDD_TELEMETRY"; + +export function personRefusesTelemetry(env: NodeJS.ProcessEnv): boolean { + return env[TELEMETRY_REFUSAL_VARIABLE] === "0"; +} + +/** The person's refusal wins unconditionally; the project's tracked switch is read only when + * it does not apply — the same order and verdict `repo.cjs` computes. */ +export function resolveTelemetryEnabled( + fileSwitch: TelemetrySwitch | null, + env: NodeJS.ProcessEnv +): boolean { + if (personRefusesTelemetry(env)) return false; + return fileSwitch?.enabled === true; +} + +function asRecord(value: unknown): Record | null { + return value !== null && typeof value === "object" && !Array.isArray(value) + ? (value as Record) + : null; +} + +function safeParse(content: string): unknown { + try { + return JSON.parse(content); + } catch { + return null; + } +} + +/** Unparseable content, or a `telemetry` key of the wrong shape, reads as `null` (off). */ +export function parseTelemetrySwitchFile(content: string): TelemetrySwitch | null { + const telemetry = asRecord(asRecord(safeParse(content))?.telemetry); + if (telemetry === null) return null; + const endpoint = typeof telemetry.endpoint === "string" ? telemetry.endpoint : undefined; + return { enabled: telemetry.enabled === true, endpoint }; +} + +/** Upserts `telemetry`, leaving every other top-level key untouched: a key this function did + * not add must survive both `on` and `off`. */ +export function buildTelemetrySwitchFile( + existingRaw: string | null, + next: TelemetrySwitch +): string { + const root = (existingRaw !== null ? asRecord(safeParse(existingRaw)) : null) ?? {}; + root.telemetry = + next.endpoint !== undefined + ? { enabled: next.enabled, endpoint: next.endpoint } + : { enabled: next.enabled }; + return `${JSON.stringify(root, null, 2)}\n`; +} diff --git a/cli/src/contexts/telemetry/infrastructure/copilot-cost-reader-adapter.ts b/cli/src/contexts/telemetry/infrastructure/copilot-cost-reader-adapter.ts new file mode 100644 index 000000000..4420545fc --- /dev/null +++ b/cli/src/contexts/telemetry/infrastructure/copilot-cost-reader-adapter.ts @@ -0,0 +1,25 @@ +import { readFile } from "node:fs/promises"; +import { join } from "node:path"; +import { mapCopilotEventsToSinkRecords } from "../domain/formats/copilot-events.js"; +import type { + LocalCostReadResult, + SessionCostReader, +} from "../domain/ports/session-cost-reader.js"; + +/** The session id names the exact file, so there is no directory to walk and the stamped + * `vendor_id` is the id asked for rather than one re-derived from the file's content. A + * missing file is no trace of the session, not a session that cost nothing. */ +export class CopilotCostReaderAdapter implements SessionCostReader { + constructor(private readonly homeDir: string) {} + + async read(sessionId: string): Promise { + const path = join(this.homeDir, ".copilot", "session-state", sessionId, "events.jsonl"); + let content: string; + try { + content = await readFile(path, "utf8"); + } catch { + return { records: [], sessionFound: false }; + } + return { records: mapCopilotEventsToSinkRecords(content, sessionId), sessionFound: true }; + } +} diff --git a/cli/src/contexts/telemetry/infrastructure/hook-trust-reader-adapter.ts b/cli/src/contexts/telemetry/infrastructure/hook-trust-reader-adapter.ts new file mode 100644 index 000000000..42d5caef2 --- /dev/null +++ b/cli/src/contexts/telemetry/infrastructure/hook-trust-reader-adapter.ts @@ -0,0 +1,50 @@ +import { readFile } from "node:fs/promises"; +import { join } from "node:path"; +import { describeError } from "../../../kernel/describe-error.js"; +import { resolveHomeDir } from "../../../kernel/reading/home-dir.js"; +import type { HookTrustReader } from "../domain/ports/hook-trust-reader.js"; +import type { TelemetryCodexHookTrust } from "../domain/telemetry-claim.js"; + +// The exact table header Codex writes to `~/.codex/config.toml` once a hook is approved. The +// plugin's own `hook-trust.cjs` is gone, so these live here alone: SessionStart is the one +// event whose trust state explains an empty journal, and the literal is exported so +// `telemetry-evidence-adapter.ts` checks it rather than a copy that could drift. +export const PLUGIN_NAME = "aidd-telemetry"; +const HOOKS_FILE = "hooks/hooks.json"; +const SESSION_START_EVENT = "session_start"; + +// The recorder's own hook entry point. Exported so a hooks block declared in a project's +// own settings is recognised by the script it invokes, not by a loose substring. +export const HOOK_ENTRY_SCRIPT = "journal.cjs"; + +function codexConfigPath(homeDir: string): string { + return join(homeDir, ".codex", "config.toml"); +} + +// Line-scanned, not TOML-parsed: the one shape needed is a header Codex emits verbatim, +// directly followed by its `trusted_hash` line. Matched on the full key including the event +// name, so a hook approved under a renamed event reads as untrusted rather than approved. +function parseHookTrust(content: string): { trusted: boolean } { + const lines = content.split("\n"); + const prefix = `[hooks.state."${PLUGIN_NAME}@`; + const suffix = `:${HOOKS_FILE}:${SESSION_START_EVENT}:0:0"]`; + const at = lines.findIndex((line) => line.startsWith(prefix) && line.endsWith(suffix)); + if (at === -1) return { trusted: false }; + return { trusted: /^trusted_hash\s*=/.test((lines[at + 1] ?? "").trim()) }; +} + +export class HookTrustReaderAdapter implements HookTrustReader { + async read(): Promise { + const configPath = codexConfigPath(resolveHomeDir()); + let content: string; + try { + content = await readFile(configPath, "utf8"); + } catch (error) { + return { + readable: false, + reason: `${configPath} could not be read (${describeError(error)})`, + }; + } + return { readable: true, configPath, ...parseHookTrust(content) }; + } +} diff --git a/cli/src/infrastructure/adapters/opencode-cost-reader-adapter.ts b/cli/src/contexts/telemetry/infrastructure/opencode-cost-reader-adapter.ts similarity index 77% rename from cli/src/infrastructure/adapters/opencode-cost-reader-adapter.ts rename to cli/src/contexts/telemetry/infrastructure/opencode-cost-reader-adapter.ts index b37b46e9e..3918b8186 100644 --- a/cli/src/infrastructure/adapters/opencode-cost-reader-adapter.ts +++ b/cli/src/contexts/telemetry/infrastructure/opencode-cost-reader-adapter.ts @@ -1,12 +1,12 @@ import { spawnSync } from "node:child_process"; import { accessSync, constants } from "node:fs"; import { delimiter, join } from "node:path"; -import { OpencodeExportError } from "../../domain/errors.js"; -import { mapOpencodeExportToSinkRecords } from "../../domain/formats/opencode-export.js"; +import { OpencodeExportError } from "../../../kernel/errors.js"; +import { mapOpencodeExportToSinkRecords } from "../domain/formats/opencode-export.js"; import type { LocalCostReadResult, SessionCostReader, -} from "../../domain/ports/session-cost-reader.js"; +} from "../domain/ports/session-cost-reader.js"; const BINARY = "opencode"; // A local export of one session's own files — not a network call — so a generous budget @@ -16,13 +16,9 @@ const DEFAULT_TIMEOUT_MS = 10000; // "no such session" (nothing to read, not an error) from any other command failure. const SESSION_NOT_FOUND = /session not found/i; -/** - * Reads one OpenCode session's counters by shelling out to `opencode export --sanitize` - * rather than opening its SQLite database — a native dependency would need a prebuild per - * platform and ABI, breaking `npm i -g` for every user to serve the fraction who use - * OpenCode. The only part of this reader that spawns anything; parsing the answer is - * `mapOpencodeExportToSinkRecords`'s job. - */ +/** Shells out to `opencode export --sanitize` rather than opening OpenCode's SQLite + * database: a native dependency would need a prebuild per platform and ABI, breaking + * `npm i -g` for every user to serve the fraction who use OpenCode. */ export class OpencodeCostReaderAdapter implements SessionCostReader { constructor(private readonly timeoutMs: number = DEFAULT_TIMEOUT_MS) {} @@ -50,9 +46,8 @@ export class OpencodeCostReaderAdapter implements SessionCostReader { }; } - /** Filesystem check, not a `--version` probe — matches - * `AbstractNativePluginCliAdapter.isAvailable`, since spawning just to test presence is - * flake-prone under load. */ + /** A filesystem check, not a `--version` probe: spawning to test presence is flake-prone + * under load. */ private isAvailable(): boolean { const dirs = (process.env.PATH ?? "").split(delimiter).filter((dir) => dir !== ""); return dirs.some((dir) => { diff --git a/cli/src/contexts/telemetry/infrastructure/person-identity-adapter.ts b/cli/src/contexts/telemetry/infrastructure/person-identity-adapter.ts new file mode 100644 index 000000000..de5171434 --- /dev/null +++ b/cli/src/contexts/telemetry/infrastructure/person-identity-adapter.ts @@ -0,0 +1,153 @@ +import { randomUUID } from "node:crypto"; +import { mkdir, readFile, rm, writeFile } from "node:fs/promises"; +import { dirname, join } from "node:path"; +import { errorMessage } from "../../../kernel/describe-error.js"; +import { IdentityWriteError, UnreadableIdentityFileError } from "../../../kernel/errors.js"; +import { resolveAiddConfigDir } from "../../../kernel/reading/home-dir.js"; +import { asPlainObjectOrEmpty, isErrnoException } from "../../../kernel/reading/json-file.js"; +import { + withAlsoMeAdded, + withAlsoMeRemoved, + withPersonIdAdopted, +} from "../domain/person-resolution.js"; +import type { PersonIdentity } from "../domain/ports/person-identity-reader.js"; +import type { PersonIdentityStore } from "../domain/ports/person-identity-store.js"; + +const PRIVATE_FILE_MODE = 0o600; +const PRIVATE_DIR_MODE = 0o700; + +// A file with no `origin` reads as `"minted"`: that is what the plugin's own deleted +// `identity.cjs` wrote, and `origin` is knowable only when an identity is created or adopted. +function parseIdentity(raw: string): PersonIdentity | null { + const parsed = asPlainObjectOrEmpty(JSON.parse(raw)); + if (typeof parsed.person_id !== "string" || parsed.person_id === "") return null; + const identity: { personId: string; origin: "minted" | "adopted"; alsoMe: string[] } = { + personId: parsed.person_id, + origin: parsed.origin === "adopted" ? "adopted" : "minted", + alsoMe: Array.isArray(parsed.also_me) + ? parsed.also_me.filter((v) => typeof v === "string") + : [], + }; + if (typeof parsed.display_name === "string" && parsed.display_name !== "") { + return { ...identity, displayName: parsed.display_name }; + } + return identity; +} + +// `also_me` is omitted when empty, like `display_name` when unset: the common case stays the +// quietest shape on disk. +function serializeIdentity(identity: PersonIdentity): string { + const record: { + person_id: string; + origin: "minted" | "adopted"; + display_name?: string; + also_me?: readonly string[]; + } = { person_id: identity.personId, origin: identity.origin }; + if (identity.displayName !== undefined) record.display_name = identity.displayName; + if (identity.alsoMe.length > 0) record.also_me = identity.alsoMe; + return `${JSON.stringify(record, null, 2)}\n`; +} + +/** Reads and writes only this machine's own user profile, never `AIDD_USER_CONFIG_DIR`: a + * team or CI can point that variable at a shared location, and an identity reachable that way + * would not be this person's own. `filePath` is resolved once, in the constructor, so a later + * relocation of `HOME` cannot change what this instance answers. */ +export class PersonIdentityAdapter implements PersonIdentityStore { + readonly filePath: string; + + constructor() { + this.filePath = join(resolveAiddConfigDir(), "identity.json"); + } + + async read(): Promise { + try { + return parseIdentity(await readFile(this.filePath, "utf8")); + } catch { + return null; + } + } + + async readStrict(): Promise { + const raw = await this.readFileOrNull(); + if (raw === null) return null; + try { + return parseIdentity(raw); + } catch (error) { + throw new UnreadableIdentityFileError(this.filePath, errorMessage(error)); + } + } + + async mint(): Promise { + const identity: PersonIdentity = { personId: randomUUID(), origin: "minted", alsoMe: [] }; + await this.write(identity); + return identity; + } + + async adopt(personId: string): Promise { + const identity = withPersonIdAdopted(await this.readStrict(), personId); + await this.write(identity); + return identity; + } + + async addAlsoMe(identity: string): Promise { + const next = withAlsoMeAdded(await this.requireCurrent("add"), identity); + await this.write(next); + return next; + } + + async removeAlsoMe(identity: string): Promise { + const next = withAlsoMeRemoved(await this.requireCurrent("remove"), identity); + await this.write(next); + return next; + } + + async setDisplayName(identity: PersonIdentity, displayName: string): Promise { + const next: PersonIdentity = { ...identity, displayName }; + await this.write(next); + return next; + } + + // `recursive: true` so withdrawing works even where the damaged identity file is a + // directory; `force: true` deliberately not set, since "already gone" is the one case this + // must report back rather than fold into success. `path` is the caller's, never re-derived. + async forget(path: string): Promise { + try { + await rm(path, { recursive: true }); + return true; + } catch (error) { + if (isErrnoException(error) && error.code === "ENOENT") return false; + throw new IdentityWriteError(path, error, "remove"); + } + } + + // The calling use case already refuses "nobody opted in", so this is the defensive + // fallback for that contract, not a path a normal call takes. + private async requireCurrent(action: "add" | "remove"): Promise { + const current = await this.readStrict(); + if (current !== null) return current; + throw new IdentityWriteError( + this.filePath, + new Error(`no identity exists to ${action} an identifier onto`), + "write" + ); + } + + private async readFileOrNull(): Promise { + try { + return await readFile(this.filePath, "utf8"); + } catch (error) { + if (isErrnoException(error) && error.code === "ENOENT") return null; + throw new UnreadableIdentityFileError(this.filePath, errorMessage(error)); + } + } + + private async write(identity: PersonIdentity): Promise { + const filePath = this.filePath; + try { + await mkdir(dirname(filePath), { recursive: true, mode: PRIVATE_DIR_MODE }); + await writeFile(filePath, serializeIdentity(identity), { mode: PRIVATE_FILE_MODE }); + } catch (error) { + throw new IdentityWriteError(filePath, error); + } + } +} diff --git a/cli/src/contexts/telemetry/infrastructure/run-journal-reader-adapter.ts b/cli/src/contexts/telemetry/infrastructure/run-journal-reader-adapter.ts new file mode 100644 index 000000000..ac65d1386 --- /dev/null +++ b/cli/src/contexts/telemetry/infrastructure/run-journal-reader-adapter.ts @@ -0,0 +1,298 @@ +import { readdir, readFile, rm } from "node:fs/promises"; +import { join } from "node:path"; +import { resolvedRunsDir } from "../../../kernel/paths.js"; +import { isBareFileName } from "../../../kernel/reading/confined-file-name.js"; +import type { + RunJournal, + RunJournalBoundary, + RunJournalFileWritten, + RunJournalSessionStart, + RunJournalStore, + RunJournalTaskDeclared, +} from "../domain/ports/run-journal-reader.js"; + +/** The one schema this reader knows, mirroring `record.cjs`'s own `SCHEMA_VERSION` and + * pinned against it by the integration suite. A journal stating any other version is refused + * rather than read, since its lines can carry another shape entirely. */ +export const READABLE_JOURNAL_SCHEMA_VERSION = 2; + +const ULID_LENGTH = 26; // encodeTime(10) + encodeRandom(16), matching record.cjs's own ULID_LENGTH. +const RUN_FILE_EXTENSION = ".jsonl"; + +// Mirrors the hook's own `sanitizePathSegment` character for character, so a vendor id +// sanitized there on write matches what is sanitized here on read. Not a shared import: the +// hook is a zero-dependency CommonJS script. Exported so a test can pin the agreement. +export function sanitizePathSegment(segment: string): string { + const cleaned = segment.replace(/[^\w.-]/gu, "-"); + return cleaned === "" || cleaned === "." || cleaned === ".." ? "-" : cleaned; +} + +// Mirrors record.cjs's parseRunFileName: split on the fixed ULID length, never on "__", +// since a sanitized vendor id can itself contain that substring. +function matchesVendorId(entry: string, wantedSegment: string): boolean { + if (!entry.endsWith(RUN_FILE_EXTENSION)) return false; + const minLength = ULID_LENGTH + "__".length + RUN_FILE_EXTENSION.length; + if (entry.length <= minLength) return false; + if (entry.slice(ULID_LENGTH, ULID_LENGTH + 2) !== "__") return false; + return entry.slice(ULID_LENGTH + 2, -RUN_FILE_EXTENSION.length) === wantedSegment; +} + +function asString(value: unknown): string | undefined { + return typeof value === "string" ? value : undefined; +} + +function asNumber(value: unknown): number | undefined { + return typeof value === "number" && Number.isFinite(value) ? value : undefined; +} + +/** Absence is never that statement, and neither is a non-finite value: a field nobody wrote, + * or a torn one, states nothing, and refusing it would drop attribution over an unknown. */ +function statesAnotherSchema(session: RunJournalSessionStart | undefined): boolean { + const stated = session?.schema_version; + return stated !== undefined && stated !== READABLE_JOURNAL_SCHEMA_VERSION; +} + +interface RawJournalLine { + readonly type?: unknown; + readonly at?: unknown; + readonly skill?: unknown; + readonly turn_id?: unknown; + readonly run_id?: unknown; + readonly tool?: unknown; + readonly vendor_id?: unknown; + readonly project_id?: unknown; + readonly project_remote?: unknown; + readonly worktree_id?: unknown; + readonly worktree_repo_id?: unknown; + readonly path?: unknown; + readonly plugin_version?: unknown; + readonly schema_version?: unknown; +} + +function parseLine(line: string): RawJournalLine | null { + const trimmed = line.trim(); + if (!trimmed) return null; + try { + return JSON.parse(trimmed) as RawJournalLine; + } catch { + return null; + } +} + +/** `null` for every other line type and every unparseable line: a torn final line from a + * session still in progress reads as nothing, not as a boundary at the wrong moment. */ +function parseBoundary(parsed: RawJournalLine): RunJournalBoundary | null { + const at = asString(parsed.at); + if (at === undefined) return null; + if (parsed.type === "turn_end") return { type: "turn_end", at }; + const skill = asString(parsed.skill); + if (skill === undefined) return null; + // An end with no skill is dropped rather than read as a bare boundary: it would close a + // step it cannot name. + if (parsed.type === "step_end") return { type: "step_end", at, skill }; + if (parsed.type !== "step_start") return null; + const turnId = asString(parsed.turn_id); + return { type: "step_start", at, skill, ...(turnId === undefined ? {} : { turn_id: turnId }) }; +} + +/** A plain checkout writes neither key; `asString` rejects `""`, so a torn or empty value + * reads as "not stated" rather than as a worktree named nothing. */ +function parseWorktree( + parsed: RawJournalLine +): Pick { + const worktreeId = asString(parsed.worktree_id); + const worktreeRepoId = asString(parsed.worktree_repo_id); + return { + ...(worktreeId === undefined ? {} : { worktree_id: worktreeId }), + ...(worktreeRepoId === undefined ? {} : { worktree_repo_id: worktreeRepoId }), + }; +} + +/** `run_id`, `tool` and `vendor_id` are all required: a header naming two of the three + * cannot say which session it belongs to, and a half-read header is worse than none. */ +function parseSessionStart(parsed: RawJournalLine): RunJournalSessionStart | null { + if (parsed.type !== "session_start") return null; + const at = asString(parsed.at); + const runId = asString(parsed.run_id); + const tool = asString(parsed.tool); + const vendorId = asString(parsed.vendor_id); + if (at === undefined || runId === undefined || tool === undefined || vendorId === undefined) { + return null; + } + return { + type: "session_start", + at, + run_id: runId, + tool, + vendor_id: vendorId, + ...headerExtras(parsed), + }; +} + +/** Each field is absent rather than defaulted: a field the writer left out is one this + * reader has nothing to say about, and a default would be an answer nobody wrote. */ +function headerExtras(parsed: RawJournalLine): Partial { + const projectId = asString(parsed.project_id); + const projectRemote = asString(parsed.project_remote); + const pluginVersion = asString(parsed.plugin_version); + const schemaVersion = asNumber(parsed.schema_version); + return { + ...(schemaVersion === undefined ? {} : { schema_version: schemaVersion }), + ...(projectId === undefined ? {} : { project_id: projectId }), + ...(projectRemote === undefined ? {} : { project_remote: projectRemote }), + ...parseWorktree(parsed), + ...(pluginVersion === undefined ? {} : { plugin_version: pluginVersion }), + }; +} + +function parseFileWritten(parsed: RawJournalLine): RunJournalFileWritten | null { + if (parsed.type !== "file_written") return null; + const at = asString(parsed.at); + const writtenPath = asString(parsed.path); + return at === undefined || writtenPath === undefined + ? null + : { type: "file_written", at, path: writtenPath }; +} + +function parseTaskDeclared(parsed: RawJournalLine): RunJournalTaskDeclared | null { + if (parsed.type !== "task_declared") return null; + const at = asString(parsed.at); + const declaredPath = asString(parsed.path); + return at === undefined || declaredPath === undefined + ? null + : { type: "task_declared", at, path: declaredPath }; +} + +/** Mutable so `classifyLine` can fill it one line at a time without every caller threading + * four separate arrays through. */ +interface JournalCollector { + readonly boundaries: RunJournalBoundary[]; + readonly filesWritten: RunJournalFileWritten[]; + readonly taskDeclarations: RunJournalTaskDeclared[]; + session: RunJournalSessionStart | undefined; +} + +function newJournalCollector(): JournalCollector { + return { boundaries: [], filesWritten: [], taskDeclarations: [], session: undefined }; +} + +/** The four line types this port promises, tried in the order they are written most often. + * A line matching none of them is the header. */ +function classifyLine(collector: JournalCollector, parsed: RawJournalLine): void { + const boundary = parseBoundary(parsed); + if (boundary) { + collector.boundaries.push(boundary); + return; + } + const written = parseFileWritten(parsed); + if (written) { + collector.filesWritten.push(written); + return; + } + const declared = parseTaskDeclared(parsed); + if (declared) { + collector.taskDeclarations.push(declared); + return; + } + // The header is written once, first, so keeping the first one read means a second never + // silently replaces the identity the file opened with. + collector.session ??= parseSessionStart(parsed) ?? undefined; +} + +/** Never throws: a missing run file, an unreadable runs directory or a truncated final line + * all answer `null` or an empty list, since a damaged journal costs attribution, not the read + * itself. `AIDD_RUNS_DIR` overrides the directory, resolved once in the constructor so a + * later relocation cannot change what this instance answers. */ +export class RunJournalReaderAdapter implements RunJournalStore { + readonly runsDir: string; + + constructor(projectRoot: string) { + this.runsDir = resolvedRunsDir(projectRoot); + } + + async read(sessionId: string): Promise { + const filePath = await this.findRunFile(this.runsDir, sessionId); + return filePath ? this.readJournal(filePath) : null; + } + + async list(): Promise { + const dir = this.runsDir; + let entries: string[]; + try { + entries = await readdir(dir); + } catch { + return []; + } + const journals: RunJournal[] = []; + for (const entry of entries.sort()) { + if (!entry.endsWith(RUN_FILE_EXTENSION)) continue; + const journal = await this.readJournal(join(dir, entry)); + if (journal) journals.push(journal); + } + return journals; + } + + async listForeignSchemas(): Promise { + const stated: number[] = []; + for (const fileName of await this.listRunFiles()) { + const collector = await this.collect(join(this.runsDir, fileName)); + const version = collector?.session?.schema_version; + if (version !== undefined && version !== READABLE_JOURNAL_SCHEMA_VERSION) + stated.push(version); + } + return stated; + } + + async listRunFiles(): Promise { + try { + const entries = await readdir(this.runsDir); + return entries.filter((entry) => entry.endsWith(RUN_FILE_EXTENSION)).sort(); + } catch { + return []; + } + } + + // `force: true`: a name already gone is nothing to remove, never a failure. `isBareFileName` + // is the confinement — `join` normalises `..` away visually but still deletes wherever the + // result lands, so a name that is not a bare component of `dir` never reaches `rm`. + async deleteRunFile(dir: string, fileName: string): Promise { + if (!isBareFileName(fileName)) { + throw new Error(`refusing to delete "${fileName}" — not a run file name inside ${dir}`); + } + await rm(join(dir, fileName), { force: true }); + } + + private async findRunFile(dir: string, sessionId: string): Promise { + let entries: string[]; + try { + entries = await readdir(dir); + } catch { + return null; + } + const wanted = sanitizePathSegment(sessionId); + const match = entries.find((entry) => matchesVendorId(entry, wanted)); + return match ? join(dir, match) : null; + } + + private async collect(filePath: string): Promise { + let content: string; + try { + content = await readFile(filePath, "utf8"); + } catch { + return null; + } + const collector = newJournalCollector(); + for (const line of content.split("\n")) { + const parsed = parseLine(line); + if (parsed) classifyLine(collector, parsed); + } + return collector; + } + + private async readJournal(filePath: string): Promise { + const collector = await this.collect(filePath); + if (!collector || statesAnotherSchema(collector.session)) return null; + const { boundaries, filesWritten, taskDeclarations, session } = collector; + return { boundaries, filesWritten, taskDeclarations, ...(session ? { session } : {}) }; + } +} diff --git a/cli/src/contexts/telemetry/infrastructure/task-backlog-adapter.ts b/cli/src/contexts/telemetry/infrastructure/task-backlog-adapter.ts new file mode 100644 index 000000000..463050917 --- /dev/null +++ b/cli/src/contexts/telemetry/infrastructure/task-backlog-adapter.ts @@ -0,0 +1,55 @@ +import { readFile } from "node:fs/promises"; +import { join } from "node:path"; +import { asPlainObjectOrEmpty, isErrnoException } from "../../../kernel/reading/json-file.js"; +import { repositoryRootAbove } from "../../../kernel/reading/repository-root.js"; +import type { TaskBacklogReader } from "../domain/ports/task-backlog-reader.js"; +import type { TaskBacklogDeclaration, TaskBacklogLink } from "../domain/task-backlog-link.js"; + +/** The one file a task folder writes to declare its backlog item — see + * `domain/task-backlog-link.ts` for why this is not `metadata.json`. */ +export const TASK_BACKLOG_LINK_FILENAME = "backlog-link.json"; + +function nonEmptyString(value: unknown): string | undefined { + return typeof value === "string" && value !== "" ? value : undefined; +} + +/** `null` for anything this file cannot be read as: a broken shape is evidence of a + * declaration someone attempted, so it must surface as damage rather than read the same as + * an absent file. */ +function parseLink(raw: string): TaskBacklogLink | null { + const parsed = asPlainObjectOrEmpty(JSON.parse(raw)); + const backlog = nonEmptyString(parsed.backlog); + const writtenAt = nonEmptyString(parsed.written_at); + const writtenBy = nonEmptyString(parsed.written_by); + if (backlog === undefined || writtenAt === undefined || writtenBy === undefined) return null; + return { backlog, writtenAt, writtenBy }; +} + +/** Never throws and never writes, on any path: that is what lets a report run against a + * checkout someone else owns without risking the work it describes. */ +export class TaskBacklogAdapter implements TaskBacklogReader { + private readonly repositoryRoot: string; + + // Resolved once, at construction, so a relocation afterwards cannot change what this + // instance answers. A task folder path arrives repository-relative, as the journal wrote it. + constructor(projectRoot: string) { + this.repositoryRoot = repositoryRootAbove(projectRoot); + } + + async read(taskFolderPath: string): Promise { + const filePath = join(this.repositoryRoot, taskFolderPath, TASK_BACKLOG_LINK_FILENAME); + let raw: string; + try { + raw = await readFile(filePath, "utf8"); + } catch (error) { + if (isErrnoException(error) && error.code === "ENOENT") return { kind: "none" }; + return { kind: "unreadable" }; + } + try { + const link = parseLink(raw); + return link === null ? { kind: "unreadable" } : { kind: "declared", link }; + } catch { + return { kind: "unreadable" }; + } + } +} diff --git a/cli/src/contexts/telemetry/infrastructure/telemetry-evidence-adapter.ts b/cli/src/contexts/telemetry/infrastructure/telemetry-evidence-adapter.ts new file mode 100644 index 000000000..e84578124 --- /dev/null +++ b/cli/src/contexts/telemetry/infrastructure/telemetry-evidence-adapter.ts @@ -0,0 +1,275 @@ +import { readFile } from "node:fs/promises"; +import { join } from "node:path"; +import { genericFlatHooksScriptPath } from "../../../kernel/materialization/flat-paths.js"; +import { AIDD_DIR, MANIFEST_FILENAME, resolvedRunsDir } from "../../../kernel/paths.js"; +import { resolveHomeDir } from "../../../kernel/reading/home-dir.js"; +import { isErrnoException } from "../../../kernel/reading/json-file.js"; +import { asPlainObject } from "../../../kernel/reading/plain-object.js"; +import { AI_TOOL_IDS } from "../../../kernel/tool.js"; +import { cursorProjectHooksScriptDir } from "../../tools/domain/formats/cursor-hooks-project-merge.js"; +import { hookCommandsForEvent } from "../../tools/domain/formats/flat-hooks-merge.js"; +import { CLAUDE_PLUGIN_ROOT_TOKEN } from "../../tools/domain/formats/plugin-root-token.js"; +import type { MarketplaceSettings } from "../../tools/domain/marketplace-settings.js"; +import { getAiToolConfig } from "../../tools/domain/registry.js"; +import type { + TelemetryEvidenceReader, + TelemetrySwitchSetupRead, + TelemetryUnrecognisedPayload, +} from "../domain/ports/telemetry-evidence-reader.js"; +import { + findLeftoverExportKeys, + type TelemetryExportLeftover, +} from "../domain/telemetry-export-leftover.js"; +import type { TelemetryRecorderDeclarationSetup } from "../domain/telemetry-setup.js"; +import { + parseTelemetrySwitchFile, + resolveTelemetryEnabled, + telemetryConfigPath, +} from "../domain/telemetry-switch.js"; +import { + HOOK_ENTRY_SCRIPT, + PLUGIN_NAME as RECORDER_PLUGIN_NAME, +} from "./hook-trust-reader-adapter.js"; + +const UNRECOGNISED_FILE_NAME = "_unrecognised.jsonl"; + +function manifestPath(projectRoot: string): string { + return join(projectRoot, AIDD_DIR, MANIFEST_FILENAME); +} + +// Claude Code is the only tool that ever wrote a settings-file export, so these three are +// where stale export keys can be. They double as the three real Claude hook scopes a person +// can hand-author into, but never as `enabledPlugins` locations: nothing writes that key to +// `settings.local.json` or the home settings file. +function claudeSettingsCandidates(projectRoot: string): readonly string[] { + return [ + join(projectRoot, ".claude", "settings.local.json"), + join(projectRoot, ".claude", "settings.json"), + join(resolveHomeDir(), ".claude", "settings.json"), + ]; +} + +// One location per AI tool declaring a `marketplaceSettings.enabledPluginsKey`, resolved the +// way `marketplace-sync-settings-use-case.ts` resolves it when writing, so this read can +// never disagree with the write it reads back. +function enabledPluginsCandidates(projectRoot: string): readonly string[] { + const paths: string[] = []; + for (const toolId of AI_TOOL_IDS) { + const caps = getAiToolConfig(toolId).capabilities as { + plugins?: { marketplaceSettings?: MarketplaceSettings | null }; + }; + const settings = caps.plugins?.marketplaceSettings; + if (!settings || settings.enabledPluginsKey === undefined) continue; + const path = join(projectRoot, settings.settingsPath); + if (!paths.includes(path)) paths.push(path); + } + return paths; +} + +// Cursor's plugin-scope hooks never fire, so this project-scope file in Cursor's flat +// `version: 1` shape is a Cursor install's only working declaration route. +function cursorHooksJsonPath(projectRoot: string): string { + return join(projectRoot, ".cursor", "hooks.json"); +} + +function dedupe(values: readonly string[]): readonly string[] { + return [...new Set(values)]; +} + +type DeclarationCheck = "declared" | "not-declared" | "unreadable"; + +type JsonRead = + | { readonly status: "absent" } + | { readonly status: "unreadable" } + | { readonly status: "ok"; readonly raw: string; readonly value: unknown }; + +// The seam where a present-but-damaged file (a trailing comma, unreadable permissions) is +// told apart from one that never existed. +async function readJsonIfExists(path: string): Promise { + let raw: string; + try { + raw = await readFile(path, "utf8"); + } catch (error) { + if (isErrnoException(error) && error.code === "ENOENT") return { status: "absent" }; + return { status: "unreadable" }; + } + try { + return { status: "ok", raw, value: JSON.parse(raw) }; + } catch { + return { status: "unreadable" }; + } +} + +async function readIfExists(path: string): Promise { + try { + return await readFile(path, "utf8"); + } catch { + return null; + } +} + +/** An absent file is `readable: true` with nothing decided yet; any other read failure, or + * content that is not JSON, is `readable: false` — a damaged file, not a choice. A file that + * parses but names no `telemetry` key is readable with nothing ever set. */ +async function readSwitchFile(projectRoot: string): Promise<{ + readonly readable: boolean; + readonly fileSwitch: ReturnType; +}> { + let content: string; + try { + content = await readFile(telemetryConfigPath(projectRoot), "utf8"); + } catch (error) { + if (isErrnoException(error) && error.code === "ENOENT") { + return { readable: true, fileSwitch: null }; + } + return { readable: false, fileSwitch: null }; + } + try { + JSON.parse(content); + } catch { + return { readable: false, fileSwitch: null }; + } + return { readable: true, fileSwitch: parseTelemetrySwitchFile(content) }; +} + +/** A lenient walk of the raw JSON rather than `Manifest.fromJSON`'s strict schema, which + * throws on a shape this read must never crash over. */ +async function manifestDeclaresPlugin(path: string, pluginName: string): Promise { + const result = await readJsonIfExists(path); + if (result.status !== "ok") return result.status === "absent" ? "not-declared" : "unreadable"; + const tools = asPlainObject(asPlainObject(result.value)?.tools); + if (tools === null) return "not-declared"; + const found = Object.values(tools).some((entry) => { + const plugins = asPlainObject(entry)?.plugins; + return Array.isArray(plugins) && plugins.some((p) => asPlainObject(p)?.name === pluginName); + }); + return found ? "declared" : "not-declared"; +} + +/** `enabledPlugins` keys look like `"@"`, so this is a prefix match: + * the marketplace half is this project's own choice, not the recorder's identity. */ +async function settingsDeclaresPlugin(path: string, pluginName: string): Promise { + const result = await readJsonIfExists(path); + if (result.status !== "ok") return result.status === "absent" ? "not-declared" : "unreadable"; + const enabledPlugins = asPlainObject(asPlainObject(result.value)?.enabledPlugins); + if (enabledPlugins === null) return "not-declared"; + const prefix = `${pluginName}@`; + return Object.keys(enabledPlugins).some((key) => key.startsWith(prefix)) + ? "declared" + : "not-declared"; +} + +// Plugin-unique paths, never the bare leaf another plugin's hooks block could name just as +// easily. Each is multi-segment and forward-slashed on every platform, so a plain substring +// check matches a quoted command with no separate boundary logic. +const CLAUDE_HOOKS_TOKEN_MARKER = `${CLAUDE_PLUGIN_ROOT_TOKEN}/hooks/${HOOK_ENTRY_SCRIPT}`; +const CLAUDE_HOOKS_FLAT_MARKER = genericFlatHooksScriptPath( + ".claude/hooks/", + RECORDER_PLUGIN_NAME, + HOOK_ENTRY_SCRIPT +); +const CURSOR_HOOKS_MARKER = `${cursorProjectHooksScriptDir(RECORDER_PLUGIN_NAME)}${HOOK_ENTRY_SCRIPT}`; + +function invokesRecorderEntryPoint(command: string): boolean { + return ( + command.includes(CLAUDE_HOOKS_TOKEN_MARKER) || + command.includes(CLAUDE_HOOKS_FLAT_MARKER) || + command.includes(CURSOR_HOOKS_MARKER) + ); +} + +/** A hooks block is a declaration exactly like `enabledPlugins`, never proof: this only + * reads that the entry point was asked for. */ +async function hooksDeclarePlugin(path: string): Promise { + const result = await readJsonIfExists(path); + if (result.status !== "ok") return result.status === "absent" ? "not-declared" : "unreadable"; + const commands = hookCommandsForEvent(result.raw, "SessionStart"); + return commands.some((command) => invokesRecorderEntryPoint(command)) + ? "declared" + : "not-declared"; +} + +function parseUnrecognisedPayload(raw: string): TelemetryUnrecognisedPayload | null { + const line = raw.split("\n").find((candidate) => candidate.trim() !== ""); + if (line === undefined) return null; + let parsed: unknown; + try { + parsed = JSON.parse(line); + } catch { + return null; + } + const record = asPlainObject(parsed); + const at = record?.at; + if (record?.type !== "unrecognised_payload" || typeof at !== "string") return null; + return { at }; +} + +/** Evidence `aidd telemetry check` needs beyond the run journal, each tool's own local + * reader, and Codex's hook trust. */ +export class TelemetryEvidenceAdapter implements TelemetryEvidenceReader { + async isTelemetryEnabled(projectRoot: string, env: NodeJS.ProcessEnv): Promise { + const { fileSwitch } = await readSwitchFile(projectRoot); + return resolveTelemetryEnabled(fileSwitch, env); + } + + async readSwitchSetup(projectRoot: string): Promise { + const { readable, fileSwitch } = await readSwitchFile(projectRoot); + return { + path: telemetryConfigPath(projectRoot), + enabled: readable && fileSwitch?.enabled === true, + readable, + }; + } + + async readRecorderDeclaration(projectRoot: string): Promise { + const manifestFile = manifestPath(projectRoot); + const enabledPluginsFiles = enabledPluginsCandidates(projectRoot); + // A hooks block is a second, independent declaration route from `enabledPlugins`: every + // real Claude hook scope, plus Cursor's project-scope file. + const hooksFiles = [...claudeSettingsCandidates(projectRoot), cursorHooksJsonPath(projectRoot)]; + const locationsChecked = dedupe([manifestFile, ...enabledPluginsFiles, ...hooksFiles]); + const declaredAt: string[] = []; + const unreadable: string[] = []; + + const record = (path: string, outcome: DeclarationCheck): void => { + if (outcome === "declared") declaredAt.push(path); + else if (outcome === "unreadable") unreadable.push(path); + }; + + record(manifestFile, await manifestDeclaresPlugin(manifestFile, RECORDER_PLUGIN_NAME)); + for (const path of enabledPluginsFiles) { + record(path, await settingsDeclaresPlugin(path, RECORDER_PLUGIN_NAME)); + } + for (const path of hooksFiles) { + if (declaredAt.includes(path)) continue; + record(path, await hooksDeclarePlugin(path)); + } + return { + declared: declaredAt.length > 0, + declaredAt: dedupe(declaredAt), + locationsChecked, + unreadable: dedupe(unreadable), + }; + } + + async readUnrecognisedPayload(projectRoot: string): Promise { + try { + const content = await readFile( + join(resolvedRunsDir(projectRoot), UNRECOGNISED_FILE_NAME), + "utf8" + ); + return parseUnrecognisedPayload(content); + } catch { + return null; + } + } + + async findLeftoverExportConfig(projectRoot: string): Promise { + const leftovers: TelemetryExportLeftover[] = []; + for (const path of claudeSettingsCandidates(projectRoot)) { + const keys = findLeftoverExportKeys(await readIfExists(path)); + if (keys.length > 0) leftovers.push({ path, keys }); + } + return leftovers; + } +} diff --git a/cli/src/contexts/telemetry/infrastructure/telemetry-sink-adapter.ts b/cli/src/contexts/telemetry/infrastructure/telemetry-sink-adapter.ts new file mode 100644 index 000000000..56cb235ba --- /dev/null +++ b/cli/src/contexts/telemetry/infrastructure/telemetry-sink-adapter.ts @@ -0,0 +1,261 @@ +import { spawnSync } from "node:child_process"; +import { chmodSync, readdirSync } from "node:fs"; +import { access, appendFile, mkdir, readdir, readFile, rm, writeFile } from "node:fs/promises"; +import { userInfo } from "node:os"; +import { join } from "node:path"; +import { TelemetrySinkUnwritableError } from "../../../kernel/errors.js"; +import { isBareFileName } from "../../../kernel/reading/confined-file-name.js"; +import { resolveHomeDir } from "../../../kernel/reading/home-dir.js"; +import type { + TelemetrySink, + TelemetrySinkAppendResult, + TelemetrySinkPeriodRead, +} from "../domain/ports/telemetry-sink.js"; +import { + parseTelemetrySinkLine, + serializeTelemetrySinkRecord, + type TelemetrySinkRecord, + telemetrySinkRecordDayKey, +} from "../domain/telemetry-sink-record.js"; + +const DAY_FILE_EXTENSION = ".jsonl"; +const PRIVATE_FILE_MODE = 0o600; +const PRIVATE_DIR_MODE = 0o700; + +const DAY_KEY_LENGTH = "YYYY-MM-DD".length; + +function dayKey(at: Date): string { + return at.toISOString().slice(0, DAY_KEY_LENGTH); +} + +function dayFileName(at: Date): string { + return `${dayKey(at)}${DAY_FILE_EXTENSION}`; +} + +async function pathExists(path: string): Promise { + try { + await access(path); + return true; + } catch { + return false; + } +} + +function legacyConfigDir(): string { + return join(resolveHomeDir(), ".config", "aidd"); +} + +function hasLegacyTelemetryData(): boolean { + try { + const entries = readdirSync(join(legacyConfigDir(), "telemetry")); + return entries.some((entry) => entry.endsWith(DAY_FILE_EXTENSION)); + } catch { + return false; + } +} + +// `%APPDATA%`, not `.config`, is where a Windows application puts this; a machine that +// already journalled under `.config` keeps landing there rather than losing access to what it +// wrote. Exported so a test can pin the resolution on any platform, not only a Windows runner. +export function defaultConfigDir(): string { + if (process.platform !== "win32") return legacyConfigDir(); + if (hasLegacyTelemetryData()) return legacyConfigDir(); + return process.env.APPDATA ? join(process.env.APPDATA, "aidd") : legacyConfigDir(); +} + +// `mkdir`/`appendFile`'s `mode` is accepted on Windows without error and does nothing there; +// `icacls` is the mechanism that actually restricts a path. +function restrictToCurrentUser(target: string, options: { recursive?: boolean } = {}): void { + try { + const owner = process.env.USERDOMAIN + ? `${process.env.USERDOMAIN}\\${process.env.USERNAME}` + : (process.env.USERNAME ?? userInfo().username); + if (!owner) return; + const grant = options.recursive ? `${owner}:(OI)(CI)F` : `${owner}:F`; + const args = [target, "/inheritance:r", "/grant:r", grant]; + if (options.recursive) args.push("/T"); + args.push("/C", "/Q"); + spawnSync("icacls", args, { encoding: "utf8" }); + } catch { + // icacls missing, no resolvable owner, or a domain-policy refusal: leave it as it is. + } +} + +/** Every write is `appendFile`. `readRecordsForVendor` is the only method that reads a day + * file's content, and only so a local re-read knows what is already stored. */ +export class TelemetrySinkAdapter implements TelemetrySink { + /** `AIDD_TELEMETRY_DIR` names this directory outright; `AIDD_USER_CONFIG_DIR` names the + * directory *above* it and also relocates `auth.json`, a GitHub token, so it can never be + * the variable a team shares. */ + readonly rootDir: string; + // A user who names their own location keeps responsibility for its permissions: sharing a + // directory is what this exists for, and locking it to one account would break that. + private readonly userNamed: boolean; + + /** Carried because one answer has a consequence a person must be told about: + * `AIDD_USER_CONFIG_DIR` also names where `auth.json` is written, so anyone using it has a + * credential in the directory they were told to share. */ + readonly locatedBy: "telemetry-dir" | "user-config-dir" | "default"; + + constructor(userConfigDir?: string) { + const named = process.env.AIDD_TELEMETRY_DIR; + const legacy = userConfigDir ?? process.env.AIDD_USER_CONFIG_DIR; + this.userNamed = named !== undefined || legacy !== undefined; + this.rootDir = named ?? join(legacy ?? defaultConfigDir(), "telemetry"); + this.locatedBy = + named !== undefined ? "telemetry-dir" : legacy !== undefined ? "user-config-dir" : "default"; + } + + private tightenDir(): void { + if (this.userNamed) return; + if (process.platform === "win32") { + restrictToCurrentUser(this.rootDir, { recursive: true }); + return; + } + // `mkdir`'s own `mode` is masked by the process umask and applies only when it creates + // the directory, so it cannot make an existing one private. The listing is what needs + // protecting here: which days this person worked, and how many. + try { + chmodSync(this.rootDir, PRIVATE_DIR_MODE); + } catch { + // Someone else's directory, or a filesystem with no modes: the content stays 0600. + } + } + + // `/T` on the directory does not reliably carry the grant onto a leaf file it walks into, + // so a day file gets its own pass on the write that creates it. + private tightenFile(filePath: string): void { + if (this.userNamed || process.platform !== "win32") return; + restrictToCurrentUser(filePath, { recursive: false }); + } + + async ensureWritable(): Promise { + try { + await mkdir(this.rootDir, { recursive: true }); + this.tightenDir(); + const probePath = join(this.rootDir, `.write-check-${process.pid}`); + await writeFile(probePath, "", { mode: PRIVATE_FILE_MODE }); + await rm(probePath, { force: true }); + } catch (error) { + throw new TelemetrySinkUnwritableError(this.rootDir, error); + } + } + + async appendRecord(record: TelemetrySinkRecord, at: Date): Promise { + const filePath = join(this.rootDir, dayFileName(at)); + const dayFileIsNew = !(await pathExists(filePath)); + await mkdir(this.rootDir, { recursive: true }); + this.tightenDir(); + await appendFile(filePath, `${serializeTelemetrySinkRecord(record)}\n`, { + mode: PRIVATE_FILE_MODE, + }); + if (dayFileIsNew) this.tightenFile(filePath); + return { filePath, dayFileIsNew }; + } + + async listDayFiles(): Promise { + try { + const entries = await readdir(this.rootDir); + return entries.filter((entry) => entry.endsWith(DAY_FILE_EXTENSION)).sort(); + } catch { + return []; + } + } + + // `isBareFileName` is the confinement: `join` normalises `..` away visually but still + // deletes wherever the result lands, so a name that is not a bare component of the + // caller-supplied `dir` never reaches `rm`. + async deleteDayFile(dir: string, fileName: string): Promise { + if (!isBareFileName(fileName)) { + throw new Error(`refusing to delete "${fileName}" — not a day file name inside ${dir}`); + } + await rm(join(dir, fileName), { force: true }); + } + + async readRecordsForVendor(vendorId: string): Promise { + const records: TelemetrySinkRecord[] = []; + for (const fileName of await this.listDayFiles()) { + records.push(...(await this.readVendorRecordsFromFile(fileName, vendorId))); + } + return records; + } + + // Every day file is opened, not only the ones the period names: a session read days after + // it ran lands in today's file while its records carry their own, older moments, so + // selecting by file name would select by when we heard about the work. + async readRecordsInPeriod(fromDay: Date, toDay: Date): Promise { + const [fromKey, toKey] = [dayKey(fromDay), dayKey(toDay)].sort(); + const records: TelemetrySinkRecord[] = []; + const undated: TelemetrySinkRecord[] = []; + let skippedLines = 0; + const projects = new Set(); + const steps = new Set(); + const models = new Set(); + for (const fileName of await this.listDayFiles()) { + const read = await this.readAllRecordsFromFile(fileName); + skippedLines += read.skippedLines; + for (const record of read.records) { + if (record.project_id !== undefined) projects.add(record.project_id); + if (record.step !== undefined) steps.add(record.step); + if (record.model !== undefined) models.add(record.model); + const key = telemetrySinkRecordDayKey(record); + if (key === undefined) undated.push(record); + else if (key >= fromKey && key <= toKey) records.push(record); + } + } + return { records, undated, skippedLines, knownValues: { projects, steps, models } }; + } + + private async readAllRecordsFromFile( + fileName: string + ): Promise<{ records: TelemetrySinkRecord[]; skippedLines: number }> { + let content: string; + try { + content = await readFile(join(this.rootDir, fileName), "utf8"); + } catch { + // A file listed a moment ago and unreadable now — rotated, deleted, or never ours. + // Nothing about it is known, so nothing is counted as skipped either. + return { records: [], skippedLines: 0 }; + } + const records: TelemetrySinkRecord[] = []; + let skippedLines = 0; + for (const line of content.split("\n")) { + if (line.trim() === "") continue; + const record = this.parseLineOrSkip(line); + if (record) records.push(record); + else skippedLines += 1; + } + return { records, skippedLines }; + } + + private async readVendorRecordsFromFile( + fileName: string, + vendorId: string + ): Promise { + let content: string; + try { + content = await readFile(join(this.rootDir, fileName), "utf8"); + } catch { + // Same tolerance as `readAllRecordsFromFile`: a file listed a moment ago and + // unreadable now must not fail a vendor-scoped read any more than a full one. + return []; + } + const records: TelemetrySinkRecord[] = []; + for (const line of content.split("\n")) { + if (line.trim() === "") continue; + const record = this.parseLineOrSkip(line); + if (record?.vendor_id === vendorId) records.push(record); + } + return records; + } + + // A torn final line or a stray older-schema line must not fail an unrelated session's + // read: skipped, since no typed exception is useful for one line among many. + private parseLineOrSkip(line: string): TelemetrySinkRecord | undefined { + try { + return parseTelemetrySinkLine(line); + } catch { + return undefined; + } + } +} diff --git a/cli/src/contexts/telemetry/infrastructure/transcript-cost-reader-adapter.ts b/cli/src/contexts/telemetry/infrastructure/transcript-cost-reader-adapter.ts new file mode 100644 index 000000000..0394ac0cd --- /dev/null +++ b/cli/src/contexts/telemetry/infrastructure/transcript-cost-reader-adapter.ts @@ -0,0 +1,64 @@ +import type { Dirent } from "node:fs"; +import { createReadStream } from "node:fs"; +import { readdir } from "node:fs/promises"; +import { join, relative } from "node:path"; +import { createInterface } from "node:readline"; +import type { TranscriptLocation } from "../../../kernel/measurement.js"; +import type { + LocalCostCandidateRecord, + LocalCostReadResult, + SessionCostReader, + TranscriptLineAccumulator, +} from "../domain/ports/session-cost-reader.js"; + +async function* walk(dir: string): AsyncGenerator { + let entries: Dirent[]; + try { + entries = await readdir(dir, { withFileTypes: true }); + } catch { + return; + } + for (const entry of entries) { + const absolutePath = join(dir, entry.name); + if (entry.isDirectory()) yield* walk(absolutePath); + else if (entry.isFile()) yield absolutePath; + } +} + +/** Where to search and which names belong to a session are the tool's own declaration; this + * class walks and reads, and encodes no path of its own. No matching file answers + * `sessionFound: false`, a different fact from a transcript holding nothing billable. Read + * through `readline`, so a large transcript is never held whole in memory. */ +export class TranscriptCostReaderAdapter implements SessionCostReader { + constructor( + private readonly homeDir: string, + private readonly location: TranscriptLocation, + private readonly createAccumulator: () => TranscriptLineAccumulator + ) {} + + async read(sessionId: string): Promise { + const root = this.location.root(this.homeDir); + const files = await this.findMatchingFiles(root, sessionId); + const records: LocalCostCandidateRecord[] = []; + for (const file of files) { + records.push(...(await this.readFile(file))); + } + return { records, sessionFound: files.length > 0 }; + } + + private async findMatchingFiles(root: string, sessionId: string): Promise { + const matches: string[] = []; + for await (const absolutePath of walk(root)) { + const relativePath = relative(root, absolutePath); + if (this.location.matches(relativePath, sessionId)) matches.push(absolutePath); + } + return matches; + } + + private async readFile(path: string): Promise { + const accumulator = this.createAccumulator(); + const lines = createInterface({ input: createReadStream(path), crlfDelay: Infinity }); + for await (const line of lines) accumulator.push(line); + return accumulator.build(); + } +} diff --git a/cli/src/contexts/tools/domain/build-contract.ts b/cli/src/contexts/tools/domain/build-contract.ts new file mode 100644 index 000000000..488c944a2 --- /dev/null +++ b/cli/src/contexts/tools/domain/build-contract.ts @@ -0,0 +1,156 @@ +import type { AssetProvider, SchemaName } from "../../../kernel/ports/asset-provider.js"; +import type { FileReader } from "../../../kernel/ports/file-reader.js"; +import type { FileWriter } from "../../../kernel/ports/file-writer.js"; +import type { JsonSchemaValidator } from "./ports/schema-validator.js"; + +/** How a plugin's artifact files are sourced: a sub-directory walked with an extension filter + * or in full, a single plugin-relative file, or hooks.json plus its sibling scripts. */ +export type ArtifactSource = + | { readonly kind: "filteredTree"; readonly srcDir: string; readonly inputExt: string } + | { readonly kind: "fullTree"; readonly srcDir: string } + | { readonly kind: "configFile"; readonly srcPath: string } + | { readonly kind: "hooksBundle"; readonly jsonPath: string; readonly scriptDir: string }; + +export type ArtifactContract = + | { readonly supported: false } + | { + readonly supported: true; + readonly source: ArtifactSource; + readonly path: (plugin: string, relPath: string) => string; + /** Absent preserves the source extension. */ + readonly ext?: string; + /** Defaults to identity, a byte-copy. */ + readonly transform?: (content: string, plugin: string, basename: string) => string; + /** VS Code Copilot discovers a skill by its parent folder name, so flat mode rewrites + * SKILL.md's `name` frontmatter to match. Meaningless outside a flat skill artifact. */ + readonly rewriteSkillName?: boolean; + /** Additive merge into an existing config file, for a config-kind artifact that merges + * rather than writing once per plugin. */ + readonly merge?: ( + existing: string | null, + incomingPrefixed: Record, + force: boolean + ) => { mergedContent: string; collisions: ReadonlyArray }; + /** servers-key of the mcp merge target — `servers` for copilot, `mcpServers` for claude. + * Only meaningful alongside `merge`. */ + readonly mcpServersKey?: string; + /** Absolute path to the shared merge target; only for merge contracts. */ + readonly mergeDest?: (outDir: string) => string; + /** Merge for hooks, where hooks.json joins an existing file rather than being written + * per plugin (codex flat, claude settings). Warnings are surfaced to the user. */ + readonly hooksMerge?: ( + existing: string | null, + incoming: string + ) => { content: string; warnings: readonly string[] }; + /** Absolute path to the shared hooks merge target; only for hooksMerge contracts. */ + readonly hooksMergeDest?: (outDir: string) => string; + /** Shape transform for a per-plugin hooks file, applied after `${CLAUDE_PLUGIN_ROOT}` + * rewriting and before the write. */ + readonly hooksTransform?: (rewrittenJson: string) => string; + /** Delivers everything under hooks/ except hooks.json, for a tool whose own side reads + * none — OpenCode today, whose scripts are still delivered and whose trigger is + * `hooksBridge`. */ + readonly skipHooksJson?: boolean; + /** A generated event bridge, for a tool with no hooks.json of its own and no other way + * to trigger a plugin's declared hooks. Read only when `skipHooksJson` is also true. */ + readonly hooksBridge?: { + /** Raw (unrewritten) hooks.json content + plugin name -> the generated bridge + * module's full text, or `null` when nothing in it named a mapped event. */ + readonly generate: (rawHooksJson: string, plugin: string) => string | null; + readonly path: (plugin: string) => string; + /** A hooks/ file whose presence in this plugin's own source means the plugin ships its + * own bridge already — generate nothing for it. */ + readonly skipIfSourceHas: string; + }; + }; + +export interface ToolBuildContract { + /** Rewrites the source `${CLAUDE_PLUGIN_ROOT}` placeholder in hooks and mcp content — e.g. + * `${CURSOR_PLUGIN_ROOT}`, `${PLUGIN_ROOT}`. Absent for a flat-only contract, which + * substitutes nothing. */ + readonly pluginRootToken?: string; + /** Plugin-manifest file relative to plugin tree root (e.g. ".claude-plugin/plugin.json"). null if no manifest. */ + readonly manifestFileRelative: string | null; + + /** Synthesize a tool-native plugin manifest from the source manifest + presence flags. null if tool has no manifest. */ + readonly synthesizeManifest: + | ((source: Record, presence: PluginPresence) => Record) + | null; + + readonly manifestSchemaName: SchemaName | null; + + readonly artifacts: { + readonly skills: ArtifactContract; + readonly agents: ArtifactContract; + readonly mcp: ArtifactContract; + readonly hooks: ArtifactContract; + readonly rules: ArtifactContract; + readonly commands: ArtifactContract; + }; + + /** Post-build step emitting a tool config artifact (codex's config.toml, opencode.json), + * returning the count of files written. */ + readonly emitConfigArtifact?: + | (( + builtPlugins: readonly string[], + outDir: string, + sourceDir: string, + fs: FileReader & FileWriter, + jsonSchemaValidator: JsonSchemaValidator, + assetProvider: AssetProvider + ) => Promise) + | undefined; + + /** Builds the marketplace catalog once every plugin is written, to write and validate. + * `null` where the tool has no marketplace. */ + readonly buildMarketplaceCatalog: + | (( + sourceMarketplace: SourceMarketplaceRef, + pluginEntries: readonly Record[], + fs: FileReader & FileWriter + ) => Promise<{ + catalog: Record; + schemaName: SchemaName | null; + destRelPath: string; + }>) + | null; + + readonly buildMarketplaceEntry: + | (( + name: string, + pluginSrc: string, + outDir: string, + srcEntry: SourcePluginEntryRef | undefined, + fs: FileReader & FileWriter + ) => Promise>) + | null; +} + +/** Minimal reference to the source marketplace catalog, so `domain/` need not import the + * application layer. */ +export interface SourceMarketplaceRef { + readonly name: string; + readonly version?: string; + readonly description?: string; + readonly owner?: unknown; + readonly plugins: readonly SourcePluginEntryRef[]; + readonly [key: string]: unknown; +} + +export interface SourcePluginEntryRef { + readonly name: string; + readonly version?: string; + readonly description?: string; + readonly strict?: boolean; + readonly recommended?: boolean; + readonly [key: string]: unknown; +} + +export interface PluginPresence { + readonly hasAgents: boolean; + /** Agent markdown files relative to the plugin's `agents/` dir (e.g. "planner.md"), sorted. */ + readonly agentsList: readonly string[]; + readonly skillsList: readonly string[]; + readonly hasHooksJson: boolean; + readonly hasMcpJson: boolean; +} diff --git a/cli/src/domain/capabilities/agents-capability.ts b/cli/src/contexts/tools/domain/capabilities/agents-capability.ts similarity index 80% rename from cli/src/domain/capabilities/agents-capability.ts rename to cli/src/contexts/tools/domain/capabilities/agents-capability.ts index ee634a216..625db1ae6 100644 --- a/cli/src/domain/capabilities/agents-capability.ts +++ b/cli/src/contexts/tools/domain/capabilities/agents-capability.ts @@ -1,4 +1,4 @@ -import { parseFrontmatter, serializeFrontmatter } from "../formats/markdown.js"; +import { parseFrontmatter, serializeFrontmatter } from "../../../../kernel/markdown.js"; function agentNameFromFrontmatter( fm: Record, @@ -57,7 +57,6 @@ export class AgentsCapability { fm: Record, fileName?: string ) => Record; - reverseConvertFrontmatter?: (fm: Record) => Record; } ) {} @@ -65,16 +64,6 @@ export class AgentsCapability { return `${this.params.directory}agents/${agentName}${this.params.toolSuffix}`; } - buildUserFilePath(userFileName: string): string { - const basename = userFileName.split("/").at(-1) ?? userFileName; - const { userFileExt } = this.params; - if (userFileExt !== undefined) { - const name = basename.endsWith(".md") ? basename.slice(0, -3) : basename; - return `${this.params.directory}agents/${name}${userFileExt}`; - } - return `${this.params.directory}agents/${basename}`; - } - buildInstallPath(relativeFileName: string): string | null { if (this.params.buildInstallPath) return this.params.buildInstallPath(relativeFileName); const basename = relativeFileName.split("/").at(-1) ?? relativeFileName; @@ -105,13 +94,6 @@ export class AgentsCapability { return { name, description: fm.description }; } - reverseConvertFrontmatter(fm: Record): Record { - if (this.params.reverseConvertFrontmatter) return this.params.reverseConvertFrontmatter(fm); - const result: Record = { name: fm.name, description: fm.description }; - if (this.params.format === "toml" && fm.model !== undefined) result.model = fm.model; - return result; - } - serialize(frontmatter: Record, body: string): string { if (this.params.format === "toml") { return buildTomlContent(frontmatter, body); diff --git a/cli/src/domain/capabilities/commands-capability.ts b/cli/src/contexts/tools/domain/capabilities/commands-capability.ts similarity index 81% rename from cli/src/domain/capabilities/commands-capability.ts rename to cli/src/contexts/tools/domain/capabilities/commands-capability.ts index 8f85be55d..dbab938e2 100644 --- a/cli/src/domain/capabilities/commands-capability.ts +++ b/cli/src/contexts/tools/domain/capabilities/commands-capability.ts @@ -1,5 +1,5 @@ -import { serializeFrontmatter } from "../formats/markdown.js"; -import { AI_TOOL_IDS } from "../tools/registry.js"; +import { serializeFrontmatter } from "../../../../kernel/markdown.js"; +import { AI_TOOL_IDS } from "../../../../kernel/tool.js"; const ALL_TOOL_SUFFIXES: readonly string[] = AI_TOOL_IDS.map((id) => `.${id}.md`); @@ -13,7 +13,6 @@ export class CommandsCapability { fm: Record, relativeFileName: string ) => Record; - reverseConvertFrontmatter: (fm: Record) => Record; } ) {} @@ -32,10 +31,6 @@ export class CommandsCapability { return this.params.convertFrontmatter(fm, relativeFileName); } - reverseConvertFrontmatter(fm: Record): Record { - return this.params.reverseConvertFrontmatter(fm); - } - acceptsFileName(fileName: string): boolean { const basename = fileName.split("/").at(-1) ?? fileName; const otherSuffixes = ALL_TOOL_SUFFIXES.filter((s) => s !== this.params.toolSuffix); diff --git a/cli/src/contexts/tools/domain/capabilities/config-refs.ts b/cli/src/contexts/tools/domain/capabilities/config-refs.ts new file mode 100644 index 000000000..a04b8c909 --- /dev/null +++ b/cli/src/contexts/tools/domain/capabilities/config-refs.ts @@ -0,0 +1,16 @@ +import type { IdeToolId } from "../../../../kernel/tool.js"; + +/** Names a config artifact a tool's capability may declare in its `consumes` list. The framework + * descriptor's own `configRefs` is keyed by these same names, so an artifact built there is + * matched against what a tool declares it accepts. */ +export const CONFIG_MCP = "mcp"; +export const CONFIG_VSCODE_SETTINGS = "vscodeSettings"; +export const CONFIG_VSCODE_EXTENSIONS = "vscodeExtensions"; +export const CONFIG_VSCODE_KEYBINDINGS = "vscodeKeybindings"; +export const CONFIG_OPENCODE = "opencode"; + +export interface ConfigRef { + readonly name: string; + readonly path: string; + readonly requiredIdeId?: IdeToolId; +} diff --git a/cli/src/domain/capabilities/hooks-capability.ts b/cli/src/contexts/tools/domain/capabilities/hooks-capability.ts similarity index 100% rename from cli/src/domain/capabilities/hooks-capability.ts rename to cli/src/contexts/tools/domain/capabilities/hooks-capability.ts diff --git a/cli/src/domain/capabilities/mcp-capability.ts b/cli/src/contexts/tools/domain/capabilities/mcp-capability.ts similarity index 96% rename from cli/src/domain/capabilities/mcp-capability.ts rename to cli/src/contexts/tools/domain/capabilities/mcp-capability.ts index 15601be35..3deed658d 100644 --- a/cli/src/domain/capabilities/mcp-capability.ts +++ b/cli/src/contexts/tools/domain/capabilities/mcp-capability.ts @@ -1,5 +1,5 @@ +import type { FileReader } from "../../../../kernel/ports/file-reader.js"; import { mcpJsonToToml, mergeJsonUserPrime } from "../formats/mcp-format.js"; -import type { FileReader } from "../ports/file-reader.js"; export class McpCapability { readonly consumes: readonly string[]; diff --git a/cli/src/contexts/tools/domain/capabilities/plugins-capability.ts b/cli/src/contexts/tools/domain/capabilities/plugins-capability.ts new file mode 100644 index 000000000..250c1d7fb --- /dev/null +++ b/cli/src/contexts/tools/domain/capabilities/plugins-capability.ts @@ -0,0 +1,301 @@ +import { CapabilityConfigError } from "../../../../kernel/errors.js"; +import type { FlatHooksLoaderEntry } from "../../../../kernel/materialization/flat-paths.js"; +import type { HooksContentFormat } from "../hooks-format.js"; +import type { MarketplaceSettings } from "../marketplace-settings.js"; +import type { PluginTranslationMode } from "../plugin-translation-mode.js"; + +export type PluginsMode = "native" | "flat" | "unsupported"; + +const DEFAULT_MCP_PATH = ".mcp.json"; +const DEFAULT_HOOKS_PATH = "hooks/hooks.json"; +const DEFAULT_HOOKS_FORMAT: HooksContentFormat = "matchers"; + +/** + * A tool that writes its own marketplace registration, through its own CLI: driving the + * command is preferred to writing the file, so the tool owns its own configuration. + * `binary` keys the matching `NativePluginActivator`. + */ +export interface NativeActivation { + binary: "claude" | "codex" | "copilot"; + /** One mapping serves add and remove: a remove omitting the scope the add used drops + * Claude's declaration from every scope at once. A project-scoped marketplace maps to + * Claude's *local* scope — the registration names an absolute path, wrong for everyone + * else in the shared project settings. Omit where the registry has no scopes. */ + scopeArgs?: Readonly>; + /** Arguments that make `plugin marketplace remove` succeed with plugins installed from it. + * Permits reclaiming a name, so declare it only alongside `sourceCheckVerb`. */ + forceRemoveArgs?: readonly string[]; + /** Verb after `plugin marketplace` whose exit code separates a registration whose source + * is gone from one that resolves. Measured: copilot's `update` discriminates, while + * codex's `upgrade` refuses every local marketplace and Claude's reports success on a + * path that does not exist. */ + sourceCheckVerb?: string; + /** Verb this CLI uses to re-index its marketplaces, after `plugin marketplace`. Omit when + * plugins are not enabled through the CLI. */ + upgradeVerb?: string; + /** Verb this CLI uses to enable a plugin, after `plugin`. Omit when the tool loads plugins + * from a project file this CLI writes. */ + enableVerb?: string; + /** How the tool spells removing a plugin it installed: `remove` for codex, `uninstall` for + * claude and copilot. Absent where this CLI enables plugins through a file it writes. */ + disableVerb?: string; + /** Arguments every `plugin ` call carries, after the reference. Claude needs + * `--yes` on install and uninstall alike: a headless stdin can answer no prompt. */ + pluginArgs?: readonly string[]; + /** Root of this tool's own marketplace registry, given a homedir — a root, not a + * per-marketplace path. Declared for Claude alone, which derives a registration's name + * from the source's own catalog and silently repoints a same-named entry from a different + * source; its absence is what keeps the guard reading it claude-only. */ + marketplaceRegistry?: (homedir: string) => string; + /** Root of this tool's own plugin cache, given a homedir. Declared only where the host's + * own CLI leaves something behind after `clean` drove its uninstall and remove: claude + * keeps the whole built tree, marked `.orphaned_at`; codex the empty `cache//` + * shell. `clean` reads this declaration alone and invents no path for a tool that omits + * it, and never removes without `realpath` containment against this root. */ + pluginCacheDir?: (homedir: string) => string; + /** This tool's own user-scope settings file, never written by aidd and read by a + * diagnostic alone. Declared only for a tool that also declares `NativeActivation`. */ + userSettingsPath?: (homedir: string, env: EnvironmentReader) => string; +} + +/** One variable, as the caller reads it: a profile is a declaration and reaches no global. */ +export type EnvironmentReader = (name: string) => string | undefined; + +export interface NativePluginsParams { + mode: "native"; + pluginsDir: string; + /** Set to `null` to suppress writing a plugin manifest file into the plugin directory. */ + pluginManifestRelativePath: string | null; + mcpRelativePath?: string; + hooksRelativePath?: string; + hooksContentFormat?: HooksContentFormat; + /** Where a delivered hook actually lands: under this capability's own plugin directory + * (default), or merged into the project's own hooks file — the destination measured to + * actually fire. Declared per capability, never guessed per tool. */ + hooksDestination?: "plugin" | "project"; + /** Where the project-scope hooks file merges into, relative to the project root. Required + * exactly when `hooksDestination` is `"project"` — nothing reads it otherwise. */ + projectHooksRelativePath?: string; + acceptsMcp?: boolean; + /** The variable this tool expands to the installed plugin's directory, as written in a + * hook or MCP command. Absent means nothing is substituted. */ + pluginRootToken?: string; + marketplaceSettings?: MarketplaceSettings; + /** Enables native CLI-driven plugin activation. See {@link NativeActivation}. */ + nativeActivation?: NativeActivation; + /** Pass `"marketplace"` alongside `marketplaceSettings` for Mode A routing. Defaults to + * `null`, neutral native, where no translation strategy applies. */ + translationMode?: PluginTranslationMode; + /** `"user"` installs plugins relative to the home directory and requires `userPluginsDir`; + * defaults to `"project"`. */ + installScope?: "project" | "user"; + /** Absolute user-scope plugins base directory, given a homedir. Required when + * `installScope === "user"`. */ + userPluginsDir?: (homedir: string) => string; +} + +/** + * A generated event bridge, for a flat loader that scans no "hooks" family and reads no + * hooks.json of its own. Both flat-materialization routes read it, so one plugin looks the + * same whichever route installed it. + */ +export interface FlatHooksBridge { + /** Raw (unrewritten) hooks.json content + plugin name -> the generated bridge module's + * full text, or `null` when nothing in it named an event the bridge maps. */ + readonly generate: (rawHooksJson: string, plugin: string) => string | null; + /** Output path for the generated bridge module, relative to the project root. */ + readonly path: (plugin: string) => string; + /** A hooks/ file whose presence in this plugin's own source means it ships its own bridge + * already — generate nothing for it. */ + readonly skipIfSourceHas: string; +} + +export type FlatHooksSupport = + | { + acceptsHooks: true; + flatHooksDir: string; + /** + * The loader's own plugin module: the runtime the loader itself imports, not a script + * a bridge must be told to run. Omit where the loader has no such convention. + */ + flatHooksLoaderEntry?: FlatHooksLoaderEntry; + /** See {@link FlatHooksBridge}. Omit when this loader triggers a plugin's hooks some + * other way. */ + flatHooksBridge?: FlatHooksBridge; + } + | { acceptsHooks: false; hooksUnsupportedReason: string }; + +export type FlatPluginsParams = { + mode: "flat"; + flatNamespacePrefix: string; +} & FlatHooksSupport; + +export interface UnsupportedPluginsParams { + mode: "unsupported"; + /** See {@link FlatPluginsParams.hooksUnsupportedReason}. */ + hooksUnsupportedReason: string; +} + +/** + * Whether this tool runs the hooks a plugin ships. Stated, never defaulted: a tool nobody + * considered loses its hooks quietly when the field falls back to `false`. + * `hooksTrustNotice` is the opposite case — the tool runs a delivered hook, but only once a + * per-hook trust a headless run is never prompted for is granted (measured on Codex: four + * clean `codex exec` sessions wrote no journal until `--dangerously-bypass-hook-trust` did). + */ +export type HooksSupport = + | { acceptsHooks: true; hooksTrustNotice?: string } + | { acceptsHooks: false; hooksUnsupportedReason: string }; + +type PluginsParams = + | (NativePluginsParams & HooksSupport) + | FlatPluginsParams + | UnsupportedPluginsParams; + +export class PluginsCapability { + readonly mode: PluginsMode; + readonly pluginsDir: string | null; + readonly pluginManifestRelativePath: string | null; + readonly flatNamespacePrefix: string | null; + readonly acceptsHooks: boolean; + /** Why no hook is delivered, or `null` when they are. */ + readonly hooksUnsupportedReason: string | null; + /** What still has to happen before a delivered hook actually runs, or `null` when nothing + * does. See {@link HooksSupport}. */ + readonly hooksTrustNotice: string | null; + readonly pluginRootToken: string | null; + readonly acceptsMcp: boolean; + readonly mcpRelativePath: string; + readonly hooksRelativePath: string; + readonly hooksContentFormat: HooksContentFormat; + readonly hooksDestination: "plugin" | "project"; + /** Relative to the project root, or `null` when `hooksDestination` is `"plugin"`. */ + readonly projectHooksRelativePath: string | null; + /** Where a flat-mode hook lands, relative to the project root, or `null` when this + * capability accepts no hooks. */ + readonly flatHooksDir: string | null; + /** See {@link FlatHooksSupport.flatHooksLoaderEntry}. */ + readonly flatHooksLoaderEntry: FlatHooksLoaderEntry | null; + /** See {@link FlatHooksBridge}, or `null` when this capability declares none. */ + readonly flatHooksBridge: FlatHooksBridge | null; + readonly marketplaceSettings: MarketplaceSettings | null; + readonly nativeActivation: NativeActivation | null; + readonly translationMode: PluginTranslationMode | null; + readonly installScope: "project" | "user"; + + private readonly _userPluginsDir?: (homedir: string) => string; + + constructor(params: PluginsParams) { + this.mode = params.mode; + this.translationMode = PluginsCapability.resolveTranslationMode(params); + this.installScope = PluginsCapability.resolveInstallScope(params); + PluginsCapability.validateUserScope(params); + if (params.mode === "native") { + this.pluginsDir = params.pluginsDir; + this.pluginManifestRelativePath = params.pluginManifestRelativePath; + this.flatNamespacePrefix = null; + this.acceptsHooks = params.acceptsHooks; + this.hooksUnsupportedReason = params.acceptsHooks ? null : params.hooksUnsupportedReason; + this.hooksTrustNotice = params.acceptsHooks ? (params.hooksTrustNotice ?? null) : null; + this.pluginRootToken = params.pluginRootToken ?? null; + this.acceptsMcp = params.acceptsMcp ?? false; + this.mcpRelativePath = params.mcpRelativePath ?? DEFAULT_MCP_PATH; + this.hooksRelativePath = params.hooksRelativePath ?? DEFAULT_HOOKS_PATH; + this.hooksContentFormat = params.hooksContentFormat ?? DEFAULT_HOOKS_FORMAT; + this.hooksDestination = params.hooksDestination ?? "plugin"; + this.projectHooksRelativePath = params.projectHooksRelativePath ?? null; + this.flatHooksDir = null; + this.flatHooksLoaderEntry = null; + this.flatHooksBridge = null; + this.marketplaceSettings = params.marketplaceSettings ?? null; + this.nativeActivation = params.nativeActivation ?? null; + this._userPluginsDir = params.userPluginsDir; + } else if (params.mode === "flat") { + this.pluginsDir = null; + this.pluginManifestRelativePath = null; + this.flatNamespacePrefix = params.flatNamespacePrefix; + this.acceptsHooks = params.acceptsHooks; + this.hooksUnsupportedReason = params.acceptsHooks ? null : params.hooksUnsupportedReason; + this.flatHooksDir = params.acceptsHooks ? params.flatHooksDir : null; + this.flatHooksLoaderEntry = params.acceptsHooks + ? (params.flatHooksLoaderEntry ?? null) + : null; + this.flatHooksBridge = params.acceptsHooks ? (params.flatHooksBridge ?? null) : null; + this.hooksTrustNotice = null; + this.pluginRootToken = null; + this.acceptsMcp = false; + this.mcpRelativePath = DEFAULT_MCP_PATH; + this.hooksRelativePath = DEFAULT_HOOKS_PATH; + this.hooksContentFormat = DEFAULT_HOOKS_FORMAT; + this.hooksDestination = "plugin"; + this.projectHooksRelativePath = null; + this.marketplaceSettings = null; + this.nativeActivation = null; + this._userPluginsDir = undefined; + } else { + this.pluginsDir = null; + this.pluginManifestRelativePath = null; + this.flatNamespacePrefix = null; + this.acceptsHooks = false; + this.hooksUnsupportedReason = params.hooksUnsupportedReason; + this.flatHooksDir = null; + this.flatHooksLoaderEntry = null; + this.flatHooksBridge = null; + this.hooksTrustNotice = null; + this.pluginRootToken = null; + this.acceptsMcp = false; + this.mcpRelativePath = DEFAULT_MCP_PATH; + this.hooksRelativePath = DEFAULT_HOOKS_PATH; + this.hooksContentFormat = DEFAULT_HOOKS_FORMAT; + this.hooksDestination = "plugin"; + this.projectHooksRelativePath = null; + this.marketplaceSettings = null; + this.nativeActivation = null; + this._userPluginsDir = undefined; + } + } + + resolvePluginsBaseDir(projectRoot: string, homedir: string): string { + if (this.installScope === "user" && this._userPluginsDir !== undefined) { + return this._userPluginsDir(homedir); + } + return projectRoot; + } + + /** The absolute user-scope plugins directory this capability declares, or `null`. Read + * independently of this capability's own `installScope`: a caller resolving a *recorded* + * scope still needs the directory that scope means. */ + userPluginsBaseDir(homedir: string): string | null { + return this._userPluginsDir?.(homedir) ?? null; + } + + pluginOutputDir(pluginName: string): string | null { + if (this.mode !== "native" || this.pluginsDir === null) return null; + return `${this.pluginsDir}${pluginName}/`; + } + + private static resolveTranslationMode(params: PluginsParams): PluginTranslationMode | null { + if (params.mode === "native") return params.translationMode ?? null; + if (params.mode === "flat") return "flat"; + return null; + } + + private static resolveInstallScope(params: PluginsParams): "project" | "user" { + if (params.mode === "native") return params.installScope ?? "project"; + return "project"; + } + + private static validateUserScope(params: PluginsParams): void { + if (params.mode !== "native") return; + if (params.installScope === "user" && params.userPluginsDir === undefined) { + throw new CapabilityConfigError( + "installScope 'user' requires a userPluginsDir resolver function." + ); + } + if (params.hooksDestination === "project" && params.projectHooksRelativePath === undefined) { + throw new CapabilityConfigError( + "hooksDestination 'project' requires a projectHooksRelativePath." + ); + } + } +} diff --git a/cli/src/contexts/tools/domain/capabilities/rules-capability.ts b/cli/src/contexts/tools/domain/capabilities/rules-capability.ts new file mode 100644 index 000000000..83a934144 --- /dev/null +++ b/cli/src/contexts/tools/domain/capabilities/rules-capability.ts @@ -0,0 +1,75 @@ +import { serializeFrontmatter } from "../../../../kernel/markdown.js"; +import { AI_TOOL_IDS } from "../../../../kernel/tool.js"; + +const ALL_TOOL_SUFFIXES: readonly string[] = AI_TOOL_IDS.map((id) => `.${id}.md`); + +export class RulesCapability { + constructor( + readonly params: { + directory: string; + toolSuffix: string; + inputSuffix?: string; + buildInstallPath: (fileName: string) => string | null; + convertFrontmatter: (fm: Record) => Record; + } + ) {} + + buildOutputPath(ruleName: string): string { + return `${this.params.directory}rules/${ruleName}${this.params.toolSuffix}`; + } + + /** A name no rule of a reader's own will ever carry, asked of `buildInstallPath` only so its + * answer can be read back. Long on purpose: it reaches no output and no disk, and a short one + * could collide with a real rule name. */ + private static readonly PROBE_STEM = "aidd-installed-location-probe"; + + /** Where an installed rule of this tool lives, and what it is called at the end, asked of the + * installer rather than restated beside it: `buildOutputPath` answers where the framework's + * own source form goes, while a reader scanning a project needs the *installed* shape, and + * only `buildInstallPath` — a closure written per tool — knows it. `null` when the tool + * installs nothing for the name asked about, or answers a path whose stem it rewrote past + * recognition; neither is a guess. */ + installedLocation(): { readonly directory: string; readonly extension: string } | null { + const suffix = this.params.inputSuffix ?? this.params.toolSuffix; + const installed = this.buildInstallPath(`${RulesCapability.PROBE_STEM}${suffix}`); + if (installed === null) return null; + const lastSlash = installed.lastIndexOf("/"); + const basename = installed.slice(lastSlash + 1); + const stemAt = basename.indexOf(RulesCapability.PROBE_STEM); + if (stemAt === -1) return null; + return { + directory: installed.slice(0, lastSlash + 1), + extension: basename.slice(stemAt + RulesCapability.PROBE_STEM.length), + }; + } + + buildInstallPath(fileName: string): string | null { + return this.params.buildInstallPath(fileName); + } + + convertFrontmatter(fm: Record): Record { + return this.params.convertFrontmatter(fm); + } + + acceptsFileName(fileName: string): boolean { + const basename = fileName.split("/").at(-1) ?? fileName; + const effectiveSuffix = this.params.inputSuffix ?? this.params.toolSuffix; + const otherSuffixes = ALL_TOOL_SUFFIXES.filter((s) => s !== effectiveSuffix); + return !otherSuffixes.some((s) => basename.endsWith(s)); + } + + serialize(frontmatter: Record, body: string): string { + return serializeFrontmatter(frontmatter, body); + } + + accepts(relativePath: string): boolean { + return relativePath.startsWith(this.params.directory); + } + + equals(other: RulesCapability): boolean { + return ( + this.params.directory === other.params.directory && + this.params.toolSuffix === other.params.toolSuffix + ); + } +} diff --git a/cli/src/domain/capabilities/settings-capability.ts b/cli/src/contexts/tools/domain/capabilities/settings-capability.ts similarity index 90% rename from cli/src/domain/capabilities/settings-capability.ts rename to cli/src/contexts/tools/domain/capabilities/settings-capability.ts index f395ef74e..1584448cc 100644 --- a/cli/src/domain/capabilities/settings-capability.ts +++ b/cli/src/contexts/tools/domain/capabilities/settings-capability.ts @@ -1,6 +1,6 @@ -import { CapabilityConfigError } from "../errors.js"; -import type { MergeStrategy } from "../models/merge.js"; -import type { ToolId } from "../models/tool-ids.js"; +import { CapabilityConfigError } from "../../../../kernel/errors.js"; +import type { MergeStrategy } from "../../../../kernel/merge.js"; +import type { ToolId } from "../../../../kernel/tool.js"; export class SettingsCapability { readonly consumes: readonly string[]; diff --git a/cli/src/domain/capabilities/skills-capability.ts b/cli/src/contexts/tools/domain/capabilities/skills-capability.ts similarity index 83% rename from cli/src/domain/capabilities/skills-capability.ts rename to cli/src/contexts/tools/domain/capabilities/skills-capability.ts index 55d7402db..052caf4c4 100644 --- a/cli/src/domain/capabilities/skills-capability.ts +++ b/cli/src/contexts/tools/domain/capabilities/skills-capability.ts @@ -1,6 +1,6 @@ -import { CapabilityConfigError } from "../errors.js"; -import { serializeFrontmatter } from "../formats/markdown.js"; -import { AI_TOOL_IDS } from "../tools/registry.js"; +import { CapabilityConfigError } from "../../../../kernel/errors.js"; +import { serializeFrontmatter } from "../../../../kernel/markdown.js"; +import { AI_TOOL_IDS } from "../../../../kernel/tool.js"; const AGENTS_SKILLS_PREFIX = ".agents/skills/"; const ALL_TOOL_SUFFIXES: readonly string[] = AI_TOOL_IDS.map((id) => `.${id}.md`); @@ -13,7 +13,6 @@ export class SkillsCapability { prefix?: string; buildInstallPath: (fileName: string) => string | null; convertFrontmatter: (fm: Record) => Record; - reverseConvertFrontmatter: (fm: Record) => Record; } ) { if (!params.prefix && !params.directory) { @@ -36,10 +35,6 @@ export class SkillsCapability { return this.params.convertFrontmatter(fm); } - reverseConvertFrontmatter(fm: Record): Record { - return this.params.reverseConvertFrontmatter(fm); - } - acceptsFileName(fileName: string): boolean { const basename = fileName.split("/").at(-1) ?? fileName; const toolSuffix = this.params.toolSuffix ?? ""; diff --git a/cli/src/contexts/tools/domain/contracts.ts b/cli/src/contexts/tools/domain/contracts.ts new file mode 100644 index 000000000..7e375b5fc --- /dev/null +++ b/cli/src/contexts/tools/domain/contracts.ts @@ -0,0 +1,101 @@ +import type { TelemetryLocalRead } from "../../../kernel/measurement.js"; +import type { AiToolId, IdeToolId } from "../../../kernel/tool.js"; +import type { ToolBuildContract } from "./build-contract.js"; +import type { AgentsCapability } from "./capabilities/agents-capability.js"; +import type { CommandsCapability } from "./capabilities/commands-capability.js"; +import type { HooksCapability } from "./capabilities/hooks-capability.js"; +import type { McpCapability } from "./capabilities/mcp-capability.js"; +import type { PluginsCapability } from "./capabilities/plugins-capability.js"; +import type { RulesCapability } from "./capabilities/rules-capability.js"; +import type { SettingsCapability } from "./capabilities/settings-capability.js"; +import type { SkillsCapability } from "./capabilities/skills-capability.js"; + +export interface HasAgents { + readonly agents: AgentsCapability; +} + +export interface HasSkills { + readonly skills: SkillsCapability; +} + +export interface HasCommands { + readonly commands: CommandsCapability; +} + +export interface HasRules { + readonly rules: RulesCapability; +} + +export interface HasMcp { + readonly mcp: McpCapability; +} + +export interface HasHooks { + readonly hooks: HooksCapability; +} + +export interface HasSettings { + readonly settings: SettingsCapability | SettingsCapability[]; +} + +export interface HasPlugins { + readonly plugins: PluginsCapability; +} + +export interface AiTool { + readonly kind: "ai"; + readonly toolId: AiToolId; + /** How the vendor writes it. `toolId` is a key, not a name: nothing user-facing + * should print `copilot` where a person reads "GitHub Copilot". */ + readonly displayName: string; + /** Whether this tool's own file(s) can be read locally for a session's counters — see + * {@link TelemetryLocalRead}. */ + readonly telemetryLocalRead: TelemetryLocalRead; + /** How the run journal's hook names this tool in its own `session_start` line — not the same + * string as `toolId`, since the hook detects a host from a payload's shape and spells Claude + * Code `claude-code`. Absent for a tool the journal hook does not run under. */ + readonly telemetryJournalHost?: string; + /** Whether a session on this tool can be traced to the task it worked on: true wherever + * `journal.cjs`'s `tool-used` dispatch reaches the host at all, since a task can be *declared* + * — a tool call's own arguments named a file under a task folder — asking nothing of the + * host's payload shape. `false` would mean no tool-used event ever reaches that host, which a + * declaration cannot work around. A tool declaring `false` is still reportable by period and + * by step; it simply belongs to no task. The truth lives in the framework's own hook scripts, + * which this side cannot import, so it is declared here and pinned by a test. */ + readonly telemetryTaskAttributable: boolean; + readonly directory: string; + readonly toolSuffix: string; + readonly signalDir: string | null; + readonly capabilities: C; + readonly configOutputPaths?: Readonly>; + /** The tool's framework-build contracts, one per supported build mode, so the build registry + * is derived from the registered tools instead of a hand-kept list of tool/mode pairs. */ + readonly buildContracts?: { + readonly marketplace?: () => ToolBuildContract; + readonly flat?: () => ToolBuildContract; + }; + /** Where this tool's plugin manifest and marketplace catalog sit inside a distribution it + * produced, so a sixth tool declares its own layout instead of joining two lists it does not + * own. Order is irrelevant: the collected probes are sorted deepest-path-first, so a specific + * location always wins over a bare `plugin.json` at the root. */ + readonly distributionProbes?: { + readonly manifest?: readonly string[]; + readonly marketplace?: readonly string[]; + }; + rewriteContent(content: string): string; +} + +export interface IdeToolConfig { + readonly kind: "ide"; + readonly toolId: IdeToolId; + readonly directory: string; + readonly signalDir: string | null; +} + +/** Whether this tool declares a rules capability at all. Generic over the tool's own capability + * set so a caller keeps whatever it had already narrowed. */ +export function hasRules( + tool: AiTool +): tool is AiTool { + return "rules" in (tool.capabilities as object); +} diff --git a/cli/src/contexts/tools/domain/formats/agent-frontmatter-strip.ts b/cli/src/contexts/tools/domain/formats/agent-frontmatter-strip.ts new file mode 100644 index 000000000..6aff4c5ad --- /dev/null +++ b/cli/src/contexts/tools/domain/formats/agent-frontmatter-strip.ts @@ -0,0 +1,48 @@ +/** Copilot-supported frontmatter keys for agent files. Order is preserved on serialization, + * for deterministic output. */ +export const COPILOT_AGENT_FRONTMATTER_KEYS: readonly [ + "name", + "description", + "model", + "tools", + "agents", + "argument-hint", +] = ["name", "description", "model", "tools", "agents", "argument-hint"]; + +/** Cursor-supported frontmatter keys: Cursor documents only name, description and model — + * never tools or color. */ +export const CURSOR_AGENT_FRONTMATTER_KEYS: readonly ["name", "description", "model"] = [ + "name", + "description", + "model", +]; + +/** Pick only the given keys, preserving their order. A key whose value is undefined is + * omitted. */ +export function pickFrontmatterKeys( + fm: Record, + keys: readonly string[] +): Record { + const result: Record = {}; + for (const key of keys) { + if (fm[key] !== undefined) { + result[key] = fm[key]; + } + } + return result; +} + +/** Only the Copilot-supported keys, in the allowlist's own order. Lossy, so there is no + * inverse: a key outside the allowlist cannot be recovered from the output. */ +export function stripCopilotAgentFrontmatter(fm: Record): Record { + return pickFrontmatterKeys(fm, COPILOT_AGENT_FRONTMATTER_KEYS); +} + +/** Only the Cursor-supported keys. Tools, color and argument-hint are discarded. */ +export function stripCursorAgentFrontmatter(fm: Record): Record { + return pickFrontmatterKeys(fm, CURSOR_AGENT_FRONTMATTER_KEYS); +} + +export function stripAgentFrontmatter(fm: Record): Record { + return stripCopilotAgentFrontmatter(fm); +} diff --git a/cli/src/contexts/tools/domain/formats/command.ts b/cli/src/contexts/tools/domain/formats/command.ts new file mode 100644 index 000000000..4ce419d1e --- /dev/null +++ b/cli/src/contexts/tools/domain/formats/command.ts @@ -0,0 +1,47 @@ +export type UserFileSection = "agents" | "commands" | "rules" | "skills"; + +export function stripToolSuffix(suffix: string, fileName: string): string { + const basename = fileName.split("/").at(-1) ?? fileName; + if (!basename.endsWith(suffix)) return fileName; + const dir = fileName.slice(0, fileName.length - basename.length); + const stripped = `${basename.slice(0, -suffix.length)}.md`; + return `${dir}${stripped}`; +} + +function buildCommandName(fm: Record, relativeFileName: string): string { + const phase = relativeFileName.split("/")[0]?.match(/^(\d+)/)?.[1]; + const baseName = String(fm.name ?? ""); + return phase ? `aidd:${phase}:${baseName}` : baseName; +} + +export function convertCommandFrontmatter( + fm: Record, + relativeFileName: string +): Record { + const name = buildCommandName(fm, relativeFileName); + const result: Record = { name, description: fm.description }; + if (fm["argument-hint"] !== undefined) result["argument-hint"] = fm["argument-hint"]; + return result; +} + +export function convertCommandFrontmatterNoHint( + fm: Record, + relativeFileName: string +): Record { + const name = buildCommandName(fm, relativeFileName); + return { name, description: fm.description }; +} + +export function buildAiddCommandFilePath(dir: string, fileName: string): string { + const slashIdx = fileName.indexOf("/"); + if (slashIdx !== -1) { + const phaseDir = fileName.slice(0, slashIdx); + const baseName = fileName.slice(slashIdx + 1); + const phase = phaseDir.match(/^(\d+)/)?.[1]; + if (phase) { + return `${dir}commands/aidd/${phase}/${baseName}`; + } + } + const baseName = fileName.split("/").at(-1) ?? fileName; + return `${dir}commands/aidd/${baseName}`; +} diff --git a/cli/src/contexts/tools/domain/formats/cursor-hooks-project-merge.ts b/cli/src/contexts/tools/domain/formats/cursor-hooks-project-merge.ts new file mode 100644 index 000000000..a63ca8a64 --- /dev/null +++ b/cli/src/contexts/tools/domain/formats/cursor-hooks-project-merge.ts @@ -0,0 +1,90 @@ +/** + * A Cursor plugin's own `hooks/hooks.json` never fires from the plugin-scope directory Cursor's + * native install writes it to; only a project-scope `.cursor/hooks.json`, the same file a flat + * translate already writes, is ever observed running. This module gives `aidd plugin install` + * that same destination, one plugin at a time. + */ + +import { rewriteClaudeRootInJson } from "../../../../kernel/materialization/claude-root-path-rewrite.js"; +import { genericFlatHooksScriptPath } from "../../../../kernel/materialization/flat-paths.js"; +import { mergeCursorFlatHooks } from "./flat-hooks-merge.js"; + +const HOOKS_PREFIX = "hooks/"; +const CURSOR_HOOKS_DIR = ".cursor/hooks/"; + +interface CursorHooksFile { + version?: number; + hooks?: Record>; +} + +/** + * Rewrites a plugin's raw `hooks/hooks.json` (Claude nested shape, `${CLAUDE_PLUGIN_ROOT}` + * commands) to the paths its scripts land at under `.cursor/hooks//`, then merges it + * into the project's own `.cursor/hooks.json`. + * + * This plugin's own prior contribution is stripped first: the merge only appends, so a second + * install would double every command it owns. Every entry names its own script directory, a + * plugin-unique substring, so no record of the last contribution is needed. + */ +export function mergeCursorProjectHooksJson( + existingJson: string | null, + pluginHooksJson: string, + pluginName: string +): { content: string; warnings: readonly string[] } { + const rewritten = rewritePluginRootTokens(pluginHooksJson, pluginName); + const deduped = + existingJson === null ? null : serialize(stripPluginEntries(existingJson, pluginName)); + return mergeCursorFlatHooks(deduped, rewritten); +} + +/** Removes one plugin's entries from `.cursor/hooks.json`, leaving every other plugin's + * untouched — what `plugin remove` unmerges an install with. */ +export function unmergeCursorProjectHooksJson(existingJson: string, pluginName: string): string { + return serialize(stripPluginEntries(existingJson, pluginName)); +} + +/** Where a hook script (everything under `hooks/` but its own manifest) lands once + * copied into the project, given its path relative to the plugin root. */ +export function cursorProjectHooksScriptPath( + pluginName: string, + hooksRelativePath: string +): string { + const rest = hooksRelativePath.startsWith(HOOKS_PREFIX) + ? hooksRelativePath.slice(HOOKS_PREFIX.length) + : hooksRelativePath; + return genericFlatHooksScriptPath(CURSOR_HOOKS_DIR, pluginName, rest); +} + +/** The directory a plugin's copied hook scripts live under — nothing else writes here, so + * `plugin remove` can delete it whole once the hooks.json entries are stripped. */ +export function cursorProjectHooksScriptDir(pluginName: string): string { + return `${CURSOR_HOOKS_DIR}${pluginName}/`; +} + +function stripPluginEntries(existingJson: string, pluginName: string): CursorHooksFile { + const parsed = JSON.parse(existingJson) as CursorHooksFile; + const marker = cursorProjectHooksScriptDir(pluginName); + const hooks: Record> = {}; + for (const [event, entries] of Object.entries(parsed.hooks ?? {})) { + const kept = entries.filter((entry) => !entry.command.includes(marker)); + if (kept.length > 0) hooks[event] = kept; + } + return { version: 1, hooks }; +} + +function serialize(cursor: CursorHooksFile): string { + return `${JSON.stringify(cursor, null, 2)}\n`; +} + +function rewritePluginRootTokens(pluginHooksJson: string, pluginName: string): string { + const parsed = JSON.parse(pluginHooksJson) as unknown; + const rewritten = rewriteClaudeRootInJson(parsed, (suffix) => resolveSuffix(suffix, pluginName)); + return JSON.stringify(rewritten); +} + +// A hooks.json command only ever names a path under its own hooks/ — unlike the +// framework-build route, this never needs an agents/ or skills/ branch too. +function resolveSuffix(suffix: string, pluginName: string): string { + if (!suffix.startsWith(HOOKS_PREFIX)) return suffix; + return `./${cursorProjectHooksScriptPath(pluginName, suffix)}`; +} diff --git a/cli/src/contexts/tools/domain/formats/flat-hooks-merge.ts b/cli/src/contexts/tools/domain/formats/flat-hooks-merge.ts new file mode 100644 index 000000000..f5a06806f --- /dev/null +++ b/cli/src/contexts/tools/domain/formats/flat-hooks-merge.ts @@ -0,0 +1,262 @@ +/** + * Pure shape transforms between the framework's Claude-shaped hooks source and each flat + * tool's own registration format, no I/O. Claude event names are PascalCase; Cursor maps the + * events it supports to camelCase. + */ + +import { asPlainObject } from "../../../../kernel/reading/plain-object.js"; + +type ClaudeHookItem = { type?: string; command?: string; [key: string]: unknown }; +type ClaudeMatcherGroup = { matcher?: string; hooks: ClaudeHookItem[] }; +type ClaudeHooksShape = { hooks?: Record }; + +type FlatHookEntry = { type: string; command: string; timeout?: number }; +type CopilotFlatShape = { version: 1; hooks?: Record }; + +type CursorHookEntry = { command: string }; +type CursorFlatShape = { version: 1; hooks: Record }; + +type CodexHookEntry = { + matcher?: string; + hooks: Array<{ type: string; command: string; timeout?: number; statusMessage?: string }>; +}; +type CodexHooksShape = { hooks?: Record }; + +// `Stop` fans out to two Cursor events, not one: interactive sessions fire `stop` and headless +// ones `sessionEnd` instead — never both from the same run, but which one depends on how the +// session ends, so both are subscribed. A run file already tolerates more than one `turn_end` +// line, so a session firing both is not a problem. +const CURSOR_EVENT_MAP: Record = { + SessionStart: ["sessionStart"], + UserPromptSubmit: ["beforeSubmitPrompt"], + PreToolUse: ["preToolUse"], + PostToolUse: ["postToolUse"], + Stop: ["stop", "sessionEnd"], + SubagentStop: ["subagentStop"], +}; + +// Codex keeps Claude's event names but has no `Stop`: probed live, a `codex exec` run with all +// four subscribed fired SessionStart and SessionEnd and never Stop, so a turn was never closed +// and every Codex session journalled a session_start with nothing after it. SessionEnd bounds +// the session rather than each turn, which the journal tolerates — one turn_end bounding the +// whole session is the honest answer rather than none at all. +const CODEX_EVENT_MAP: Record = { + Stop: ["SessionEnd"], +}; + +/** + * Renames a plugin hooks.json's events to the ones Codex delivers, without merging. + * + * Codex is installed two ways — a built marketplace tree and a merged project config — and both + * call this, so the rename cannot land on one route and not the other. + */ +export function renameCodexHookEvents(pluginHooksJson: string): string { + const parsed = JSON.parse(pluginHooksJson) as ClaudeHooksShape; + if (!parsed.hooks) return pluginHooksJson; + const renamed: Record = {}; + for (const [event, matchers] of Object.entries(parsed.hooks)) { + for (const codexEvent of CODEX_EVENT_MAP[event] ?? [event]) { + renamed[codexEvent] = [...(renamed[codexEvent] ?? []), ...matchers]; + } + } + return `${JSON.stringify({ ...parsed, hooks: renamed }, null, 2)}\n`; +} + +/** Merges a plugin's hooks (Claude nested shape) additively into the top-level `hooks` key of + * `.claude/settings.json`, preserving every other settings key. */ +export function mergeClaudeSettingsHooks( + existingSettings: string | null, + pluginHooksJson: string +): { content: string; warnings: readonly string[] } { + const settings = existingSettings + ? (JSON.parse(existingSettings) as Record) + : {}; + const plugin = JSON.parse(pluginHooksJson) as ClaudeHooksShape; + const pluginHooks = plugin.hooks ?? {}; + const existing = (settings.hooks as Record) ?? {}; + const merged = appendHooksEntries(existing, pluginHooks); + return { + content: `${JSON.stringify({ ...settings, hooks: merged }, null, 2)}\n`, + warnings: [], + }; +} + +function appendHooksEntries( + existing: Record, + incoming: Record +): Record { + const result: Record = { ...existing }; + for (const [event, matchers] of Object.entries(incoming)) { + result[event] = [...(result[event] ?? []), ...matchers]; + } + return result; +} + +/** Flattens the Claude nested matcher-group shape into Copilot's flat `hooks.EVENT[]` of + * `{type, command, timeout?}`. */ +export function flattenCopilotHooksShape(pluginHooksJson: string): string { + const parsed = JSON.parse(pluginHooksJson) as ClaudeHooksShape; + const claudeHooks = parsed.hooks ?? {}; + const flat: Record = {}; + + for (const [event, matchers] of Object.entries(claudeHooks)) { + const entries = flattenMatcherGroups(matchers); + if (entries.length > 0) flat[event] = entries; + } + + const output: CopilotFlatShape = { version: 1 }; + if (Object.keys(flat).length > 0) output.hooks = flat; + return `${JSON.stringify(output, null, 2)}\n`; +} + +function flattenMatcherGroups(matchers: ClaudeMatcherGroup[]): FlatHookEntry[] { + const entries: FlatHookEntry[] = []; + for (const group of matchers) { + for (const item of group.hooks ?? []) { + if (typeof item.command !== "string") continue; + const entry: FlatHookEntry = { type: item.type ?? "command", command: item.command }; + if (typeof item.timeout === "number") entry.timeout = item.timeout; + entries.push(entry); + } + } + return entries; +} + +/** Merges a plugin's hooks (Claude nested shape) into the accumulated `.cursor/hooks.json`: + * version 1, event-mapped keys, flat `{command}` entries. An unmapped event is skipped and + * reported in the returned warnings. */ +export function mergeCursorFlatHooks( + existingCursorJson: string | null, + pluginHooksJson: string +): { content: string; warnings: readonly string[] } { + const cursor = parseCursorHooks(existingCursorJson); + const plugin = JSON.parse(pluginHooksJson) as ClaudeHooksShape; + const pluginHooks = plugin.hooks ?? {}; + const warnings: string[] = []; + + for (const [claudeEvent, matchers] of Object.entries(pluginHooks)) { + const cursorEvents = CURSOR_EVENT_MAP[claudeEvent]; + if (!cursorEvents) { + warnings.push(`cursor: unmapped event '${claudeEvent}' skipped`); + continue; + } + const entries = extractCursorEntries(matchers); + for (const cursorEvent of cursorEvents) { + cursor.hooks[cursorEvent] = [...(cursor.hooks[cursorEvent] ?? []), ...entries]; + } + } + + return { content: `${JSON.stringify(cursor, null, 2)}\n`, warnings }; +} + +function parseCursorHooks(content: string | null): CursorFlatShape { + if (!content) return { version: 1, hooks: {} }; + const parsed = JSON.parse(content) as Partial; + return { version: 1, hooks: parsed.hooks ?? {} }; +} + +function extractCursorEntries(matchers: ClaudeMatcherGroup[]): CursorHookEntry[] { + const entries: CursorHookEntry[] = []; + for (const group of matchers) { + for (const item of group.hooks ?? []) { + if (typeof item.command === "string") entries.push({ command: item.command }); + } + } + return entries; +} + +/** Merges a plugin's hooks (Claude nested shape) into `.codex/hooks.json`, Codex's nested shape + * under a top-level `hooks` wrapper. Emits no install-mode memory hook — that one belongs to + * `HooksCapability.mergeFn`. */ +export function mergeCodexFrameworkHooksJson( + existingJson: string | null, + pluginHooksJson: string +): { content: string; warnings: readonly string[] } { + const codex = parseCodexHooks(existingJson); + const plugin = JSON.parse(pluginHooksJson) as ClaudeHooksShape; + const pluginHooks = plugin.hooks ?? {}; + + for (const [event, matchers] of Object.entries(pluginHooks)) { + for (const codexEvent of CODEX_EVENT_MAP[event] ?? [event]) { + codex.hooks[codexEvent] = [ + ...(codex.hooks[codexEvent] ?? []), + ...convertToCodexEntries(matchers), + ]; + } + } + + return { + content: `${JSON.stringify({ hooks: codex.hooks }, null, 2)}\n`, + warnings: [], + }; +} + +function parseCodexHooks( + content: string | null +): CodexHooksShape & { hooks: Record } { + if (!content) return { hooks: {} }; + const parsed = JSON.parse(content) as CodexHooksShape; + return { hooks: parsed.hooks ?? {} }; +} + +function convertToCodexEntries(matchers: ClaudeMatcherGroup[]): CodexHookEntry[] { + return matchers.map((group) => ({ + ...(group.matcher !== undefined ? { matcher: group.matcher } : {}), + hooks: group.hooks + .filter((item) => typeof item.command === "string") + .map((item) => buildCodexHookItem(item)), + })); +} + +function buildCodexHookItem(item: ClaudeHookItem): { + type: string; + command: string; + timeout?: number; + statusMessage?: string; +} { + const entry: { type: string; command: string; timeout?: number; statusMessage?: string } = { + type: item.type ?? "command", + command: item.command as string, + }; + if (typeof item.timeout === "number") entry.timeout = item.timeout; + if (typeof item.statusMessage === "string") entry.statusMessage = item.statusMessage; + return entry; +} + +/** + * Every `command` string registered for `claudeEvent` in a hooks file already written in any of + * the four shapes this module writes, plus whatever alias `CURSOR_EVENT_MAP` maps that event to. + * Malformed content, or a shape none of the four writers produce, answers `[]` rather than + * throwing: an unrecognised shape is not evidence of anything. A reader asking whether a hooks + * block called for a command calls this rather than restating the four shapes, so a fifth shape + * recognised here is recognised there too. + */ +export function hookCommandsForEvent(hooksFileContent: string, claudeEvent: string): string[] { + let parsed: unknown; + try { + parsed = JSON.parse(hooksFileContent); + } catch { + return []; + } + const hooks = asPlainObject(asPlainObject(parsed)?.hooks); + if (hooks === null) return []; + const commands: string[] = []; + for (const eventName of [claudeEvent, ...(CURSOR_EVENT_MAP[claudeEvent] ?? [])]) { + const entries = hooks[eventName]; + if (Array.isArray(entries)) for (const entry of entries) collectCommands(entry, commands); + } + return commands; +} + +// Both known entry depths in one walk: a nested group (`{ hooks: [...] }`, Claude/Codex) +// recurses one level into its own `hooks` array; a flat entry (`{ command }`, Copilot/Cursor) +// has none and contributes its own command directly. +function collectCommands(entry: unknown, out: string[]): void { + const record = asPlainObject(entry); + if (record === null) return; + if (Array.isArray(record.hooks)) { + for (const nested of record.hooks) collectCommands(nested, out); + return; + } + if (typeof record.command === "string") out.push(record.command); +} diff --git a/cli/src/domain/formats/mcp-format.ts b/cli/src/contexts/tools/domain/formats/mcp-format.ts similarity index 100% rename from cli/src/domain/formats/mcp-format.ts rename to cli/src/contexts/tools/domain/formats/mcp-format.ts diff --git a/cli/src/contexts/tools/domain/formats/opencode-mcp-merge.ts b/cli/src/contexts/tools/domain/formats/opencode-mcp-merge.ts new file mode 100644 index 000000000..55dba42ca --- /dev/null +++ b/cli/src/contexts/tools/domain/formats/opencode-mcp-merge.ts @@ -0,0 +1,127 @@ +import type { Hasher } from "../../../../kernel/ports/hasher.js"; +import { stripJsonComments } from "../../../../kernel/reading/jsonc.js"; + +interface OpencodeMcpSection { + mcp?: Record; +} + +const MCP_COLLISION_REASON = + "server already exists in opencode.json (user-owned); plugin entry skipped"; + +/** + * Merges incoming OpenCode-format MCP servers into the existing opencode.json. + * + * Keys this plugin contributed before are stripped first, so a re-install is idempotent; a + * user-owned server is preserved, and an incoming server colliding with one is skipped and + * returned as a collision. Both inputs must be JSON serialized with a two-space indent. + */ +export function mergeOpencodeMcp( + existingContent: string | null, + incomingTransformed: string, + previousEntriesForThisPlugin: ReadonlyMap, + hasher: Hasher +): { + mergedContent: string; + contributedEntries: ReadonlyMap; + collisions: ReadonlyArray; +} { + const { full, mcp } = parseExisting(existingContent); + const incoming = parseIncoming(incomingTransformed); + const cleaned = stripPreviousEntries(mcp, previousEntriesForThisPlugin); + return applyIncoming(full, cleaned, incoming, previousEntriesForThisPlugin, hasher); +} + +/** + * Builds the opencode.json the flat framework build emits — written even with zero MCP servers, + * so the archive always ships a config, matching every sibling flat target. + * + * The framework-owned keys come from `baseConfig`, the same bundled asset the install path + * writes, and win over a stale existing copy; any other top-level key is preserved. `mcp` is + * omitted entirely when neither side contributes one, and malformed `existing` content throws + * rather than being silently discarded. + */ +export function buildOpencodeFlatConfig( + baseConfig: string, + existing: string | null, + incoming: Record +): string { + const base = JSON.parse(baseConfig) as Record; + const { full, mcp } = parseExisting(existing); + const userKeys = { ...full }; + for (const key of Object.keys(base)) delete userKeys[key]; + delete userKeys.mcp; + const mergedMcp = { ...mcp, ...incoming }; + const result: Record = { ...base, ...userKeys }; + delete result.mcp; + if (Object.keys(mergedMcp).length > 0) result.mcp = mergedMcp; + return JSON.stringify(result, null, 2); +} + +/** Removes servers previously contributed by a plugin from opencode.json's mcp section. A key + * absent from `entries` is left untouched. */ +export function unmergeOpencodeMcp( + existingContent: string, + entries: ReadonlyMap +): string { + const parsed = JSON.parse(stripJsonComments(existingContent)) as OpencodeMcpSection; + const mcp = { ...(parsed.mcp ?? {}) }; + for (const name of entries.keys()) { + delete mcp[name]; + } + return JSON.stringify({ ...parsed, mcp }, null, 2); +} + +function parseExisting(content: string | null): { + full: Record; + mcp: Record; +} { + if (content === null) return { full: {}, mcp: {} }; + // opencode.json is user-owned and may be JSONC (comments / trailing commas). + const parsed = JSON.parse(stripJsonComments(content)) as OpencodeMcpSection; + return { + full: parsed as Record, + mcp: (parsed.mcp as Record) ?? {}, + }; +} + +function parseIncoming(transformed: string): Record { + const parsed = JSON.parse(transformed) as OpencodeMcpSection; + return (parsed.mcp as Record) ?? {}; +} + +function stripPreviousEntries( + existing: Record, + previous: ReadonlyMap +): Record { + const result = { ...existing }; + for (const name of previous.keys()) { + delete result[name]; + } + return result; +} + +function applyIncoming( + full: Record, + cleanedMcp: Record, + incoming: Record, + previous: ReadonlyMap, + hasher: Hasher +): { + mergedContent: string; + contributedEntries: ReadonlyMap; + collisions: ReadonlyArray; +} { + const mcp = { ...cleanedMcp }; + const contributed = new Map(); + const collisions: string[] = []; + for (const [name, server] of Object.entries(incoming)) { + if (name in cleanedMcp && !previous.has(name)) { + collisions.push(`${name}: ${MCP_COLLISION_REASON}`); + continue; + } + mcp[name] = server; + contributed.set(name, hasher.hash(JSON.stringify(server)).value); + } + const mergedContent = JSON.stringify({ ...full, mcp }, null, 2); + return { mergedContent, contributedEntries: contributed, collisions }; +} diff --git a/cli/src/contexts/tools/domain/formats/plugin-root-token.ts b/cli/src/contexts/tools/domain/formats/plugin-root-token.ts new file mode 100644 index 000000000..3f956c78d --- /dev/null +++ b/cli/src/contexts/tools/domain/formats/plugin-root-token.ts @@ -0,0 +1,12 @@ +/** + * The variable each tool expands to an installed plugin's own directory. A tool declares which + * one it speaks (`plugins.pluginRootToken`) and its build contract substitutes the same one, + * both reading this vocabulary rather than repeating a literal, so the install route and the + * build route cannot drift apart. The Claude spelling is also the source spelling: plugins are + * authored against it and every other tool's token is what it gets translated into. + */ + +// Split literals to avoid biome's noTemplateCurlyInString warning. +export const CLAUDE_PLUGIN_ROOT_TOKEN = "$" + "{CLAUDE_PLUGIN_ROOT}"; +export const CURSOR_PLUGIN_ROOT_TOKEN = "$" + "{CURSOR_PLUGIN_ROOT}"; +export const PLUGIN_ROOT_TOKEN = "$" + "{PLUGIN_ROOT}"; diff --git a/cli/src/contexts/tools/domain/formats/vscode-mcp-merge.ts b/cli/src/contexts/tools/domain/formats/vscode-mcp-merge.ts new file mode 100644 index 000000000..a5363417b --- /dev/null +++ b/cli/src/contexts/tools/domain/formats/vscode-mcp-merge.ts @@ -0,0 +1,52 @@ +/** + * Merges pre-prefixed MCP server entries into an existing workspace MCP config, purely + * additively. + * + * No manifest and no strip step, unlike the OpenCode merge: flat mode is fire-and-forget, so + * there is no inverse. A colliding key is recorded and skipped unless `force` overwrites it, + * and a user-owned server is always preserved. `serversKey` names the property holding the + * map — `servers` for VS Code, `mcpServers` for Claude and Cursor. + */ +export function mergeVscodeMcp( + existing: string | null, + incoming: Record, + force: boolean, + serversKey = "servers" +): { mergedContent: string; collisions: ReadonlyArray } { + const { full, servers } = parseExisting(existing, serversKey); + const { servers: mergedServers, collisions } = applyIncoming(servers, incoming, force); + const merged: Record = { ...full, [serversKey]: mergedServers }; + return { mergedContent: `${JSON.stringify(merged, null, 2)}\n`, collisions }; +} + +function parseExisting( + content: string | null, + serversKey: string +): { + full: Record; + servers: Record; +} { + if (content === null) return { full: {}, servers: {} }; + const parsed = JSON.parse(content) as Record; + return { + full: parsed, + servers: (parsed[serversKey] as Record) ?? {}, + }; +} + +function applyIncoming( + existingServers: Record, + incoming: Record, + force: boolean +): { servers: Record; collisions: ReadonlyArray } { + const servers = { ...existingServers }; + const collisions: string[] = []; + for (const [key, value] of Object.entries(incoming)) { + if (key in servers && !force) { + collisions.push(key); + continue; + } + servers[key] = value; + } + return { servers, collisions }; +} diff --git a/cli/src/contexts/tools/domain/hooks-format.ts b/cli/src/contexts/tools/domain/hooks-format.ts new file mode 100644 index 000000000..7d97aefae --- /dev/null +++ b/cli/src/contexts/tools/domain/hooks-format.ts @@ -0,0 +1,8 @@ +/** + * Which shape a tool wants its hooks file in, named after the shape and not the tool that first + * used it: `matchers` nests items under an event and a matcher, `flat` lists them directly under + * the event. The name is a tool's declaration and converting to it is translation; keeping the + * two in one module would make a tool profile import the context that translates for it, the one + * direction the chain forbids. + */ +export type HooksContentFormat = "matchers" | "flat"; diff --git a/cli/src/contexts/tools/domain/host-plugin-registration.ts b/cli/src/contexts/tools/domain/host-plugin-registration.ts new file mode 100644 index 000000000..1b7667c63 --- /dev/null +++ b/cli/src/contexts/tools/domain/host-plugin-registration.ts @@ -0,0 +1,161 @@ +import type { AiToolId } from "../../../kernel/tool.js"; +import type { + HostPluginRegistryEntry, + HostPluginRegistryReading, +} from "./ports/host-plugin-registry-reader.js"; + +/** + * Whether a host will actually load a plugin AIDD installed for it — the comparison + * `telemetry`'s own diagnostic and `framework`'s `doctor` both need and neither owns. A + * project's settings can carry a perfectly good `enabledPlugins` entry while the host's + * registry knows nothing about it, at which point the host drops the entry as orphaned and + * every visible signal still says healthy. + * + * Pure, and driven from whatever the caller already resolved as expected, never from a settings + * file: a plugin a settings sync skipped would otherwise be absent from both sides of the + * comparison and read as agreement while it never loads. + */ +export interface HostRegistration { + /** One per plugin whose registration can be asked about at all. Empty when nothing was + * expected, which is a normal state, not a fault. */ + readonly entries: readonly HostRegistrationEntry[]; +} + +/** + * Four answers, and none of them collapses into another. `registered-disabled` is not a shade + * of `registered`: Codex records `enabled` per plugin table, so `enabled = false` is a host that + * knows the plugin and still declines it. `unanswerable` is not a shade of `not-registered`: an + * unknown is never a zero, and a registry that is absent, unreadable, or JSONC where JSON was + * expected has said nothing — Copilot's own `config.json` opens with two `//` lines, so a naive + * parse throws on the first registry a reader meets. + */ +export type HostRegistrationAnswer = + | "registered" + | "registered-disabled" + | "not-registered" + | "unanswerable"; + +export interface HostRegistrationEntry { + readonly tool: AiToolId; + readonly plugin: string; + /** `@`, the one string all three measured hosts key their registry on. + * Absent exactly when no marketplace was recorded, the case no registry can be asked about. */ + readonly ref?: string; + readonly answer: HostRegistrationAnswer; + /** One sentence naming what was read and what it said, so the answer can be acted on + * rather than merely believed. */ + readonly detail: string; +} + +/** What one tool contributes to the comparison: the plugins expected for it, and what its + * registry answered — `undefined` when nothing here knows how to ask that host, a different + * fact from asking and getting nothing back. */ +export interface HostRegistrationEvidence { + readonly tool: AiToolId; + readonly plugins: readonly { readonly name: string; readonly marketplace?: string }[]; + readonly reading?: HostPluginRegistryReading; + /** Whether the tool declares a native activation at all. The two silences are different + * problems: a tool declaring none has no registry to look for, while one that declares an + * activation and has no reader here has a registry nobody has measured. */ + readonly declaresNativeActivation?: boolean; +} + +export function buildHostRegistration( + evidence: readonly HostRegistrationEvidence[] +): HostRegistration { + const entries: HostRegistrationEntry[] = []; + for (const item of evidence) { + const { tool, plugins, reading } = item; + for (const plugin of plugins) { + entries.push(hostRegistrationEntry(tool, plugin, reading, item.declaresNativeActivation)); + } + } + return { entries }; +} + +function hostRegistrationEntry( + tool: AiToolId, + plugin: { readonly name: string; readonly marketplace?: string }, + reading: HostPluginRegistryReading | undefined, + declaresNativeActivation: boolean | undefined +): HostRegistrationEntry { + // No marketplace recorded means no ref exists to look up — every measured host keys its + // registry on `@`, so this is unanswerable at the source rather than + // a lookup that failed. + if (plugin.marketplace === undefined || plugin.marketplace === "") { + return { + tool, + plugin: plugin.name, + answer: "unanswerable", + detail: "AIDD records no marketplace for it, so no host registry can be asked", + }; + } + const ref = `${plugin.name}@${plugin.marketplace}`; + return { + tool, + plugin: plugin.name, + ref, + ...answerForRef(tool, ref, reading, declaresNativeActivation), + }; +} + +/** What one registry says about one ref, given the reading it produced. Split from the + * entry it becomes so the two absences above — no ref to ask about, and no registry to ask + * — stay visibly separate from the four answers a registry can give. */ +function answerForRef( + tool: AiToolId, + ref: string, + reading: HostPluginRegistryReading | undefined, + declaresNativeActivation: boolean | undefined +): { answer: HostRegistrationAnswer; detail: string } { + const answered = answeredRegistry(tool, reading, declaresNativeActivation); + if ("detail" in answered) return answered; + const entry = answered.refs.get(ref); + if (entry === undefined) { + return { + answer: "not-registered", + detail: `${answered.location} does not carry ${ref} — ${tool} will drop the declaration as orphaned`, + }; + } + if (!entry.enabled) { + return { + answer: "registered-disabled", + detail: `${answered.location} carries ${ref} and records it disabled`, + }; + } + return { answer: "registered", detail: answered.location }; +} + +/** + * Either the refs a registry actually produced, or the reason nothing did — one value, so no + * branch can reach a lookup against a registry that never opened. + * + * Three reasons, not one, because they send a person somewhere different: a tool that drives its + * own CLI keeps a registry somebody could go and measure, a tool that declares no native + * activation has none to look for, and a registry found but unreadable names the file and the + * failure. + */ +export function answeredRegistry( + tool: AiToolId, + reading: HostPluginRegistryReading | undefined, + declaresNativeActivation: boolean | undefined +): + | { readonly refs: ReadonlyMap; readonly location: string } + | { readonly answer: "unanswerable"; readonly detail: string } { + if (reading === undefined) { + return { + answer: "unanswerable", + detail: + declaresNativeActivation === true + ? `${tool} keeps a plugin registry, and nothing here has established its shape` + : `${tool} declares no plugin registry to read`, + }; + } + if (reading.refs === undefined) { + return { + answer: "unanswerable", + detail: `${reading.location} could not be read — ${reading.unreadable ?? "no reason given"}`, + }; + } + return { refs: reading.refs, location: reading.location }; +} diff --git a/cli/src/contexts/tools/domain/marketplace-catalog.ts b/cli/src/contexts/tools/domain/marketplace-catalog.ts new file mode 100644 index 000000000..470e7e792 --- /dev/null +++ b/cli/src/contexts/tools/domain/marketplace-catalog.ts @@ -0,0 +1,192 @@ +/** + * Marketplace catalog and manifest shaping shared by more than one tool's build contract. + * + * A tool's build contract otherwise lives entirely inside that tool's own profile directory; + * these are the exception, since claude and cursor emit byte-identical plugin manifests and + * catalog entries and claude and copilot transform an agent's frontmatter identically. One + * tool importing another's directory would break the boundary, so both reach this instead. + */ +import { join } from "node:path"; +import { InvalidSourceMarketplaceError } from "../../../kernel/errors.js"; +import { parseFrontmatter, serializeFrontmatter } from "../../../kernel/markdown.js"; +import { rewriteRelativeLinks } from "../../../kernel/materialization/relative-link-rewrite.js"; +import type { FileReader } from "../../../kernel/ports/file-reader.js"; +import type { FileWriter } from "../../../kernel/ports/file-writer.js"; +import type { PluginPresence } from "./build-contract.js"; + +type SrcEntry = + | { version?: string; description?: string; strict?: boolean; recommended?: boolean } + | undefined; + +/** Marketplace-mode agent transform shared by claude and copilot: the frontmatter is kept + * untouched and only relative links are rewritten to the flattened output path. */ +export function transformClaudeAgent(content: string, _plugin: string, outName: string): string { + const { frontmatter, body } = parseFrontmatter(content); + const rewrittenBody = rewriteRelativeLinks(body, { + currentFilePluginRelative: `agents/${outName}`, + }); + return serializeFrontmatter(frontmatter, rewrittenBody); +} + +export interface SynthesizeClaudeStyleManifestOpts { + /** When true, include `agents` as a list of `./agents/*.md` file paths if agents are present. */ + readonly agentsField: boolean; + /** + * When true, point `hooks` at `./hooks/hooks.json` if the plugin ships one. Declared per tool + * because Claude Code loads that path by its own convention and rejects a plugin whose + * manifest names it too — "Duplicate hooks file detected", while the hooks fired anyway, so + * the plugin read as failed while working. Codex and the others still need the pointer. + */ + readonly hooksField: boolean; +} + +/** Synthesize a Claude-style plugin manifest, shared by the claude, cursor and copilot + * strategies. Key insertion order is part of the output shape. */ +export function synthesizeClaudeStyleManifest( + source: Record, + presence: PluginPresence, + opts: SynthesizeClaudeStyleManifestOpts +): Record { + const manifest: Record = {}; + if (typeof source.name === "string") manifest.name = source.name; + if (typeof source.description === "string") manifest.description = source.description; + if (typeof source.version === "string") manifest.version = source.version; + if (typeof source.author === "string" || typeof source.author === "object") + manifest.author = source.author; + if (typeof source.homepage === "string") manifest.homepage = source.homepage; + if (typeof source.repository === "string") manifest.repository = source.repository; + if (typeof source.license === "string") manifest.license = source.license; + if (Array.isArray(source.keywords)) manifest.keywords = source.keywords; + if (opts.agentsField && presence.agentsList.length > 0) + manifest.agents = presence.agentsList.map((n) => `./agents/${n}`); + if (presence.skillsList.length > 0) + manifest.skills = presence.skillsList.map((n) => `./skills/${n}`); + if (opts.hooksField && presence.hasHooksJson) manifest.hooks = "./hooks/hooks.json"; + if (presence.hasMcpJson) manifest.mcpServers = "./.mcp.json"; + return manifest; +} + +export function buildClaudeStyleMarketplace( + source: { name: string; version?: string; description?: string; owner?: unknown }, + pluginEntries: readonly Record[] +): Record { + const obj: Record = { name: source.name }; + if (typeof source.version === "string") obj.version = source.version; + if (typeof source.description === "string") obj.description = source.description; + if (source.owner !== undefined) obj.owner = source.owner; + obj.plugins = pluginEntries; + return obj; +} + +export function buildClaudeStyleCatalogEntry( + name: string, + description: string, + version: string, + srcEntry: Record | undefined +): Record { + const entry: Record = { + name, + source: `./plugins/${name}`, + description, + version, + }; + if (typeof srcEntry?.strict === "boolean") entry.strict = srcEntry.strict; + if (typeof srcEntry?.recommended === "boolean") entry.recommended = srcEntry.recommended; + return entry; +} + +export async function resolveVersion( + fs: FileReader, + name: string, + srcEntry: { version?: string } | undefined, + outDir: string, + outputManifestRelative: string +): Promise { + if (srcEntry?.version) return srcEntry.version; + const manifestPath = join(outDir, "plugins", name, outputManifestRelative); + const raw = await fs.readFile(manifestPath); + const manifest = JSON.parse(raw) as Record; + if (typeof manifest.version === "string") return manifest.version; + throw new InvalidSourceMarketplaceError( + `plugin '${name}' has no version in marketplace entry or plugin.json` + ); +} + +export async function resolveDescription( + fs: FileReader, + name: string, + srcEntry: { description?: string } | undefined, + outDir: string, + outputManifestRelative: string +): Promise { + if (srcEntry?.description) return srcEntry.description; + const manifestPath = join(outDir, "plugins", name, outputManifestRelative); + const raw = await fs.readFile(manifestPath); + const manifest = JSON.parse(raw) as Record; + if (typeof manifest.description === "string" && manifest.description.length > 0) { + return manifest.description; + } + throw new InvalidSourceMarketplaceError( + `plugin '${name}' has no description in marketplace entry or plugin.json` + ); +} + +/** Resolve version and description, then shape the catalog entry — the marketplace-entry + * builder claude and cursor both hand to `ToolBuildContract.buildMarketplaceEntry`. */ +export async function buildClaudeStyleEntry( + name: string, + outDir: string, + srcEntry: SrcEntry, + manifestRelative: string, + fs: FileReader & FileWriter +): Promise> { + const args = [fs, name, srcEntry, outDir, manifestRelative] as const; + const version = await resolveVersion(...args); + const description = await resolveDescription(...args); + return buildClaudeStyleCatalogEntry( + name, + description, + version, + srcEntry as Record | undefined + ); +} + +// Codex-native marketplace catalog, for `codex plugin marketplace add`. Shape verified against +// OpenAI's own published plugins marketplace and its plugin-build documentation. + +/** Default category when the source marketplace entry does not specify one. */ +const CODEX_DEFAULT_CATEGORY = "Developer Tools"; +/** Default per-plugin auth policy. AIDD plugins bundle skills/agents/hooks with no external + * OAuth, so auth is deferred to first use rather than forced at install. */ +const CODEX_DEFAULT_AUTHENTICATION = "ON_USE"; +const CODEX_INSTALLATION_AVAILABLE = "AVAILABLE"; + +/** Build a Codex marketplace catalog. `displayName` falls back to the marketplace name when + * the source omits it. */ +export function buildCodexMarketplace( + source: { name: string; displayName?: string }, + pluginEntries: readonly Record[] +): Record { + const displayName = typeof source.displayName === "string" ? source.displayName : source.name; + return { name: source.name, interface: { displayName }, plugins: pluginEntries }; +} + +/** Build a single Codex marketplace entry. `installation`, `authentication` and `category` are + * required by the plugin-creator spec; the last two accept a source-entry override. */ +export function buildCodexMarketplaceEntry( + name: string, + srcEntry: Record | undefined +): Record { + const authentication = + typeof srcEntry?.authentication === "string" + ? srcEntry.authentication + : CODEX_DEFAULT_AUTHENTICATION; + const category = + typeof srcEntry?.category === "string" ? srcEntry.category : CODEX_DEFAULT_CATEGORY; + return { + name, + source: { source: "local", path: `./plugins/${name}` }, + policy: { installation: CODEX_INSTALLATION_AVAILABLE, authentication }, + category, + }; +} diff --git a/cli/src/contexts/tools/domain/marketplace-entry.ts b/cli/src/contexts/tools/domain/marketplace-entry.ts new file mode 100644 index 000000000..6c44d7640 --- /dev/null +++ b/cli/src/contexts/tools/domain/marketplace-entry.ts @@ -0,0 +1,14 @@ +import type { MarketplaceSettingsInput } from "./marketplace-settings.js"; + +/** + * The key a Claude-schema tool records a marketplace under, or `null` when it cannot. The name + * is the key; the source decides the `null`. These tools express a marketplace as a local + * directory or a GitHub repository and nothing else, so one fetched from a bare URL, a git + * subdirectory or npm gets no entry rather than a wrong one, and its plugins stay out of the + * enabled-plugins map instead of being keyed against a source the tool cannot resolve. + */ +export function claudeStyleMarketplaceKey(input: MarketplaceSettingsInput): string | null { + const { name, source } = input; + if (source.kind !== "local" && source.kind !== "github") return null; + return name; +} diff --git a/cli/src/contexts/tools/domain/marketplace-settings.ts b/cli/src/contexts/tools/domain/marketplace-settings.ts new file mode 100644 index 000000000..6f6f83550 --- /dev/null +++ b/cli/src/contexts/tools/domain/marketplace-settings.ts @@ -0,0 +1,26 @@ +import type { PluginSource } from "../../../kernel/source.js"; + +export interface MarketplaceSettingsInput { + name: string; + source: PluginSource; +} + +/** Where and how a tool records the marketplaces it knows about, for the tools whose settings + * file this CLI writes itself. Kept apart from {@link PluginsCapability} because the two answer + * different questions: this one is read only by marketplace settings synchronisation. */ +export interface MarketplaceSettings { + settingsPath: string; + settingsKey: string; + enabledPluginsKey?: string; + /** + * Where the tool keeps its registered marketplaces, for `doctor`, which checks the tool + * actually wrote one. A path names a file of its own, which this CLI neither commits nor + * hashes since its entries name built trees by absolute path; `null` means nowhere — the tool + * offers no machine-local project file, and its shared one is for recommending plugins to + * teammates, where a path belonging to whoever ran the install is worse than nothing. + */ + marketplacesSettingsPath: string | null; + /** The name this marketplace is keyed by in the enabled-plugins map, or `null` when the tool + * cannot express its source and no key should be written. */ + toEntryKey(input: MarketplaceSettingsInput): string | null; +} diff --git a/cli/src/contexts/tools/domain/marketplace-source-conflict.ts b/cli/src/contexts/tools/domain/marketplace-source-conflict.ts new file mode 100644 index 000000000..3af3b0f05 --- /dev/null +++ b/cli/src/contexts/tools/domain/marketplace-source-conflict.ts @@ -0,0 +1,107 @@ +import type { HostMarketplaceRegistryReading } from "./ports/host-marketplace-registry-reader.js"; + +/** + * What a catalog's own `marketplace.json` declares about itself — the fact that decides whether + * two directories are the same marketplace or two different ones that happen to share a + * registered name. Read from the file itself, never from a path. `version` is deliberately + * absent: identity is the declared name plus the plugin set — see {@link sameCatalog}. + */ +export interface MarketplaceCatalogIdentity { + readonly name: string; + readonly pluginNames: readonly string[]; +} + +/** + * A marketplace name a host's own registry already holds, pointed at a *different catalog* than + * the one this project is about to register — the case `claude plugin marketplace add`'s silent + * overwrite (exit 0, no prompt, `installLocation` simply replaced) actually breaks. A version + * or migration drift is a separate question, decided from the path's own segments by a caller + * that then never calls this at all. + */ +export interface MarketplaceSourceConflict { + readonly name: string; + /** What the host's registry currently resolves this name to. */ + readonly registeredSource: string; + /** What this project is about to ask the host to register it as instead. */ + readonly requestedSource: string; + readonly registeredIdentity: MarketplaceCatalogIdentity; + readonly requestedIdentity: MarketplaceCatalogIdentity; + /** The file the reading came from, so the message names something a person can open. */ + readonly location: string; +} + +/** + * Whether registering `requestedSource` under `expectedName` would silently replace a + * *different* catalog a host's registry already holds under that name — not merely a different + * directory. Pure: the caller reads both identities from each source's own `marketplace.json` + * first. + * + * A registry that answers nothing, and a registered source whose own catalog cannot be read, + * are both unknown rather than a conflict — a dead entry is what a re-add repairs. The same + * identity reached by two resolved paths is one catalog reached twice, which is what two + * projects building the same framework fixture measure; refusing it would refuse every second + * `aidd sync` either of them runs. + */ +export function marketplaceSourceConflict( + reading: HostMarketplaceRegistryReading, + expectedName: string, + requestedSource: string, + registeredIdentity: MarketplaceCatalogIdentity | undefined, + requestedIdentity: MarketplaceCatalogIdentity +): MarketplaceSourceConflict | undefined { + if (reading.entries === undefined) return undefined; + const registeredSource = reading.entries.get(expectedName); + if (registeredSource === undefined) return undefined; + if (registeredIdentity === undefined) return undefined; + if (sameCatalog(registeredIdentity, requestedIdentity)) return undefined; + return { + name: expectedName, + registeredSource, + requestedSource, + registeredIdentity, + requestedIdentity, + location: reading.location, + }; +} + +/** A catalog's identity is its declared name plus its plugin set: a version bump under the same + * name and the same plugins is the host repointing to a newer build of the marketplace it + * already knows, so the version is deliberately left out of this comparison. */ +function sameCatalog(a: MarketplaceCatalogIdentity, b: MarketplaceCatalogIdentity): boolean { + return a.name === b.name && samePluginNames(a.pluginNames, b.pluginNames); +} + +/** Order-independent: what a catalog declares is a set of plugins, not a sequence. */ +function samePluginNames(a: readonly string[], b: readonly string[]): boolean { + if (a.length !== b.length) return false; + const sortedA = [...a].sort(); + const sortedB = [...b].sort(); + return sortedA.every((name, index) => name === sortedB[index]); +} + +/** The plugin names a conflict message can point at: what `requested` carries that `registered` + * does not, and the reverse. Two conflicting identities may differ in the declared name alone, + * so an empty diff here is legitimate — the name difference is then the fact to report. */ +export function pluginSetDifference( + registered: MarketplaceCatalogIdentity, + requested: MarketplaceCatalogIdentity +): { readonly added: readonly string[]; readonly removed: readonly string[] } { + const registeredNames = new Set(registered.pluginNames); + const requestedNames = new Set(requested.pluginNames); + return { + added: requested.pluginNames.filter((name) => !registeredNames.has(name)), + removed: registered.pluginNames.filter((name) => !requestedNames.has(name)), + }; +} + +/** Renders a {@link pluginSetDifference} result into the fragment doctor's and sync's own + * conflict messages name the plugins by — one wording, not one written fresh in each caller. */ +export function describePluginDiff(diff: { + readonly added: readonly string[]; + readonly removed: readonly string[]; +}): string { + const parts: string[] = []; + if (diff.added.length > 0) parts.push(`+${diff.added.join(", ")}`); + if (diff.removed.length > 0) parts.push(`-${diff.removed.join(", ")}`); + return parts.length > 0 ? `differ (${parts.join(", ")})` : "match, but the declared name differs"; +} diff --git a/cli/src/contexts/tools/domain/mcp-exclusion.ts b/cli/src/contexts/tools/domain/mcp-exclusion.ts new file mode 100644 index 000000000..8cbe77d68 --- /dev/null +++ b/cli/src/contexts/tools/domain/mcp-exclusion.ts @@ -0,0 +1,39 @@ +interface McpServerWin32 { + command?: string; + args?: string[]; + [key: string]: unknown; +} + +interface McpConfigWin32 { + mcpServers?: Record; + [key: string]: unknown; +} + +function transformMcpForWin32(content: string): string { + const config = JSON.parse(content) as McpConfigWin32; + if (!config.mcpServers) return JSON.stringify(config, null, 2); + for (const server of Object.values(config.mcpServers)) { + if (server.command === "npx") { + server.args = ["/c", "npx", ...(server.args ?? [])]; + server.command = "cmd"; + } else if (server.command === "uvx") { + server.command = "uvx.exe"; + } else if (server.command === "uv") { + server.command = "uv.exe"; + } + } + return JSON.stringify(config, null, 2); +} + +export function transformFor(platform: string): ((content: string) => string) | undefined { + return platform === "win32" ? transformMcpForWin32 : undefined; +} + +export interface McpExclusion { + readonly configPath: string; + readonly entryKey: string; +} + +export function mcpExclusionEquals(a: McpExclusion, b: McpExclusion): boolean { + return a.configPath === b.configPath && a.entryKey === b.entryKey; +} diff --git a/cli/src/contexts/tools/domain/models/plugin-install-notice.ts b/cli/src/contexts/tools/domain/models/plugin-install-notice.ts new file mode 100644 index 000000000..3b11f9824 --- /dev/null +++ b/cli/src/contexts/tools/domain/models/plugin-install-notice.ts @@ -0,0 +1,12 @@ +import type { AiToolId } from "../../../../kernel/tool.js"; + +/** A component was delivered, not skipped, but runs only once a precondition outside the install + * is met — unlike `PluginTranslationSkip`, which names a component never delivered at all. */ +export interface PluginInstallNotice { + readonly pluginName: string; + readonly component: "hooks"; + readonly toolId: AiToolId; + readonly message: string; +} + +export type ReadonlyNoticeList = readonly PluginInstallNotice[]; diff --git a/cli/src/contexts/tools/domain/plugin-translation-mode.ts b/cli/src/contexts/tools/domain/plugin-translation-mode.ts new file mode 100644 index 000000000..ec175dd15 --- /dev/null +++ b/cli/src/contexts/tools/domain/plugin-translation-mode.ts @@ -0,0 +1,3 @@ +/** `"marketplace"`: register a plugin reference in the tool's native config, writing no plugin + * files. `"flat"`: materialize the plugin's content as files on disk. */ +export type PluginTranslationMode = "marketplace" | "flat"; diff --git a/cli/src/contexts/tools/domain/ports/file-merger.ts b/cli/src/contexts/tools/domain/ports/file-merger.ts new file mode 100644 index 000000000..e0c682896 --- /dev/null +++ b/cli/src/contexts/tools/domain/ports/file-merger.ts @@ -0,0 +1,5 @@ +import type { MergeStrategy } from "../../../../kernel/merge.js"; + +export interface FileMerger { + mergeJsonFile(path: string, content: string, strategy: MergeStrategy): Promise; +} diff --git a/cli/src/contexts/tools/domain/ports/host-marketplace-registry-reader.ts b/cli/src/contexts/tools/domain/ports/host-marketplace-registry-reader.ts new file mode 100644 index 000000000..bbac11a3e --- /dev/null +++ b/cli/src/contexts/tools/domain/ports/host-marketplace-registry-reader.ts @@ -0,0 +1,35 @@ +/** + * What a host's own marketplace registry says, read from the file that host maintains itself. + * + * Distinct from {@link HostPluginRegistryReading}, which answers whether a plugin ref loads: + * this answers whether a marketplace *name* is already held, and by which resolved source. + * Measured against the real `claude` binary, `claude plugin marketplace add ` derives the + * registered name from the source's own `marketplace.json`, never from an argument, and + * re-adding that name from a different directory silently overwrites `installLocation` — no + * prompt, no error, exit 0. One implementation exists, Claude Code's, because Codex refuses a + * re-add from a different source itself and Copilot refuses every re-add. + */ +export interface HostMarketplaceRegistryReading { + /** The file consulted, named whatever it answered, so a person can open the same one. */ + readonly location: string; + /** + * Every marketplace name the registry carries, mapped to the resolved (`realpath`'d) source + * it currently points at. **Absent, never empty, when the registry could not be read**: an + * empty map is a real answer — the file opened and holds no marketplace — and must not be + * reachable from a file that never opened at all. + */ + readonly entries?: ReadonlyMap; + /** + * `true` when the registry file itself does not exist — nothing has ever named a marketplace + * there, so a consumer proving a cache safe to purge may treat this as an empty registry. A + * file that exists but could not be parsed proves nothing. Never set with `unreadable`. + */ + readonly absent?: true; + /** Why the registry could not be read, when it exists but reading or parsing it failed. + * Present exactly when `entries` and `absent` are both absent. */ + readonly unreadable?: string; +} + +export interface HostMarketplaceRegistryReader { + read(): Promise; +} diff --git a/cli/src/contexts/tools/domain/ports/host-plugin-registry-reader.ts b/cli/src/contexts/tools/domain/ports/host-plugin-registry-reader.ts new file mode 100644 index 000000000..8f56d84b0 --- /dev/null +++ b/cli/src/contexts/tools/domain/ports/host-plugin-registry-reader.ts @@ -0,0 +1,43 @@ +import type { MarketplaceScope } from "../../../../kernel/scope.js"; + +/** + * What a host's own plugin registry says, read from the file that host maintains itself. + * + * A host loads a plugin only once it appears in its own user-global registry, whatever the + * project's settings declare: `aidd` writes a declaration, the host keeps a registry, and only + * the second one decides. All three hosts that declare a native activation key that registry on + * the same `@` string `enabledPlugins` uses, so a reading is a set of refs + * whatever file it came out of. A host with no implementation here is simply absent from the + * map the diagnostic consults, never assumed to agree. + */ +/** What a registry says about one ref: whether the host records it enabled and, for a host + * whose registry carries a per-entry scope (Claude), the scope of the entry that answers for + * the project asked about. `undefined` for a host whose registry has no scope concept at all + * (Codex, Copilot). Read before a registration is undone, since a real `claude` binary refuses + * a mismatched-scope uninstall outright. */ +export interface HostPluginRegistryEntry { + readonly enabled: boolean; + readonly scope?: MarketplaceScope; +} + +export interface HostPluginRegistryReading { + /** The file consulted, named whatever it answered, so a person can open the same one. */ + readonly location: string; + /** + * Every ref the registry carries, mapped to what it says about it. **Absent, never empty, + * when the registry could not be read**: an empty map is a real answer, and keeping the two + * apart in the type is what stops a caller inventing "not registered" out of a permissions + * error. + */ + readonly refs?: ReadonlyMap; + /** Why the registry could not be read: absent, unreadable, or holding something this reader + * will not pretend to understand. Present exactly when `refs` is absent. */ + readonly unreadable?: string; +} + +export interface HostPluginRegistryReader { + /** `projectRoot` because a registry may bind a ref to one project rather than to the machine + * — Claude's does. A reader whose host records no such binding ignores it and says so, rather + * than silently answering a narrower question than it was asked. */ + read(projectRoot: string): Promise; +} diff --git a/cli/src/contexts/tools/domain/ports/native-plugin-activator.ts b/cli/src/contexts/tools/domain/ports/native-plugin-activator.ts new file mode 100644 index 000000000..c2da415bb --- /dev/null +++ b/cli/src/contexts/tools/domain/ports/native-plugin-activator.ts @@ -0,0 +1,44 @@ +import type { MarketplaceScope } from "../../../../kernel/scope.js"; + +/** + * Drives a tool's native plugin CLI, so the tool writes its own configuration. + * + * What each tool delegates differs: Codex and Copilot load plugins only from user-global state + * their own `plugin` subcommands populate, so both steps are driven, while Claude registers its + * marketplaces through its command but reads enabled plugins from a project file this CLI + * writes, so only the registration is. Implementations shell out to the binary declared by + * `NativeActivation.binary`. + */ +export interface NativePluginActivator { + /** Returns true when the tool's CLI binary is callable on PATH. Never throws. */ + isAvailable(): boolean; + /** Registers a marketplace source (local path, `owner/repo[@ref]`, or git URL). Idempotent. */ + addMarketplace(source: string, scope: MarketplaceScope): void; + /** True when this tool enables plugins through its CLI rather than through a file. */ + enablesPlugins(): boolean; + /** Unregisters a marketplace by name, in the scope it was added to. May throw when absent. */ + removeMarketplace(name: string, scope: MarketplaceScope, options?: { force?: boolean }): void; + /** Whether the registration under this name still resolves to something. `"unknown"` where + * the tool offers no way to tell, which callers must read as "leave it alone": a registration + * that might belong to a live project elsewhere is not one to take over. */ + registrationState(name: string): "live" | "dead" | "unknown"; + /** Refreshes marketplace snapshots so plugin installs pick up new versions. No-op when unsupported. */ + upgradeMarketplaces(): void; + /** + * Installs and enables a plugin referenced as `@`. Idempotent. + * + * `scope` is not where the marketplace registration lives but at what scope *this* plugin is + * enabled — `"project"` by default, mapping to claude's own `--scope local`. Omitting the + * argument entirely makes a real `claude` binary choose its own `"user"` default, + * machine-wide, whatever scope `aidd` ran at. A tool declaring no `scopeArgs` (codex, + * copilot) ignores it. + */ + enablePlugin(pluginRef: string, scope?: MarketplaceScope): void; + /** + * Uninstalls a plugin referenced as `@`, the counterpart of + * {@link enablePlugin}. May throw when the plugin is already absent from the tool's own + * registry; callers wrap it best-effort. `scope` must match the scope it was enabled at — a + * real `claude` binary refuses a mismatched-scope uninstall outright. + */ + uninstallPlugin(pluginRef: string, scope?: MarketplaceScope): void; +} diff --git a/cli/src/contexts/tools/domain/ports/schema-validator.ts b/cli/src/contexts/tools/domain/ports/schema-validator.ts new file mode 100644 index 000000000..208396248 --- /dev/null +++ b/cli/src/contexts/tools/domain/ports/schema-validator.ts @@ -0,0 +1,4 @@ +/** Validates data against a JSON schema, throwing `JsonSchemaValidationError` on failure. */ +export interface JsonSchemaValidator { + validate(schema: object, data: unknown): void; +} diff --git a/cli/src/contexts/tools/domain/profiles/claude/build.ts b/cli/src/contexts/tools/domain/profiles/claude/build.ts new file mode 100644 index 000000000..7bcdb38f2 --- /dev/null +++ b/cli/src/contexts/tools/domain/profiles/claude/build.ts @@ -0,0 +1,154 @@ +/** + * Claude's build contracts: marketplace (a native plugin tree) and flat (direct workspace + * materialization). The transforms, path computations and merges are pure functions reused from + * `domain/formats/`; the contracts themselves are thin wiring. + */ + +import { parseFrontmatter, serializeFrontmatter } from "../../../../../kernel/markdown.js"; +import { + genericFlatAgentPath, + genericFlatHooksFile, + genericFlatHooksScriptPath, + genericFlatSkillPath, +} from "../../../../../kernel/materialization/flat-paths.js"; +import { rewriteRelativeLinks } from "../../../../../kernel/materialization/relative-link-rewrite.js"; +import type { ToolBuildContract } from "../../build-contract.js"; +import { mergeClaudeSettingsHooks } from "../../formats/flat-hooks-merge.js"; +import { CLAUDE_PLUGIN_ROOT_TOKEN } from "../../formats/plugin-root-token.js"; +import { mergeVscodeMcp } from "../../formats/vscode-mcp-merge.js"; +import { + buildClaudeStyleEntry, + buildClaudeStyleMarketplace, + synthesizeClaudeStyleManifest, + transformClaudeAgent, +} from "../../marketplace-catalog.js"; +import { + OUTPUT_CLAUDE_MANIFEST_RELATIVE, + OUTPUT_CLAUDE_MARKETPLACE_RELATIVE, +} from "./claude-build-paths.js"; + +export function buildClaudeContract(): ToolBuildContract { + const manifestRelative = OUTPUT_CLAUDE_MANIFEST_RELATIVE; + const marketplaceRelative = OUTPUT_CLAUDE_MARKETPLACE_RELATIVE; + return { + pluginRootToken: CLAUDE_PLUGIN_ROOT_TOKEN, + manifestFileRelative: manifestRelative, + synthesizeManifest: (source, presence) => + synthesizeClaudeStyleManifest(source, presence, { + agentsField: true, + // Claude Code loads ./hooks/hooks.json by its own convention and rejects the plugin + // outright when a manifest names it too — "Duplicate hooks file detected". The hooks + // fired anyway, so the plugin read as failed while working. + hooksField: false, + }), + manifestSchemaName: "plugin-manifest", + artifacts: { + skills: { + supported: true, + source: { kind: "fullTree", srcDir: "skills" }, + path: (_p, rel) => rel, + }, + agents: { + supported: true, + source: { kind: "filteredTree", srcDir: "agents", inputExt: ".md" }, + path: (_p, rel) => rel, + transform: transformClaudeAgent, + }, + mcp: { + supported: true, + source: { kind: "configFile", srcPath: ".mcp.json" }, + path: () => ".mcp.json", + }, + hooks: { + supported: true, + source: { kind: "hooksBundle", jsonPath: "hooks/hooks.json", scriptDir: "hooks" }, + path: (_p, rel) => rel, + }, + rules: { supported: false }, + commands: { supported: false }, + }, + buildMarketplaceCatalog: async (source, entries, _fs) => ({ + catalog: buildClaudeStyleMarketplace( + source as Parameters[0], + entries + ), + schemaName: "claude-marketplace", + destRelPath: marketplaceRelative, + }), + buildMarketplaceEntry: async (name, _src, outDir, srcEntry, fs) => + buildClaudeStyleEntry(name, outDir, srcEntry, manifestRelative, fs), + }; +} + +function claudeFlatAgentPath(plugin: string, rel: string): string { + return genericFlatAgentPath(".claude/agents/", plugin, rel.replace(/^agents\//, ""), ".md"); +} + +function claudeFlatSkillPath(plugin: string, rel: string): string { + return genericFlatSkillPath(".claude/skills/", plugin, rel.replace(/^skills\//, "")); +} + +function claudeFlatHooksPath(plugin: string, rel: string): string { + const rest = rel.replace(/^hooks\//, ""); + if (rest === `${plugin}.hooks.json`) return genericFlatHooksFile(".claude/hooks/", plugin); + return genericFlatHooksScriptPath(".claude/hooks/", plugin, rest); +} + +function claudeFlatResolveTarget(plugin: string, rel: string): string { + if (rel.startsWith("agents/")) return claudeFlatAgentPath(plugin, rel); + if (rel.startsWith("skills/")) return claudeFlatSkillPath(plugin, rel); + return rel; +} + +function transformClaudeFlatAgent(content: string, plugin: string, outName: string): string { + const { frontmatter, body } = parseFrontmatter(content); + const flatRelPath = claudeFlatAgentPath(plugin, `agents/${outName}`); + const rewrittenBody = rewriteRelativeLinks(body, { + currentFilePluginRelative: flatRelPath, + resolveTargetPath: (rel) => claudeFlatResolveTarget(plugin, rel), + }); + const prefixedName = `${plugin}-${outName.replace(/\.md$/, "")}`; + return serializeFrontmatter({ ...frontmatter, name: prefixedName }, rewrittenBody); +} + +export function buildClaudeFlatContract(): ToolBuildContract { + return { + manifestFileRelative: null, + synthesizeManifest: null, + manifestSchemaName: null, + artifacts: { + skills: { + supported: true, + source: { kind: "fullTree", srcDir: "skills" }, + path: claudeFlatSkillPath, + rewriteSkillName: true, + }, + agents: { + supported: true, + source: { kind: "filteredTree", srcDir: "agents", inputExt: ".md" }, + path: claudeFlatAgentPath, + transform: transformClaudeFlatAgent, + }, + mcp: { + supported: true, + source: { kind: "configFile", srcPath: ".mcp.json" }, + path: () => ".mcp.json", + merge: (existing, incoming, force) => + mergeVscodeMcp(existing, incoming, force, "mcpServers"), + mcpServersKey: "mcpServers", + mergeDest: (outDir) => `${outDir}/.mcp.json`, + }, + hooks: { + supported: true, + source: { kind: "hooksBundle", jsonPath: "hooks/hooks.json", scriptDir: "hooks" }, + path: claudeFlatHooksPath, + hooksMerge: (existing, incoming) => mergeClaudeSettingsHooks(existing, incoming), + hooksMergeDest: (outDir) => `${outDir}/.claude/settings.json`, + }, + rules: { supported: false }, + commands: { supported: false }, + }, + buildMarketplaceCatalog: null, + buildMarketplaceEntry: null, + }; +} diff --git a/cli/src/contexts/tools/domain/profiles/claude/claude-build-paths.ts b/cli/src/contexts/tools/domain/profiles/claude/claude-build-paths.ts new file mode 100644 index 000000000..0540b2d8b --- /dev/null +++ b/cli/src/contexts/tools/domain/profiles/claude/claude-build-paths.ts @@ -0,0 +1,6 @@ +/** Claude build output path constants, deliberately distinct from the source-side constants + * even where the literal values coincide: a change to either side must not collapse them. */ + +export const OUTPUT_CLAUDE_MANIFEST_RELATIVE = ".claude-plugin/plugin.json"; + +export const OUTPUT_CLAUDE_MARKETPLACE_RELATIVE = ".claude-plugin/marketplace.json"; diff --git a/cli/src/contexts/tools/domain/profiles/claude/claude-transcript-location.ts b/cli/src/contexts/tools/domain/profiles/claude/claude-transcript-location.ts new file mode 100644 index 000000000..9d81cf755 --- /dev/null +++ b/cli/src/contexts/tools/domain/profiles/claude/claude-transcript-location.ts @@ -0,0 +1,28 @@ +import { sep } from "node:path"; +import type { TranscriptLocation } from "../../../../../kernel/measurement.js"; + +/** Where Claude Code keeps a session's transcript, and which file belongs to which session. + * Declared beside the profile that names it: only the tool knows its own directory layout, + * and the adapter that opens the files never encodes one itself. */ +function matchesMainTranscript(segments: readonly string[], sessionId: string): boolean { + return segments.length === 2 && segments[1] === `${sessionId}.jsonl`; +} + +function matchesSubagentTranscript(segments: readonly string[], sessionId: string): boolean { + return ( + segments.length === 4 && + segments[1] === sessionId && + segments[2] === "subagents" && + segments[3].endsWith(".jsonl") + ); +} + +export const CLAUDE_CODE_TRANSCRIPT_LOCATION: TranscriptLocation = { + root: (homeDir) => `${homeDir}${sep}.claude${sep}projects`, + matches: (relativePath, sessionId) => { + const segments = relativePath.split(sep); + return ( + matchesMainTranscript(segments, sessionId) || matchesSubagentTranscript(segments, sessionId) + ); + }, +}; diff --git a/cli/src/contexts/tools/domain/profiles/claude/profile.ts b/cli/src/contexts/tools/domain/profiles/claude/profile.ts new file mode 100644 index 000000000..8752f893d --- /dev/null +++ b/cli/src/contexts/tools/domain/profiles/claude/profile.ts @@ -0,0 +1,167 @@ +import { join } from "node:path"; +import { AgentsCapability } from "../../capabilities/agents-capability.js"; +import { CommandsCapability } from "../../capabilities/commands-capability.js"; +import { CONFIG_MCP } from "../../capabilities/config-refs.js"; +import { McpCapability } from "../../capabilities/mcp-capability.js"; +import { PluginsCapability } from "../../capabilities/plugins-capability.js"; +import { RulesCapability } from "../../capabilities/rules-capability.js"; +import { SkillsCapability } from "../../capabilities/skills-capability.js"; +import type { + AiTool, + HasAgents, + HasCommands, + HasMcp, + HasPlugins, + HasRules, + HasSkills, +} from "../../contracts.js"; +import { convertCommandFrontmatter, stripToolSuffix } from "../../formats/command.js"; +import { CLAUDE_PLUGIN_ROOT_TOKEN } from "../../formats/plugin-root-token.js"; +import { claudeStyleMarketplaceKey } from "../../marketplace-entry.js"; +import { registerTool } from "../../registry.js"; +import { buildClaudeContract, buildClaudeFlatContract } from "./build.js"; +import { CLAUDE_CODE_TRANSCRIPT_LOCATION } from "./claude-transcript-location.js"; + +const DIRECTORY = ".claude/"; +const TOOL_SUFFIX = ".claude.md"; + +function commandsDir(phase: string): string { + return `${DIRECTORY}commands/aidd/${phase}/`; +} + +export const claude: AiTool = + { + kind: "ai", + toolId: "claude", + distributionProbes: { + manifest: [".claude-plugin/plugin.json"], + marketplace: [".claude-plugin/marketplace.json"], + }, + directory: DIRECTORY, + toolSuffix: TOOL_SUFFIX, + displayName: "Claude Code", + telemetryLocalRead: { + kind: "declared", + transcript: CLAUDE_CODE_TRANSCRIPT_LOCATION, + // The mirror image of the export: the transcript names the running skill exactly, on + // the same line as the counters, and carries no amount at all. + supplies: { tokenCounters: true, amount: false, toolStatedStep: true, agentName: true }, + }, + telemetryTaskAttributable: true, + telemetryJournalHost: "claude-code", + signalDir: ".claude/commands", + configOutputPaths: { "settings.json": ".claude/settings.json" }, + buildContracts: { marketplace: buildClaudeContract, flat: buildClaudeFlatContract }, + + capabilities: { + agents: new AgentsCapability({ + directory: DIRECTORY, + toolSuffix: TOOL_SUFFIX, + format: "markdown", + }), + skills: new SkillsCapability({ + directory: DIRECTORY, + toolSuffix: TOOL_SUFFIX, + buildInstallPath: (fileName) => + `${DIRECTORY}skills/${stripToolSuffix(TOOL_SUFFIX, fileName)}`, + convertFrontmatter: (fm) => fm, + }), + commands: new CommandsCapability({ + directory: DIRECTORY, + toolSuffix: TOOL_SUFFIX, + buildInstallPath: (fileName) => { + const slashIdx = fileName.indexOf("/"); + if (slashIdx !== -1) { + const phaseDir = fileName.slice(0, slashIdx); + const rest = fileName.slice(slashIdx + 1); + const phase = phaseDir.match(/^(\d+)/)?.[1]; + if (phase) return `${commandsDir(phase)}${rest}`; + } + return `${DIRECTORY}commands/${stripToolSuffix(TOOL_SUFFIX, fileName)}`; + }, + convertFrontmatter: (fm, relativeFileName) => + convertCommandFrontmatter(fm, relativeFileName), + }), + rules: new RulesCapability({ + directory: DIRECTORY, + toolSuffix: TOOL_SUFFIX, + buildInstallPath: (fileName) => + `${DIRECTORY}rules/${stripToolSuffix(TOOL_SUFFIX, fileName)}`, + convertFrontmatter: (fm) => { + if ("paths" in fm) { + const paths = fm.paths; + if (Array.isArray(paths) && paths.length === 0) return {}; + return { paths }; + } + if ("globs" in fm) return { paths: fm.globs }; + if ("alwaysApply" in fm) { + if (fm.alwaysApply === false && fm.description !== undefined) { + return { description: fm.description }; + } + return {}; + } + return {}; + }, + }), + mcp: new McpCapability({ + outputPath: ".mcp.json", + format: "json", + entrySection: "mcpServers", + consumes: [CONFIG_MCP], + }), + plugins: new PluginsCapability({ + mode: "native", + pluginsDir: ".claude/plugins/", + pluginManifestRelativePath: "plugin.json", + acceptsHooks: true, + pluginRootToken: CLAUDE_PLUGIN_ROOT_TOKEN, + acceptsMcp: true, + translationMode: "marketplace", + // Claude registers its own marketplaces and enables its own plugins, both driven here. + // At project scope the command rewrites `.claude/settings.json` after this CLI hashed + // it — two writers, one recorder, and `status` reporting drift forever. `--scope local` + // writes `.claude/settings.local.json` instead, a file this CLI neither writes nor + // tracks, which is what makes the verbs below safe to declare. + nativeActivation: { + binary: "claude", + scopeArgs: { project: ["--scope", "local"], user: ["--scope", "user"] }, + enableVerb: "install", + disableVerb: "uninstall", + upgradeVerb: "update", + // `--yes` gates a prune confirmation these calls never request, but a headless + // stdin has no terminal to answer any prompt at all. + pluginArgs: ["--yes"], + // Root of claude's own marketplace registry, read by the sync guard that refuses a + // name-stealing re-add before driving `marketplace add` at all. Declared here alone, + // which is what keeps that guard claude-only by declaration. + marketplaceRegistry: (h) => join(h, ".claude", "plugins", "known_marketplaces.json"), + // Root of claude's own plugin cache. `clean` composes `/` and purges + // only once a fresh read of `marketplaceRegistry` above no longer names it — claude + // marks an orphaned tree `.orphaned_at` but never deletes it itself. + pluginCacheDir: (h) => join(h, ".claude", "plugins", "cache"), + // Where a `--scope user` install or marketplace add lands, measured against the real + // binary. Never written by aidd; named here for a diagnostic alone. + userSettingsPath: (h) => join(h, ".claude", "settings.json"), + }, + marketplaceSettings: { + settingsPath: ".claude/settings.json", + settingsKey: "extraKnownMarketplaces", + // The registered marketplace is the tree this CLI builds under `.aidd/cache/`, named + // by absolute path, so the entry describes one machine and must not be committed. It + // is the file `claude plugin marketplace add --scope local` writes itself. + marketplacesSettingsPath: ".claude/settings.local.json", + enabledPluginsKey: "enabledPlugins", + toEntryKey: claudeStyleMarketplaceKey, + }, + }), + }, + + rewriteContent(content: string): string { + return content.replace( + /(@?)\.claude\/commands\/(\d+)[_][^/]+\//g, + (_, at, phase) => `${at}${commandsDir(phase)}` + ); + }, + }; + +registerTool(claude); diff --git a/cli/src/contexts/tools/domain/profiles/codex/build.ts b/cli/src/contexts/tools/domain/profiles/codex/build.ts new file mode 100644 index 000000000..de0654f22 --- /dev/null +++ b/cli/src/contexts/tools/domain/profiles/codex/build.ts @@ -0,0 +1,272 @@ +/** + * Codex's build contracts: marketplace (a native plugin tree) and flat (direct workspace + * materialization). `mergeCodexConfigToml` and `stripCodexSkillFrontmatter` live here because + * both contracts need them as much as the installed-content capabilities do; the profile + * imports them back from here. + */ + +import { parseFrontmatter, serializeFrontmatter } from "../../../../../kernel/markdown.js"; +import { + flatMcpKeyPrefix, + genericFlatHooksScriptPath, + genericFlatSkillPath, +} from "../../../../../kernel/materialization/flat-paths.js"; +import type { FileReader } from "../../../../../kernel/ports/file-reader.js"; +import type { FileWriter } from "../../../../../kernel/ports/file-writer.js"; +import type { PluginPresence, ToolBuildContract } from "../../build-contract.js"; +import { + mergeCodexFrameworkHooksJson, + renameCodexHookEvents, +} from "../../formats/flat-hooks-merge.js"; +import { PLUGIN_ROOT_TOKEN } from "../../formats/plugin-root-token.js"; +import { buildCodexMarketplace, buildCodexMarketplaceEntry } from "../../marketplace-catalog.js"; +import { codexAgentMarkdownToToml } from "./codex-agent-toml.js"; +import { + OUTPUT_CODEX_AGENTS_DIR, + OUTPUT_CODEX_MANIFEST_RELATIVE, + OUTPUT_CODEX_MARKETPLACE_RELATIVE, +} from "./codex-paths.js"; +import { parseToml, stringifyToml } from "./toml.js"; + +type FsType = FileReader & FileWriter; + +type TomlRecord = Record; + +const MIN_PROJECT_DOC_MAX_BYTES = 262144; + +function parseSafe(content: string): TomlRecord { + if (!content.trim()) return {}; + try { + return parseToml(content); + } catch { + return {}; + } +} + +function mergeMcpServers(existing: TomlRecord, incoming: TomlRecord): void { + const incomingServers = incoming.mcp_servers as TomlRecord | undefined; + if (!incomingServers) return; + const existingServers = (existing.mcp_servers ?? {}) as TomlRecord; + for (const [name, value] of Object.entries(incomingServers)) { + if (!(name in existingServers)) { + existingServers[name] = value; + } + } + existing.mcp_servers = existingServers; +} + +function ensureProjectDocMaxBytes(existing: TomlRecord, incoming: TomlRecord): void { + const existingVal = + typeof existing.project_doc_max_bytes === "number" ? existing.project_doc_max_bytes : 0; + const incomingVal = + typeof incoming.project_doc_max_bytes === "number" + ? incoming.project_doc_max_bytes + : MIN_PROJECT_DOC_MAX_BYTES; + if (existingVal >= MIN_PROJECT_DOC_MAX_BYTES) return; + existing.project_doc_max_bytes = Math.max(existingVal, incomingVal, MIN_PROJECT_DOC_MAX_BYTES); +} + +function ensureCodexHooks(existing: TomlRecord): void { + const features = existing.features as TomlRecord | undefined; + if (features?.hooks !== undefined || features?.codex_hooks !== undefined) return; + existing.features = { ...(features ?? {}), hooks: true }; +} + +export function mergeCodexConfigToml(existing: string, aiddPayload: string): string { + const result = parseSafe(existing); + const payload = parseSafe(aiddPayload); + mergeMcpServers(result, payload); + ensureProjectDocMaxBytes(result, payload); + ensureCodexHooks(result); + return stringifyToml(result); +} + +export function stripCodexSkillFrontmatter(fm: Record): Record { + const result: Record = {}; + if (fm.name !== undefined) result.name = fm.name; + if (fm.description !== undefined) result.description = fm.description; + if (fm.allowed_tools !== undefined) result.allowed_tools = fm.allowed_tools; + return result; +} + +const CODEX_MANIFEST_STRING_KEYS = [ + "name", + "description", + "version", + "homepage", + "repository", + "license", +] as const; + +function copyCodexManifestStringFields( + source: Record, + manifest: Record +): void { + for (const key of CODEX_MANIFEST_STRING_KEYS) { + if (typeof source[key] === "string") manifest[key] = source[key]; + } + if (typeof source.author === "string" || typeof source.author === "object") { + manifest.author = source.author; + } + if (Array.isArray(source.keywords)) manifest.keywords = source.keywords; +} + +function buildCodexManifest( + source: Record, + presence: PluginPresence +): Record { + const manifest: Record = {}; + copyCodexManifestStringFields(source, manifest); + // The agents field is omitted: the Codex plugin schema has no such key. `skills` must be a + // STRING directory — the array form makes `codex plugin add` fail with "missing or invalid + // plugin.json". + if (presence.skillsList.length > 0) manifest.skills = "./skills"; + if (presence.hasHooksJson) manifest.hooks = "./hooks/hooks.json"; + if (presence.hasMcpJson) manifest.mcpServers = "./.mcp.json"; + return manifest; +} + +function transformCodexSkill(content: string): string { + const { frontmatter, body } = parseFrontmatter(content); + return serializeFrontmatter(stripCodexSkillFrontmatter(frontmatter), body); +} + +export function buildCodexContract(): ToolBuildContract { + const manifestRelative = OUTPUT_CODEX_MANIFEST_RELATIVE; + const marketplaceRelative = OUTPUT_CODEX_MARKETPLACE_RELATIVE; + return { + pluginRootToken: PLUGIN_ROOT_TOKEN, + manifestFileRelative: manifestRelative, + synthesizeManifest: buildCodexManifest, + manifestSchemaName: "codex-plugin-manifest", + artifacts: { + skills: { + supported: true, + source: { kind: "fullTree", srcDir: "skills" }, + path: (_p, rel) => rel, + transform: transformCodexSkill, + }, + agents: { + supported: true, + source: { kind: "filteredTree", srcDir: "agents", inputExt: ".md" }, + path: (_p, rel) => + `${OUTPUT_CODEX_AGENTS_DIR}/${rel.replace(/^agents\//, "").replace(/\.md$/, ".toml")}`, + transform: (content, plugin, outName) => codexAgentMarkdownToToml(content, plugin, outName), + }, + mcp: { + supported: true, + source: { kind: "configFile", srcPath: ".mcp.json" }, + path: () => ".mcp.json", + }, + hooks: { + supported: true, + source: { kind: "hooksBundle", jsonPath: "hooks/hooks.json", scriptDir: "hooks" }, + path: (_p, rel) => rel, + // The same rename the merged install route applies. Codex has no `Stop`, so without + // this the built tree subscribes the turn-end hook to an event that never arrives and + // the turn is never closed, in silence. + transform: (content, _plugin, base) => + base === "hooks.json" ? renameCodexHookEvents(content) : content, + }, + rules: { supported: false }, + commands: { supported: false }, + }, + buildMarketplaceCatalog: async (source, entries, _fs) => ({ + catalog: buildCodexMarketplace( + source as Parameters[0], + entries + ), + schemaName: "codex-marketplace", + destRelPath: marketplaceRelative, + }), + buildMarketplaceEntry: async (name, _src, _outDir, srcEntry, _fs) => + buildCodexMarketplaceEntry(name, srcEntry as Record | undefined), + }; +} + +// Codex scans `.agents/skills/` (cwd to repo root) for workspace skills, the documented project +// skill root; verified live, a SKILL.md there appears in Codex's "Available skills" context. +// `.codex/skills/` also resolves but is undocumented, so the documented root is what is used. +const CODEX_SKILLS_PREFIX = ".agents/skills/"; + +function codexFlatSkillPath(plugin: string, rel: string): string { + return genericFlatSkillPath(CODEX_SKILLS_PREFIX, plugin, rel.replace(/^skills\//, "")); +} + +function codexFlatAgentPath(plugin: string, rel: string): string { + const base = rel.replace(/^agents\//, "").replace(/\.md$/, ".toml"); + return `.codex/agents/${plugin}-${base}`; +} + +function codexFlatHooksPath(plugin: string, rel: string): string { + const rest = rel.replace(/^hooks\//, ""); + return genericFlatHooksScriptPath(".codex/hooks/", plugin, rest); +} + +async function collectPrefixedMcpServers( + builtPlugins: readonly string[], + sourceDir: string, + fs: FsType +): Promise> { + const mcpServers: Record = {}; + for (const plugin of builtPlugins) { + const mcpSrc = `${sourceDir}/plugins/${plugin}/.mcp.json`; + if (!(await fs.fileExists(mcpSrc))) continue; + const raw = await fs.readFile(mcpSrc); + const parsed = JSON.parse(raw) as { mcpServers?: Record }; + const prefix = flatMcpKeyPrefix(plugin); + for (const [k, v] of Object.entries(parsed.mcpServers ?? {})) { + mcpServers[`${prefix}${k}`] = v; + } + } + return mcpServers; +} + +function buildCodexConfigPayload(mcpServers: Record): string { + if (Object.keys(mcpServers).length === 0) return ""; + return stringifyToml({ mcp_servers: mcpServers } as Record); +} + +export function buildCodexFlatContract(): ToolBuildContract { + return { + manifestFileRelative: null, + synthesizeManifest: null, + manifestSchemaName: null, + artifacts: { + skills: { + supported: true, + source: { kind: "fullTree", srcDir: "skills" }, + path: codexFlatSkillPath, + rewriteSkillName: true, + }, + agents: { + supported: true, + source: { kind: "filteredTree", srcDir: "agents", inputExt: ".md" }, + path: codexFlatAgentPath, + transform: (content, plugin, outName) => + codexAgentMarkdownToToml(content, plugin, outName, true), + }, + mcp: { supported: false }, // handled by emitConfigArtifact (config.toml mcp_servers) + hooks: { + supported: true, + source: { kind: "hooksBundle", jsonPath: "hooks/hooks.json", scriptDir: "hooks" }, + path: codexFlatHooksPath, + hooksMerge: (existing, incoming) => mergeCodexFrameworkHooksJson(existing, incoming), + hooksMergeDest: (outDir) => `${outDir}/.codex/hooks.json`, + }, + rules: { supported: false }, + commands: { supported: false }, + }, + buildMarketplaceCatalog: null, + buildMarketplaceEntry: null, + emitConfigArtifact: async (builtPlugins, outDir, sourceDir, fs) => { + const configPath = `${outDir}/.codex/config.toml`; + const existing = (await fs.fileExists(configPath)) ? await fs.readFile(configPath) : ""; + const mcpServers = await collectPrefixedMcpServers(builtPlugins, sourceDir, fs); + const aiddPayload = buildCodexConfigPayload(mcpServers); + const merged = mergeCodexConfigToml(existing, aiddPayload); + await fs.writeFile(configPath, merged); + return 1; + }, + }; +} diff --git a/cli/src/contexts/tools/domain/profiles/codex/codex-agent-toml.ts b/cli/src/contexts/tools/domain/profiles/codex/codex-agent-toml.ts new file mode 100644 index 000000000..3ed7225db --- /dev/null +++ b/cli/src/contexts/tools/domain/profiles/codex/codex-agent-toml.ts @@ -0,0 +1,50 @@ +import { parseFrontmatter } from "../../../../../kernel/markdown.js"; +import { stringifyToml } from "./toml.js"; + +/** + * Converts a Claude-format agent markdown file (frontmatter + body) into a Codex subagent TOML + * string. Key insertion order is fixed for deterministic output, and the conversion is lossy — + * no model field is emitted and the TOML schema diverges from the frontmatter, so there is no + * inverse. + * + * `prefixName` is for flat mode, where every plugin shares one `.codex/agents/` directory and + * the plugin prefix is what keeps two plugins' agents from colliding. + */ +export function codexAgentMarkdownToToml( + content: string, + pluginName: string, + fileBaseName: string, + prefixName = false +): string { + const { frontmatter, body } = parseFrontmatter(content); + const name = resolveName(frontmatter, pluginName, fileBaseName, prefixName); + const obj = buildTomlObject(name, frontmatter, body); + return stringifyToml(obj); +} + +function resolveName( + frontmatter: Record, + pluginName: string, + fileBaseName: string, + prefixName: boolean +): string { + const basename = fileBaseName.replace(/\.md$/, ""); + if (!prefixName && typeof frontmatter.name === "string" && frontmatter.name.length > 0) { + return frontmatter.name; + } + return `${pluginName}-${basename}`; +} + +function buildTomlObject( + name: string, + frontmatter: Record, + body: string +): Record { + const obj: Record = {}; + obj.name = name; + // description is a required subagent key; default to "" when absent. + obj.description = typeof frontmatter.description === "string" ? frontmatter.description : ""; + // model is intentionally omitted: no known Codex model id set. + obj.developer_instructions = body; + return obj; +} diff --git a/cli/src/contexts/tools/domain/profiles/codex/codex-paths.ts b/cli/src/contexts/tools/domain/profiles/codex/codex-paths.ts new file mode 100644 index 000000000..e36a285aa --- /dev/null +++ b/cli/src/contexts/tools/domain/profiles/codex/codex-paths.ts @@ -0,0 +1,17 @@ +/** + * Codex build output path constants, deliberately distinct from their source-side equivalents + * even where the literal values coincide: a change to either side must not collapse them. + */ + +/** Relative path for the Codex-native plugin manifest inside each plugin output directory. */ +export const OUTPUT_CODEX_MANIFEST_RELATIVE = ".codex-plugin/plugin.json"; + +/** + * Relative path for the Codex-native marketplace catalog in the codex output tree — the official + * repo-scoped path `codex plugin marketplace add owner/repo` discovers. The legacy + * `.claude-plugin/marketplace.json` fallback is intentionally not emitted. + */ +export const OUTPUT_CODEX_MARKETPLACE_RELATIVE = ".agents/plugins/marketplace.json"; + +/** Subdirectory name inside each plugin output for staged Codex agent TOML files. */ +export const OUTPUT_CODEX_AGENTS_DIR = "codex-agents"; diff --git a/cli/src/contexts/tools/domain/profiles/codex/codex-transcript-location.ts b/cli/src/contexts/tools/domain/profiles/codex/codex-transcript-location.ts new file mode 100644 index 000000000..0b193ede3 --- /dev/null +++ b/cli/src/contexts/tools/domain/profiles/codex/codex-transcript-location.ts @@ -0,0 +1,13 @@ +import { sep } from "node:path"; +import type { TranscriptLocation } from "../../../../../kernel/measurement.js"; + +/** Where Codex keeps a session's rollout, and which file belongs to which session. + * Declared beside the profile that names it: only the tool knows its own directory layout, + * and the adapter that opens the files never encodes one itself. */ +export const CODEX_ROLLOUT_LOCATION: TranscriptLocation = { + root: (homeDir) => `${homeDir}${sep}.codex${sep}sessions`, + matches: (relativePath, sessionId) => { + const base = relativePath.split(sep).pop() ?? relativePath; + return base.startsWith("rollout-") && base.endsWith(`-${sessionId}.jsonl`); + }, +}; diff --git a/cli/src/contexts/tools/domain/profiles/codex/profile.ts b/cli/src/contexts/tools/domain/profiles/codex/profile.ts new file mode 100644 index 000000000..adb01072b --- /dev/null +++ b/cli/src/contexts/tools/domain/profiles/codex/profile.ts @@ -0,0 +1,217 @@ +import { join } from "node:path"; +import { AgentsCapability } from "../../capabilities/agents-capability.js"; +import { CommandsCapability } from "../../capabilities/commands-capability.js"; +import { CONFIG_MCP } from "../../capabilities/config-refs.js"; +import { HooksCapability } from "../../capabilities/hooks-capability.js"; +import { McpCapability } from "../../capabilities/mcp-capability.js"; +import { PluginsCapability } from "../../capabilities/plugins-capability.js"; +import { RulesCapability } from "../../capabilities/rules-capability.js"; +import { SkillsCapability } from "../../capabilities/skills-capability.js"; +import type { + AiTool, + HasAgents, + HasCommands, + HasHooks, + HasMcp, + HasPlugins, + HasRules, + HasSkills, +} from "../../contracts.js"; +import { + buildAiddCommandFilePath, + convertCommandFrontmatter, + stripToolSuffix, +} from "../../formats/command.js"; +import { PLUGIN_ROOT_TOKEN } from "../../formats/plugin-root-token.js"; +import { registerTool } from "../../registry.js"; +import { + buildCodexContract, + buildCodexFlatContract, + mergeCodexConfigToml, + stripCodexSkillFrontmatter, +} from "./build.js"; +import { CODEX_ROLLOUT_LOCATION } from "./codex-transcript-location.js"; + +const DIRECTORY = ".codex/"; +const TOOL_SUFFIX = ".codex.md"; +const AGENTS_SKILLS_PREFIX = ".agents/skills/"; + +const SKILLS_TO_AGENTS_RE = /\.codex\/skills\//g; + +function remapSkillPaths(content: string): string { + return content.replace(SKILLS_TO_AGENTS_RE, ".agents/skills/aidd-"); +} + +export function rewriteCodexContent(content: string): string { + return remapSkillPaths(content).replace( + /(@?)\.codex\/commands\/(\d+)[_-][^/]+\/([^\s]+)/g, + "$1.codex/commands/aidd/$2/$3" + ); +} + +const CONFIG_CODEX_HOOKS = "codex-hooks"; + +// Measured: four consecutive `codex exec` sessions installed a plugin's hooks, ran clean and +// journalled nothing — no warning, no line in the output — until `--dangerously-bypass-hook-trust` +// made the same install produce all three hooks and its journal. Codex writes one `trusted_hash` +// per hook under `[hooks.state]` when a person approves it; a hook with no entry is skipped in +// silence, and nothing prompts for it outside a terminal. +const CODEX_HOOKS_TRUST_NOTICE = + "Codex will not run this plugin's hooks until each one is trusted — approve the prompt " + + "once in an interactive session, or pass --dangerously-bypass-hook-trust to codex exec " + + "for a headless run. Until then, a session leaves no run journal and nothing says why."; + +const AIDD_HOOK_COMMAND = "node .aidd/scripts/update_memory.cjs"; + +const AIDD_HOOK_ENTRY = { + type: "command", + command: AIDD_HOOK_COMMAND, + statusMessage: "Syncing AIDD memory...", + timeout: 30, +}; + +const AIDD_SESSION_START_ENTRY = { + matcher: "startup|resume", + hooks: [AIDD_HOOK_ENTRY], +}; + +type HookEntry = { type: string; command: string; [key: string]: unknown }; +type SessionStartEntry = { matcher?: string; hooks: HookEntry[]; [key: string]: unknown }; +type HooksRoot = { SessionStart?: SessionStartEntry[]; [key: string]: unknown }; + +function isAiddHookPresent(entries: SessionStartEntry[]): boolean { + return entries.some((entry) => entry.hooks.some((hook) => hook.command === AIDD_HOOK_COMMAND)); +} + +function appendAiddEntry(entries: SessionStartEntry[]): SessionStartEntry[] { + if (isAiddHookPresent(entries)) return entries; + return [...entries, AIDD_SESSION_START_ENTRY]; +} + +function mergeSessionStart(existing: HooksRoot): HooksRoot { + const current = existing.SessionStart; + if (!Array.isArray(current)) { + return { ...existing, SessionStart: [AIDD_SESSION_START_ENTRY] }; + } + return { ...existing, SessionStart: appendAiddEntry(current) }; +} + +export function mergeCodexHooksJson(existing: string): string { + let parsed: HooksRoot = {}; + if (existing.trim()) { + try { + parsed = JSON.parse(existing) as HooksRoot; + } catch { + parsed = {}; + } + } + const merged = mergeSessionStart(parsed); + return JSON.stringify(merged, null, 2); +} + +function skillNameFromPath(fileName: string): string { + const parts = fileName.split("/"); + if (parts.length > 1) return parts[0]; + const base = parts[0]; + if (base.endsWith(TOOL_SUFFIX)) return base.slice(0, -TOOL_SUFFIX.length); + if (base.endsWith(".md")) return base.slice(0, -3); + return base; +} + +function buildCodexSkillFilePath(fileName: string): string { + return `${AGENTS_SKILLS_PREFIX}aidd-${skillNameFromPath(fileName)}/SKILL.md`; +} + +export const codex: AiTool< + HasAgents & HasSkills & HasCommands & HasRules & HasMcp & HasHooks & HasPlugins +> = { + kind: "ai", + toolId: "codex", + distributionProbes: { + manifest: [".codex-plugin/plugin.json"], + marketplace: [".agents/plugins/marketplace.json"], + }, + directory: DIRECTORY, + toolSuffix: TOOL_SUFFIX, + displayName: "Codex", + telemetryLocalRead: { + kind: "declared", + transcript: CODEX_ROLLOUT_LOCATION, + // Complete counters per turn, no currency anywhere in a rollout, and no field naming a + // running skill - so a step here can only ever come from a run journal interval. + supplies: { tokenCounters: true, amount: false, toolStatedStep: false, agentName: false }, + }, + telemetryTaskAttributable: true, + telemetryJournalHost: "codex", + signalDir: `${DIRECTORY}commands`, + configOutputPaths: { "config.toml": ".codex/config.toml" }, + buildContracts: { marketplace: buildCodexContract, flat: buildCodexFlatContract }, + + capabilities: { + agents: new AgentsCapability({ directory: DIRECTORY, toolSuffix: TOOL_SUFFIX, format: "toml" }), + skills: new SkillsCapability({ + prefix: "aidd-", + buildInstallPath: buildCodexSkillFilePath, + convertFrontmatter: stripCodexSkillFrontmatter, + }), + commands: new CommandsCapability({ + directory: DIRECTORY, + toolSuffix: TOOL_SUFFIX, + buildInstallPath: (fileName) => buildAiddCommandFilePath(DIRECTORY, fileName), + convertFrontmatter: (fm, relativeFileName) => convertCommandFrontmatter(fm, relativeFileName), + }), + rules: new RulesCapability({ + directory: DIRECTORY, + toolSuffix: TOOL_SUFFIX, + buildInstallPath: (fileName) => `${DIRECTORY}rules/${stripToolSuffix(TOOL_SUFFIX, fileName)}`, + convertFrontmatter: (fm) => fm, + }), + mcp: new McpCapability({ + outputPath: ".codex/config.toml", + format: "toml", + entrySection: "mcp_servers", + mergeFn: mergeCodexConfigToml, + consumes: [CONFIG_MCP], + }), + hooks: new HooksCapability({ + outputPath: ".codex/hooks.json", + mergeStrategy: "user-prime", + entrySection: "SessionStart", + mergeFn: mergeCodexHooksJson, + consumes: [CONFIG_CODEX_HOOKS], + }), + plugins: new PluginsCapability({ + mode: "native", + pluginsDir: ".codex/plugins/", + pluginManifestRelativePath: "plugin.json", + acceptsMcp: true, + translationMode: "marketplace", + // Codex only enables plugins from its user-global config plus its own plugin cache; a + // project-local settings file is inert, so the `codex` CLI is driven directly instead. + acceptsHooks: true, + hooksTrustNotice: CODEX_HOOKS_TRUST_NOTICE, + pluginRootToken: PLUGIN_ROOT_TOKEN, + nativeActivation: { + binary: "codex", + upgradeVerb: "upgrade", + enableVerb: "add", + disableVerb: "remove", + // Codex's own `plugin remove` deletes a marketplace's cached content but leaves the + // now-empty `cache//` shell behind. No `marketplaceRegistry` is declared — + // codex refuses a re-add from a different source itself, so there is no registry to + // reread — which is why `clean` proves this leftover safe to remove by its own + // emptiness instead. + pluginCacheDir: (h) => join(h, ".codex", "plugins", "cache"), + // `$CODEX_HOME/config.toml` when set — a real codex binary reads there whatever `HOME` + // says — else `~/.codex/config.toml`. Never written by aidd; named for a diagnostic alone. + userSettingsPath: (h, env) => join(env("CODEX_HOME") || join(h, ".codex"), "config.toml"), + }, + }), + }, + + rewriteContent(content: string): string { + return rewriteCodexContent(content); + }, +}; + +registerTool(codex); diff --git a/cli/src/domain/formats/toml.ts b/cli/src/contexts/tools/domain/profiles/codex/toml.ts similarity index 100% rename from cli/src/domain/formats/toml.ts rename to cli/src/contexts/tools/domain/profiles/codex/toml.ts diff --git a/cli/src/contexts/tools/domain/profiles/copilot/build.ts b/cli/src/contexts/tools/domain/profiles/copilot/build.ts new file mode 100644 index 000000000..4bb9deaff --- /dev/null +++ b/cli/src/contexts/tools/domain/profiles/copilot/build.ts @@ -0,0 +1,183 @@ +/** + * Copilot's build contracts: marketplace (OpenPlugin format) and flat (direct workspace + * materialization). The transforms, path computations and merges are pure functions reused from + * `domain/formats/`; the contracts themselves are thin wiring. + */ + +import { parseFrontmatter, serializeFrontmatter } from "../../../../../kernel/markdown.js"; +import { + genericFlatAgentPath, + genericFlatHooksFile, + genericFlatHooksScriptPath, + genericFlatSkillPath, +} from "../../../../../kernel/materialization/flat-paths.js"; +import { rewriteRelativeLinks } from "../../../../../kernel/materialization/relative-link-rewrite.js"; +import type { ToolBuildContract } from "../../build-contract.js"; +import { stripAgentFrontmatter } from "../../formats/agent-frontmatter-strip.js"; +import { flattenCopilotHooksShape } from "../../formats/flat-hooks-merge.js"; +import { PLUGIN_ROOT_TOKEN } from "../../formats/plugin-root-token.js"; +import { mergeVscodeMcp } from "../../formats/vscode-mcp-merge.js"; +import { + resolveDescription, + resolveVersion, + synthesizeClaudeStyleManifest, + transformClaudeAgent, +} from "../../marketplace-catalog.js"; +import { COPILOT_VSCODE_MCP_PATH, COPILOT_WORKSPACE_DIR } from "./copilot-paths.js"; + +const OUTPUT_PLUGIN_MANIFEST_RELATIVE = ".plugin/plugin.json"; + +const OUTPUT_MARKETPLACE_RELATIVE = ".plugin/marketplace.json"; + +/** Output prefix for agents in flat mode: .github/agents//.agent.md */ +const FLAT_GITHUB_AGENTS_PREFIX = `${COPILOT_WORKSPACE_DIR}agents/`; + +/** Output prefix for skills in flat mode: .github/skills/// */ +const FLAT_GITHUB_SKILLS_PREFIX = `${COPILOT_WORKSPACE_DIR}skills/`; + +/** Output prefix for hooks in flat mode: .github/hooks/.hooks.json */ +const FLAT_GITHUB_HOOKS_PREFIX = `${COPILOT_WORKSPACE_DIR}hooks/`; + +const FLAT_VSCODE_MCP_PATH = COPILOT_VSCODE_MCP_PATH; + +const FLAT_AGENT_OUTPUT_EXT = ".agent.md"; + +export function buildCopilotMarketplaceContract(): ToolBuildContract { + const manifestRelative = OUTPUT_PLUGIN_MANIFEST_RELATIVE; + const marketplaceRelative = OUTPUT_MARKETPLACE_RELATIVE; + return { + pluginRootToken: PLUGIN_ROOT_TOKEN, + manifestFileRelative: manifestRelative, + synthesizeManifest: (source, presence) => + synthesizeClaudeStyleManifest(source, presence, { + agentsField: true, + hooksField: true, + }), + manifestSchemaName: null, // Copilot does not use AJV for the plugin manifest + artifacts: { + skills: { + supported: true, + source: { kind: "fullTree", srcDir: "skills" }, + path: (_p, rel) => rel, + }, + agents: { + supported: true, + source: { kind: "filteredTree", srcDir: "agents", inputExt: ".md" }, + path: (_p, rel) => rel, + transform: transformClaudeAgent, + }, + mcp: { + supported: true, + source: { kind: "configFile", srcPath: ".mcp.json" }, + path: () => ".mcp.json", + }, + hooks: { + supported: true, + source: { kind: "hooksBundle", jsonPath: "hooks/hooks.json", scriptDir: "hooks" }, + path: (_p, rel) => rel, + }, + rules: { supported: false }, + commands: { supported: false }, + }, + buildMarketplaceCatalog: async (source, entries, _fs) => ({ + catalog: { + name: source.name, + metadata: { + description: source.description, + version: source.version, + pluginRoot: "./plugins", + }, + owner: source.owner, + plugins: entries, + }, + schemaName: "marketplace", + destRelPath: marketplaceRelative, + }), + buildMarketplaceEntry: async (name, _src, outDir, srcEntry, fs) => { + const args = [fs, name, srcEntry, outDir, manifestRelative] as const; + const version = await resolveVersion(...args); + const description = await resolveDescription(...args); + return { name, source: name, description, version }; + }, + }; +} + +function copilotFlatAgentPath(plugin: string, rel: string): string { + return genericFlatAgentPath( + FLAT_GITHUB_AGENTS_PREFIX, + plugin, + rel.replace(/^agents\//, ""), + FLAT_AGENT_OUTPUT_EXT + ); +} + +function copilotFlatSkillPath(plugin: string, rel: string): string { + return genericFlatSkillPath(FLAT_GITHUB_SKILLS_PREFIX, plugin, rel.replace(/^skills\//, "")); +} + +function copilotFlatHooksPath(plugin: string, rel: string): string { + const rest = rel.replace(/^hooks\//, ""); + if (rest === `${plugin}.hooks.json`) + return genericFlatHooksFile(FLAT_GITHUB_HOOKS_PREFIX, plugin); + return genericFlatHooksScriptPath(FLAT_GITHUB_HOOKS_PREFIX, plugin, rest); +} + +function transformCopilotFlatAgent(content: string, plugin: string, outName: string): string { + const { frontmatter, body } = parseFrontmatter(content); + const stripped = stripAgentFrontmatter(frontmatter); + const flatRelPath = copilotFlatAgentPath(plugin, `agents/${outName}`); + const rewrittenBody = rewriteRelativeLinks(body, { + currentFilePluginRelative: flatRelPath, + resolveTargetPath: (rel) => copilotFlatResolveTarget(plugin, rel), + }); + const prefixedName = `${plugin}-${outName.replace(/\.md$/, "")}`; + return serializeFrontmatter({ ...stripped, name: prefixedName }, rewrittenBody); +} + +function copilotFlatResolveTarget(plugin: string, rel: string): string { + if (rel.startsWith("agents/")) return copilotFlatAgentPath(plugin, rel); + if (rel.startsWith("skills/")) return copilotFlatSkillPath(plugin, rel); + return rel; +} + +export function buildCopilotFlatContract(): ToolBuildContract { + return { + manifestFileRelative: null, + synthesizeManifest: null, + manifestSchemaName: null, + artifacts: { + skills: { + supported: true, + source: { kind: "fullTree", srcDir: "skills" }, + path: copilotFlatSkillPath, + // VS Code Copilot requires SKILL.md frontmatter name === parent folder name. + rewriteSkillName: true, + }, + agents: { + supported: true, + source: { kind: "filteredTree", srcDir: "agents", inputExt: ".md" }, + ext: FLAT_AGENT_OUTPUT_EXT, + path: copilotFlatAgentPath, + transform: transformCopilotFlatAgent, + }, + mcp: { + supported: true, + source: { kind: "configFile", srcPath: ".mcp.json" }, + path: () => FLAT_VSCODE_MCP_PATH, + merge: (existing, incoming, force) => mergeVscodeMcp(existing, incoming, force), + mcpServersKey: "servers", + mergeDest: (outDir) => `${outDir}/${FLAT_VSCODE_MCP_PATH}`, + }, + hooks: { + supported: true, + source: { kind: "hooksBundle", jsonPath: "hooks/hooks.json", scriptDir: "hooks" }, + path: copilotFlatHooksPath, + hooksTransform: (rewrittenJson) => flattenCopilotHooksShape(rewrittenJson), + }, + rules: { supported: false }, + commands: { supported: false }, + }, + buildMarketplaceCatalog: null, + buildMarketplaceEntry: null, + }; +} diff --git a/cli/src/contexts/tools/domain/profiles/copilot/copilot-paths.ts b/cli/src/contexts/tools/domain/profiles/copilot/copilot-paths.ts new file mode 100644 index 000000000..f74a33429 --- /dev/null +++ b/cli/src/contexts/tools/domain/profiles/copilot/copilot-paths.ts @@ -0,0 +1,7 @@ +/** Canonical path constants for the GitHub Copilot workspace layout, in a file of their own so + * the profile and the flat-mode build helpers share one source without a cross-layer + * dependency. */ + +export const COPILOT_WORKSPACE_DIR = ".github/"; + +export const COPILOT_VSCODE_MCP_PATH = ".vscode/mcp.json"; diff --git a/cli/src/contexts/tools/domain/profiles/copilot/profile.ts b/cli/src/contexts/tools/domain/profiles/copilot/profile.ts new file mode 100644 index 000000000..75830b677 --- /dev/null +++ b/cli/src/contexts/tools/domain/profiles/copilot/profile.ts @@ -0,0 +1,340 @@ +import { join } from "node:path"; +import { GITKEEP_FILE } from "../../../../../kernel/file.js"; +import { DOCS_DIR } from "../../../../../kernel/paths.js"; +import { AgentsCapability } from "../../capabilities/agents-capability.js"; +import { CommandsCapability } from "../../capabilities/commands-capability.js"; +import { CONFIG_MCP } from "../../capabilities/config-refs.js"; +import { McpCapability } from "../../capabilities/mcp-capability.js"; +import { PluginsCapability } from "../../capabilities/plugins-capability.js"; +import { RulesCapability } from "../../capabilities/rules-capability.js"; +import { SettingsCapability } from "../../capabilities/settings-capability.js"; +import { SkillsCapability } from "../../capabilities/skills-capability.js"; +import type { + AiTool, + HasAgents, + HasCommands, + HasMcp, + HasPlugins, + HasRules, + HasSettings, + HasSkills, +} from "../../contracts.js"; +import { convertCommandFrontmatter } from "../../formats/command.js"; +import { PLUGIN_ROOT_TOKEN } from "../../formats/plugin-root-token.js"; +import { claudeStyleMarketplaceKey } from "../../marketplace-entry.js"; +import { registerTool } from "../../registry.js"; +import { buildCopilotFlatContract, buildCopilotMarketplaceContract } from "./build.js"; +import { COPILOT_WORKSPACE_DIR } from "./copilot-paths.js"; + +const DIRECTORY = COPILOT_WORKSPACE_DIR; +const TOOL_SUFFIX = ".copilot.md"; + +// Canon's framework-doc reference placeholders. Copilot is the only tool that rewrites +// content between the canonical form and its own workspace-relative paths, so these +// tokens live here rather than in a shared location nothing else reads. +const TOOLS_PLACEHOLDER = "{{TOOLS}}/"; +const DOCS_PLACEHOLDER = "{{DOCS}}/"; +const AT_TOOLS_PLACEHOLDER = "@{{TOOLS}}/"; +const AT_DOCS_PLACEHOLDER = "@{{DOCS}}/"; + +const EXT_AGENT = ".agent.md"; +const EXT_PROMPT = ".prompt.md"; +const EXT_INSTRUCTIONS = ".instructions.md"; + +function escapedRegex(literal: string): string { + return literal.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + +function basename(path: string): string { + return path.split("/").at(-1) ?? path; +} + +function flattenFileName( + fileName: string, + targetExt: string, + options: { toolSuffix?: string; stripNumericPrefix?: boolean } = {} +): string { + const parts = fileName.split("/"); + let baseName = parts[parts.length - 1]; + + if (options.stripNumericPrefix) { + baseName = baseName.replace(/^\d+[_-]/, ""); + } + if (options.toolSuffix && baseName.endsWith(options.toolSuffix)) { + baseName = `${baseName.slice(0, -options.toolSuffix.length)}.md`; + } + baseName = baseName.replaceAll("_", "-"); + + const withExt = addTargetExtension(baseName, targetExt); + + if (parts.length === 1) { + return withExt; + } + + const prefix = buildPrefix(parts.slice(0, -1).join("/")); + return `${prefix}-${withExt}`; +} + +function buildPrefix(subPath: string): string { + return subPath + .split("/") + .map((p) => p.replace(/^(\d+)[_-].*$/, "$1")) + .join("-"); +} + +function addTargetExtension(baseName: string, targetExt: string): string { + if (baseName.endsWith(targetExt)) return baseName; + const withoutMd = baseName.endsWith(".md") ? baseName.slice(0, -3) : baseName; + return `${withoutMd}${targetExt}`; +} + +const agentsHandler = { + buildFilePath(fileName: string): string | null { + const base = basename(fileName); + if (base === GITKEEP_FILE) return null; + const name = base.endsWith(".md") ? `${base.slice(0, -3)}${EXT_AGENT}` : base; + return `${DIRECTORY}agents/${name}`; + }, + convertFrontmatter(fm: Record, fileName?: string): Record { + const base = fileName?.split("/").at(-1); + const name = fm.name ?? base?.replace(/\.md$/, ""); + return { name: typeof name === "string" ? name : undefined, description: fm.description }; + }, +}; + +const commandsHandler = { + buildFilePath(fileName: string): string | null { + const base = basename(fileName); + if (base === GITKEEP_FILE) return null; + const flat = flattenFileName(fileName, EXT_PROMPT); + return `${DIRECTORY}prompts/${flat}`; + }, + convertFrontmatter( + fm: Record, + relativeFileName: string + ): Record { + return convertCommandFrontmatter(fm, relativeFileName); + }, +}; + +const rulesHandler = { + buildFilePath(fileName: string): string | null { + const base = basename(fileName); + if (base === GITKEEP_FILE) return null; + const flat = flattenFileName(fileName, EXT_INSTRUCTIONS, { + toolSuffix: TOOL_SUFFIX, + stripNumericPrefix: true, + }); + return `${DIRECTORY}instructions/${flat}`; + }, + convertFrontmatter(fm: Record): Record { + const { paths, globs } = fm; + const patterns = Array.isArray(paths) ? paths : Array.isArray(globs) ? globs : null; + if (patterns !== null && patterns.length > 0) return { applyTo: patterns.join(",") }; + if (fm.alwaysApply === false && fm.description !== undefined) { + return { description: fm.description }; + } + return {}; + }, +}; + +const skillsHandler = { + buildFilePath(fileName: string): string | null { + const base = basename(fileName); + if (base === GITKEEP_FILE) return null; + return `${DIRECTORY}skills/${fileName}`; + }, + convertFrontmatter(fm: Record): Record { + return fm; + }, +}; + +function resolveInstalledPath(path: string): string { + if (path.startsWith("agents/")) { + const subPath = path.slice("agents/".length); + if (subPath === "" || subPath.endsWith("/")) return `${DIRECTORY}agents/${subPath}`; + return agentsHandler.buildFilePath(subPath) ?? `${DIRECTORY}${path}`; + } + if (path.startsWith("commands/")) { + const subPath = path.slice("commands/".length); + if (subPath === "" || subPath.endsWith("/")) return `${DIRECTORY}prompts/${subPath}`; + return commandsHandler.buildFilePath(subPath) ?? `${DIRECTORY}${path}`; + } + if (path.startsWith("rules/")) { + const subPath = path.slice("rules/".length); + if (subPath === "" || subPath.endsWith("/")) return `${DIRECTORY}instructions/${subPath}`; + return rulesHandler.buildFilePath(subPath) ?? `${DIRECTORY}${path}`; + } + if (path.startsWith("skills/")) { + const subPath = path.slice("skills/".length); + if (subPath === "" || subPath.endsWith("/")) return `${DIRECTORY}skills/${subPath}`; + return skillsHandler.buildFilePath(subPath) ?? `${DIRECTORY}${path}`; + } + // Unknown section: a predictable directory-prefixed default rather than a silently dropped + // reference when a new section is added to the framework. + return `${DIRECTORY}${path}`; +} + +function rewriteCopilotContent(content: string): string { + return ( + content + .replace( + new RegExp(`${escapedRegex(AT_TOOLS_PLACEHOLDER)}([^\\s\`'">,]+)`, "g"), + (_match, path: string) => { + const fullPath = resolveInstalledPath(path); + return `[${fullPath}](../../${fullPath})`; + } + ) + .replace( + new RegExp(`${escapedRegex(AT_DOCS_PLACEHOLDER)}([^\\s\`'">,]+)`, "g"), + (_match, path: string) => { + return `[${DOCS_DIR}/${path}](../../${DOCS_DIR}/${path})`; + } + ) + // {{TOOLS}}/ (without @) replaces directory prefix only — used for path references in frontmatter or prose. + // @{{TOOLS}}/ (with @) resolves to a full installed path via resolveInstalledPath — used for @-include syntax. + .replaceAll("{{TOOLS}}/agents/", `${DIRECTORY}agents/`) + .replace(/\{\{TOOLS\}\}\/commands\/([^\s\n`'">,]+)/g, (_match, path: string) => { + const flat = flattenFileName(path, EXT_PROMPT); + return `${DIRECTORY}prompts/${flat}`; + }) + .replaceAll("{{TOOLS}}/rules/", `${DIRECTORY}instructions/`) + .replaceAll("{{TOOLS}}/skills/", `${DIRECTORY}skills/`) + .replaceAll(TOOLS_PLACEHOLDER, DIRECTORY) + .replaceAll(DOCS_PLACEHOLDER, `${DOCS_DIR}/`) + ); +} + +export const copilot: AiTool< + HasAgents & HasSkills & HasCommands & HasRules & HasMcp & HasSettings & HasPlugins +> = { + kind: "ai", + toolId: "copilot", + distributionProbes: { + manifest: [".plugin/plugin.json", ".github/plugin/plugin.json", "plugin.json"], + // `.github/plugin/plugin.json` is the manifest's own second-choice location, never a + // marketplace catalog: a real build leaves one at `.plugin/marketplace.json`. + marketplace: [".plugin/marketplace.json"], + }, + directory: DIRECTORY, + toolSuffix: TOOL_SUFFIX, + displayName: "GitHub Copilot", + telemetryLocalRead: { + kind: "declared", + supplies: { tokenCounters: true, amount: false, toolStatedStep: false, agentName: false }, + // `input` is measured exclusive of `cache_read`: on a session carrying a non-zero + // `cache_read`, 9 (`input`) + 42038 (`cache_read`) + 21404 (`cache_write`) = 63451, exactly + // `modelMetrics..usage.inputTokens`. The four counters this reader stores are + // therefore disjoint and the report is right to add them; an `input` that included + // `cache_read` would have over-counted every Copilot session by its cached share. + limitation: + "Its own file names outputTokens per turn, but session.shutdown carries all four " + + "counters for the whole session — a session total, never a sum of requests. Its four " + + "counters are measured disjoint, cached prompt included.", + }, + telemetryTaskAttributable: true, + telemetryJournalHost: "copilot", + signalDir: ".github/prompts", + buildContracts: { + marketplace: buildCopilotMarketplaceContract, + flat: buildCopilotFlatContract, + }, + + capabilities: { + agents: new AgentsCapability({ + directory: DIRECTORY, + toolSuffix: EXT_AGENT, + format: "markdown", + userFileExt: EXT_AGENT, + buildInstallPath: (fileName) => agentsHandler.buildFilePath(fileName), + convertFrontmatter: (fm, fileName) => agentsHandler.convertFrontmatter(fm, fileName), + }), + skills: new SkillsCapability({ + directory: DIRECTORY, + toolSuffix: TOOL_SUFFIX, + buildInstallPath: (fileName) => skillsHandler.buildFilePath(fileName), + convertFrontmatter: (fm) => skillsHandler.convertFrontmatter(fm), + }), + commands: new CommandsCapability({ + directory: DIRECTORY, + toolSuffix: EXT_PROMPT, + buildInstallPath: (fileName) => commandsHandler.buildFilePath(fileName), + convertFrontmatter: (fm, relativeFileName) => convertCommandFrontmatter(fm, relativeFileName), + }), + rules: new RulesCapability({ + directory: DIRECTORY, + toolSuffix: EXT_INSTRUCTIONS, + inputSuffix: TOOL_SUFFIX, + buildInstallPath: (fileName) => rulesHandler.buildFilePath(fileName), + convertFrontmatter: (fm) => rulesHandler.convertFrontmatter(fm), + }), + mcp: new McpCapability({ + outputPath: ".vscode/mcp.json", + format: "json", + entrySection: "servers", + consumes: [CONFIG_MCP], + transformContent: (content) => { + const parsed = JSON.parse(content) as Record; + if ("mcpServers" in parsed && !("servers" in parsed)) { + const { mcpServers, ...rest } = parsed as { mcpServers: unknown } & Record< + string, + unknown + >; + return JSON.stringify({ ...rest, servers: mcpServers }, null, 2); + } + return content; + }, + }), + settings: new SettingsCapability({ + outputPath: ".vscode/settings.json", + mergeStrategy: "framework-prime", + staticContentAssetFile: "vscode-settings.json", + requiresTool: "vscode", + }), + plugins: new PluginsCapability({ + mode: "native", + pluginsDir: ".github/plugins/", + pluginManifestRelativePath: "plugin.json", + acceptsHooks: true, + pluginRootToken: PLUGIN_ROOT_TOKEN, + acceptsMcp: true, + translationMode: "marketplace", + // Copilot treats enabledPlugins in settings.json as a recommendation, not an auto-install, + // and a project marketplace is not installable from project scope: `copilot plugin install` + // is what actually loads a plugin, while the settings file below surfaces recommendations. + // Its registry is global to the user and keyed by name, so a name held by a project that + // no longer exists breaks every other project's installs — measured; `update` exits 1 on a + // local path that is gone and 0 otherwise, which is what lets a dead name be reclaimed with + // `--force` without ever taking one that still resolves. + nativeActivation: { + binary: "copilot", + upgradeVerb: "update", + enableVerb: "install", + disableVerb: "uninstall", + sourceCheckVerb: "update", + forceRemoveArgs: ["--force"], + // Where `copilot plugin marketplace add`/`install` land, measured against the real + // binary. Never written by aidd; named here for a diagnostic alone. + userSettingsPath: (h) => join(h, ".copilot", "settings.json"), + }, + // VS Code Copilot reads this file, not the `copilot` CLI, which writes + // ~/.copilot/settings.json and leaves this one untouched. `chat.plugins.marketplaces` + // cannot stand in for it: it has application scope and VS Code rejects it in a workspace + // .vscode/settings.json. This file is a shared, committed recommendation, so + // `enabledPlugins`, which names plugins, belongs in it, while a marketplace registration + // naming an absolute path on one machine does not — hence `null`, with the registration + // driven through `copilot plugin install` instead. + marketplaceSettings: { + settingsPath: ".github/copilot/settings.json", + settingsKey: "extraKnownMarketplaces", + marketplacesSettingsPath: null, + enabledPluginsKey: "enabledPlugins", + toEntryKey: claudeStyleMarketplaceKey, + }, + }), + }, + + rewriteContent: rewriteCopilotContent, +}; + +registerTool(copilot); diff --git a/cli/src/contexts/tools/domain/profiles/cursor/build.ts b/cli/src/contexts/tools/domain/profiles/cursor/build.ts new file mode 100644 index 000000000..55c90fe60 --- /dev/null +++ b/cli/src/contexts/tools/domain/profiles/cursor/build.ts @@ -0,0 +1,161 @@ +/** + * Cursor's build contracts: marketplace (a native plugin tree) and flat (direct workspace + * materialization). The transforms, path computations and merges are pure functions reused from + * `domain/formats/`; the contracts themselves are thin wiring. + */ + +import { parseFrontmatter, serializeFrontmatter } from "../../../../../kernel/markdown.js"; +import { + genericFlatAgentPath, + genericFlatHooksFile, + genericFlatHooksScriptPath, + genericFlatSkillPath, +} from "../../../../../kernel/materialization/flat-paths.js"; +import { rewriteRelativeLinks } from "../../../../../kernel/materialization/relative-link-rewrite.js"; +import type { ToolBuildContract } from "../../build-contract.js"; +import { stripCursorAgentFrontmatter } from "../../formats/agent-frontmatter-strip.js"; +import { mergeCursorFlatHooks } from "../../formats/flat-hooks-merge.js"; +import { CURSOR_PLUGIN_ROOT_TOKEN } from "../../formats/plugin-root-token.js"; +import { mergeVscodeMcp } from "../../formats/vscode-mcp-merge.js"; +import { + buildClaudeStyleEntry, + buildClaudeStyleMarketplace, + synthesizeClaudeStyleManifest, +} from "../../marketplace-catalog.js"; +import { + OUTPUT_CURSOR_MANIFEST_RELATIVE, + OUTPUT_CURSOR_MARKETPLACE_RELATIVE, +} from "./cursor-paths.js"; + +function transformCursorAgent(content: string, _plugin: string, outName: string): string { + const { frontmatter, body } = parseFrontmatter(content); + const stripped = stripCursorAgentFrontmatter(frontmatter); + const rewrittenBody = rewriteRelativeLinks(body, { + currentFilePluginRelative: `agents/${outName}`, + }); + return serializeFrontmatter(stripped, rewrittenBody); +} + +export function buildCursorContract(): ToolBuildContract { + const manifestRelative = OUTPUT_CURSOR_MANIFEST_RELATIVE; + const marketplaceRelative = OUTPUT_CURSOR_MARKETPLACE_RELATIVE; + return { + pluginRootToken: CURSOR_PLUGIN_ROOT_TOKEN, + manifestFileRelative: manifestRelative, + synthesizeManifest: (source, presence) => + synthesizeClaudeStyleManifest(source, presence, { + agentsField: true, + hooksField: true, + }), + manifestSchemaName: "plugin-manifest", + artifacts: { + skills: { + supported: true, + source: { kind: "fullTree", srcDir: "skills" }, + path: (_p, rel) => rel, + }, + agents: { + supported: true, + source: { kind: "filteredTree", srcDir: "agents", inputExt: ".md" }, + path: (_p, rel) => rel, + transform: transformCursorAgent, + }, + mcp: { + supported: true, + source: { kind: "configFile", srcPath: ".mcp.json" }, + path: () => ".mcp.json", + }, + hooks: { + supported: true, + source: { kind: "hooksBundle", jsonPath: "hooks/hooks.json", scriptDir: "hooks" }, + path: (_p, rel) => rel, + }, + rules: { supported: false }, + commands: { supported: false }, + }, + buildMarketplaceCatalog: async (source, entries, _fs) => ({ + catalog: buildClaudeStyleMarketplace( + source as Parameters[0], + entries + ), + schemaName: "claude-marketplace", + destRelPath: marketplaceRelative, + }), + buildMarketplaceEntry: async (name, _src, outDir, srcEntry, fs) => + buildClaudeStyleEntry(name, outDir, srcEntry, manifestRelative, fs), + }; +} + +function cursorFlatAgentPath(plugin: string, rel: string): string { + return genericFlatAgentPath(".cursor/agents/", plugin, rel.replace(/^agents\//, ""), ".md"); +} + +function cursorFlatSkillPath(plugin: string, rel: string): string { + return genericFlatSkillPath(".cursor/skills/", plugin, rel.replace(/^skills\//, "")); +} + +function cursorFlatHooksPath(plugin: string, rel: string): string { + const rest = rel.replace(/^hooks\//, ""); + if (rest === `${plugin}.hooks.json`) return genericFlatHooksFile(".cursor/hooks/", plugin); + return genericFlatHooksScriptPath(".cursor/hooks/", plugin, rest); +} + +function cursorFlatResolveTarget(plugin: string, rel: string): string { + if (rel.startsWith("agents/")) return cursorFlatAgentPath(plugin, rel); + if (rel.startsWith("skills/")) return cursorFlatSkillPath(plugin, rel); + return rel; +} + +function transformCursorFlatAgent(content: string, plugin: string, outName: string): string { + const { frontmatter, body } = parseFrontmatter(content); + const stripped = stripCursorAgentFrontmatter(frontmatter); + const flatRelPath = cursorFlatAgentPath(plugin, `agents/${outName}`); + const rewrittenBody = rewriteRelativeLinks(body, { + currentFilePluginRelative: flatRelPath, + resolveTargetPath: (rel) => cursorFlatResolveTarget(plugin, rel), + }); + const prefixedName = `${plugin}-${outName.replace(/\.md$/, "")}`; + return serializeFrontmatter({ ...stripped, name: prefixedName }, rewrittenBody); +} + +export function buildCursorFlatContract(): ToolBuildContract { + return { + manifestFileRelative: null, + synthesizeManifest: null, + manifestSchemaName: null, + artifacts: { + skills: { + supported: true, + source: { kind: "fullTree", srcDir: "skills" }, + path: cursorFlatSkillPath, + rewriteSkillName: true, + }, + agents: { + supported: true, + source: { kind: "filteredTree", srcDir: "agents", inputExt: ".md" }, + path: cursorFlatAgentPath, + transform: transformCursorFlatAgent, + }, + mcp: { + supported: true, + source: { kind: "configFile", srcPath: ".mcp.json" }, + path: () => ".cursor/mcp.json", + merge: (existing, incoming, force) => + mergeVscodeMcp(existing, incoming, force, "mcpServers"), + mcpServersKey: "mcpServers", + mergeDest: (outDir) => `${outDir}/.cursor/mcp.json`, + }, + hooks: { + supported: true, + source: { kind: "hooksBundle", jsonPath: "hooks/hooks.json", scriptDir: "hooks" }, + path: cursorFlatHooksPath, + hooksMerge: (existing, incoming) => mergeCursorFlatHooks(existing, incoming), + hooksMergeDest: (outDir) => `${outDir}/.cursor/hooks.json`, + }, + rules: { supported: false }, + commands: { supported: false }, + }, + buildMarketplaceCatalog: null, + buildMarketplaceEntry: null, + }; +} diff --git a/cli/src/contexts/tools/domain/profiles/cursor/cursor-paths.ts b/cli/src/contexts/tools/domain/profiles/cursor/cursor-paths.ts new file mode 100644 index 000000000..612616639 --- /dev/null +++ b/cli/src/contexts/tools/domain/profiles/cursor/cursor-paths.ts @@ -0,0 +1,7 @@ +/** Cursor build output path constants, deliberately distinct from their source-side + * equivalents even where the literal values coincide: a change to either side must not + * collapse them. */ + +export const OUTPUT_CURSOR_MANIFEST_RELATIVE = ".cursor-plugin/plugin.json"; + +export const OUTPUT_CURSOR_MARKETPLACE_RELATIVE = ".cursor-plugin/marketplace.json"; diff --git a/cli/src/contexts/tools/domain/profiles/cursor/profile.ts b/cli/src/contexts/tools/domain/profiles/cursor/profile.ts new file mode 100644 index 000000000..cb2854479 --- /dev/null +++ b/cli/src/contexts/tools/domain/profiles/cursor/profile.ts @@ -0,0 +1,139 @@ +import { join } from "node:path"; +import { AgentsCapability } from "../../capabilities/agents-capability.js"; +import { CommandsCapability } from "../../capabilities/commands-capability.js"; +import { CONFIG_MCP } from "../../capabilities/config-refs.js"; +import { McpCapability } from "../../capabilities/mcp-capability.js"; +import { PluginsCapability } from "../../capabilities/plugins-capability.js"; +import { RulesCapability } from "../../capabilities/rules-capability.js"; +import { SkillsCapability } from "../../capabilities/skills-capability.js"; +import type { + AiTool, + HasAgents, + HasCommands, + HasMcp, + HasPlugins, + HasRules, + HasSkills, +} from "../../contracts.js"; +import { + buildAiddCommandFilePath, + convertCommandFrontmatter, + stripToolSuffix, +} from "../../formats/command.js"; +import { CURSOR_PLUGIN_ROOT_TOKEN } from "../../formats/plugin-root-token.js"; +import { registerTool } from "../../registry.js"; +import { buildCursorContract, buildCursorFlatContract } from "./build.js"; + +const DIRECTORY = ".cursor/"; +const TOOL_SUFFIX = ".cursor.md"; +const MDC_EXT = ".mdc"; + +function toMdc(fileName: string): string { + return fileName.endsWith(".md") ? `${fileName.slice(0, -3)}${MDC_EXT}` : fileName; +} + +export const cursor: AiTool = + { + kind: "ai", + toolId: "cursor", + distributionProbes: { + manifest: [".cursor-plugin/plugin.json"], + marketplace: [".cursor-plugin/marketplace.json"], + }, + directory: DIRECTORY, + toolSuffix: TOOL_SUFFIX, + displayName: "Cursor", + telemetryLocalRead: { + kind: "unsupported", + reason: "It writes no token count in any file it produces.", + }, + telemetryTaskAttributable: true, + telemetryJournalHost: "cursor", + signalDir: ".cursor/commands", + configOutputPaths: { "settings.json": ".cursor/settings.json" }, + buildContracts: { marketplace: buildCursorContract, flat: buildCursorFlatContract }, + + capabilities: { + agents: new AgentsCapability({ + directory: DIRECTORY, + toolSuffix: TOOL_SUFFIX, + format: "markdown", + }), + skills: new SkillsCapability({ + directory: DIRECTORY, + toolSuffix: TOOL_SUFFIX, + buildInstallPath: (fileName) => + `${DIRECTORY}skills/${stripToolSuffix(TOOL_SUFFIX, fileName)}`, + convertFrontmatter: (fm) => fm, + }), + commands: new CommandsCapability({ + directory: DIRECTORY, + toolSuffix: TOOL_SUFFIX, + buildInstallPath: (fileName) => buildAiddCommandFilePath(DIRECTORY, fileName), + convertFrontmatter: (fm, relativeFileName) => + convertCommandFrontmatter(fm, relativeFileName), + }), + rules: new RulesCapability({ + directory: DIRECTORY, + toolSuffix: TOOL_SUFFIX, + buildInstallPath: (fileName) => + `${DIRECTORY}rules/${toMdc(stripToolSuffix(TOOL_SUFFIX, fileName))}`, + convertFrontmatter: (fm) => { + const { paths, globs, description } = fm; + const patterns = Array.isArray(paths) ? paths : Array.isArray(globs) ? globs : null; + if (patterns === null || patterns.length === 0) { + if (fm.alwaysApply === false && description !== undefined) { + return { description, alwaysApply: false }; + } + return {}; + } + const result: Record = {}; + if (description !== undefined) result.description = description; + return { + ...result, + globs: JSON.stringify(patterns).replace(/,/g, ", "), + alwaysApply: false, + }; + }, + }), + mcp: new McpCapability({ + outputPath: `${DIRECTORY}mcp.json`, + format: "json", + entrySection: "mcpServers", + consumes: [CONFIG_MCP], + }), + plugins: new PluginsCapability({ + mode: "native", + // Empty so the translator computes pluginRoot as `/`, giving base-relative + // keys such as "aidd-context/commands/foo.md". + pluginsDir: "", + pluginManifestRelativePath: null, + // Cursor auto-discovers mcp.json at a plugin's root but never a plugin-scope + // hooks.json — three probes fired zero of seven events. Only a project-scope + // .cursor/hooks.json is ever observed firing, so `hooksDestination` routes hooks there; + // `hooksRelativePath` and `hooksContentFormat` stay declared for the shape they + // describe but are no longer read for Cursor's own install. + acceptsHooks: true, + pluginRootToken: CURSOR_PLUGIN_ROOT_TOKEN, + hooksRelativePath: "hooks.json", + hooksContentFormat: "flat", + hooksDestination: "project", + projectHooksRelativePath: ".cursor/hooks.json", + acceptsMcp: true, + mcpRelativePath: "mcp.json", + installScope: "user", + userPluginsDir: (h) => join(h, ".cursor", "plugins", "local"), + }), + }, + + rewriteContent(content: string): string { + return content + .replace( + /(@?)\.cursor\/commands\/(\d+)[_-][^/]+\/([^\s]+)/g, + "$1.cursor/commands/aidd/$2/$3" + ) + .replace(/(@\.cursor\/rules\/[^\s]+)\.md\b/g, "$1.mdc"); + }, + }; + +registerTool(cursor); diff --git a/cli/src/contexts/tools/domain/profiles/opencode/build.ts b/cli/src/contexts/tools/domain/profiles/opencode/build.ts new file mode 100644 index 000000000..23c384738 --- /dev/null +++ b/cli/src/contexts/tools/domain/profiles/opencode/build.ts @@ -0,0 +1,203 @@ +/** + * Opencode's build contract: flat only — opencode has no marketplace mode. + * `transformMcpToOpencode` lives here because the flat contract's config-artifact step needs it + * as much as the installed-content mcp capability does; the profile imports it back. + */ + +import { InvalidMcpServerConfigError, McpConfigError } from "../../../../../kernel/errors.js"; +import { parseFrontmatter, serializeFrontmatter } from "../../../../../kernel/markdown.js"; +import { + flatHooksPathWithLoaderEntry, + flatMcpKeyPrefix, + genericFlatAgentPath, + genericFlatSkillTreePath, +} from "../../../../../kernel/materialization/flat-paths.js"; +import { rewriteRelativeLinks } from "../../../../../kernel/materialization/relative-link-rewrite.js"; +import type { FileReader } from "../../../../../kernel/ports/file-reader.js"; +import type { FileWriter } from "../../../../../kernel/ports/file-writer.js"; +import type { ArtifactContract, ToolBuildContract } from "../../build-contract.js"; +import { buildOpencodeFlatConfig } from "../../formats/opencode-mcp-merge.js"; +import { generateOpencodeHooksBridge } from "./opencode-hooks-bridge.js"; +import { + makeOpencodeHooksBridgePath, + OPENCODE_FLAT_HOOKS_DIR, + OPENCODE_HOOKS_DIR, + OPENCODE_PLUGIN_ENTRY_BASENAME, +} from "./opencode-paths.js"; + +type FsType = FileReader & FileWriter; + +type RawServer = + | { command: string; args?: string[]; env?: Record; disabled?: boolean } + | { url: string; disabled?: boolean }; + +interface OpencodeMcpLocalServer { + type: "local"; + command: string[]; + enabled: boolean; + environment?: Record; +} + +interface OpencodeMcpRemoteServer { + type: "remote"; + url: string; + enabled: boolean; +} + +type OpencodeMcpServer = OpencodeMcpLocalServer | OpencodeMcpRemoteServer; + +function convertRawServer(name: string, server: RawServer): OpencodeMcpServer { + const enabled = server.disabled !== true; + if ("command" in server) { + const { command, args = [], env } = server; + const local: OpencodeMcpLocalServer = { type: "local", command: [command, ...args], enabled }; + if (env && Object.keys(env).length > 0) local.environment = env; + return local; + } + if ("url" in server) { + return { type: "remote", url: server.url, enabled }; + } + throw new InvalidMcpServerConfigError(name); +} + +export function transformMcpToOpencode(content: string): string { + let parsed: { mcpServers?: Record }; + try { + parsed = JSON.parse(content) as typeof parsed; + } catch (err) { + throw new McpConfigError( + `Cannot parse MCP config: ${err instanceof Error ? err.message : String(err)}` + ); + } + if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) { + throw new McpConfigError("MCP config must be a JSON object"); + } + const mcp: Record = {}; + for (const [name, server] of Object.entries(parsed.mcpServers ?? {})) { + mcp[name] = convertRawServer(name, server); + } + return JSON.stringify({ mcp }, null, 2); +} + +function opencodeFlatAgentPath(plugin: string, rel: string): string { + return genericFlatAgentPath(".opencode/agents/", plugin, rel.replace(/^agents\//, ""), ".md"); +} + +function opencodeFlatSkillPath(plugin: string, rel: string): string { + return genericFlatSkillTreePath(".opencode/skills/", plugin, rel.replace(/^skills\//, "")); +} + +/** OpenCode's loader scans one flat directory for its own plugin modules, never a "hooks" + * family, so a plugin's hook scripts are namespaced under `OPENCODE_HOOKS_DIR` instead. The one + * exception is a script named `OPENCODE_PLUGIN_ENTRY_BASENAME`: that loader's own module, + * delivered flat and renamed to the plugin's own name. */ +function makeOpencodeFlatHooksPath(): (plugin: string, rel: string) => string { + return (plugin, rel) => + flatHooksPathWithLoaderEntry( + OPENCODE_HOOKS_DIR, + { dir: OPENCODE_FLAT_HOOKS_DIR, baseName: OPENCODE_PLUGIN_ENTRY_BASENAME }, + plugin, + rel + ); +} + +/** Supported exactly when the profile names a directory to deliver into — the same + * declaration `acceptsHooks` is paired with, read once rather than restated here. */ +function buildOpencodeFlatHooksArtifact(): ArtifactContract { + return { + supported: true, + source: { kind: "hooksBundle", jsonPath: "hooks/hooks.json", scriptDir: "hooks" }, + path: makeOpencodeFlatHooksPath(), + skipHooksJson: true, + hooksBridge: { + generate: generateOpencodeHooksBridge, + path: makeOpencodeHooksBridgePath, + skipIfSourceHas: OPENCODE_PLUGIN_ENTRY_BASENAME, + }, + }; +} + +function opencodeFlatResolveTarget(plugin: string, rel: string): string { + if (rel.startsWith("agents/")) return opencodeFlatAgentPath(plugin, rel); + if (rel.startsWith("skills/")) return opencodeFlatSkillPath(plugin, rel); + return rel; +} + +function transformOpencodeFlatAgent(content: string, plugin: string, outName: string): string { + const { frontmatter, body } = parseFrontmatter(content); + const flatRelPath = opencodeFlatAgentPath(plugin, `agents/${outName}`); + const rewrittenBody = rewriteRelativeLinks(body, { + currentFilePluginRelative: flatRelPath, + resolveTargetPath: (rel) => opencodeFlatResolveTarget(plugin, rel), + }); + const prefixedName = `${plugin}-${outName.replace(/\.md$/, "")}`; + // mode: subagent ensures opencode treats copied agents as subagents, not primary agents. + return serializeFrontmatter( + { ...frontmatter, name: prefixedName, mode: "subagent" }, + rewrittenBody + ); +} + +async function resolveOpencodeJsonPath(outDir: string, fs: FsType): Promise { + const jsoncExists = await fs.fileExists(`${outDir}/opencode.jsonc`); + if (jsoncExists) return `${outDir}/opencode.jsonc`; + return `${outDir}/opencode.json`; +} + +async function collectOpencodeMcp( + builtPlugins: readonly string[], + sourceDir: string, + fs: FsType +): Promise> { + const incoming: Record = {}; + for (const plugin of builtPlugins) { + const mcpSrc = `${sourceDir}/plugins/${plugin}/.mcp.json`; + if (!(await fs.fileExists(mcpSrc))) continue; + const raw = await fs.readFile(mcpSrc); + const transformed = JSON.parse(transformMcpToOpencode(raw)) as { + mcp?: Record; + }; + const prefix = flatMcpKeyPrefix(plugin); + for (const [k, v] of Object.entries(transformed.mcp ?? {})) { + incoming[`${prefix}${k}`] = v; + } + } + return incoming; +} + +export function buildOpencodeFlatContract(): ToolBuildContract { + return { + manifestFileRelative: null, + synthesizeManifest: null, + manifestSchemaName: null, + artifacts: { + skills: { + supported: true, + source: { kind: "fullTree", srcDir: "skills" }, + path: opencodeFlatSkillPath, + rewriteSkillName: true, + }, + agents: { + supported: true, + source: { kind: "filteredTree", srcDir: "agents", inputExt: ".md" }, + path: opencodeFlatAgentPath, + transform: transformOpencodeFlatAgent, + }, + mcp: { supported: false }, // handled by emitConfigArtifact (opencode.json mcp) + hooks: buildOpencodeFlatHooksArtifact(), + rules: { supported: false }, + commands: { supported: false }, + }, + buildMarketplaceCatalog: null, + buildMarketplaceEntry: null, + emitConfigArtifact: async (builtPlugins, outDir, sourceDir, fs, _validator, assetProvider) => { + const configPath = await resolveOpencodeJsonPath(outDir, fs); + const existing = (await fs.fileExists(configPath)) ? await fs.readFile(configPath) : null; + const incoming = await collectOpencodeMcp(builtPlugins, sourceDir, fs); + const baseAsset = assetProvider.loadConfigAsset("opencode", "opencode.json"); + const base = typeof baseAsset === "string" ? baseAsset : JSON.stringify(baseAsset); + await fs.writeFile(configPath, buildOpencodeFlatConfig(base, existing, incoming)); + return 1; + }, + }; +} diff --git a/cli/src/contexts/tools/domain/profiles/opencode/opencode-hooks-bridge.ts b/cli/src/contexts/tools/domain/profiles/opencode/opencode-hooks-bridge.ts new file mode 100644 index 000000000..510d8cc0d --- /dev/null +++ b/cli/src/contexts/tools/domain/profiles/opencode/opencode-hooks-bridge.ts @@ -0,0 +1,230 @@ +/** + * Generates OpenCode's event bridge for one plugin's hooks.json. OpenCode's loader scans no + * "hooks" family and this profile writes no hooks.json, so without this module a plugin's + * declared hooks have no trigger on OpenCode at all. The generated file is a real OpenCode + * plugin, one function-valued export, spawning the same scripts every other host's hooks.json + * already names over the stdin-JSON contract those scripts already read. + * + * Only three events map; anything else is dropped, OpenCode's plugin surface delivering no + * event those hooks could ride on: + * + * - `SessionStart` runs when the generated plugin's own factory is called — once per + * server/directory, not once per session, since `session.created` is published on OpenCode's + * bus but was never observed delivered to a plugin's `event` hook. Safe only for an + * idempotent hook, which every `SessionStart` hook this generator sees today is. + * - `Stop` maps to `session.idle`, delivered once per turn. + * - `PostToolUse` maps to `message.part.updated` whose `part.state.status === "completed"`, the + * one shape measured live: `part.tool` names the tool, `part.state.input` its arguments. + * `tool.execute.after` reads cleaner in OpenCode's own docs but is a separate named hook + * `(input, output)`, never an `event({event})` payload, and nothing here has captured it. + * + * A `matcher` on a `PostToolUse` group filters by tool name, exact or pipe-separated + * alternation; absent, every tool matches. + */ + +interface ParsedHookCall { + readonly script: string; + readonly args: readonly string[]; +} + +interface ParsedPostToolUseCall extends ParsedHookCall { + readonly matcher?: string; +} + +interface OpencodeHookTable { + readonly sessionStart: readonly ParsedHookCall[]; + readonly stop: readonly ParsedHookCall[]; + readonly postToolUse: readonly ParsedPostToolUseCall[]; +} + +interface ClaudeHookItem { + readonly type?: string; + readonly command?: string; +} + +interface ClaudeMatcherGroup { + readonly matcher?: string; + readonly hooks?: readonly ClaudeHookItem[]; +} + +interface ClaudeHooksShape { + readonly hooks?: Record; +} + +// Only a command this generator can actually replay: `node ${CLAUDE_PLUGIN_ROOT}/hooks/ +// [args...]`. Anything else is a shape only Claude's own settings.json target ever runs, and is +// dropped here rather than guessed at. +const COMMAND_PATTERN = /^node\s+\$\{CLAUDE_PLUGIN_ROOT\}\/hooks\/(\S+)(.*)$/; + +function parseCommand(command: string | undefined): ParsedHookCall | null { + if (typeof command !== "string") return null; + const match = COMMAND_PATTERN.exec(command.trim()); + if (!match) return null; + const [, script, rest] = match; + const args = rest.trim().length > 0 ? rest.trim().split(/\s+/) : []; + return { script, args }; +} + +function parseGroups(groups: readonly ClaudeMatcherGroup[] | undefined): ParsedPostToolUseCall[] { + const calls: ParsedPostToolUseCall[] = []; + for (const group of groups ?? []) { + for (const item of group.hooks ?? []) { + const parsed = parseCommand(item.command); + if (parsed === null) continue; + calls.push(group.matcher ? { ...parsed, matcher: group.matcher } : parsed); + } + } + return calls; +} + +/** A plugin's raw hooks.json — still carrying `${CLAUDE_PLUGIN_ROOT}`, since the generated + * bridge resolves its scripts from its own `import.meta.url` and the outDir-relative rewrite + * every other flat target needs buys this one nothing — reduced to the three mapped events. + * Exported for the generator's own unit test. */ +export function parseHooksJsonForBridge(rawHooksJson: string): OpencodeHookTable { + const parsed = JSON.parse(rawHooksJson) as ClaudeHooksShape; + const hooks = parsed.hooks ?? {}; + return { + sessionStart: parseGroups(hooks.SessionStart), + stop: parseGroups(hooks.Stop), + postToolUse: parseGroups(hooks.PostToolUse), + }; +} + +// Every plugin this generator sees is already named "aidd-", so an "Aidd" prefix +// here would stutter rather than name anything the plugin's own PascalCased name does not. +function toIdentifier(plugin: string): string { + const pascal = plugin + .split("-") + .filter(Boolean) + .map((part) => part[0]?.toUpperCase() + part.slice(1)) + .join(""); + return `${pascal}Hooks`; +} + +function callTableLiteral(calls: readonly ParsedHookCall[]): string { + return JSON.stringify(calls); +} + +/** A plugin's raw hooks.json + its name -> the full text of its generated OpenCode bridge + * module, or `null` when none of the three mapped events named anything replayable — a bridge + * with nothing to spawn is not a file worth writing. */ +export function generateOpencodeHooksBridge(rawHooksJson: string, plugin: string): string | null { + const table = parseHooksJsonForBridge(rawHooksJson); + if ( + table.sessionStart.length === 0 && + table.stop.length === 0 && + table.postToolUse.length === 0 + ) { + return null; + } + const ident = toIdentifier(plugin); + return `// Generated by aidd from plugins/${plugin}/hooks/hooks.json - do not edit by hand. +// OpenCode's plugin loader scans no "hooks" family and this profile writes no hooks.json +// (build.ts's skipHooksJson, translated here rather than skipped) - this file is the only +// trigger this plugin's declared hooks have on OpenCode. See opencode-hooks-bridge.ts for +// the mapping this generator applies and the measurements behind it. +import { spawn } from "node:child_process"; +import { fileURLToPath } from "node:url"; + +// Never process.execPath: OpenCode ships as its own standalone binary (see +// plugins/aidd-telemetry/hooks/opencode-plugin.js:29-30) - that path names \`opencode\` +// itself, not a Node runtime able to run this plugin's own hook scripts. +const HOOKS_DIR = fileURLToPath(new URL("../hooks/${plugin}/", import.meta.url)); + +const SESSION_START = ${callTableLiteral(table.sessionStart)}; +const STOP = ${callTableLiteral(table.stop)}; +const POST_TOOL_USE = ${callTableLiteral(table.postToolUse)}; + +// Asynchronous on purpose, unlike opencode-plugin.js's own spawnSync: that file spawns at +// most one script per event, this one can spawn one per matching hook across every mapped +// event, and blocking OpenCode's event loop once per hook multiplies the cost its own +// comment already accepts for a single call. A failed spawn (ENOENT, a killed timeout) +// must not throw past this function - both listeners below, plus the caller's own +// try/catch, exist because a spawn that never launches can still throw on the stdin write. +function runHook(script, args, payload, directory) { + const child = spawn("node", [HOOKS_DIR + script, ...args], { + cwd: directory, + stdio: ["pipe", "ignore", "ignore"], + timeout: 5000, + }); + child.on("error", () => {}); + child.stdin.on("error", () => {}); + child.stdin.end(JSON.stringify(payload)); +} + +/** Pure: \`session.idle\` -> every Stop hook's own {script, args, payload} - or \`[]\` for + * any other event. Exported as a property (never a second named export - F6) so this + * generated module's own mapping can be asserted without spawning anything, the same seam + * \`AiddTelemetry.journalCallFor\` already gives opencode-plugin.js. */ +function stopCallsFor(event, directory) { + if (event?.type !== "session.idle") return []; + const sessionId = event.properties?.sessionID; + return STOP.map((hook) => ({ + script: hook.script, + args: hook.args, + payload: { hook_event_name: "Stop", session_id: sessionId ?? null, cwd: directory }, + })); +} + +/** Pure: \`message.part.updated\` for a completed tool part -> every PostToolUse hook whose + * matcher (absent, or an exact / pipe-separated tool name) allows this tool - or \`[]\` for + * any other event, an incomplete part, or one naming no tool. */ +function postToolUseCallsFor(event, directory) { + if (event?.type !== "message.part.updated") return []; + const part = event.properties?.part; + if (part?.type !== "tool" || part.state?.status !== "completed") return []; + const toolName = part.tool; + const sessionId = event.properties?.sessionID; + const matches = (matcher) => !matcher || matcher.split("|").includes(toolName); + return POST_TOOL_USE.filter((hook) => matches(hook.matcher)).map((hook) => ({ + script: hook.script, + args: hook.args, + payload: { + hook_event_name: "PostToolUse", + session_id: sessionId ?? null, + cwd: directory, + tool_name: toolName, + tool_input: part.state.input, + }, + })); +} + +export const ${ident} = async (input) => { + // SessionStart's own approximation (module doc comment above): fired once here, never + // per session. Silent on purpose, the same rule journal.cjs's own main() and + // opencode-plugin.js's own event handler both state: a measurement or a memory refresh + // that breaks OpenCode's own startup is worse than one that never ran. + try { + for (const hook of SESSION_START) { + runHook( + hook.script, + hook.args, + { hook_event_name: "SessionStart", session_id: null, cwd: input.directory }, + input.directory + ); + } + } catch { + // Silent on purpose - see above. + } + return { + event: async ({ event }) => { + try { + const calls = [ + ...stopCallsFor(event, input.directory), + ...postToolUseCallsFor(event, input.directory), + ]; + for (const call of calls) { + runHook(call.script, call.args, call.payload, input.directory); + } + } catch { + // Silent on purpose - see above. + } + }, + }; +}; + +${ident}.stopCallsFor = stopCallsFor; +${ident}.postToolUseCallsFor = postToolUseCallsFor; +`; +} diff --git a/cli/src/contexts/tools/domain/profiles/opencode/opencode-paths.ts b/cli/src/contexts/tools/domain/profiles/opencode/opencode-paths.ts new file mode 100644 index 000000000..97e9b5e91 --- /dev/null +++ b/cli/src/contexts/tools/domain/profiles/opencode/opencode-paths.ts @@ -0,0 +1,28 @@ +/** Where OpenCode keeps what this CLI writes for it. Held apart from the profile so the build + * contract can read the same values: the profile imports the contract, so the contract cannot + * import the profile back. */ + +export const OPENCODE_DIRECTORY = ".opencode/"; + +/** + * The directory OpenCode's own plugin loader scans (`{plugin,plugins}/*.{ts,js}`, one level, no + * `hooks` family). Not where a plugin's hook scripts land: only a script literally named + * `OPENCODE_PLUGIN_ENTRY_BASENAME` belongs here, renamed to the plugin's own name so two + * plugins delivering one cannot collide. + */ +export const OPENCODE_FLAT_HOOKS_DIR = `${OPENCODE_DIRECTORY}plugin/`; + +/** Where a plugin's hook scripts land instead, namespaced per plugin. No family the loader + * scans is named "hooks", so nothing here is ever imported. */ +export const OPENCODE_HOOKS_DIR = `${OPENCODE_DIRECTORY}hooks/`; + +/** The one hook filename that is, by convention, a plugin's own OpenCode plugin module — the + * runtime the loader is meant to import — rather than a script an external bridge must run. */ +export const OPENCODE_PLUGIN_ENTRY_BASENAME = "opencode-plugin.js"; + +/** Where a plugin's generated event bridge lands: flat, in the directory the loader scans, + * renamed per plugin so two plugins each getting one cannot collide. Read by both + * flat-materialization routes, so the path is one fact rather than two that could drift. */ +export function makeOpencodeHooksBridgePath(plugin: string): string { + return `${OPENCODE_FLAT_HOOKS_DIR}${plugin}-hooks.js`; +} diff --git a/cli/src/contexts/tools/domain/profiles/opencode/profile.ts b/cli/src/contexts/tools/domain/profiles/opencode/profile.ts new file mode 100644 index 000000000..c32b5ece2 --- /dev/null +++ b/cli/src/contexts/tools/domain/profiles/opencode/profile.ts @@ -0,0 +1,145 @@ +import { join } from "node:path"; +import { OpencodeDualConfigError } from "../../../../../kernel/errors.js"; +import { AgentsCapability } from "../../capabilities/agents-capability.js"; +import { CommandsCapability } from "../../capabilities/commands-capability.js"; +import { CONFIG_MCP, CONFIG_OPENCODE } from "../../capabilities/config-refs.js"; +import { McpCapability } from "../../capabilities/mcp-capability.js"; +import { PluginsCapability } from "../../capabilities/plugins-capability.js"; +import { RulesCapability } from "../../capabilities/rules-capability.js"; +import { SkillsCapability } from "../../capabilities/skills-capability.js"; +import type { + AiTool, + HasAgents, + HasCommands, + HasMcp, + HasPlugins, + HasRules, + HasSkills, +} from "../../contracts.js"; +import { + buildAiddCommandFilePath, + convertCommandFrontmatterNoHint, + stripToolSuffix, +} from "../../formats/command.js"; +import { registerTool } from "../../registry.js"; +import { buildOpencodeFlatContract, transformMcpToOpencode } from "./build.js"; +import { generateOpencodeHooksBridge } from "./opencode-hooks-bridge.js"; +import { + makeOpencodeHooksBridgePath, + OPENCODE_DIRECTORY, + OPENCODE_FLAT_HOOKS_DIR, + OPENCODE_HOOKS_DIR, + OPENCODE_PLUGIN_ENTRY_BASENAME, +} from "./opencode-paths.js"; + +const DIRECTORY = OPENCODE_DIRECTORY; +const TOOL_SUFFIX = ".opencode.md"; + +export const opencode: AiTool< + HasAgents & HasSkills & HasCommands & HasRules & HasMcp & HasPlugins +> = { + kind: "ai", + toolId: "opencode", + distributionProbes: { + marketplace: ["opencode.json"], + }, + directory: DIRECTORY, + toolSuffix: TOOL_SUFFIX, + displayName: "OpenCode", + telemetryLocalRead: { + kind: "declared", + // Counters per message, and no amount: `info.cost` is `0` in every message captured and its + // denomination was never established, so it is deliberately never read. No field names a + // running skill either. + supplies: { tokenCounters: true, amount: false, toolStatedStep: false, agentName: false }, + // `input` is measured exclusive of `cache.read` for providerID "anthropic", matching that + // API's own documented behaviour. A second, OpenAI-compatible provider reconciled the same + // way but never exercised its cache across two turns, so it corroborates without confirming; + // a provider reporting prompt tokens inclusive of the cached ones has never been captured. + limitation: + "Its four counters are measured disjoint for the anthropic provider and for one " + + "OpenAI-compatible provider whose cache was exercised — not confirmed for a " + + "provider that reports prompt tokens inclusive of the cached ones, which none " + + "captured here does.", + }, + telemetryTaskAttributable: true, + telemetryJournalHost: "opencode", + signalDir: ".opencode/commands", + configOutputPaths: { "opencode.json": "opencode.json" }, + buildContracts: { flat: buildOpencodeFlatContract }, + + capabilities: { + agents: new AgentsCapability({ + directory: DIRECTORY, + toolSuffix: TOOL_SUFFIX, + format: "markdown", + convertFrontmatter: (fm) => ({ description: fm.description, mode: "subagent" }), + }), + skills: new SkillsCapability({ + directory: DIRECTORY, + toolSuffix: TOOL_SUFFIX, + buildInstallPath: (fileName) => + `${DIRECTORY}skills/${stripToolSuffix(TOOL_SUFFIX, fileName)}`, + convertFrontmatter: (fm) => fm, + }), + commands: new CommandsCapability({ + directory: DIRECTORY, + toolSuffix: TOOL_SUFFIX, + buildInstallPath: (fileName) => buildAiddCommandFilePath(DIRECTORY, fileName), + convertFrontmatter: (fm, relativeFileName) => + convertCommandFrontmatterNoHint(fm, relativeFileName), + }), + rules: new RulesCapability({ + directory: DIRECTORY, + toolSuffix: TOOL_SUFFIX, + buildInstallPath: (fileName) => `${DIRECTORY}rules/${stripToolSuffix(TOOL_SUFFIX, fileName)}`, + convertFrontmatter: (fm) => { + if (fm.alwaysApply === false && fm.description !== undefined) { + return { description: fm.description }; + } + return {}; + }, + }), + mcp: new McpCapability({ + outputPath: "opencode.json", + format: "json", + entrySection: "mcp", + mergeStrategy: "framework-prime", + transformContent: transformMcpToOpencode, + consumes: [CONFIG_MCP, CONFIG_OPENCODE], + resolveOutputPath: async (projectRoot, fs) => { + const jsonExists = await fs.fileExists(join(projectRoot, "opencode.json")); + const jsoncExists = await fs.fileExists(join(projectRoot, "opencode.jsonc")); + if (jsonExists && jsoncExists) throw new OpencodeDualConfigError(); + if (jsoncExists) return "opencode.jsonc"; + return "opencode.json"; + }, + }), + // Flat mode has no `marketplaceSettings` field, and opencode's `plugin[]` array accepts + // only npm package names — no source or version a marketplace entry could express. + plugins: new PluginsCapability({ + mode: "flat", + flatNamespacePrefix: "aidd-", + acceptsHooks: true, + flatHooksDir: OPENCODE_HOOKS_DIR, + flatHooksLoaderEntry: { + dir: OPENCODE_FLAT_HOOKS_DIR, + baseName: OPENCODE_PLUGIN_ENTRY_BASENAME, + }, + flatHooksBridge: { + generate: generateOpencodeHooksBridge, + path: makeOpencodeHooksBridgePath, + skipIfSourceHas: OPENCODE_PLUGIN_ENTRY_BASENAME, + }, + }), + }, + + rewriteContent(content: string): string { + return content.replace( + /(@?)\.opencode\/commands\/(\d+)[_-][^/]+\/([^\s]+)/g, + "$1.opencode/commands/aidd/$2/$3" + ); + }, +}; + +registerTool(opencode); diff --git a/cli/src/contexts/tools/domain/profiles/vscode/profile.ts b/cli/src/contexts/tools/domain/profiles/vscode/profile.ts new file mode 100644 index 000000000..137ea6af6 --- /dev/null +++ b/cli/src/contexts/tools/domain/profiles/vscode/profile.ts @@ -0,0 +1,37 @@ +import { + CONFIG_VSCODE_EXTENSIONS, + CONFIG_VSCODE_KEYBINDINGS, + CONFIG_VSCODE_SETTINGS, +} from "../../capabilities/config-refs.js"; +import { SettingsCapability } from "../../capabilities/settings-capability.js"; +import type { HasSettings, IdeToolConfig } from "../../contracts.js"; +import { registerTool } from "../../registry.js"; + +const DIRECTORY = ".vscode/"; + +export const vscodeToolConfig: IdeToolConfig & HasSettings = { + kind: "ide", + toolId: "vscode", + directory: DIRECTORY, + signalDir: null, + + settings: [ + new SettingsCapability({ + outputPath: ".vscode/extensions.json", + mergeStrategy: "user-prime", + consumes: [CONFIG_VSCODE_EXTENSIONS], + }), + new SettingsCapability({ + outputPath: ".vscode/keybindings.json", + mergeStrategy: "none", + consumes: [CONFIG_VSCODE_KEYBINDINGS], + }), + new SettingsCapability({ + outputPath: ".vscode/settings.json", + mergeStrategy: "user-prime", + consumes: [CONFIG_VSCODE_SETTINGS], + }), + ], +}; + +registerTool(vscodeToolConfig); diff --git a/cli/src/contexts/tools/domain/registry.ts b/cli/src/contexts/tools/domain/registry.ts new file mode 100644 index 000000000..75cfa0968 --- /dev/null +++ b/cli/src/contexts/tools/domain/registry.ts @@ -0,0 +1,198 @@ +import { join } from "node:path"; +import { + CategoryMismatchError, + UnknownToolCategoryError, + UnregisteredToolError, +} from "../../../kernel/errors.js"; +import type { FileReader } from "../../../kernel/ports/file-reader.js"; +import { + AI_TOOL_IDS, + type AiToolId, + IDE_TOOL_IDS, + type IdeToolId, + type ToolCategory, + type ToolId, +} from "../../../kernel/tool.js"; +import type { ToolBuildContract } from "./build-contract.js"; +import type { + EnvironmentReader, + NativeActivation, + PluginsCapability, +} from "./capabilities/plugins-capability.js"; +import type { AiTool, IdeToolConfig } from "./contracts.js"; + +/** Output layout: a marketplace dist versus a flat workspace inject. Declared here, not by + * translate, because it is read off a tool's own plugins capability — a tool's build mode is + * tool knowledge. */ +export type FrameworkBuildMode = "marketplace" | "flat"; + +export type ToolConfig = AiTool | IdeToolConfig; + +export function isAiTool(config: ToolConfig): config is AiTool { + return config.kind === "ai"; +} + +export function toolIdsForCategory(category: ToolCategory): readonly ToolId[] { + switch (category) { + case "ai": + return AI_TOOL_IDS; + case "ide": + return IDE_TOOL_IDS; + default: { + const _exhaustive: never = category; + throw new UnknownToolCategoryError(String(_exhaustive)); + } + } +} + +export function isIdeToolId(id: string): id is IdeToolId { + return (IDE_TOOL_IDS as readonly string[]).includes(id); +} + +export function assertToolIdsMatchCategory(toolIds: ToolId[], category: ToolCategory): void { + const allowed = toolIdsForCategory(category); + const wrong = toolIds.filter((id) => !(allowed as readonly string[]).includes(id)); + if (wrong.length === 0) return; + throw new CategoryMismatchError(wrong, category, allowed); +} + +const TOOL_REGISTRY = new Map(); + +export function registerTool(config: ToolConfig): void { + TOOL_REGISTRY.set(config.toolId, config); +} + +export function getToolConfig(toolId: ToolId): ToolConfig { + const config = TOOL_REGISTRY.get(toolId); + if (!config) throw new UnregisteredToolError(toolId); + return config; +} + +export function getAiToolConfig(toolId: AiToolId): AiTool { + const config = getToolConfig(toolId); + if (!isAiTool(config)) throw new UnregisteredToolError(toolId); + return config; +} + +/** The `AiToolId` whose declaration claims a journal host, or `null` for a host no registered + * tool claims. The one place the journal hook's host names and this codebase's tool ids are + * related, and it relates them by reading declarations rather than a table a fifth host would + * have to be remembered into. */ +export function journalHostToAiToolId(journalHost: string): AiToolId | null { + for (const toolId of AI_TOOL_IDS) { + if (getAiToolConfig(toolId).telemetryJournalHost === journalHost) return toolId; + } + return null; +} + +export function getAllRegisteredTools(): Map { + return new Map(TOOL_REGISTRY); +} + +export async function hasToolSignals( + fs: FileReader, + config: ToolConfig, + projectRoot: string +): Promise { + if (!config.signalDir) return []; + const dir = join(projectRoot, config.signalDir); + if (!(await fs.fileExists(dir))) return []; + const files = await fs.listDirectory(dir); + const matches: string[] = []; + for (const filePath of files) { + if (!filePath.endsWith(".md")) continue; + const content = await fs.readFile(join(dir, filePath)); + if (/^name:\s*['"]?aidd[_:]/m.test(content)) matches.push(join(config.signalDir, filePath)); + } + return matches; +} + +/** The tool's native plugin CLI declaration, or undefined when it has none. Read from the + * profile so the set of driven tools is data, not a hand-kept list. */ +export function nativeActivationOf(toolId: ToolId): NativeActivation | undefined { + return resolvePluginsCapability(toolId)?.nativeActivation ?? undefined; +} + +/** Flat when the tool's plugins capability is flat, a marketplace otherwise. Read from the + * profile rather than branched on the tool's name, so a sixth flat tool needs no edit outside + * its own profile. */ +export function frameworkBuildModeFor(toolId: ToolId): FrameworkBuildMode { + return resolvePluginsCapability(toolId)?.mode === "flat" ? "flat" : "marketplace"; +} + +/** Whether this tool enables a plugin for the whole machine rather than for one project: a + * tool declaring no `NativeActivation.scopeArgs` (codex, copilot) has nothing to tell + * `enablePlugin`/`uninstallPlugin` which scope to ask for, so its own CLI always acts + * machine-wide. Also `true` for a tool with no native activation at all. */ +export function pluginEnablementIsMachineGlobal(toolId: ToolId): boolean { + return nativeActivationOf(toolId)?.scopeArgs === undefined; +} + +/** Whether `--scope user` has anywhere to point this tool: either it drives its own CLI + * machine-wide, or it installs a plugin's files into a user-scope directory. `false` for a + * tool declaring neither — opencode today, with no such directory and no CLI to drive. */ +export function supportsUserScopeActivation(toolId: ToolId): boolean { + const capability = resolvePluginsCapability(toolId); + if (capability === null) return false; + return capability.nativeActivation !== null || capability.installScope === "user"; +} + +/** The tool's declared build contract for one framework-build mode, or undefined when the tool + * does not support that mode. Read from the profile, so the build registry is derived by + * iterating the registered tools instead of listing every tool/mode pair by hand. */ +export function buildContractFor( + toolId: ToolId, + mode: FrameworkBuildMode +): (() => ToolBuildContract) | undefined { + const config = getToolConfig(toolId); + if (!isAiTool(config)) return undefined; + return config.buildContracts?.[mode]; +} + +/** A tool's own user-scope settings file, absolute under `homedir` — never written by aidd, + * only named by a diagnostic. Empty for a tool whose profile declares no + * `NativeActivation.userSettingsPath`. */ +export function userMachineLocalFilesOf( + toolId: ToolId, + homedir: string, + env: EnvironmentReader +): readonly string[] { + const path = nativeActivationOf(toolId)?.userSettingsPath?.(homedir, env); + return path === undefined ? [] : [path]; +} + +/** + * Files this CLI writes for a tool and deliberately does not track. + * + * Their content names absolute paths, so they describe one machine: committing them would hand + * a teammate a pointer that cannot resolve, and hashing them would make every other machine + * read as drift. + */ +export function machineLocalFilesOf(toolId: ToolId): readonly string[] { + const path = resolvePluginsCapability(toolId)?.marketplaceSettings?.marketplacesSettingsPath; + // `null` means the tool has nowhere machine-local to write, so there is no such file to keep + // out of `status` or the gitignore either. + return typeof path === "string" ? [path] : []; +} + +/** + * The project's own hooks file a plugin's hooks are merged into, for a tool declaring + * `hooksDestination: "project"`, or `undefined` for a tool with nothing merged there. + * + * Deliberately not folded into `machineLocalFilesOf`: every path this file names is + * project-relative and shareable, and `aiddGitignoreEntries` reads that function — folding + * this in would gitignore a file that belongs in the repo. + */ +export function projectHooksFileOf(toolId: ToolId): string | undefined { + return resolvePluginsCapability(toolId)?.projectHooksRelativePath ?? undefined; +} + +/** A tool's plugin capability, or `null` when it declares none. Here rather than beside one of + * its callers: it reads nothing but this registry, and its callers span three of them. */ +export function resolvePluginsCapability(toolId: ToolId): PluginsCapability | null { + const toolConfig = getToolConfig(toolId); + if (!isAiTool(toolConfig)) return null; + const caps = toolConfig.capabilities as Record; + if (!("plugins" in caps)) return null; + return caps.plugins as PluginsCapability; +} diff --git a/cli/src/contexts/tools/infrastructure/abstract-native-plugin-cli-adapter.ts b/cli/src/contexts/tools/infrastructure/abstract-native-plugin-cli-adapter.ts new file mode 100644 index 000000000..480b1b62e --- /dev/null +++ b/cli/src/contexts/tools/infrastructure/abstract-native-plugin-cli-adapter.ts @@ -0,0 +1,85 @@ +import { type SpawnSyncReturns, spawnSync } from "node:child_process"; +import { NativePluginCliError } from "../../../kernel/errors.js"; +import type { MarketplaceScope } from "../../../kernel/scope.js"; +import type { NativePluginActivator } from "../domain/ports/native-plugin-activator.js"; +import { + hostExecutableLookup, + resolveExecutableOnPath, + runsThroughShell, + windowsCommandLine, +} from "./executable-on-path.js"; + +// `plugin add/install` may fetch and cache a marketplace snapshot from a git remote. +const COMMAND_TIMEOUT_MS = 120000; + +/** Shared shell-out machinery for a tool's plugin CLI. Subclasses declare the binary and the + * tool-specific verbs that differ between CLIs. */ +export abstract class AbstractNativePluginCliAdapter implements NativePluginActivator { + protected abstract readonly binary: string; + + /** Resolves the binary on PATH by filesystem check, with no process spawn: a `--version` + * probe just to test presence is flake-prone under load. */ + isAvailable(): boolean { + return resolveExecutableOnPath(this.binary, hostExecutableLookup()) !== undefined; + } + + /** The binary as the OS will run it. A `.cmd`/`.bat` shim — what npm installs on Windows — + * cannot be spawned directly, so it goes through the command interpreter with its arguments + * quoted; anything else is spawned by its bare name. */ + private spawn( + args: readonly string[], + stdio: ["ignore", "ignore" | "pipe", "ignore" | "pipe"] + ): SpawnSyncReturns { + const options = { timeout: COMMAND_TIMEOUT_MS, stdio, encoding: "utf-8" as const }; + const executable = resolveExecutableOnPath(this.binary, hostExecutableLookup()); + if (executable !== undefined && runsThroughShell(executable)) { + return spawnSync(windowsCommandLine(executable, args), { ...options, shell: true }); + } + return spawnSync(this.binary, [...args], options); + } + + /** Scope arguments the profile declares, empty for a tool whose registry is global. */ + protected abstract scopeArgsFor(scope: MarketplaceScope): readonly string[]; + /** Arguments that force a removal past installed plugins, empty when unsupported. */ + protected abstract forceRemoveArgs(): readonly string[]; + + addMarketplace(source: string, scope: MarketplaceScope): void { + this.run( + ["plugin", "marketplace", "add", source, ...this.scopeArgsFor(scope)], + `marketplace add ${source}` + ); + } + + removeMarketplace(name: string, scope: MarketplaceScope, options?: { force?: boolean }): void { + const force = options?.force === true ? this.forceRemoveArgs() : []; + this.run( + ["plugin", "marketplace", "remove", name, ...this.scopeArgsFor(scope), ...force], + `marketplace remove ${name}` + ); + } + + abstract enablesPlugins(): boolean; + abstract registrationState(name: string): "live" | "dead" | "unknown"; + abstract upgradeMarketplaces(): void; + abstract enablePlugin(pluginRef: string, scope?: MarketplaceScope): void; + abstract uninstallPlugin(pluginRef: string, scope?: MarketplaceScope): void; + + /** Runs a command purely for its exit code; never throws. */ + protected succeeds(args: readonly string[]): boolean { + const result = this.spawn(args, ["ignore", "ignore", "ignore"]); + return result.error === undefined && result.status === 0; + } + + protected run(args: readonly string[], label: string): void { + const result = this.spawn(args, ["ignore", "pipe", "pipe"]); + if (result.error) { + throw new NativePluginCliError(`${this.binary} ${label} failed: ${result.error.message}`); + } + if (result.status !== 0) { + const detail = result.stderr?.trim() ?? ""; + throw new NativePluginCliError( + `${this.binary} ${label} failed: ${detail || `exited with code ${result.status ?? "unknown"}`}` + ); + } + } +} diff --git a/cli/src/contexts/tools/infrastructure/executable-on-path.ts b/cli/src/contexts/tools/infrastructure/executable-on-path.ts new file mode 100644 index 000000000..bfdfefabc --- /dev/null +++ b/cli/src/contexts/tools/infrastructure/executable-on-path.ts @@ -0,0 +1,81 @@ +import { accessSync, constants } from "node:fs"; +import { posix, win32 } from "node:path"; + +/** What resolving a tool's binary on `PATH` needs to know about the machine. Injected so + * a test can describe a Windows machine from anywhere. */ +export interface ExecutableLookup { + readonly pathEnv: string | undefined; + readonly pathExt: string | undefined; + readonly platform: NodeJS.Platform; + readonly isExecutable: (path: string) => boolean; +} + +const DEFAULT_WINDOWS_PATHEXT = ".COM;.EXE;.BAT;.CMD"; + +/** The file names a bare command can stand for. On Windows a `claude` on `PATH` is + * `claude.cmd` (an npm shim) or `claude.exe`, never a file named `claude`; every other + * platform means the bare name. */ +export function candidateExecutableNames( + binary: string, + platform: NodeJS.Platform, + pathExt: string | undefined +): readonly string[] { + if (platform !== "win32") return [binary]; + const exts = (pathExt ?? DEFAULT_WINDOWS_PATHEXT).split(";").filter((ext) => ext !== ""); + // PATHEXT is conventionally upper-case while the shim on disk is `claude.cmd`; the file + // system there does not care, but a lookup handed a case-sensitive answer must not. + const spellings = [...new Set(exts.flatMap((ext) => [ext, ext.toLowerCase()]))]; + return [binary, ...spellings.map((ext) => `${binary}${ext}`)]; +} + +/** The first file on `PATH` the command resolves to, or `undefined` when none does. */ +export function resolveExecutableOnPath( + binary: string, + lookup: ExecutableLookup +): string | undefined { + // The platform described by the lookup decides how PATH splits and paths join, never + // the one this process happens to run on — that is what lets a test describe Windows. + const impl = lookup.platform === "win32" ? win32 : posix; + const dirs = (lookup.pathEnv ?? "").split(impl.delimiter).filter((dir) => dir !== ""); + const names = candidateExecutableNames(binary, lookup.platform, lookup.pathExt); + for (const dir of dirs) { + for (const name of names) { + const candidate = impl.join(dir, name); + if (lookup.isExecutable(candidate)) return candidate; + } + } + return undefined; +} + +/** A batch file cannot be spawned directly; Windows runs it through its command + * interpreter, and node refuses the direct spawn outright. */ +export function runsThroughShell(executable: string): boolean { + return /\.(cmd|bat)$/i.test(executable); +} + +/** One command line for `cmd.exe`, each argument quoted when it holds a character the + * interpreter would otherwise read as its own. */ +export function windowsCommandLine(executable: string, args: readonly string[]): string { + return [executable, ...args].map(quoteForCmd).join(" "); +} + +function quoteForCmd(arg: string): string { + if (arg !== "" && !/[\s"&|<>^()%!]/.test(arg)) return arg; + return `"${arg.replaceAll('"', '""')}"`; +} + +export function hostExecutableLookup(): ExecutableLookup { + return { + pathEnv: process.env.PATH, + pathExt: process.env.PATHEXT, + platform: process.platform, + isExecutable: (path) => { + try { + accessSync(path, constants.X_OK); + return true; + } catch { + return false; + } + }, + }; +} diff --git a/cli/src/contexts/tools/infrastructure/host-marketplace-registry-reader-adapter.ts b/cli/src/contexts/tools/infrastructure/host-marketplace-registry-reader-adapter.ts new file mode 100644 index 000000000..918d7be6e --- /dev/null +++ b/cli/src/contexts/tools/infrastructure/host-marketplace-registry-reader-adapter.ts @@ -0,0 +1,83 @@ +import { readFile, realpath } from "node:fs/promises"; +import { describeError } from "../../../kernel/describe-error.js"; +import { resolveHomeDir } from "../../../kernel/reading/home-dir.js"; +import { AI_TOOL_IDS, type AiToolId } from "../../../kernel/tool.js"; +import type { + HostMarketplaceRegistryReader, + HostMarketplaceRegistryReading, +} from "../domain/ports/host-marketplace-registry-reader.js"; +import { nativeActivationOf } from "../domain/registry.js"; + +/** + * One reader per host whose own profile declares `NativeActivation.marketplaceRegistry` — claude + * only, today: Codex refuses a re-add from a different source itself and Copilot refuses every + * re-add, so neither declares a resolver. The path is never repeated as a literal here; it comes + * from calling that resolver, since a second hard-coded copy is exactly what let it drift from + * the profile unnoticed. + * + * Of the entry a real `claude plugin marketplace add` writes, `installLocation` is the field + * that decides what the name currently resolves to — `source.path` is only what the CLI was + * pointed at. + */ +export function hostMarketplaceRegistryReaders( + home: string = resolveHomeDir() +): ReadonlyMap { + const readers = new Map(); + for (const toolId of AI_TOOL_IDS) { + const registryPath = nativeActivationOf(toolId)?.marketplaceRegistry?.(home); + if (registryPath === undefined) continue; + readers.set(toolId, new ClaudeKnownMarketplacesReader(registryPath)); + } + return readers; +} + +class ClaudeKnownMarketplacesReader implements HostMarketplaceRegistryReader { + constructor(private readonly path: string) {} + + async read(): Promise { + let content: string; + try { + content = await readFile(this.path, "utf8"); + } catch (error) { + const described = describeError(error); + if (described === "ENOENT") return { location: this.path, absent: true }; + return { location: this.path, unreadable: described }; + } + let parsed: unknown; + try { + parsed = JSON.parse(content); + } catch (error) { + return { location: this.path, unreadable: describeError(error) }; + } + if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) { + return { location: this.path, unreadable: "not a JSON object" }; + } + const entries = new Map(); + for (const [name, value] of Object.entries(parsed as Record)) { + const installLocation = installLocationOf(value); + if (installLocation === undefined) continue; + entries.set(name, await resolvedPath(installLocation)); + } + return { location: this.path, entries }; + } +} + +function installLocationOf(value: unknown): string | undefined { + if (value === null || typeof value !== "object") return undefined; + const installLocation = (value as { installLocation?: unknown }).installLocation; + return typeof installLocation === "string" ? installLocation : undefined; +} + +/** + * Both sides of a source comparison go through `realpath` before they are compared, the same + * `/var` → `/private/var` lesson the plugin-registry reader already paid for. A path that + * cannot be resolved falls back to itself rather than throwing: a dead registration should not + * cost every other entry its answer. + */ +async function resolvedPath(path: string): Promise { + try { + return await realpath(path); + } catch { + return path; + } +} diff --git a/cli/src/contexts/tools/infrastructure/host-plugin-registry-reader-adapter.ts b/cli/src/contexts/tools/infrastructure/host-plugin-registry-reader-adapter.ts new file mode 100644 index 000000000..085023c4e --- /dev/null +++ b/cli/src/contexts/tools/infrastructure/host-plugin-registry-reader-adapter.ts @@ -0,0 +1,228 @@ +import { readFile, realpath } from "node:fs/promises"; +import { join } from "node:path"; +import { describeError } from "../../../kernel/describe-error.js"; +import { resolveHomeDir } from "../../../kernel/reading/home-dir.js"; +import type { MarketplaceScope } from "../../../kernel/scope.js"; +import type { AiToolId } from "../../../kernel/tool.js"; +import type { + HostPluginRegistryEntry, + HostPluginRegistryReader, + HostPluginRegistryReading, +} from "../domain/ports/host-plugin-registry-reader.js"; + +/** + * One reader per host whose own plugin registry was measured; a tool absent from the map is + * one nothing here claims to know, and the diagnostic reports it unanswerable rather than + * assuming it agrees. The shapes measured: + * + * Claude Code ~/.claude/plugins/installed_plugins.json { version, plugins: { ref: [ … ] } } + * Codex ~/.codex/config.toml [plugins."ref"] enabled = true + * Copilot ~/.copilot/settings.json { enabledPlugins: { ref: true } } + * + * Copilot's registry is `settings.json`, never `config.json`, whose `installedPlugins` reads + * empty on a machine that has run installs. `copilot plugin uninstall` sets a key to `false` + * rather than deleting it, so registered-but-off is an ordinary state, not a Codex-only one. + */ +export function hostPluginRegistryReaders( + home: string = resolveHomeDir() +): ReadonlyMap { + return new Map([ + [ + "claude", + new ClaudeInstalledPluginsReader(join(home, ".claude", "plugins", "installed_plugins.json")), + ], + ["codex", new CodexConfigPluginsReader(join(home, ".codex", "config.toml"))], + ["copilot", new CopilotSettingsPluginsReader(join(home, ".copilot", "settings.json"))], + ]); +} + +/** + * Claude Code's own registry: a JSON document whose `plugins` object is keyed by the same + * `@` ref `enabledPlugins` uses, each key holding one entry per scope the + * ref was installed at. Presence is not the whole answer — an entry installed at a project + * scope also carries `projectPath`, which is what tells one project's ref from a machine-wide + * one. + */ +class ClaudeInstalledPluginsReader implements HostPluginRegistryReader { + constructor(private readonly path: string) {} + + async read(projectRoot: string): Promise { + let content: string; + try { + content = await readFile(this.path, "utf8"); + } catch (error) { + return { location: this.path, unreadable: describeError(error) }; + } + try { + const parsed = JSON.parse(content) as { plugins?: Record }; + const plugins = parsed.plugins; + if (plugins === undefined || typeof plugins !== "object") { + return { location: this.path, unreadable: "no `plugins` object" }; + } + const refs = new Map(); + const here = await resolvedPath(projectRoot); + for (const [ref, entries] of Object.entries(plugins)) { + const scope = await scopeForProject(entries, here); + if (scope !== null) refs.set(ref, { enabled: true, scope }); + } + return { location: this.path, refs }; + } catch (error) { + return { location: this.path, unreadable: describeError(error) }; + } + } +} + +/** + * Codex's own registry, line-scanned rather than parsed: the file carries arbitrary nested + * tables and multi-line values this adapter has no business understanding, and most of it is + * `[projects.""]` tables a diagnostic must not pull into a terminal. Only + * `[plugins.""]` and the `enabled` key under it are read, and a `false` there is carried + * through: a host that knows a plugin and declines it must not print like one that never + * heard of it. + */ +class CodexConfigPluginsReader implements HostPluginRegistryReader { + constructor(private readonly path: string) {} + + // `projectRoot` is unused: Codex's plugin tables carry `enabled` and nothing else — no path, + // no scope — so its registry answers for the machine and cannot answer for one project. + async read(_projectRoot: string): Promise { + let content: string; + try { + content = await readFile(this.path, "utf8"); + } catch (error) { + return { location: this.path, unreadable: describeError(error) }; + } + return { location: this.path, refs: scanCodexPluginTables(content) }; + } +} + +/** + * Copilot's own registry: `enabledPlugins` in `~/.copilot/settings.json`, keyed on the same + * `@` ref as every other host and carrying a boolean. No project binding, + * so like Codex it answers for the machine and cannot answer for one project. + */ +class CopilotSettingsPluginsReader implements HostPluginRegistryReader { + constructor(private readonly path: string) {} + + async read(_projectRoot: string): Promise { + let content: string; + try { + content = await readFile(this.path, "utf8"); + } catch (error) { + return { location: this.path, unreadable: describeError(error) }; + } + try { + const parsed = JSON.parse(content) as { enabledPlugins?: Record }; + const enabled = parsed.enabledPlugins; + // Absent is a real answer here, unlike a file that would not open: Copilot writes the + // key on its first install, so a settings file without one carries no plugin. + if (enabled === undefined) return { location: this.path, refs: new Map() }; + return { + location: this.path, + refs: new Map(Object.entries(enabled).map(([ref, on]) => [ref, { enabled: on !== false }])), + }; + } catch (error) { + return { location: this.path, unreadable: describeError(error) }; + } + } +} + +const CODEX_PLUGIN_HEADER = /^\[plugins\."(.+?)"\]\s*(?:#.*)?$/u; +const CODEX_TABLE_HEADER = /^\[/u; +const CODEX_ENABLED_LINE = /^enabled\s*=\s*(true|false)\s*(?:#.*)?$/u; +const CODEX_MULTILINE_DELIMITER = /"""|'''/gu; + +/** + * Reads each plugin table's body, and only outside multi-line strings: a header spelled inside + * one would otherwise be taken for the real table, and keeping the first occurrence of a ref + * cannot tell a fake header from a real one. Skipping them leaves exactly one real header, + * which TOML's own prohibition on defining a table twice then guarantees. + * + * Absent `enabled` reads as enabled: Codex writes the key on every table it creates, so + * between "the host listed this plugin" and "listed it and said nothing", the listing is the + * fact. + */ +function scanCodexPluginTables(content: string): ReadonlyMap { + const refs = new Map(); + const lines = outsideMultilineStrings(content.split("\n")); + for (const [index, line] of lines.entries()) { + if (line === null) continue; + const ref = CODEX_PLUGIN_HEADER.exec(line.trim())?.[1]; + if (ref === undefined || refs.has(ref)) continue; + refs.set(ref, { enabled: enabledInTableBody(lines, index + 1) }); + } + return refs; +} + +/** + * The same lines, with every one inside a multi-line string replaced by `null`. Positions are + * preserved rather than filtered out, so a table's body still begins at the line after its + * header; a delimiter can open and close on one line, so an odd count on a line is what flips + * the state, and the line carrying the opening delimiter is itself outside — it is the + * assignment, not the content. + */ +function outsideMultilineStrings(lines: readonly string[]): readonly (string | null)[] { + let inside = false; + return lines.map((line) => { + const wasInside = inside; + const delimiters = line.match(CODEX_MULTILINE_DELIMITER)?.length ?? 0; + if (delimiters % 2 === 1) inside = !inside; + return wasInside ? null : line; + }); +} + +/** The first `enabled` assignment between a table header and the next table, defaulting to + * enabled when the table declares none. A line inside a multi-line string is `null` here and + * neither ends the table nor answers for it. */ +function enabledInTableBody(lines: readonly (string | null)[], from: number): boolean { + for (let at = from; at < lines.length; at += 1) { + const line = lines[at]; + if (line === null || line === undefined) continue; + const trimmed = line.trim(); + if (CODEX_TABLE_HEADER.test(trimmed)) return true; + if (trimmed === "" || trimmed.startsWith("#")) continue; + const enabled = CODEX_ENABLED_LINE.exec(trimmed); + if (enabled !== null) return enabled[1] === "true"; + } + return true; +} + +/** One installed-plugin entry, narrowed to the two fields that decide whether a ref counts for + * the project being diagnosed. Everything else Claude records there describes what was + * installed, never where it applies. */ +interface ClaudeEntry { + readonly scope?: string; + readonly projectPath?: string; +} + +/** A user-scoped entry applies everywhere and carries no `projectPath`; any other scope applies + * to the project it names, and an entry with neither is ignored rather than guessed. `null` + * when nothing here answers for `projectRoot`. A user-scope entry wins over a project-path + * match whatever the array's order, so a caller choosing an uninstall's own scope reads the + * answer that is true rather than the one that sorted first. */ +async function scopeForProject( + entries: readonly ClaudeEntry[], + projectRoot: string +): Promise { + if (!Array.isArray(entries)) return null; + if (entries.some((entry) => entry.scope === "user")) return "user"; + for (const entry of entries) { + if (entry.projectPath === undefined) continue; + if ((await resolvedPath(entry.projectPath)) === projectRoot) return "project"; + } + return null; +} + +/** + * Both sides of the project comparison resolve through `realpath`: on macOS `/var` is a symlink + * to `/private/var`, and comparing the raw strings there reports a registered plugin as + * missing. A path that cannot be resolved falls back to itself rather than throwing, so a stale + * entry for a deleted project fails to match instead of costing every other entry its answer. + */ +async function resolvedPath(path: string): Promise { + try { + return await realpath(path); + } catch { + return path; + } +} diff --git a/cli/src/contexts/tools/infrastructure/native-plugin-cli-adapter.ts b/cli/src/contexts/tools/infrastructure/native-plugin-cli-adapter.ts new file mode 100644 index 000000000..98cce54f3 --- /dev/null +++ b/cli/src/contexts/tools/infrastructure/native-plugin-cli-adapter.ts @@ -0,0 +1,80 @@ +import type { MarketplaceScope } from "../../../kernel/scope.js"; +import { AbstractNativePluginCliAdapter } from "./abstract-native-plugin-cli-adapter.js"; + +/** Everything about a tool's plugin CLI that differs between tools, read off its profile. */ +export interface NativePluginCliShape { + readonly scopeArgs?: Readonly>; + readonly forceRemoveArgs?: readonly string[]; + readonly sourceCheckVerb?: string; + readonly upgradeVerb?: string; + readonly enableVerb?: string; + /** How the tool spells removing a plugin it installed: `remove` for codex, `uninstall` for + * claude and copilot. Absent where this CLI enables plugins through a file it writes. */ + readonly disableVerb?: string; + /** Arguments every `plugin ` call carries, after the reference. Claude needs + * `--yes`: a headless stdin cannot answer its prune confirmation. */ + readonly pluginArgs?: readonly string[]; +} + +/** + * Drives a tool's own plugin CLI. Everything that differs between tools comes from the tool's + * profile, so supporting one more is a profile entry rather than another subclass, and no tool + * name is written outside its profile. A tool that enables plugins through a file this CLI + * writes declares no verbs; it still registers its marketplaces here, because that it does + * better. + */ +export class NativePluginCliAdapter extends AbstractNativePluginCliAdapter { + constructor( + protected readonly binary: string, + private readonly shape: NativePluginCliShape + ) { + super(); + } + + enablesPlugins(): boolean { + return this.shape.enableVerb !== undefined; + } + + /** A tool that cannot tell a dead registration from a live one answers `"unknown"`, which + * keeps callers from taking over a name that may belong to a live project. */ + registrationState(name: string): "live" | "dead" | "unknown" { + const verb = this.shape.sourceCheckVerb; + if (verb === undefined) return "unknown"; + return this.succeeds(["plugin", "marketplace", verb, name]) ? "live" : "dead"; + } + + upgradeMarketplaces(): void { + const verb = this.shape.upgradeVerb; + if (verb === undefined) return; + this.run(["plugin", "marketplace", verb], `marketplace ${verb}`); + } + + enablePlugin(pluginRef: string, scope: MarketplaceScope = "project"): void { + const verb = this.shape.enableVerb; + if (verb === undefined) return; + this.run( + ["plugin", verb, pluginRef, ...(this.shape.pluginArgs ?? []), ...this.scopeArgsFor(scope)], + `plugin ${verb} ${pluginRef}` + ); + } + + /** Undoes what `enablePlugin` did. `scope` must match what `enablePlugin` was called with: a + * tool that installed at one scope and uninstalls at its default would silently miss the + * entry it wrote. */ + uninstallPlugin(pluginRef: string, scope: MarketplaceScope = "project"): void { + const verb = this.shape.disableVerb; + if (verb === undefined) return; + this.run( + ["plugin", verb, pluginRef, ...(this.shape.pluginArgs ?? []), ...this.scopeArgsFor(scope)], + `plugin ${verb} ${pluginRef}` + ); + } + + protected scopeArgsFor(scope: MarketplaceScope): readonly string[] { + return this.shape.scopeArgs?.[scope] ?? []; + } + + protected forceRemoveArgs(): readonly string[] { + return this.shape.forceRemoveArgs ?? []; + } +} diff --git a/cli/src/contexts/translate/application/shared-plugin-helpers.ts b/cli/src/contexts/translate/application/shared-plugin-helpers.ts new file mode 100644 index 000000000..ea15b9f5a --- /dev/null +++ b/cli/src/contexts/translate/application/shared-plugin-helpers.ts @@ -0,0 +1,14 @@ +import { FrameworkPlaceholderInPluginError } from "../../../kernel/errors.js"; + +const TOOLS_PLACEHOLDER = "@{{TOOLS}}/"; + +/** Guards against `@{{TOOLS}}/` references inside plugin content. */ +export function assertNoToolsPlaceholder( + content: string, + pluginName: string, + relPath: string +): void { + if (content.includes(TOOLS_PLACEHOLDER)) { + throw new FrameworkPlaceholderInPluginError(pluginName, relPath); + } +} diff --git a/cli/src/contexts/translate/application/strategies/build-output-strategy.ts b/cli/src/contexts/translate/application/strategies/build-output-strategy.ts new file mode 100644 index 000000000..a81e3ce35 --- /dev/null +++ b/cli/src/contexts/translate/application/strategies/build-output-strategy.ts @@ -0,0 +1,40 @@ +import type { + SourceMarketplaceRef, + SourcePluginEntryRef, +} from "../../../tools/domain/build-contract.js"; +import type { BuildPluginResult } from "../../domain/build-target.js"; + +/** Source marketplace catalog entry from the framework's `.claude-plugin/marketplace.json`. + * The build contract already describes this shape for tool authors, so the orchestrator speaks + * the same type rather than a near-identical twin only a cast could bridge. */ +export type SourcePluginEntry = SourcePluginEntryRef; + +export type SourceMarketplace = SourceMarketplaceRef; + +/** The output layout strategy the framework build calls: a marketplace layout and a flat one + * each implement it, and the strategy owns all path computation and file I/O for its own + * layout. */ +export interface BuildOutputStrategy { + /** Called once before iterating plugins: the marketplace layout wipes and recreates outDir, + * flat mode only validates that it exists. */ + preBuild(outDir: string, sourceDir: string): Promise; + + /** Returns the number of files written, 0 or 1. */ + writePluginManifest(pluginName: string, pluginSrc: string, outDir: string): Promise; + + writeAgents(pluginName: string, pluginSrc: string, outDir: string): Promise; + + writeSkills(pluginName: string, pluginSrc: string, outDir: string): Promise; + + writeHooks(pluginName: string, pluginSrc: string, outDir: string): Promise; + + writeMcp(pluginName: string, pluginSrc: string, outDir: string): Promise; + + /** Called once after every plugin is built. Returns extra files written — a marketplace + * catalog counts 1, flat mode 0. */ + postBuild( + sourceMarketplace: SourceMarketplace, + builtPlugins: readonly BuildPluginResult[], + outDir: string + ): Promise; +} diff --git a/cli/src/application/use-cases/framework/strategies/flat-build-strategy.ts b/cli/src/contexts/translate/application/strategies/flat-build-strategy.ts similarity index 84% rename from cli/src/application/use-cases/framework/strategies/flat-build-strategy.ts rename to cli/src/contexts/translate/application/strategies/flat-build-strategy.ts index 8cf66732f..93c9acc0d 100644 --- a/cli/src/application/use-cases/framework/strategies/flat-build-strategy.ts +++ b/cli/src/contexts/translate/application/strategies/flat-build-strategy.ts @@ -1,24 +1,21 @@ -import { basename, join, relative } from "node:path"; -import { FlatTargetExistsError, OutDirNotDirectoryError } from "../../../../domain/errors.js"; -import { rewriteClaudeRootInJson } from "../../../../domain/formats/claude-root-path-rewrite.js"; -import { flatMcpKeyPrefix } from "../../../../domain/formats/flat-paths.js"; -import { parseFrontmatter, serializeFrontmatter } from "../../../../domain/formats/markdown.js"; -import { rewriteRelativeLinks } from "../../../../domain/formats/relative-link-rewrite.js"; +import { basename, dirname, join, relative } from "node:path"; +import { FlatTargetExistsError, OutDirNotDirectoryError } from "../../../../kernel/errors.js"; +import { parseFrontmatter, serializeFrontmatter } from "../../../../kernel/markdown.js"; +import { rewriteClaudeRootInJson } from "../../../../kernel/materialization/claude-root-path-rewrite.js"; +import { flatMcpKeyPrefix } from "../../../../kernel/materialization/flat-paths.js"; +import { rewriteRelativeLinks } from "../../../../kernel/materialization/relative-link-rewrite.js"; +import type { AssetProvider } from "../../../../kernel/ports/asset-provider.js"; +import type { FileReader } from "../../../../kernel/ports/file-reader.js"; +import type { FileWriter } from "../../../../kernel/ports/file-writer.js"; +import type { Logger } from "../../../../kernel/ports/logger.js"; +import type { ArtifactContract, ToolBuildContract } from "../../../tools/domain/build-contract.js"; +import type { JsonSchemaValidator } from "../../../tools/domain/ports/schema-validator.js"; import { PLUGIN_AGENT_INPUT_EXT, PLUGIN_HOOKS_RELATIVE, PLUGIN_MCP_RELATIVE, -} from "../../../../domain/models/framework-build.js"; -import type { AssetProvider } from "../../../../domain/ports/asset-provider.js"; -import type { FileReader } from "../../../../domain/ports/file-reader.js"; -import type { FileWriter } from "../../../../domain/ports/file-writer.js"; -import type { JsonSchemaValidator } from "../../../../domain/ports/json-schema-validator.js"; -import type { Logger } from "../../../../domain/ports/logger.js"; -import type { - ArtifactContract, - ToolBuildContract, -} from "../../../../domain/tools/build-contract.js"; -import { assertNoToolsPlaceholder } from "../assert-no-tools-placeholder.js"; +} from "../../domain/build-target.js"; +import { assertNoToolsPlaceholder } from "../shared-plugin-helpers.js"; import type { BuildOutputStrategy, SourceMarketplace } from "./build-output-strategy.js"; export class FlatBuildStrategy implements BuildOutputStrategy { @@ -86,7 +83,7 @@ export class FlatBuildStrategy implements BuildOutputStrategy { const hooksSrc = join(pluginSrc, PLUGIN_HOOKS_RELATIVE); if (!(await this.fs.fileExists(hooksSrc))) return 0; const jsonCount = artifact.skipHooksJson - ? 0 + ? await this.writeHooksBridgeIfDeclared(artifact, pluginName, hooksSrc) : await this.writeFlatHooksJson(artifact, pluginName, hooksSrc); const scriptCount = await this.writeFlatHooksScripts(artifact, pluginName, pluginSrc); return jsonCount + scriptCount; @@ -117,8 +114,6 @@ export class FlatBuildStrategy implements BuildOutputStrategy { ); } - // ── Private helpers ────────────────────────────────────────────────────────── - private async writeFlatAgent( artifact: Extract, pluginName: string, @@ -196,6 +191,28 @@ export class FlatBuildStrategy implements BuildOutputStrategy { return this.writeRewrittenHooksJson(artifact, pluginName, raw); } + // OpenCode reads no hooks.json, so a plugin's declared hooks have no trigger there unless a + // generated bridge replaces the manifest a native host would read. Raw content only: the + // bridge resolves its own scripts from its own `import.meta.url`, never from an outDir-relative + // path this strategy would otherwise rewrite for it. + private async writeHooksBridgeIfDeclared( + artifact: Extract, + pluginName: string, + hooksSrc: string + ): Promise { + const bridge = artifact.hooksBridge; + if (!bridge) return 0; + const skipEntry = join(dirname(hooksSrc), bridge.skipIfSourceHas); + if (await this.fs.fileExists(skipEntry)) return 0; + const raw = await this.fs.readFile(hooksSrc); + const generated = bridge.generate(raw, pluginName); + if (generated === null) return 0; + const destPath = join(this.absOut, bridge.path(pluginName)); + await this.checkCollision(destPath, pluginName); + await this.fs.writeFile(destPath, generated); + return 1; + } + private async writeMergedHooksJson( artifact: Extract, pluginName: string, @@ -297,10 +314,9 @@ export class FlatBuildStrategy implements BuildOutputStrategy { return `./${this.resolveSuffixToFlatPath(suffix, pluginName)}`; } - // "/"-joined and separator-normalized: this value is embedded into written JSON content - // (an MCP server command, a rewritten CLAUDE_PLUGIN_ROOT), and on Windows this.absOut is - // backslash-native - JSON.stringify then escapes each backslash, so a reader comparing - // against the plain absOut string never finds it as a substring. + // "/"-joined and separator-normalized: this value is embedded into written JSON content, and + // on Windows `this.absOut` is backslash-native — JSON.stringify then escapes each backslash, + // so a reader comparing against the plain absOut string never finds it as a substring. private resolveClaudeRootAbsolute(suffix: string, pluginName: string): string { const normalizedOut = this.absOut.replace(/\\/g, "/"); return `${normalizedOut}/${this.resolveSuffixToFlatPath(suffix, pluginName)}`; diff --git a/cli/src/application/use-cases/framework/strategies/marketplace-build-strategy.ts b/cli/src/contexts/translate/application/strategies/marketplace-build-strategy.ts similarity index 83% rename from cli/src/application/use-cases/framework/strategies/marketplace-build-strategy.ts rename to cli/src/contexts/translate/application/strategies/marketplace-build-strategy.ts index c93515929..1aac8e977 100644 --- a/cli/src/application/use-cases/framework/strategies/marketplace-build-strategy.ts +++ b/cli/src/contexts/translate/application/strategies/marketplace-build-strategy.ts @@ -1,15 +1,16 @@ import { basename, join, relative } from "node:path"; -import { rewritePluginRootToken } from "../../../../domain/formats/plugin-root-token-rewrite.js"; +import { MarketplaceOutDirNotEmptyError } from "../../../../kernel/errors.js"; +import type { AssetProvider, SchemaName } from "../../../../kernel/ports/asset-provider.js"; +import type { FileReader } from "../../../../kernel/ports/file-reader.js"; +import type { FileWriter } from "../../../../kernel/ports/file-writer.js"; +import type { PluginPresence, ToolBuildContract } from "../../../tools/domain/build-contract.js"; +import type { JsonSchemaValidator } from "../../../tools/domain/ports/schema-validator.js"; import { PLUGIN_AGENT_INPUT_EXT, SOURCE_PLUGIN_MANIFEST_RELATIVE, -} from "../../../../domain/models/framework-build.js"; -import type { AssetProvider, SchemaName } from "../../../../domain/ports/asset-provider.js"; -import type { FileReader } from "../../../../domain/ports/file-reader.js"; -import type { FileWriter } from "../../../../domain/ports/file-writer.js"; -import type { JsonSchemaValidator } from "../../../../domain/ports/json-schema-validator.js"; -import type { PluginPresence, ToolBuildContract } from "../../../../domain/tools/build-contract.js"; -import { assertNoToolsPlaceholder } from "../assert-no-tools-placeholder.js"; +} from "../../domain/build-target.js"; +import { rewritePluginRootToken } from "../../domain/formats/plugin-root-token-rewrite.js"; +import { assertNoToolsPlaceholder } from "../shared-plugin-helpers.js"; import type { BuildOutputStrategy, SourceMarketplace } from "./build-output-strategy.js"; import { detectPluginPresenceFlags } from "./plugin-source-tree-reader.js"; import { writeSkillTree } from "./write-skill-tree.js"; @@ -19,11 +20,22 @@ export class MarketplaceBuildStrategy implements BuildOutputStrategy { private readonly fs: FileReader & FileWriter, private readonly jsonSchemaValidator: JsonSchemaValidator, private readonly assetProvider: AssetProvider, - private readonly contract: ToolBuildContract + private readonly contract: ToolBuildContract, + private readonly force: boolean = false ) {} + /** + * Never wipes outDir: a build only ever writes the canonical paths it produces, so anything + * else already there survives. A non-empty outDir is refused unless --force, naming the + * directory so the message says exactly what to pass --force at. + */ async preBuild(outDir: string): Promise { - await this.fs.deleteDirectory(outDir); + if (await this.fs.fileExists(outDir)) { + const entries = await this.fs.listDirectory(outDir); + if (entries.length > 0 && !this.force) { + throw new MarketplaceOutDirNotEmptyError(outDir); + } + } await this.fs.createDirectory(outDir); } @@ -60,10 +72,8 @@ export class MarketplaceBuildStrategy implements BuildOutputStrategy { const content = await this.fs.readFile(absPath); const agentBaseName = basename(absPath); assertNoToolsPlaceholder(content, pluginName, relative(agentsSrc, absPath)); - // path() returns the destination path relative to pluginOut (e.g. "agents/foo.md") const destRelPath = artifact.path(pluginName, `agents/${agentBaseName}`); const destPath = join(outDir, "plugins", pluginName, destRelPath); - // transform() receives raw content and returns the final file content const outContent = artifact.transform ? artifact.transform(content, pluginName, agentBaseName) : content; @@ -139,8 +149,6 @@ export class MarketplaceBuildStrategy implements BuildOutputStrategy { return 1; } - // ── Private helpers ────────────────────────────────────────────────────────── - private applyPluginRootToken(content: string): string { if (!this.contract.pluginRootToken) return content; return rewritePluginRootToken(content, this.contract.pluginRootToken); diff --git a/cli/src/contexts/translate/application/strategies/plugin-source-tree-reader.ts b/cli/src/contexts/translate/application/strategies/plugin-source-tree-reader.ts new file mode 100644 index 000000000..480661a1d --- /dev/null +++ b/cli/src/contexts/translate/application/strategies/plugin-source-tree-reader.ts @@ -0,0 +1,59 @@ +import { join, relative } from "node:path"; +import type { FileReader } from "../../../../kernel/ports/file-reader.js"; +import { + PLUGIN_AGENT_INPUT_EXT, + PLUGIN_HOOKS_RELATIVE, + PLUGIN_MCP_RELATIVE, +} from "../../domain/build-target.js"; + +export interface PluginPresenceFlags { + readonly hasAgents: boolean; + /** Agent markdown files relative to the plugin's `agents/` dir (e.g. "planner.md"), sorted. */ + readonly agentsList: readonly string[]; + readonly skillsList: readonly string[]; + readonly hasHooksJson: boolean; + readonly hasMcpJson: boolean; +} + +export async function listAgentFiles( + fs: FileReader, + agentsDir: string +): Promise { + if (!(await fs.fileExists(agentsDir))) return []; + const files = await fs.listFilesRecursive(agentsDir); + return files + .filter((f) => f.endsWith(PLUGIN_AGENT_INPUT_EXT)) + .map((f) => relative(agentsDir, f).replace(/\\/g, "/")) + .sort(); +} + +export async function listSkillNames( + fs: FileReader, + pluginSrc: string +): Promise { + const skillsDir = join(pluginSrc, "skills"); + if (!(await fs.fileExists(skillsDir))) return []; + const files = await fs.listFilesRecursive(skillsDir); + const names = new Set(); + for (const f of files) { + if (!f.endsWith("/SKILL.md") && !f.endsWith("\\SKILL.md") && !f.endsWith("SKILL.md")) { + continue; + } + const rel = relative(skillsDir, f); + const parts = rel.replace(/\\/g, "/").split("/"); + if (parts.length >= 2) names.add(parts[0]); + } + return [...names].sort(); +} + +export async function detectPluginPresenceFlags( + fs: FileReader, + pluginSrc: string +): Promise { + const agentsDir = join(pluginSrc, "agents"); + const agentsList = await listAgentFiles(fs, agentsDir); + const skillsList = await listSkillNames(fs, pluginSrc); + const hasHooksJson = await fs.fileExists(join(pluginSrc, PLUGIN_HOOKS_RELATIVE)); + const hasMcpJson = await fs.fileExists(join(pluginSrc, PLUGIN_MCP_RELATIVE)); + return { hasAgents: agentsList.length > 0, agentsList, skillsList, hasHooksJson, hasMcpJson }; +} diff --git a/cli/src/application/use-cases/framework/strategies/write-skill-tree.ts b/cli/src/contexts/translate/application/strategies/write-skill-tree.ts similarity index 80% rename from cli/src/application/use-cases/framework/strategies/write-skill-tree.ts rename to cli/src/contexts/translate/application/strategies/write-skill-tree.ts index 92ceaf9df..94def7be5 100644 --- a/cli/src/application/use-cases/framework/strategies/write-skill-tree.ts +++ b/cli/src/contexts/translate/application/strategies/write-skill-tree.ts @@ -1,9 +1,9 @@ import { basename, join, relative } from "node:path"; -import { rewriteRelativeLinks } from "../../../../domain/formats/relative-link-rewrite.js"; -import { PLUGIN_SKILL_ENTRY_FILE } from "../../../../domain/models/framework-build.js"; -import type { FileReader } from "../../../../domain/ports/file-reader.js"; -import type { FileWriter } from "../../../../domain/ports/file-writer.js"; -import { assertNoToolsPlaceholder } from "../assert-no-tools-placeholder.js"; +import { rewriteRelativeLinks } from "../../../../kernel/materialization/relative-link-rewrite.js"; +import type { FileReader } from "../../../../kernel/ports/file-reader.js"; +import type { FileWriter } from "../../../../kernel/ports/file-writer.js"; +import { PLUGIN_SKILL_ENTRY_FILE } from "../../domain/build-target.js"; +import { assertNoToolsPlaceholder } from "../shared-plugin-helpers.js"; type SkillContentTransform = (content: string, plugin: string, basename: string) => string; diff --git a/cli/src/contexts/translate/application/translate-source.ts b/cli/src/contexts/translate/application/translate-source.ts new file mode 100644 index 000000000..ef29f4921 --- /dev/null +++ b/cli/src/contexts/translate/application/translate-source.ts @@ -0,0 +1,134 @@ +import { join, resolve } from "node:path"; +import { InvalidBuildPathsError, InvalidSourceMarketplaceError } from "../../../kernel/errors.js"; +import { pathsOverlap } from "../../../kernel/paths.js"; +import type { AssetProvider } from "../../../kernel/ports/asset-provider.js"; +import type { FileReader } from "../../../kernel/ports/file-reader.js"; +import type { FileWriter } from "../../../kernel/ports/file-writer.js"; +import type { Logger } from "../../../kernel/ports/logger.js"; +import type { JsonSchemaValidator } from "../../tools/domain/ports/schema-validator.js"; +import { + type BuildPluginResult, + type FrameworkBuildOptions, + type FrameworkBuildResult, + OUT_OF_SCOPE_PLUGIN_SECTIONS, + SOURCE_MARKETPLACE_RELATIVE, + SOURCE_PLUGIN_MANIFEST_RELATIVE, +} from "../domain/build-target.js"; +import type { + BuildOutputStrategy, + SourceMarketplace, + SourcePluginEntry, +} from "./strategies/build-output-strategy.js"; + +/** Running one framework build, as its callers need it. */ +export interface FrameworkBuild { + execute(options: FrameworkBuildOptions): Promise; +} + +export class FrameworkBuildUseCase implements FrameworkBuild { + constructor( + private readonly fs: FileReader & FileWriter, + private readonly jsonSchemaValidator: JsonSchemaValidator, + private readonly assetProvider: AssetProvider, + private readonly logger: Logger, + private readonly strategy: BuildOutputStrategy + ) {} + + async execute(options: FrameworkBuildOptions): Promise { + const sourceDir = resolve(options.sourceDir); + const outDir = resolve(options.outDir); + this.guardPaths(sourceDir, outDir); + const sourceMarketplace = await this.readSourceMarketplace(sourceDir); + await this.strategy.preBuild(outDir, sourceDir); + const builtPlugins: BuildPluginResult[] = []; + for (const entry of sourceMarketplace.plugins) { + const plugin = await this.buildPlugin(entry, sourceDir, outDir); + builtPlugins.push(plugin); + } + const extraFiles = await this.strategy.postBuild(sourceMarketplace, builtPlugins, outDir); + const totalFiles = builtPlugins.reduce((sum, p) => sum + p.filesWritten, 0) + extraFiles; + return { outDir, plugins: builtPlugins, totalFiles }; + } + + private guardPaths(sourceDir: string, outDir: string): void { + if (pathsOverlap(sourceDir, outDir)) throw new InvalidBuildPathsError(sourceDir, outDir); + } + + private async readSourceMarketplace(sourceDir: string): Promise { + const marketplacePath = join(sourceDir, SOURCE_MARKETPLACE_RELATIVE); + let raw: string; + try { + raw = await this.fs.readFile(marketplacePath); + } catch { + throw new InvalidSourceMarketplaceError(`cannot read ${marketplacePath}`); + } + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch (err) { + throw new InvalidSourceMarketplaceError(`malformed JSON: ${(err as Error).message}`); + } + return this.validateSourceMarketplace(parsed); + } + + private validateSourceMarketplace(parsed: unknown): SourceMarketplace { + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { + throw new InvalidSourceMarketplaceError("root must be an object"); + } + const obj = parsed as Record; + if (!Array.isArray(obj.plugins)) { + throw new InvalidSourceMarketplaceError("missing 'plugins' array"); + } + for (const entry of obj.plugins as unknown[]) { + if ( + !entry || + typeof entry !== "object" || + typeof (entry as Record).name !== "string" + ) { + throw new InvalidSourceMarketplaceError("each plugin entry must have a 'name' string"); + } + } + return obj as unknown as SourceMarketplace; + } + + private async buildPlugin( + entry: SourcePluginEntry, + sourceDir: string, + outDir: string + ): Promise { + const pluginSrc = join(sourceDir, "plugins", entry.name); + if (!(await this.fs.fileExists(pluginSrc))) { + throw new InvalidSourceMarketplaceError(`plugin '${entry.name}' not found at ${pluginSrc}`); + } + await this.validateManifest(pluginSrc); + let filesWritten = 0; + filesWritten += await this.strategy.writePluginManifest(entry.name, pluginSrc, outDir); + filesWritten += await this.strategy.writeAgents(entry.name, pluginSrc, outDir); + filesWritten += await this.strategy.writeSkills(entry.name, pluginSrc, outDir); + filesWritten += await this.strategy.writeHooks(entry.name, pluginSrc, outDir); + filesWritten += await this.strategy.writeMcp(entry.name, pluginSrc, outDir); + const skippedSections = await this.warnOutOfScopeSections(entry.name, pluginSrc); + return { name: entry.name, filesWritten, skippedSections }; + } + + private async validateManifest(pluginSrc: string): Promise { + const manifestPath = join(pluginSrc, SOURCE_PLUGIN_MANIFEST_RELATIVE); + const raw = await this.fs.readFile(manifestPath); + const data = JSON.parse(raw) as unknown; + this.jsonSchemaValidator.validate(this.assetProvider.loadSchema("plugin-manifest"), data); + } + + private async warnOutOfScopeSections( + pluginName: string, + pluginSrc: string + ): Promise { + const skipped: string[] = []; + for (const section of OUT_OF_SCOPE_PLUGIN_SECTIONS) { + if (await this.fs.fileExists(join(pluginSrc, section))) { + this.logger.warn(`Skipping ${section}/ in plugin '${pluginName}' (out of scope for MVP1).`); + skipped.push(section); + } + } + return skipped; + } +} diff --git a/cli/src/contexts/translate/domain/build-target.ts b/cli/src/contexts/translate/domain/build-target.ts new file mode 100644 index 000000000..465407afd --- /dev/null +++ b/cli/src/contexts/translate/domain/build-target.ts @@ -0,0 +1,78 @@ +import { AI_TOOL_IDS, type AiToolId, type ToolId } from "../../../kernel/tool.js"; +import type { FrameworkBuildMode, ToolConfig } from "../../tools/domain/registry.js"; +import { getAllRegisteredTools, isAiTool } from "../../tools/domain/registry.js"; + +/** The tool a framework build produces for. An alias rather than its own union: every AI tool + * is buildable, so a sixth tool is a sixth target by construction, and writing the members + * again would only create a second list to keep in step. */ +export type FrameworkBuildTarget = AiToolId; + +export interface FrameworkBuildTargetMode { + readonly target: FrameworkBuildTarget; + readonly mode: FrameworkBuildMode; +} + +const BUILD_MODES: readonly FrameworkBuildMode[] = ["marketplace", "flat"]; + +/** A tool supports a mode when its profile declares a build contract for it. Exported so the + * rule can be probed with synthetic tools — the version reading the live registry cannot say + * what it would do with a tool that declares nothing. */ +export function buildTargetModesOf( + tools: ReadonlyMap +): readonly FrameworkBuildTargetMode[] { + const pairs: FrameworkBuildTargetMode[] = []; + for (const target of AI_TOOL_IDS) { + const config = tools.get(target); + if (config === undefined || !isAiTool(config)) continue; + for (const mode of BUILD_MODES) { + if (config.buildContracts?.[mode] !== undefined) pairs.push({ target, mode }); + } + } + return pairs; +} + +/** Every target/mode pair the build pipeline supports, read off the registered profiles. A + * function and not a constant: the registry fills at wiring time, so a constant evaluated at + * import would capture an empty one. */ +export function frameworkBuildTargetModes(): readonly FrameworkBuildTargetMode[] { + return buildTargetModesOf(getAllRegisteredTools()); +} + +/** Every target with at least one supported build mode. */ +export function supportedBuildTargets(): readonly FrameworkBuildTarget[] { + return [...new Set(frameworkBuildTargetModes().map((entry) => entry.target))]; +} + +export interface FrameworkBuildOptions { + readonly sourceDir: string; + readonly outDir: string; + readonly target: FrameworkBuildTarget; + /** Output layout. Defaults to "marketplace" (Mode A) when absent. */ + readonly mode?: FrameworkBuildMode; +} + +export interface BuildPluginResult { + readonly name: string; + readonly filesWritten: number; + readonly skippedSections: readonly string[]; +} + +export interface FrameworkBuildResult { + readonly outDir: string; + readonly plugins: readonly BuildPluginResult[]; + readonly totalFiles: number; +} + +/** Path to the source (Claude-format) plugin manifest inside each plugin directory. */ +export const SOURCE_PLUGIN_MANIFEST_RELATIVE = ".claude-plugin/plugin.json"; + +/** Path to the source (Claude-format) marketplace catalog. */ +export const SOURCE_MARKETPLACE_RELATIVE = ".claude-plugin/marketplace.json"; + +export const PLUGIN_HOOKS_RELATIVE = "hooks/hooks.json"; +export const PLUGIN_MCP_RELATIVE = ".mcp.json"; +export const PLUGIN_AGENT_INPUT_EXT = ".md"; +export const PLUGIN_SKILL_ENTRY_FILE = "SKILL.md"; + +/** Subdirectory names a build warns about and skips. */ +export const OUT_OF_SCOPE_PLUGIN_SECTIONS: readonly ["commands", "rules"] = ["commands", "rules"]; diff --git a/cli/src/contexts/translate/domain/canon.ts b/cli/src/contexts/translate/domain/canon.ts new file mode 100644 index 000000000..91593c272 --- /dev/null +++ b/cli/src/contexts/translate/domain/canon.ts @@ -0,0 +1,45 @@ +import type { ConfigRef } from "../../tools/domain/capabilities/config-refs.js"; + +export const FRAMEWORK_CONFIG_PREFIX = "config/"; + +export interface ContentSection { + readonly name: string; + readonly directory: string; + readonly entryFile: string | null; +} + +export interface TemplateRef { + readonly name: string; + readonly path: string; +} + +export class FrameworkDescriptor { + readonly version: string; + readonly contentSections: readonly ContentSection[]; + readonly templateRefs: readonly TemplateRef[]; + readonly configRefs: readonly ConfigRef[]; + + constructor(params: { + version: string; + contentSections: ContentSection[]; + templateRefs: TemplateRef[]; + configRefs: ConfigRef[]; + }) { + this.version = params.version; + this.contentSections = Object.freeze([...params.contentSections]); + this.templateRefs = Object.freeze([...params.templateRefs]); + this.configRefs = Object.freeze([...params.configRefs]); + } + + getContentSection(name: string): ContentSection | undefined { + return this.contentSections.find((s) => s.name === name); + } + + getTemplate(name: string): TemplateRef | undefined { + return this.templateRefs.find((t) => t.name === name); + } + + getConfig(name: string): ConfigRef | undefined { + return this.configRefs.find((c) => c.name === name); + } +} diff --git a/cli/src/contexts/translate/domain/content-translator.ts b/cli/src/contexts/translate/domain/content-translator.ts new file mode 100644 index 000000000..bcd8b9c9f --- /dev/null +++ b/cli/src/contexts/translate/domain/content-translator.ts @@ -0,0 +1,468 @@ +import { InstallationFile } from "../../../kernel/file.js"; +import { parseFrontmatter, serializeFrontmatter } from "../../../kernel/markdown.js"; +import { flatHooksPathWithLoaderEntry } from "../../../kernel/materialization/flat-paths.js"; +import type { Hasher } from "../../../kernel/ports/hasher.js"; +import type { FlatHooksBridge } from "../../tools/domain/capabilities/plugins-capability.js"; +import type { + AiTool, + HasAgents, + HasCommands, + HasPlugins, + HasSkills, +} from "../../tools/domain/contracts.js"; +import { hasRules } from "../../tools/domain/contracts.js"; +import type { + PluginInstallNotice, + ReadonlyNoticeList, +} from "../../tools/domain/models/plugin-install-notice.js"; +import type { ToolConfig } from "../../tools/domain/registry.js"; +import { isAiTool } from "../../tools/domain/registry.js"; +import { convertHooksFormat } from "./formats/cursor-hooks.js"; +import { rewritePluginRootToken } from "./formats/plugin-root-token-rewrite.js"; +import type { PluginComponentFile, PluginDistribution } from "./plugin-distribution.js"; +import type { PluginTranslationSkip, ReadonlySkipList } from "./plugin-translation-skip.js"; + +const PLUGIN_MANIFEST_PATHS: readonly string[] = [ + ".claude-plugin/plugin.json", + ".cursor-plugin/plugin.json", + ".codex-plugin/plugin.json", + "plugin.json", +]; + +interface TranslatedFile { + relativePath: string; + content: string; + /** An artefact, not prose: copied byte for byte, with no frontmatter round-trip and no path + * rewriting. Codex's and Copilot's own rewrites change a bundled script by six and one bytes + * respectively, which is a file that no longer parses. */ + verbatim?: true; +} + +interface MarkdownCap { + buildInstallPath: (fileName: string) => string | null; + convertFrontmatter: (fm: Record, fileName: string) => Record; + serialize: (fm: Record, body: string) => string; +} + +interface SkillCap { + convertFrontmatter: (fm: Record) => Record; + serialize: (fm: Record, body: string) => string; +} + +const PLUGIN_HOOKS_DIR = "hooks"; +const MARKDOWN_EXTENSION = ".md"; + +function parentDirOf(path: string): string { + return path.split("/").slice(0, -1).join("/"); +} + +// A hook script requires its siblings relative to itself, so the tree below hooks/ has to +// survive translation intact; flattening it breaks every such require. +function pathBelow(dir: string, path: string): string { + return path.startsWith(`${dir}/`) ? path.slice(dir.length + 1) : path; +} + +export class PluginContentTranslator { + constructor(private readonly hasher: Hasher) {} + + translate(dist: PluginDistribution, toolConfig: ToolConfig): InstallationFile[] { + return this.translateWithComponentPaths(dist, toolConfig).files; + } + + translateWithComponentPaths( + dist: PluginDistribution, + toolConfig: ToolConfig + ): { + files: InstallationFile[]; + componentPaths: ReadonlyMap; + skipped: ReadonlySkipList; + notices: ReadonlyNoticeList; + } { + const tool = asPluginTool(toolConfig); + if (tool === null) return { files: [], componentPaths: new Map(), skipped: [], notices: [] }; + const { mode } = tool.capabilities.plugins; + if (mode === "native") return this.translateNativeWithPaths(dist, tool); + if (mode === "flat") { + const { files, skipped } = this.translateFlat(dist, tool); + return { files, componentPaths: new Map(), skipped, notices: [] }; + } + return { files: [], componentPaths: new Map(), skipped: [], notices: [] }; + } + + detectFlatCollisions( + dists: PluginDistribution[], + toolConfig: ToolConfig + ): Array<{ plugin: string; path: string }> { + const tool = asPluginTool(toolConfig); + if (tool === null) return []; + if (tool.capabilities.plugins.mode !== "flat") return []; + const seen = new Map(); + const collisions: Array<{ plugin: string; path: string }> = []; + for (const dist of dists) { + for (const file of this.translate(dist, toolConfig)) { + if (seen.has(file.relativePath)) { + collisions.push({ plugin: dist.manifest.name, path: file.relativePath }); + } else { + seen.set(file.relativePath, dist.manifest.name); + } + } + } + return collisions; + } + + private translateNativeWithPaths( + dist: PluginDistribution, + tool: AiTool + ): { + files: InstallationFile[]; + componentPaths: ReadonlyMap; + skipped: ReadonlySkipList; + notices: ReadonlyNoticeList; + } { + const { pluginsDir } = tool.capabilities.plugins; + if (pluginsDir === null) { + return { files: [], componentPaths: new Map(), skipped: [], notices: [] }; + } + const pluginRoot = `${pluginsDir}${dist.manifest.name}/`; + const { files, componentPaths } = this.buildNativeFiles(dist, tool, pluginRoot); + const notices = this.collectHooksTrustNotices(dist, tool); + return { files, componentPaths, skipped: [], notices }; + } + + private buildNativeFiles( + dist: PluginDistribution, + tool: AiTool, + pluginRoot: string + ): { files: InstallationFile[]; componentPaths: ReadonlyMap } { + const result: InstallationFile[] = []; + const componentPaths = new Map(); + for (const file of dist.files) { + const translated = this.translateFile(file, tool); + if (translated === null) continue; + const hooked = this.maybeConvertHooks(file.relativePath, translated.content, tool); + // Prose is rewritten, an artefact is carried byte for byte: a tool's own rewrite leaving + // a script intact is luck rather than a guarantee. + const rewritten = isProse(file.relativePath) ? tool.rewriteContent(hooked) : hooked; + // The plugin-root variable is a path, not prose: a hook manifest and an mcp manifest both + // name one, in JSON. Gating it on prose left every tool but Claude installing a hook that + // points at a variable its own runtime never expands. + const content = + translated.verbatim === true ? rewritten : this.rewritePluginRoot(rewritten, tool); + const installedPath = `${pluginRoot}${translated.relativePath}`; + result.push(this.makeFile(installedPath, content)); + if (isComponentFile(file.relativePath)) componentPaths.set(installedPath, file.relativePath); + } + this.appendManifestFile(dist, tool, pluginRoot, result); + return { files: result, componentPaths }; + } + + private appendManifestFile( + dist: PluginDistribution, + tool: AiTool, + pluginRoot: string, + result: InstallationFile[] + ): void { + const { pluginManifestRelativePath } = tool.capabilities.plugins; + if (pluginManifestRelativePath === null) return; + const sourceManifest = findSourceManifestContent(dist); + if (sourceManifest === null) return; + result.push(this.makeFile(`${pluginRoot}${pluginManifestRelativePath}`, sourceManifest)); + } + + // A delivered hook is not a skip: `hooksTrustNotice` names what a person still has to do + // before it runs, and only applies when this plugin actually ships one. + private collectHooksTrustNotices( + dist: PluginDistribution, + tool: AiTool + ): ReadonlyNoticeList { + if (dist.components.hooks.length === 0) return []; + const { hooksTrustNotice } = tool.capabilities.plugins; + if (hooksTrustNotice === null) return []; + const entry: PluginInstallNotice = { + pluginName: dist.manifest.name, + component: "hooks", + toolId: tool.toolId, + message: hooksTrustNotice, + }; + return [entry]; + } + + /** A plugin is authored with one spelling of the plugin root and the installer translates + * it into the one this tool expands. A file carried verbatim keeps its own bytes. */ + private rewritePluginRoot(content: string, tool: AiTool): string { + const { pluginRootToken } = tool.capabilities.plugins; + if (pluginRootToken === null) return content; + return rewritePluginRootToken(content, pluginRootToken); + } + + private maybeConvertHooks(sourcePath: string, content: string, tool: AiTool): string { + if (sourcePath !== "hooks/hooks.json") return content; + return convertHooksFormat(content, tool.capabilities.plugins.hooksContentFormat); + } + + private translateFile( + file: PluginComponentFile, + tool: AiTool + ): TranslatedFile | null { + if (PLUGIN_MANIFEST_PATHS.includes(file.relativePath)) return null; + const cap = tool.capabilities.plugins; + if (file.relativePath === ".mcp.json") { + return cap.acceptsMcp ? { relativePath: cap.mcpRelativePath, content: file.content } : null; + } + if (file.relativePath.split("/")[0] === PLUGIN_HOOKS_DIR) { + if (!cap.acceptsHooks) return null; + if (file.relativePath === `${PLUGIN_HOOKS_DIR}/hooks.json`) { + return { relativePath: cap.hooksRelativePath, content: file.content }; + } + // Everything under `hooks/` but its own manifest is a script the host runs. It goes beside + // the manifest, and where the manifest sits at the plugin root it keeps its own directory + // — a script at the root would leave the command naming `hooks/` pointing at nothing. + const manifestDir = parentDirOf(cap.hooksRelativePath) || PLUGIN_HOOKS_DIR; + return { + relativePath: `${manifestDir}/${pathBelow(PLUGIN_HOOKS_DIR, file.relativePath)}`, + content: file.content, + verbatim: true, + }; + } + return this.translateComponent(file, tool); + } + + private translateComponent( + file: PluginComponentFile, + tool: AiTool + ): TranslatedFile | null { + const top = file.relativePath.split("/")[0]; + if (top === "commands" && hasCommands(tool)) { + return translateMarkdown(file, "commands/", tool.directory, tool.capabilities.commands); + } + if (top === "agents" && hasAgents(tool)) { + return translateMarkdown(file, "agents/", tool.directory, tool.capabilities.agents); + } + if (top === "rules" && hasRules(tool)) { + return translateMarkdown(file, "rules/", tool.directory, tool.capabilities.rules); + } + if (top === "skills" && hasSkills(tool)) { + return translateSkill(file, tool.capabilities.skills); + } + return null; + } + + private translateFlat( + dist: PluginDistribution, + tool: AiTool + ): { files: InstallationFile[]; skipped: ReadonlySkipList } { + const { flatNamespacePrefix } = tool.capabilities.plugins; + if (flatNamespacePrefix === null) return { files: [], skipped: [] }; + const result: InstallationFile[] = []; + for (const file of dist.components.commands) { + result.push(this.flatCommandFile(file, dist.manifest.name, tool, flatNamespacePrefix)); + } + for (const section of ["agents", "rules", "skills"] as const) { + for (const file of dist.components[section]) { + const f = this.flatSectionFile(file, section, dist.manifest.name, tool); + if (f !== null) result.push(f); + } + } + result.push(...this.flatHooksFiles(dist, tool)); + const skipped = this.collectHooksSkips(dist, tool); + return { files: result, skipped }; + } + + // hooks/hooks.json is a manifest a merge reads, never a runtime module a loader scans for, so + // it is never delivered here. Everything else under hooks/ is carried verbatim, namespaced per + // plugin — unless it is that loader's own plugin module, delivered flat and renamed instead. + private flatHooksFiles(dist: PluginDistribution, tool: AiTool): InstallationFile[] { + const { flatHooksDir, flatHooksLoaderEntry, flatHooksBridge } = tool.capabilities.plugins; + if (flatHooksDir === null) return []; + const scripts = dist.components.hooks + .filter((file) => file.relativePath !== `${PLUGIN_HOOKS_DIR}/hooks.json`) + .map((file) => + this.makeFile( + flatHooksPathWithLoaderEntry( + flatHooksDir, + flatHooksLoaderEntry, + dist.manifest.name, + file.relativePath + ), + file.content + ) + ); + const bridge = this.flatHooksBridgeFile(dist, flatHooksBridge); + return bridge === null ? scripts : [...scripts, bridge]; + } + + // The install-time counterpart of the flat build strategy's own bridge write: `setup` and + // `plugin install` reach OpenCode through this translator, never through the build strategy, + // so without this a plugin's hooks trigger only on a tree `translate` produced. + private flatHooksBridgeFile( + dist: PluginDistribution, + bridge: FlatHooksBridge | null + ): InstallationFile | null { + if (bridge === null) return null; + const manifestFile = dist.components.hooks.find( + (f) => f.relativePath === `${PLUGIN_HOOKS_DIR}/hooks.json` + ); + if (manifestFile === undefined) return null; + const hasOwnBridge = dist.components.hooks.some( + (f) => basename(f.relativePath) === bridge.skipIfSourceHas + ); + if (hasOwnBridge) return null; + const generated = bridge.generate(manifestFile.content, dist.manifest.name); + if (generated === null) return null; + return this.makeFile(bridge.path(dist.manifest.name), generated); + } + + private collectHooksSkips(dist: PluginDistribution, tool: AiTool): ReadonlySkipList { + if (dist.components.hooks.length === 0) return []; + const { acceptsHooks, hooksUnsupportedReason } = tool.capabilities.plugins; + if (acceptsHooks || hooksUnsupportedReason === null) return []; + const entry: PluginTranslationSkip = { + pluginName: dist.manifest.name, + component: "hooks", + toolId: tool.toolId, + reason: hooksUnsupportedReason, + }; + return [entry]; + } + + private flatCommandFile( + file: PluginComponentFile, + pluginName: string, + tool: AiTool, + prefix: string + ): InstallationFile { + const filename = basename(file.relativePath); + const raw = prefixCommandName(file.content, file.relativePath, prefix, pluginName); + const content = tool.rewriteContent(raw); + return this.makeFile(`${tool.directory}commands/${pluginName}/${filename}`, content); + } + + private flatSectionFile( + file: PluginComponentFile, + section: "agents" | "rules" | "skills", + pluginName: string, + tool: AiTool + ): InstallationFile | null { + if (!sectionPresent(tool, section)) return null; + const sectionDir = `${section}/`; + const fileName = file.relativePath.slice(sectionDir.length); + // Same rule as the native path: prose is rewritten, an artefact is carried. Rewriting every + // flat file left a script intact only where a tool's own rewrite happened to spare it. + const content = isProse(file.relativePath) ? tool.rewriteContent(file.content) : file.content; + return this.makeFile(`${tool.directory}${section}/${pluginName}/${fileName}`, content); + } + + private makeFile(relativePath: string, content: string): InstallationFile { + return new InstallationFile({ + relativePath, + content, + hash: this.hasher.hash(content), + }); + } +} + +function asPluginTool(config: ToolConfig): AiTool | null { + if (!isAiTool(config)) return null; + if (!hasPlugins(config)) return null; + return config; +} + +function hasPlugins(tool: AiTool): tool is AiTool { + return "plugins" in (tool.capabilities as object); +} + +function hasCommands(tool: AiTool): tool is AiTool { + return "commands" in (tool.capabilities as object); +} + +function hasAgents(tool: AiTool): tool is AiTool { + return "agents" in (tool.capabilities as object); +} + +function hasSkills(tool: AiTool): tool is AiTool { + return "skills" in (tool.capabilities as object); +} + +function sectionPresent(tool: AiTool, section: "agents" | "rules" | "skills"): boolean { + return section in (tool.capabilities as object); +} + +/** Prose is translated; anything else a plugin ships is an artefact, carried byte for byte. The + * extension is the whole test: a plugin's components are markdown by definition. */ +function isProse(relativePath: string): boolean { + return relativePath.endsWith(MARKDOWN_EXTENSION); +} + +function isComponentFile(relativePath: string): boolean { + const top = relativePath.split("/")[0]; + return top === "agents" || top === "commands" || top === "rules" || top === "skills"; +} + +function findSourceManifestContent(dist: PluginDistribution): string | null { + for (const path of PLUGIN_MANIFEST_PATHS) { + const file = dist.files.find((f) => f.relativePath === path); + if (file !== undefined) return file.content; + } + return null; +} + +function basename(relativePath: string): string { + return relativePath.split("/").at(-1) ?? relativePath; +} + +function prefixCommandName( + content: string, + relativePath: string, + prefix: string, + pluginName: string +): string { + const { frontmatter, body } = parseFrontmatter(content); + const rawName = typeof frontmatter.name === "string" ? frontmatter.name : ""; + const simpleName = stripCommandPrefix(rawName) || basename(relativePath); + const newFrontmatter = { ...frontmatter, name: `${prefix}${pluginName}:${simpleName}` }; + return serializeFrontmatter(newFrontmatter, body); +} + +function stripCommandPrefix(name: string): string { + const match = /^aidd:\d+:(.+)$/.exec(name); + if (match) return match[1]; + const colonIdx = name.lastIndexOf(":"); + if (colonIdx !== -1) return name.slice(colonIdx + 1); + return name; +} + +function toPluginRelativePath(fullPath: string, toolDirectory: string): string { + const relative = fullPath.startsWith(toolDirectory) + ? fullPath.slice(toolDirectory.length) + : fullPath; + return relative.replace(/^([^/]+)\/aidd\//, "$1/"); +} + +function translateMarkdown( + file: PluginComponentFile, + sectionDir: string, + toolDirectory: string, + cap: MarkdownCap +): TranslatedFile | null { + const fileName = file.relativePath.slice(sectionDir.length); + const fullPath = cap.buildInstallPath(fileName); + if (fullPath === null) return null; + const relativePath = toPluginRelativePath(fullPath, toolDirectory); + const { frontmatter, body } = parseFrontmatter(file.content); + const newFm = cap.convertFrontmatter(frontmatter, fileName); + const content = cap.serialize(newFm, body); + return { relativePath, content }; +} + +/** A skill is prose with frontmatter; anything else under `skills/` is an asset the skill + * carries. A frontmatter round-trip and a path rewrite are meaningless for a file that is not + * prose, and both can damage it. */ +function translateSkill(file: PluginComponentFile, cap: SkillCap): TranslatedFile { + if (!isProse(file.relativePath)) { + return { relativePath: file.relativePath, content: file.content, verbatim: true }; + } + const { frontmatter, body } = parseFrontmatter(file.content); + const newFm = cap.convertFrontmatter(frontmatter); + const content = cap.serialize(newFm, body); + return { relativePath: file.relativePath, content }; +} diff --git a/cli/src/domain/formats/cursor-hooks.ts b/cli/src/contexts/translate/domain/formats/cursor-hooks.ts similarity index 76% rename from cli/src/domain/formats/cursor-hooks.ts rename to cli/src/contexts/translate/domain/formats/cursor-hooks.ts index 2a5ca0818..a8ad86c23 100644 --- a/cli/src/domain/formats/cursor-hooks.ts +++ b/cli/src/contexts/translate/domain/formats/cursor-hooks.ts @@ -1,14 +1,13 @@ -export type HooksContentFormat = "claude" | "cursor"; +import type { HooksContentFormat } from "../../../tools/domain/hooks-format.js"; type ClaudeHookMatcher = { hooks: ClaudeHookItem[] }; type ClaudeHookItem = { type?: string; command?: string; [key: string]: unknown }; type ClaudeHooksJson = { hooks?: Record }; -// No inverse: convertHooksFormat (and convertClaudeHooksToCursorPlugin) is a one-way -// schema transformation — the Cursor plugin format drops Claude-specific matcher -// structure and cannot be reversed to the original Claude hooks JSON. +// One-way: the Cursor plugin format drops Claude's matcher structure, so the transform cannot +// be reversed to the original hooks JSON. export function convertHooksFormat(content: string, format: HooksContentFormat): string { - if (format === "cursor") return convertClaudeHooksToCursorPlugin(content); + if (format === "flat") return convertClaudeHooksToCursorPlugin(content); return content; } diff --git a/cli/src/contexts/translate/domain/formats/plugin-root-token-rewrite.ts b/cli/src/contexts/translate/domain/formats/plugin-root-token-rewrite.ts new file mode 100644 index 000000000..5acfab6eb --- /dev/null +++ b/cli/src/contexts/translate/domain/formats/plugin-root-token-rewrite.ts @@ -0,0 +1,12 @@ +import { CLAUDE_PLUGIN_ROOT_TOKEN } from "../../../tools/domain/formats/plugin-root-token.js"; + +/** + * Replaces every occurrence of the canonical `${CLAUDE_PLUGIN_ROOT}` source token with the one + * this tool expands, declared on that tool as `plugins.pluginRootToken`. Only that literal path + * token is rewritten — every other `${…}` variable is left untouched — and a target equal to + * the source returns the content unchanged. + */ +export function rewritePluginRootToken(content: string, targetToken: string): string { + if (targetToken === CLAUDE_PLUGIN_ROOT_TOKEN) return content; + return content.replaceAll(CLAUDE_PLUGIN_ROOT_TOKEN, targetToken); +} diff --git a/cli/src/domain/models/plugin-distribution.ts b/cli/src/contexts/translate/domain/plugin-distribution.ts similarity index 82% rename from cli/src/domain/models/plugin-distribution.ts rename to cli/src/contexts/translate/domain/plugin-distribution.ts index 41f705245..bdb1c12a0 100644 --- a/cli/src/domain/models/plugin-distribution.ts +++ b/cli/src/contexts/translate/domain/plugin-distribution.ts @@ -39,4 +39,13 @@ export class PluginDistribution { this.files = params.files; this.components = params.components; } + + withStrict(strict: boolean): PluginDistribution { + return new PluginDistribution({ + manifest: { ...this.manifest, strict }, + format: this.format, + files: this.files, + components: this.components, + }); + } } diff --git a/cli/src/contexts/translate/domain/plugin-format.ts b/cli/src/contexts/translate/domain/plugin-format.ts new file mode 100644 index 000000000..4bf9c41df --- /dev/null +++ b/cli/src/contexts/translate/domain/plugin-format.ts @@ -0,0 +1,47 @@ +import { AI_TOOL_IDS, type AiToolId, type ToolId } from "../../../kernel/tool.js"; +import type { ToolConfig } from "../../tools/domain/registry.js"; +import { getAllRegisteredTools, isAiTool } from "../../tools/domain/registry.js"; + +/** The tool whose layout a plugin distribution follows. An alias rather than its own union: a + * format is a tool's way of laying out a plugin, so a sixth tool is a sixth format by + * construction. */ +export type PluginFormat = AiToolId; + +export interface DistributionProbe { + readonly format: PluginFormat; + readonly relativePath: string; +} + +/** + * Probes ordered most specific first — deepest path wins, ties broken by tool order. The order + * is behaviour, not presentation: the reader takes the first probe that resolves, and copilot + * declares a bare `plugin.json` at the root that any directory can satisfy. Takes the profiles + * explicitly so the rule can be probed with synthetic tools. + */ +export function distributionProbesOf( + tools: ReadonlyMap, + kind: "manifest" | "marketplace" +): readonly DistributionProbe[] { + const probes: DistributionProbe[] = []; + for (const format of AI_TOOL_IDS) { + const config = tools.get(format); + if (config === undefined || !isAiTool(config)) continue; + for (const relativePath of config.distributionProbes?.[kind] ?? []) { + probes.push({ format, relativePath }); + } + } + return probes + .map((probe, index) => ({ probe, index, depth: probe.relativePath.split("/").length })) + .sort((a, b) => b.depth - a.depth || a.index - b.index) + .map((entry) => entry.probe); +} + +/** Where a plugin manifest can sit, across every registered tool's layout. */ +export function pluginManifestProbes(): readonly DistributionProbe[] { + return distributionProbesOf(getAllRegisteredTools(), "manifest"); +} + +/** Where a marketplace catalog can sit, across every registered tool's layout. */ +export function marketplaceProbes(): readonly DistributionProbe[] { + return distributionProbesOf(getAllRegisteredTools(), "marketplace"); +} diff --git a/cli/src/domain/models/plugin-translation-skip.ts b/cli/src/contexts/translate/domain/plugin-translation-skip.ts similarity index 81% rename from cli/src/domain/models/plugin-translation-skip.ts rename to cli/src/contexts/translate/domain/plugin-translation-skip.ts index b6e0c2949..235dbbca3 100644 --- a/cli/src/domain/models/plugin-translation-skip.ts +++ b/cli/src/contexts/translate/domain/plugin-translation-skip.ts @@ -1,4 +1,4 @@ -import type { AiToolId } from "./tool-ids.js"; +import type { AiToolId } from "../../../kernel/tool.js"; export interface PluginTranslationSkip { readonly pluginName: string; diff --git a/cli/src/contexts/translate/infrastructure/schema-validator.ts b/cli/src/contexts/translate/infrastructure/schema-validator.ts new file mode 100644 index 000000000..1ed6d998a --- /dev/null +++ b/cli/src/contexts/translate/infrastructure/schema-validator.ts @@ -0,0 +1,44 @@ +import { createRequire } from "node:module"; +import { JsonSchemaValidationError } from "../../../kernel/errors.js"; +import type { JsonSchemaValidator } from "../../tools/domain/ports/schema-validator.js"; + +// CJS interop: ajv v8 + ajv-formats are CommonJS; NodeNext requires createRequire. +// require("ajv") returns a module where the constructor is at .default. +const require = createRequire(import.meta.url); +const ajvModule = require("ajv") as { default: new (opts?: unknown) => AjvInstance }; +const AjvClass = ajvModule.default; +const addFormats = require("ajv-formats") as (ajv: AjvInstance) => void; + +interface ValidateFunction { + (data: unknown): boolean; + errors?: AjvError[] | null; +} + +interface AjvInstance { + compile(schema: object): ValidateFunction; +} + +interface AjvError { + instancePath: string; + message?: string; +} + +export class AjvSchemaValidatorAdapter implements JsonSchemaValidator { + private readonly ajv: AjvInstance; + + constructor() { + this.ajv = new AjvClass({ allErrors: true }); + addFormats(this.ajv); + } + + validate(schema: object, data: unknown): void { + const validateFn = this.ajv.compile(schema); + const valid = validateFn(data); + if (!valid) { + const errors = (validateFn.errors ?? []).map( + (e) => `${e.instancePath || "(root)"} ${e.message ?? "unknown error"}` + ); + throw new JsonSchemaValidationError(errors); + } + } +} diff --git a/cli/src/domain/capabilities/marketplace-entry.ts b/cli/src/domain/capabilities/marketplace-entry.ts deleted file mode 100644 index 52dd8d48b..000000000 --- a/cli/src/domain/capabilities/marketplace-entry.ts +++ /dev/null @@ -1,21 +0,0 @@ -import type { MarketplaceSettingsEntry, MarketplaceSettingsInput } from "./plugins-capability.js"; - -/** `{ source: { source: "github"|"directory", repo/path: "..." }, version? }` — the entry - * shape every tool accepts unless it declares its own. */ -export function buildDefaultMarketplaceEntry( - input: MarketplaceSettingsInput -): MarketplaceSettingsEntry | null { - const { name, source, version } = input; - const value: Record = {}; - - if (source.kind === "local") { - value.source = { source: "directory", path: source.path }; - } else if (source.kind === "github") { - value.source = { source: "github", repo: source.repo }; - } else { - return null; - } - - if (version != null) value.version = version; - return { valueShape: "map", key: name, value }; -} diff --git a/cli/src/domain/capabilities/plugins-capability.ts b/cli/src/domain/capabilities/plugins-capability.ts deleted file mode 100644 index f4f6760c4..000000000 --- a/cli/src/domain/capabilities/plugins-capability.ts +++ /dev/null @@ -1,275 +0,0 @@ -import { CapabilityConfigError } from "../errors.js"; -import type { HooksContentFormat } from "../formats/cursor-hooks.js"; -import type { PluginSource } from "../models/plugin-source.js"; -import type { PluginTranslationMode } from "../models/plugin-translation-mode.js"; - -export type PluginsMode = "native" | "flat" | "unsupported"; -export type { HooksContentFormat }; - -const DEFAULT_MCP_PATH = ".mcp.json"; -const DEFAULT_HOOKS_PATH = "hooks/hooks.json"; -const DEFAULT_HOOKS_FORMAT: HooksContentFormat = "claude"; - -export interface MarketplaceSettingsEntryMap { - valueShape: "map"; - key: string; - value: Record; -} - -export interface MarketplaceSettingsEntryArray { - valueShape: "array"; - value: string; -} - -export type MarketplaceSettingsEntry = MarketplaceSettingsEntryMap | MarketplaceSettingsEntryArray; - -export interface MarketplaceSettingsInput { - name: string; - source: PluginSource; - version?: string; -} - -export interface MarketplaceSettings { - settingsPath: string; - settingsKey: string; - valueShape?: "map" | "array"; - enabledPluginsKey?: string; - enabledPluginsSettingsPath?: string; - toEntry(input: MarketplaceSettingsInput): MarketplaceSettingsEntry | null; -} - -/** - * Declares that a tool enables plugins by driving an external CLI binary - * (e.g. `codex plugin add`, `copilot plugin install`) because a project-local - * settings file alone does not load its plugins. The `binary` keys the matching - * `NativePluginActivator` in the marketplace-sync registry. - */ -export interface NativeActivation { - binary: "claude" | "codex" | "copilot"; -} - -export interface NativePluginsParams { - mode: "native"; - pluginsDir: string; - /** Set to `null` to suppress writing a plugin manifest file into the plugin directory. */ - pluginManifestRelativePath: string | null; - mcpRelativePath?: string; - hooksRelativePath?: string; - hooksContentFormat?: HooksContentFormat; - /** - * Where a delivered hook actually lands. `"plugin"` (default): under this - * capability's own plugin directory, at `hooksRelativePath` — read by nothing for - * a tool whose hooks only fire from project scope. `"project"`: merged into the - * project's own hooks file instead (see `mergeCursorProjectHooksJson`), the - * destination measured to actually fire. Declared per capability, not guessed - * per tool, so a tool proven to need it is the only one that sets it. - */ - hooksDestination?: "plugin" | "project"; - acceptsMcp?: boolean; - /** - * The variable this tool expands to the installed plugin's directory, as - * written in a hook or MCP command. Absent means nothing is substituted. - */ - pluginRootToken?: string; - marketplaceSettings?: MarketplaceSettings; - /** Enables native CLI-driven plugin activation (e.g. Codex). See {@link NativeActivation}. */ - nativeActivation?: NativeActivation; - /** - * Explicit translation mode for this native capability. - * Pass `"marketplace"` when `marketplaceSettings` is provided and Mode A routing is intended. - * Defaults to `null` (neutral native, no translation strategy applies). - */ - translationMode?: PluginTranslationMode; - /** - * Declare `"user"` to install plugins relative to the user home directory instead of the project root. - * Requires `userPluginsDir` when set to `"user"`. - * Defaults to `"project"` (project-root-relative install). - */ - installScope?: "project" | "user"; - /** - * Resolver that returns the absolute user-scope plugins base directory given a homedir string. - * Required when `installScope === "user"`. Example: `(h) => join(h, ".cursor", "plugins", "local")`. - */ - userPluginsDir?: (homedir: string) => string; -} - -/** - * Flat mode's own hooks declaration. Unlike native mode's `hooksRelativePath` (a file - * beside a manifest a merge writes to), a flat-mode hook lands as files an extension - * loader scans a directory for — `flatHooksDir` names that directory, relative to the - * project root. See {@link HooksSupport} for the shape of the "no" case. - */ -export type FlatHooksSupport = - | { acceptsHooks: true; flatHooksDir: string } - | { acceptsHooks: false; hooksUnsupportedReason: string }; - -export type FlatPluginsParams = { - mode: "flat"; - flatNamespacePrefix: string; -} & FlatHooksSupport; - -export interface UnsupportedPluginsParams { - mode: "unsupported"; - /** See {@link FlatPluginsParams.hooksUnsupportedReason}. */ - hooksUnsupportedReason: string; -} - -/** - * Whether this tool runs the hooks a plugin ships. Stated, never defaulted: a tool nobody - * considered loses its hooks quietly when the field falls back to `false`, and one that - * runs none owes whoever installs a plugin a reason. - * - * `hooksTrustNotice` is the opposite case: the tool runs a delivered hook, but only once - * something outside the install grants it — a per-hook trust the tool itself gates and - * that a headless run never gets prompted for (measured on Codex: four clean `codex exec` - * sessions wrote no journal and said nothing, until `--dangerously-bypass-hook-trust` did). - * `null`/omitted for a tool that runs what it delivers with no such gate — told nothing, - * same as `hooksUnsupportedReason` for a tool that never runs hooks at all. - */ -export type HooksSupport = - | { acceptsHooks: true; hooksTrustNotice?: string } - | { acceptsHooks: false; hooksUnsupportedReason: string }; - -type PluginsParams = - | (NativePluginsParams & HooksSupport) - | FlatPluginsParams - | UnsupportedPluginsParams; - -export class PluginsCapability { - readonly mode: PluginsMode; - readonly pluginsDir: string | null; - readonly pluginManifestRelativePath: string | null; - readonly flatNamespacePrefix: string | null; - readonly acceptsHooks: boolean; - /** Why no hook is delivered, or `null` when they are. */ - readonly hooksUnsupportedReason: string | null; - /** What still has to happen before a delivered hook actually runs, or `null` when - * nothing does. See {@link HooksSupport}. */ - readonly hooksTrustNotice: string | null; - readonly pluginRootToken: string | null; - readonly acceptsMcp: boolean; - readonly mcpRelativePath: string; - readonly hooksRelativePath: string; - readonly hooksContentFormat: HooksContentFormat; - readonly hooksDestination: "plugin" | "project"; - /** Where a flat-mode hook lands, relative to the project root, or `null` when this - * capability's `acceptsHooks` is `false`. See {@link FlatHooksSupport}. */ - readonly flatHooksDir: string | null; - readonly marketplaceSettings: MarketplaceSettings | null; - /** Native CLI-driven plugin activation declaration, or `null` when not applicable. */ - readonly nativeActivation: NativeActivation | null; - /** - * Explicit declaration of the plugin translation strategy for this capability. - * - `"marketplace"`: Mode A — register plugin reference in the tool's native config (no file materialization). - * - `"flat"`: Mode B — materialize plugin content as files on disk. - * - `null`: no translation strategy applies (neutral native or unsupported). - * - * Set explicitly via `NativePluginsParams.translationMode` for native tools that use Mode A. - * Flat mode always resolves to `"flat"` automatically; unsupported always resolves to `null`. - */ - readonly translationMode: PluginTranslationMode | null; - /** - * Scope for plugin installation. - * - `"project"` (default): plugins are installed relative to the project root. - * - `"user"`: plugins are installed relative to the user home directory via `resolvePluginsBaseDir`. - */ - readonly installScope: "project" | "user"; - - private readonly _userPluginsDir?: (homedir: string) => string; - - constructor(params: PluginsParams) { - this.mode = params.mode; - this.translationMode = PluginsCapability.resolveTranslationMode(params); - this.installScope = PluginsCapability.resolveInstallScope(params); - PluginsCapability.validateUserScope(params); - if (params.mode === "native") { - this.pluginsDir = params.pluginsDir; - this.pluginManifestRelativePath = params.pluginManifestRelativePath; - this.flatNamespacePrefix = null; - this.acceptsHooks = params.acceptsHooks; - this.hooksUnsupportedReason = params.acceptsHooks ? null : params.hooksUnsupportedReason; - this.hooksTrustNotice = params.acceptsHooks ? (params.hooksTrustNotice ?? null) : null; - this.pluginRootToken = params.pluginRootToken ?? null; - this.acceptsMcp = params.acceptsMcp ?? false; - this.mcpRelativePath = params.mcpRelativePath ?? DEFAULT_MCP_PATH; - this.hooksRelativePath = params.hooksRelativePath ?? DEFAULT_HOOKS_PATH; - this.hooksContentFormat = params.hooksContentFormat ?? DEFAULT_HOOKS_FORMAT; - this.hooksDestination = params.hooksDestination ?? "plugin"; - this.flatHooksDir = null; - this.marketplaceSettings = params.marketplaceSettings ?? null; - this.nativeActivation = params.nativeActivation ?? null; - this._userPluginsDir = params.userPluginsDir; - } else if (params.mode === "flat") { - this.pluginsDir = null; - this.pluginManifestRelativePath = null; - this.flatNamespacePrefix = params.flatNamespacePrefix; - this.acceptsHooks = params.acceptsHooks; - this.hooksUnsupportedReason = params.acceptsHooks ? null : params.hooksUnsupportedReason; - this.flatHooksDir = params.acceptsHooks ? params.flatHooksDir : null; - this.hooksTrustNotice = null; - this.pluginRootToken = null; - this.acceptsMcp = false; - this.mcpRelativePath = DEFAULT_MCP_PATH; - this.hooksRelativePath = DEFAULT_HOOKS_PATH; - this.hooksContentFormat = DEFAULT_HOOKS_FORMAT; - this.hooksDestination = "plugin"; - this.marketplaceSettings = null; - this.nativeActivation = null; - this._userPluginsDir = undefined; - } else { - this.pluginsDir = null; - this.pluginManifestRelativePath = null; - this.flatNamespacePrefix = null; - this.acceptsHooks = false; - this.hooksUnsupportedReason = params.hooksUnsupportedReason; - this.flatHooksDir = null; - this.hooksTrustNotice = null; - this.pluginRootToken = null; - this.acceptsMcp = false; - this.mcpRelativePath = DEFAULT_MCP_PATH; - this.hooksRelativePath = DEFAULT_HOOKS_PATH; - this.hooksContentFormat = DEFAULT_HOOKS_FORMAT; - this.hooksDestination = "plugin"; - this.marketplaceSettings = null; - this.nativeActivation = null; - this._userPluginsDir = undefined; - } - } - - /** - * Resolves the absolute base directory for plugin file writes. - * - For `installScope === "project"`: returns `projectRoot`. - * - For `installScope === "user"`: returns the user-scope plugins dir resolved from `homedir`. - */ - resolvePluginsBaseDir(projectRoot: string, homedir: string): string { - if (this.installScope === "user" && this._userPluginsDir !== undefined) { - return this._userPluginsDir(homedir); - } - return projectRoot; - } - - pluginOutputDir(pluginName: string): string | null { - if (this.mode !== "native" || this.pluginsDir === null) return null; - return `${this.pluginsDir}${pluginName}/`; - } - - private static resolveTranslationMode(params: PluginsParams): PluginTranslationMode | null { - if (params.mode === "native") return params.translationMode ?? null; - if (params.mode === "flat") return "flat"; - return null; - } - - private static resolveInstallScope(params: PluginsParams): "project" | "user" { - if (params.mode === "native") return params.installScope ?? "project"; - return "project"; - } - - private static validateUserScope(params: PluginsParams): void { - if (params.mode !== "native") return; - if (params.installScope === "user" && params.userPluginsDir === undefined) { - throw new CapabilityConfigError( - "installScope 'user' requires a userPluginsDir resolver function." - ); - } - } -} diff --git a/cli/src/domain/capabilities/rules-capability.ts b/cli/src/domain/capabilities/rules-capability.ts deleted file mode 100644 index 1117fdced..000000000 --- a/cli/src/domain/capabilities/rules-capability.ts +++ /dev/null @@ -1,89 +0,0 @@ -import { serializeFrontmatter } from "../formats/markdown.js"; -import { AI_TOOL_IDS } from "../tools/registry.js"; - -const ALL_TOOL_SUFFIXES: readonly string[] = AI_TOOL_IDS.map((id) => `.${id}.md`); - -export class RulesCapability { - constructor( - readonly params: { - directory: string; - toolSuffix: string; - inputSuffix?: string; - buildInstallPath: (fileName: string) => string | null; - convertFrontmatter: (fm: Record) => Record; - reverseConvertFrontmatter: (fm: Record) => Record; - } - ) {} - - buildOutputPath(ruleName: string): string { - return `${this.params.directory}rules/${ruleName}${this.params.toolSuffix}`; - } - - /** A name no rule of a reader's own will ever carry, asked of `buildInstallPath` only so - * its answer can be read back. Long and self-describing on purpose: it appears in no - * output and on no disk, and a short one could collide with a real rule name if a tool - * ever branched on it. */ - private static readonly PROBE_STEM = "aidd-installed-location-probe"; - - /** Where an installed rule of this tool lives, and what it is called at the end — the - * directory and the extension, asked of the installer rather than restated beside it. - * - * `buildOutputPath` answers a different question: where the framework's own source form - * goes. What a reader scanning a project needs is the *installed* shape, and the only - * thing that knows it is `buildInstallPath`, which is a closure written per tool — a - * template for Claude Code, Codex and OpenCode, `toMdc` for Cursor, a delegated handler - * for Copilot. Probing it keeps the answer in the one place that already holds it; a - * caller splitting a path string apart would be a second copy, free to disagree the day - * a tool changes where it installs. - * - * `null` when the tool installs nothing for the name it was asked about, and when it - * answers a path whose stem it rewrote past recognition. Both mean the same thing to a - * caller — nothing here can say where to look — and neither is a guess. */ - installedLocation(): { readonly directory: string; readonly extension: string } | null { - const suffix = this.params.inputSuffix ?? this.params.toolSuffix; - const installed = this.buildInstallPath(`${RulesCapability.PROBE_STEM}${suffix}`); - if (installed === null) return null; - const lastSlash = installed.lastIndexOf("/"); - const basename = installed.slice(lastSlash + 1); - const stemAt = basename.indexOf(RulesCapability.PROBE_STEM); - if (stemAt === -1) return null; - return { - directory: installed.slice(0, lastSlash + 1), - extension: basename.slice(stemAt + RulesCapability.PROBE_STEM.length), - }; - } - - buildInstallPath(fileName: string): string | null { - return this.params.buildInstallPath(fileName); - } - - convertFrontmatter(fm: Record): Record { - return this.params.convertFrontmatter(fm); - } - - reverseConvertFrontmatter(fm: Record): Record { - return this.params.reverseConvertFrontmatter(fm); - } - - acceptsFileName(fileName: string): boolean { - const basename = fileName.split("/").at(-1) ?? fileName; - const effectiveSuffix = this.params.inputSuffix ?? this.params.toolSuffix; - const otherSuffixes = ALL_TOOL_SUFFIXES.filter((s) => s !== effectiveSuffix); - return !otherSuffixes.some((s) => basename.endsWith(s)); - } - - serialize(frontmatter: Record, body: string): string { - return serializeFrontmatter(frontmatter, body); - } - - accepts(relativePath: string): boolean { - return relativePath.startsWith(this.params.directory); - } - - equals(other: RulesCapability): boolean { - return ( - this.params.directory === other.params.directory && - this.params.toolSuffix === other.params.toolSuffix - ); - } -} diff --git a/cli/src/domain/capabilities/telemetry-capability.ts b/cli/src/domain/capabilities/telemetry-capability.ts deleted file mode 100644 index 45401cdf5..000000000 --- a/cli/src/domain/capabilities/telemetry-capability.ts +++ /dev/null @@ -1,72 +0,0 @@ -/** What a route was **measured to supply**, not what it might. Three facts, because a - * consumer reading a report has to tell four things apart that all look like a missing - * number: a tool that supplies no counters at all, one that supplies counters but no - * amount, one that supplies an amount, and one whose figures carry the step the tool - * itself named. - * - * Declared per route rather than per tool: local read is the only route this system can - * still produce, but different tools' local reads still differ in what they carry. - * - * Every field is required. A default here would be a capability nobody measured, quietly - * asserted for a tool nobody looked at. */ -export interface TelemetryRouteSupply { - /** The four token counters. */ - readonly tokenCounters: boolean; - /** A figure denominated in currency. Never a credit, a premium request, or a zero whose - * denomination was never established. */ - readonly amount: boolean; - /** The tool names the running step itself, on the record. An interval derived from the - * run journal is not this — that is the framework's inference, not the tool's statement. */ - readonly toolStatedStep: boolean; - /** The tool names the agent a record belongs to, and so also says when a record is the - * main thread's own. Without it a record carrying no agent states nothing: `by_agent` - * cannot read it as the main thread, because the tool never had a main thread to - * distinguish. Only Claude Code's reader sets it today (`isSidechain` and - * `attributionAgent`, see `claude-code-transcript.ts`). */ - readonly agentName: boolean; -} - -/** Where a tool's own transcript files live, and how to recognise the one file (or files) - * for a session — declared per tool since only the tool knows its own directory layout, so - * the adapter that opens files never encodes one itself. `matches` receives the candidate's - * path already relative to `root`, not its basename: Claude Code's subagent transcripts live - * one directory per session (`/subagents/*.jsonl`), distinguishable only by that - * nesting, not by file name alone. */ -export interface TranscriptLocation { - root(homeDir: string): string; - matches(relativePath: string, sessionId: string): boolean; -} - -/** This tool's own file(s) can be read for a session's counters without exporting anything - * and without a process running. Read through `ReadLocalCostUseCase`, which asks every - * tool's declaration and never branches on `toolId`. `transcript` is optional: a tool read - * by another means entirely (OpenCode shells out to its own CLI instead of opening a file) - * declares `{ kind: "declared" }` with no transcript location at all. */ -export interface TelemetryLocalReadDeclared { - readonly kind: "declared"; - readonly transcript?: TranscriptLocation; - readonly supplies: TelemetryRouteSupply; - /** A caveat that survives to the person reading the result, when what this tool can be - * read for is narrower than the others. Data rather than a source comment, because a - * comment reaches nobody downstream: a consumer would otherwise see figures with no - * journal entry beside them and be left to guess why. */ - readonly limitation?: string; -} - -/** No reader has been wired for this tool yet in this codebase — a fact about current - * coverage, not a claim that the tool's file could never be read. */ -export interface TelemetryLocalReadUnmeasured { - readonly kind: "unmeasured"; -} - -/** This tool's own file cannot yield what a local read needs, established by probe rather - * than assumed from an empty result. */ -export interface TelemetryLocalReadUnsupported { - readonly kind: "unsupported"; - readonly reason: string; -} - -export type TelemetryLocalRead = - | TelemetryLocalReadDeclared - | TelemetryLocalReadUnmeasured - | TelemetryLocalReadUnsupported; diff --git a/cli/src/domain/describe-error.ts b/cli/src/domain/describe-error.ts deleted file mode 100644 index 49d68c837..000000000 --- a/cli/src/domain/describe-error.ts +++ /dev/null @@ -1,43 +0,0 @@ -/** - * The concise half of a failure, for a diagnostic line a person reads. - * - * A filesystem failure's `code` — `ENOENT`, `EACCES` — is the half that says what happened; - * `.message` on the same error restates the path the sentence around it already names. A - * parse failure carries no `code`, so it falls through to the message, which is where the - * useful half of a `SyntaxError` lives. - * - * In the domain rather than beside the adapters that raise these errors: a use case has to - * describe one too, and a use case may not import infrastructure. Pure, no I/O. - * - * Shared rather than copied. `hook-trust-reader-adapter.ts` carried this rule, the host - * registry reader grew a second copy of it, and the telemetry diagnostic inlined a third; - * this repository's norm is that a duplicate is either justified against its neighbour or - * removed, and none of the three had a reason the others did not. - * - * `infrastructure/json-file.ts` exported a `describeError` that was in fact this file's - * `errorMessage`, byte for byte. Two exported functions of one name with different - * behaviour is worse than either duplicate: an import chosen by autocomplete would have - * turned a JSON parse message into `ENOENT` with nothing to notice it. That copy is gone and - * its one caller imports `errorMessage` from here. - */ -export function describeError(error: unknown): string { - if (error instanceof Error && "code" in error && typeof error.code === "string") { - return error.code; - } - return error instanceof Error ? error.message : String(error); -} - -/** - * The message alone, for a failure whose `code` says nothing worth reading — a JSON parse - * error being the case that matters here, where the `SyntaxError`'s message is the whole - * answer and there is no `code` at all. - * - * Beside `describeError` rather than in a second module, because the two are one decision - * with two answers and a reader choosing between them should see both at once. Two call - * sites had each grown a byte-identical private copy, both justified by "this layer does not - * import infrastructure" — true when the only shared version lived there, and no longer a - * reason now that one lives here. - */ -export function errorMessage(error: unknown): string { - return error instanceof Error ? error.message : String(error); -} diff --git a/cli/src/domain/errors.ts b/cli/src/domain/errors.ts deleted file mode 100644 index 691645fe4..000000000 --- a/cli/src/domain/errors.ts +++ /dev/null @@ -1,505 +0,0 @@ -import type { ToolCategory } from "./tools/registry.js"; - -export class CapabilityConfigError extends Error { - constructor(message: string) { - super(message); - this.name = "CapabilityConfigError"; - } -} - -export class CursorProjectScopeUnsupportedError extends Error { - constructor() { - super( - "Cursor plugins only support user-scope install (~/.cursor/plugins/local/). Project-scope is not auto-loaded by Cursor." - ); - this.name = "CursorProjectScopeUnsupportedError"; - } -} - -export class InvalidPluginScopeError extends Error { - constructor(toolId: string, requested: "project" | "user", supported: "project" | "user") { - super( - `Tool '${toolId}' does not support scope '${requested}'. Supported scope: '${supported}'. ` + - `Re-run with --scope ${supported} or omit the flag.` - ); - this.name = "InvalidPluginScopeError"; - } -} - -export class AuthenticationError extends Error { - constructor(source: string) { - super(`Authentication failed (${source}). Run \`aidd auth login\` to authenticate.`); - this.name = "AuthenticationError"; - } -} - -export class UpdateError extends Error { - constructor() { - super( - "Update failed. If you saw a 403 error above, ensure your GitHub token includes both repo and read:packages scopes.\n" + - "Update your token at https://github.com/settings/tokens, then re-run `aidd auth login`." - ); - this.name = "UpdateError"; - } -} - -export class ElevatedPermissionUpdateError extends Error { - constructor(installCommand: string) { - super( - "Update failed: the global package directory is not writable (EPERM/EACCES).\n" + - "Pick one:\n" + - " 1. Run the terminal as Administrator (Windows) or with sudo (macOS/Linux), then re-run `aidd self-update`.\n" + - " 2. Move global installs to a user-writable prefix, then re-run the update:\n" + - " Windows: npm config set prefix %APPDATA%\\npm\n" + - " macOS/Linux: npm config set prefix ~/.npm-global\n" + - ` 3. Run the update directly: ${installCommand}` - ); - this.name = "ElevatedPermissionUpdateError"; - } -} - -export class ManifestValidationError extends Error { - constructor(message: string) { - super(message); - this.name = "ManifestValidationError"; - } -} - -export class McpConfigError extends Error { - constructor(message: string) { - super(message); - this.name = "McpConfigError"; - } -} - -export class FrameworkResolutionError extends Error { - constructor(message: string) { - super(message); - this.name = "FrameworkResolutionError"; - } -} - -export class InvalidToolIdError extends Error { - constructor(invalid: string[], validToolIds: readonly string[]) { - super(`Unknown tool(s): ${invalid.join(", ")}. Valid tools: ${validToolIds.join(", ")}`); - this.name = "InvalidToolIdError"; - } -} - -export class CategoryMismatchError extends Error { - constructor(wrong: string[], category: ToolCategory, validToolIds: readonly string[]) { - const label = category === "ai" ? "AI" : "IDE"; - const verb = wrong.length === 1 ? `is not an ${label} tool` : `are not ${label} tools`; - super(`${wrong.join(", ")} ${verb}. Valid ${label} tools: ${validToolIds.join(", ")}`); - this.name = "CategoryMismatchError"; - } -} - -export class UnregisteredToolError extends Error { - constructor(toolId: string) { - super(`Tool '${toolId}' is not registered.`); - this.name = "UnregisteredToolError"; - } -} - -export class ToolNotInManifestError extends Error { - constructor(toolId: string) { - super(`Tool '${toolId}' is not installed in the manifest.`); - this.name = "ToolNotInManifestError"; - } -} - -export class InvalidManifestDataError extends Error { - constructor(detail?: string) { - super(detail ? `Invalid manifest data: ${detail}` : "Invalid manifest data."); - this.name = "InvalidManifestDataError"; - } -} - -export class InvalidManifestToolIdError extends Error { - constructor(key: string) { - super(`Invalid tool id in manifest: '${key}'.`); - this.name = "InvalidManifestToolIdError"; - } -} - -export class InvalidMcpServerConfigError extends Error { - constructor(name: string) { - super(`MCP server "${name}" must have either a "command" or "url" field`); - this.name = "InvalidMcpServerConfigError"; - } -} - -export class OpencodeDualConfigError extends Error { - constructor() { - super("Both opencode.json and opencode.jsonc exist. Remove one."); - this.name = "OpencodeDualConfigError"; - } -} - -export class PackageManagerDetectionError extends Error { - constructor(commands: readonly string[]) { - super(`Could not detect package manager. Run manually:\n ${commands.join("\n ")}`); - this.name = "PackageManagerDetectionError"; - } -} - -export class InvalidPluginSourceError extends Error { - constructor(detail?: string) { - super(detail ? `Invalid plugin source: ${detail}` : "Invalid plugin source."); - this.name = "InvalidPluginSourceError"; - } -} - -export class InvalidPluginNameError extends Error { - constructor(name: string) { - super( - `Invalid plugin name: "${name}". Use lowercase alphanumeric characters and hyphens only.` - ); - this.name = "InvalidPluginNameError"; - } -} - -export class InvalidPluginVersionError extends Error { - constructor(version: string) { - super(`Invalid plugin version: "${version}". Expected semver format (e.g. 1.0.0).`); - this.name = "InvalidPluginVersionError"; - } -} - -export class InvalidPluginManifestError extends Error { - constructor(detail?: string) { - super(detail ? `Invalid plugin manifest: ${detail}` : "Invalid plugin manifest."); - this.name = "InvalidPluginManifestError"; - } -} - -// Thrown when a marketplace catalog file (.claude-plugin/marketplace.json or -// .plugin/marketplace.json) exists but cannot be parsed. Extends -// InvalidPluginManifestError so existing `instanceof` checks still hold, while -// adding an actionable recovery hint — a cached catalog is healed by re-fetch, -// a user-provided source must be fixed by hand. -export class MalformedMarketplaceCatalogError extends InvalidPluginManifestError { - constructor(path: string, detail: string, cached: boolean) { - const recovery = cached - ? "Run 'aidd marketplace refresh --force' to re-fetch a clean copy." - : "Fix or re-create the marketplace catalog file."; - super(`catalog at "${path}" is malformed (${detail}). ${recovery}`); - this.name = "MalformedMarketplaceCatalogError"; - } -} - -export class PluginNotFoundError extends Error { - constructor(name: string) { - super(`Plugin '${name}' is not installed.`); - this.name = "PluginNotFoundError"; - } -} - -export class DuplicatePluginError extends Error { - constructor(name: string) { - super(`Plugin '${name}' is already installed.`); - this.name = "DuplicatePluginError"; - } -} - -export class PluginFetchError extends Error { - constructor(detail: string) { - super(`Failed to fetch plugin: ${detail}`); - this.name = "PluginFetchError"; - } -} - -export class InvalidMarketplaceNameError extends Error { - constructor(detail: string) { - super( - `Invalid marketplace name: "${detail}". Use lowercase alphanumeric characters and hyphens only.` - ); - this.name = "InvalidMarketplaceNameError"; - } -} - -export class InvalidMarketplaceScopeError extends Error { - constructor(scope: string) { - super(`Invalid marketplace scope: "${scope}". Expected "project" or "user".`); - this.name = "InvalidMarketplaceScopeError"; - } -} - -export class MarketplaceAlreadyRegisteredError extends Error { - constructor(name: string) { - super(`Marketplace '${name}' is already registered.`); - this.name = "MarketplaceAlreadyRegisteredError"; - } -} - -export class MarketplaceNotFoundError extends Error { - constructor(name: string) { - super(`Marketplace '${name}' is not registered.`); - this.name = "MarketplaceNotFoundError"; - } -} - -export class TrustDeniedError extends Error { - constructor(name: string) { - super(`Trust denied for marketplace '${name}'. Aborting.`); - this.name = "TrustDeniedError"; - } -} - -export class PluginNotInMarketplaceError extends Error { - constructor(plugin: string) { - super(`Plugin '${plugin}' was not found in any registered marketplace.`); - this.name = "PluginNotInMarketplaceError"; - } -} - -export class VersionMismatchError extends Error { - constructor(plugin: string, requested: string, actual: string) { - super( - `Plugin '${plugin}': requested version '${requested}' does not match catalog version '${actual}'.` - ); - this.name = "VersionMismatchError"; - } -} - -export class AmbiguousPluginMatchError extends Error { - constructor(plugin: string, marketplaces: readonly string[]) { - super( - `Plugin '${plugin}' matches multiple marketplaces: ${marketplaces.join(", ")}. Use --from .` - ); - this.name = "AmbiguousPluginMatchError"; - } -} - -export class NoMarketplacesRegisteredError extends Error { - constructor() { - super("No marketplaces registered. Use `aidd plugin marketplace add ` first."); - this.name = "NoMarketplacesRegisteredError"; - } -} - -export class InteractiveOnlyError extends Error { - constructor(action: string) { - super(`'${action}' requires an interactive terminal.`); - this.name = "InteractiveOnlyError"; - } -} - -export class ForeignSchemaValidationError extends Error { - constructor(source: string, detail: string) { - super(`Foreign marketplace schema validation failed (${source}): ${detail}`); - this.name = "ForeignSchemaValidationError"; - } -} - -export class CatalogFetchNotFoundError extends Error { - constructor(url: string) { - super(`Catalog not found (HTTP 404): ${url}`); - this.name = "CatalogFetchNotFoundError"; - } -} - -export class CatalogFetchAuthError extends Error { - constructor(url: string) { - super( - `Authentication required to fetch catalog from "${url}". Run \`aidd auth login\` first or use \`--source local --path \`.` - ); - this.name = "CatalogFetchAuthError"; - } -} - -export class CatalogFetchError extends Error { - constructor(url: string, detail: string) { - super(`Failed to fetch catalog from "${url}": ${detail}`); - this.name = "CatalogFetchError"; - } -} - -export class MissingPluginMetadataError extends Error { - constructor() { - super("Cannot register github marketplace plugin: catalog entry is missing plugin metadata."); - this.name = "MissingPluginMetadataError"; - } -} - -export class InvalidPluginComponentKindError extends Error { - constructor(kind: string) { - super(`Invalid kind: "${kind}". Valid: skills|agents|hooks|mcp|full.`); - this.name = "InvalidPluginComponentKindError"; - } -} - -export class JsonSchemaValidationError extends Error { - constructor(errors: string[]) { - super(`Manifest validation failed: ${errors.join("; ")}`); - this.name = "JsonSchemaValidationError"; - } -} - -export class PluginTargetExistsError extends Error { - constructor(path: string) { - super(`Directory '${path}' already exists. Use '--force' to overwrite.`); - this.name = "PluginTargetExistsError"; - } -} - -export class MarketplaceEntryAlreadyExistsError extends Error { - constructor(name: string, index: number, marketplacePath: string) { - super(`Plugin '${name}' already in ${marketplacePath} at index ${index}.`); - this.name = "MarketplaceEntryAlreadyExistsError"; - } -} - -export class FrameworkPlaceholderInPluginError extends Error { - constructor(pluginName: string, relativePath: string) { - super( - `Framework placeholder '@{{TOOLS}}/' is not allowed inside plugin '${pluginName}' (file: ${relativePath}).` - ); - this.name = "FrameworkPlaceholderInPluginError"; - } -} - -export class InvalidBuildPathsError extends Error { - constructor(sourceDir: string, outDir: string) { - super( - `Refusing to build: --out '${outDir}' and --source '${sourceDir}' must not contain each other.` - ); - this.name = "InvalidBuildPathsError"; - } -} - -export class InvalidSourceMarketplaceError extends Error { - constructor(detail: string) { - super(`Invalid source marketplace: ${detail}.`); - this.name = "InvalidSourceMarketplaceError"; - } -} - -export class OutDirNotDirectoryError extends Error { - constructor(outDir: string) { - super(`Refusing to build: --out '${outDir}' does not exist or is not a directory.`); - this.name = "OutDirNotDirectoryError"; - } -} - -export class FlatTargetExistsError extends Error { - constructor(targetPath: string, pluginName: string) { - super( - `Flat build conflict: '${targetPath}' already exists (plugin '${pluginName}'). ` + - "Re-run with --force to overwrite." - ); - this.name = "FlatTargetExistsError"; - } -} - -export class UnknownToolCategoryError extends Error { - constructor(category: string) { - super(`Unknown category: ${category}`); - this.name = "UnknownToolCategoryError"; - } -} - -export class MarketplaceSourceKindError extends Error { - constructor(expected: "remote" | "local") { - super(expected === "remote" ? "Not a remote source" : "Not a local source"); - this.name = "MarketplaceSourceKindError"; - } -} - -export class EmptyLocalSourcePathError extends Error { - constructor() { - super("Local source path must not be empty."); - this.name = "EmptyLocalSourcePathError"; - } -} - -export class InvalidSetupToolIdError extends Error { - constructor(id: string, validIds: readonly string[]) { - super(`Invalid tool ID: "${id}". Valid IDs: ${validIds.join(", ")}`); - this.name = "InvalidSetupToolIdError"; - } -} - -export class InvalidPluginModeConfigError extends Error { - constructor(message: string) { - super(message); - this.name = "InvalidPluginModeConfigError"; - } -} - -export class InvalidInstallScopeError extends Error { - constructor(value: string) { - super(`Invalid scope '${value}'. Expected 'project' or 'user'.`); - this.name = "InvalidInstallScopeError"; - } -} - -export class UnknownAiToolIdError extends Error { - constructor(tool: string, validIds: readonly string[]) { - super(`Unknown AI tool: ${tool}. Valid AI tools: ${validIds.join(", ")}`); - this.name = "UnknownAiToolIdError"; - } -} - -export class EmptyMarketplaceCacheNameError extends Error { - constructor() { - super("MarketplaceCacheEntry: name must not be empty"); - this.name = "EmptyMarketplaceCacheNameError"; - } -} - -export class NativePluginCliError extends Error { - constructor(message: string) { - super(message); - this.name = "NativePluginCliError"; - } -} - -export class UnknownTelemetrySinkSchemaVersionError extends Error { - constructor(version: unknown) { - super( - `Unknown telemetry sink schema version '${String(version)}' — refusing to guess its shape.` - ); - this.name = "UnknownTelemetrySinkSchemaVersionError"; - } -} - -/** A genuine `opencode export` failure — a non-zero exit not explained by "no such - * session", or the command exceeding its timeout. An absent binary or an unknown session - * are not this: those mean the machine simply holds no OpenCode data, and the reader - * resolves to an empty array for them instead of throwing. */ -export class OpencodeExportError extends Error { - constructor(message: string) { - super(message); - this.name = "OpencodeExportError"; - } -} - -export class InvalidReportDayError extends Error { - constructor(flag: string, value: string) { - super(`Invalid ${flag} '${value}'. Expected a UTC day, as YYYY-MM-DD.`); - this.name = "InvalidReportDayError"; - } -} - -export class InvalidReportSpanError extends Error { - constructor(value: string, maxDays: number) { - super(`Invalid --days '${value}'. Expected an integer between 1 and ${maxDays}.`); - this.name = "InvalidReportSpanError"; - } -} - -/** The identity file exists but could not be read back — a read failure (e.g. it is a - * directory) or content that does not parse. Distinct from no file at all, which is a - * person never having opted in and answers `null` rather than throwing. - * - * Also what a damaged separate declaration file would have thrown, back when one existed - * as its own file (`UnreadablePersonMappingFileError`, deleted alongside it): one file, - * one error for a read that could not come back. */ -export class UnreadableIdentityFileError extends Error { - constructor(filePath: string, cause: string) { - super(`Could not read the identity file at ${filePath} (${cause}).`); - this.name = "UnreadableIdentityFileError"; - } -} diff --git a/cli/src/domain/formats/agent-frontmatter-strip.ts b/cli/src/domain/formats/agent-frontmatter-strip.ts deleted file mode 100644 index 634dc398a..000000000 --- a/cli/src/domain/formats/agent-frontmatter-strip.ts +++ /dev/null @@ -1,66 +0,0 @@ -/** - * Copilot-supported frontmatter keys for agent files. - * Order is preserved on serialization for deterministic output (AC #2). - */ -export const COPILOT_AGENT_FRONTMATTER_KEYS: readonly [ - "name", - "description", - "model", - "tools", - "agents", - "argument-hint", -] = ["name", "description", "model", "tools", "agents", "argument-hint"]; - -/** - * Cursor-supported frontmatter keys for agent files. - * Cursor documents only name/description/model — never tools/color. - */ -export const CURSOR_AGENT_FRONTMATTER_KEYS: readonly ["name", "description", "model"] = [ - "name", - "description", - "model", -]; - -/** - * Pick only the specified keys from a frontmatter object, preserving the given key order. - * Keys with undefined values are omitted. - */ -export function pickFrontmatterKeys( - fm: Record, - keys: readonly string[] -): Record { - const result: Record = {}; - for (const key of keys) { - if (fm[key] !== undefined) { - result[key] = fm[key]; - } - } - return result; -} - -/** - * Returns a new object containing only the Copilot-supported frontmatter keys - * with non-undefined values. Iteration order matches the allowlist constant. - * - * No inverse: stripCopilotAgentFrontmatter is lossy — keys not in the allowlist are - * permanently discarded and cannot be recovered from the output. - */ -export function stripCopilotAgentFrontmatter(fm: Record): Record { - return pickFrontmatterKeys(fm, COPILOT_AGENT_FRONTMATTER_KEYS); -} - -/** - * Returns a new object containing only the Cursor-supported frontmatter keys - * (name, description, model) with non-undefined values. Tools/color/argument-hint - * are permanently discarded. - */ -export function stripCursorAgentFrontmatter(fm: Record): Record { - return pickFrontmatterKeys(fm, CURSOR_AGENT_FRONTMATTER_KEYS); -} - -/** - * Alias for stripCopilotAgentFrontmatter — preserves backward compatibility. - */ -export function stripAgentFrontmatter(fm: Record): Record { - return stripCopilotAgentFrontmatter(fm); -} diff --git a/cli/src/domain/formats/claude-build-paths.ts b/cli/src/domain/formats/claude-build-paths.ts deleted file mode 100644 index 9fa4abf71..000000000 --- a/cli/src/domain/formats/claude-build-paths.ts +++ /dev/null @@ -1,13 +0,0 @@ -/** - * Claude build output path constants. - * - * These constants are intentionally distinct from the source-side constants - * (SOURCE_PLUGIN_MANIFEST_RELATIVE / SOURCE_MARKETPLACE_RELATIVE in framework-build.ts) - * even when the literal values coincide. Future changes must not collapse them. - */ - -/** Relative path for the Claude-native plugin manifest inside each plugin output directory. */ -export const OUTPUT_CLAUDE_MANIFEST_RELATIVE = ".claude-plugin/plugin.json"; - -/** Relative path for the Claude marketplace catalog in the claude output tree. */ -export const OUTPUT_CLAUDE_MARKETPLACE_RELATIVE = ".claude-plugin/marketplace.json"; diff --git a/cli/src/domain/formats/claude-code-transcript.ts b/cli/src/domain/formats/claude-code-transcript.ts deleted file mode 100644 index b762bccbd..000000000 --- a/cli/src/domain/formats/claude-code-transcript.ts +++ /dev/null @@ -1,378 +0,0 @@ -import { sep } from "node:path"; -import type { TranscriptLocation } from "../capabilities/telemetry-capability.js"; -import type { - LocalCostCandidateRecord, - TranscriptLineAccumulator, -} from "../ports/session-cost-reader.js"; - -// Measured 2026-08-20 against two real files: a main transcript line from -// ~/.claude/projects/*/*.jsonl (Claude Code 2.1.229) and a subagent's own line from -// ~/.claude/projects/*//subagents/agent-*.jsonl (2.1.232). If Claude Code moves -// any of these field names, tests/domain/formats/claude-code-transcript.unit.test.ts turns -// red against the captured fixture before a zero could be stored in the moved field's place. -// -// A subagent's own messages are never inline in the main transcript — every `isSidechain: -// true` line measured lives only in its own `/subagents/agent-*.jsonl` file, -// which is why the adapter's `TranscriptLocation` below matches both layouts. -const VENDOR_FIELD = "sessionId"; -const TURN_FIELD = "requestId"; - -// Claude Code writes its own fabricated assistant messages into the transcript with this -// literal in `message.model` - a session-limit notice, an "API Error: your computer went -// to sleep" notice. They are messages the tool composed, not calls anyone was -// billed for, so they yield no record at all. -// -// The filter is the marker, never all-counters-zero: measured 2026-08-23 across every -// transcript in ~/.claude/projects, all 251 `` messages carried four zero -// counters and `` was the only such placeholder any of them used for a model. -// A genuinely billed call that happened to read zero on all four - improbable, not -// impossible - is still an observation, and still yields its record. -const SYNTHETIC_MODEL = ""; - -interface ClaudeUsage { - readonly input_tokens?: unknown; - readonly cache_creation_input_tokens?: unknown; - readonly cache_read_input_tokens?: unknown; - readonly output_tokens?: unknown; -} - -interface ClaudeTranscriptLine { - readonly type?: unknown; - readonly sessionId?: unknown; - readonly uuid?: unknown; - readonly parentUuid?: unknown; - readonly promptId?: unknown; - readonly requestId?: unknown; - readonly isSidechain?: unknown; - readonly timestamp?: unknown; - readonly effort?: unknown; - readonly attributionAgent?: unknown; - readonly attributionSkill?: unknown; - readonly attributionPlugin?: unknown; - readonly message?: { - readonly model?: unknown; - readonly id?: unknown; - readonly usage?: ClaudeUsage; - readonly content?: unknown; - }; -} - -interface ClaudeCounters { - readonly input_tokens: number; - readonly cache_creation_input_tokens: number; - readonly cache_read_input_tokens: number; - readonly output_tokens: number; -} - -function asNumber(value: unknown): number | undefined { - return typeof value === "number" ? value : undefined; -} - -function asString(value: unknown): string | undefined { - return typeof value === "string" ? value : undefined; -} - -/** All four or none: a partial `usage` — a truncated final line, or a shape this file has - * not been taught — yields no record rather than one with a missing counter read as zero. */ -function readCounters(usage: ClaudeUsage | undefined): ClaudeCounters | null { - const input = asNumber(usage?.input_tokens); - const cacheCreation = asNumber(usage?.cache_creation_input_tokens); - const cacheRead = asNumber(usage?.cache_read_input_tokens); - const output = asNumber(usage?.output_tokens); - if (input === undefined || cacheCreation === undefined) return null; - if (cacheRead === undefined || output === undefined) return null; - return { - input_tokens: input, - cache_creation_input_tokens: cacheCreation, - cache_read_input_tokens: cacheRead, - output_tokens: output, - }; -} - -function buildIdentity( - line: ClaudeTranscriptLine, - vendorId: string -): Pick< - LocalCostCandidateRecord, - "vendor_id" | "vendor_field" | "turn_id" | "turn_field" | "billed_request_id" -> { - const turnId = asString(line.requestId); - return { - vendor_id: vendorId, - vendor_field: VENDOR_FIELD, - ...(turnId !== undefined ? { turn_id: turnId, turn_field: TURN_FIELD } : {}), - // The same value as `turn_id` on this route — Claude Code's local transcript names one - // billed call the same way it names one turn, `requestId`. Stated separately rather - // than derived from `turn_id` downstream: `turn_id` is not guaranteed unique per billed - // request on every tool and route, and a consumer collapsing two records into one must - // never key that on a field with that caveat. See telemetry-sink-record.ts. - ...(turnId !== undefined ? { billed_request_id: turnId } : {}), - }; -} - -// The export path sets `agent_name` for a subagent's own request (see -// otlp-logs-claude-code-subagent.json); matching that here is what keeps a consumer from -// being able to tell a local-read subagent record from an exported one by anything but -// `provenance`. -// `attributionSkill` is exact and unflagged, per message, on the same line as `usage` — -// measured 2026-08-20 against 40 real transcripts (2267 attributed messages, 25 distinct -// skills). It arrived around Claude Code 2.1.220 and is omitted, never nulled, when no -// skill is running; a version that predates the field omits it identically. Nothing on the -// line separates those two cases, so its absence here yields no `step` at all, leaving -// attribution to fall back to a run-journal interval (or unattributed) rather than -// asserting "no skill ran". `attributionPlugin` is read alongside it, and only alongside -// it — a plugin name with no skill name is not a fact this line can state. -function buildOptionalFields( - line: ClaudeTranscriptLine -): Pick< - LocalCostCandidateRecord, - "model" | "effort" | "event_timestamp" | "agent_name" | "step" | "step_plugin" -> { - const model = asString(line.message?.model); - const effort = asString(line.effort); - const timestamp = asString(line.timestamp); - const agentName = line.isSidechain === true ? asString(line.attributionAgent) : undefined; - const step = asString(line.attributionSkill); - const stepPlugin = step !== undefined ? asString(line.attributionPlugin) : undefined; - return { - ...(model !== undefined ? { model } : {}), - ...(effort !== undefined ? { effort } : {}), - ...(timestamp !== undefined ? { event_timestamp: timestamp } : {}), - ...(agentName !== undefined ? { agent_name: agentName } : {}), - ...(step !== undefined ? { step } : {}), - ...(stepPlugin !== undefined ? { step_plugin: stepPlugin } : {}), - }; -} - -function buildRecord( - line: ClaudeTranscriptLine, - vendorId: string, - counters: ClaudeCounters -): LocalCostCandidateRecord { - return { - kind: "request", - ...buildIdentity(line, vendorId), - ...buildOptionalFields(line), - input_tokens: counters.input_tokens, - output_tokens: counters.output_tokens, - cache_read_tokens: counters.cache_read_input_tokens, - cache_creation_tokens: counters.cache_creation_input_tokens, - }; -} - -/** One JSONL line as an object, or `null` for a blank or unparseable one. Shared by the - * billed-turn parser and the link walk, so a line either reaches both or neither. */ -function parseLine(line: string): ClaudeTranscriptLine | null { - const trimmed = line.trim(); - if (!trimmed) return null; - try { - return JSON.parse(trimmed) as ClaudeTranscriptLine; - } catch { - return null; - } -} - -function uuidOf(line: string): string | undefined { - const parsed = parseLine(line); - return parsed === null ? undefined : asString(parsed.uuid); -} - -/** The prompt a line belongs to, found by walking `parentUuid` upward. - * - * A billed call and the prompt that caused it never share a line: measured on a real - * 810-record session, zero lines carry both `requestId` and `promptId`, only `type: "user"` - * lines carry the second, and all 209 lines bearing counters reach one this way — three hops - * in the median. - * - * `seen` bounds the walk instead of a hop count: a transcript is appended to by a live - * process and can be truncated mid-write, so a parent that points at a line which never - * arrived, or a cycle a damaged file leaves behind, must end the walk rather than search - * forever. A hop cap would also terminate, but it would silently stop answering for a - * legitimately deep chain, which is the kind of number nobody could ever justify. */ -/** The skill a `Skill` tool call on this line invokes, or `undefined` for every other line. - * - * Only a `Skill` call names a step. Every other tool call is work done inside whatever step - * was already running, and reading one as a start would name a skill for a prompt that - * invoked none. `input.skill` is the field Claude Code puts the name in - the same one - * `skill-detection.cjs` reads out of the hook payload, so the transcript and the run - * journal name a step identically. */ -function skillInvokedOn(line: ClaudeTranscriptLine): string | undefined { - const content = line.message?.content; - if (!Array.isArray(content)) return undefined; - for (const part of content) { - if (typeof part !== "object" || part === null) continue; - const call = part as { type?: unknown; name?: unknown; input?: { skill?: unknown } }; - if (call.type !== "tool_use" || call.name !== "Skill") continue; - const skill = asString(call.input?.skill); - if (skill !== undefined) return skill; - } - return undefined; -} - -function resolvePromptId( - startUuid: string | undefined, - parents: ReadonlyMap, - prompts: ReadonlyMap -): string | undefined { - const seen = new Set(); - let current = startUuid; - while (current !== undefined && !seen.has(current)) { - const prompt = prompts.get(current); - if (prompt !== undefined) return prompt; - seen.add(current); - current = parents.get(current); - } - return undefined; -} - -/** One parsed JSONL line, keyed by `message.id` — the identifier that ties together the - * separate log lines one API call can produce. Mapping every such line to its own record - * would count that single call's tokens more than once. - * - * The lines do NOT all carry the same `usage`, which an earlier version of this comment - * claimed. Measured across 1,604 real transcripts on one machine: of 83,626 `message.id` - * groups, 25,702 carry differing figures, and in 25,702 of 25,702 the last line's - * `output_tokens` is greater than or equal to the first's. Claude Code writes a line when a - * message starts and again when it completes, and only the last carries - * `output_tokens_details` and `iterations`. Keeping the first kept the placeholder: 37.4% of - * every output token on that machine was being discarded, and up to 94% of a - * subagent-heavy session's. - * - * The last line wins, and the figures are never summed. In 25,143 of those 25,702 groups - * `input_tokens` and `cache_read_input_tokens` are identical across the lines — they are one - * call restated, not two calls — so adding them would multiply the cache counters, which are - * by far the largest. */ -function parseAssistantLine( - line: string -): { readonly dedupeKey: string; readonly record: LocalCostCandidateRecord } | null { - const trimmed = line.trim(); - if (!trimmed) return null; - let parsed: ClaudeTranscriptLine; - try { - parsed = JSON.parse(trimmed) as ClaudeTranscriptLine; - } catch { - return null; - } - if (parsed.type !== "assistant") return null; - // Before the dedupe key is computed: a line that is not a request must not consume a - // key either, or the first real call sharing it would be dropped as a duplicate. - if (parsed.message?.model === SYNTHETIC_MODEL) return null; - const vendorId = asString(parsed.sessionId); - if (vendorId === undefined) return null; - const counters = readCounters(parsed.message?.usage); - if (!counters) return null; - const dedupeKey = asString(parsed.message?.id) ?? asString(parsed.requestId) ?? trimmed; - return { dedupeKey, record: buildRecord(parsed, vendorId, counters) }; -} - -class ClaudeCodeTranscriptAccumulator implements TranscriptLineAccumulator { - // Insertion-ordered, and the value is replaced rather than skipped: a later line for a key - // already seen is the same call, restated with figures that have grown. The record's - // position stays where the call first appeared, so the order a reader sees is the order - // the calls happened. - private readonly byKey = new Map(); - // Which line each record came from, so its prompt can be resolved once every line has - // been seen — a parent almost always appears earlier, but nothing in the format promises - // it, and a walk run mid-stream would answer from a half-built map. - private readonly uuidByKey = new Map(); - // Every line's own links, gathered from *all* lines rather than only billed ones: the - // chain from a call to its prompt runs through lines that carry no counters at all. - private readonly parents = new Map(); - private readonly prompts = new Map(); - /** Every `Skill` call the transcript holds, in the order it holds them, paired with the - * line that made it. Resolved to prompts in `build()` and not here, for the reason the - * class already resolves prompts there: a walk run mid-stream reads a half-built chain. */ - private readonly skillCalls: { readonly uuid: string; readonly skill: string }[] = []; - - push(line: string): void { - this.rememberLinks(line); - const parsed = parseAssistantLine(line); - if (!parsed) return; - this.byKey.set(parsed.dedupeKey, parsed.record); - const uuid = uuidOf(line); - if (uuid !== undefined) this.uuidByKey.set(parsed.dedupeKey, uuid); - } - - /** Parsed a second time, deliberately: `parseAssistantLine` answers `null` for every line - * that is not a billed assistant turn, and those are exactly the lines this walk needs. */ - private rememberLinks(line: string): void { - const parsed = parseLine(line); - if (parsed === null) return; - const uuid = asString(parsed.uuid); - if (uuid === undefined) return; - const parent = asString(parsed.parentUuid); - if (parent !== undefined) this.parents.set(uuid, parent); - const prompt = asString(parsed.promptId); - if (prompt !== undefined) this.prompts.set(uuid, prompt); - const skill = skillInvokedOn(parsed); - if (skill !== undefined) this.skillCalls.push({ uuid, skill }); - } - - /** The skill each prompt invoked, first call wins. - * - * The first and not the last: a prompt that invokes two skills invoked the second from - * inside the first, and the prompt is named for the work it began - the same rule - * `promptToSkill` follows over the run journal's own `step_start` lines, so the two - * sources cannot disagree about a prompt they both saw. */ - private skillByPrompt(): ReadonlyMap { - const byPrompt = new Map(); - for (const { uuid, skill } of this.skillCalls) { - const prompt = resolvePromptId(uuid, this.parents, this.prompts); - if (prompt !== undefined && !byPrompt.has(prompt)) byPrompt.set(prompt, skill); - } - return byPrompt; - } - - build(): readonly LocalCostCandidateRecord[] { - const skillByPrompt = this.skillByPrompt(); - return [...this.byKey.entries()].map(([key, record]) => { - const promptId = resolvePromptId(this.uuidByKey.get(key), this.parents, this.prompts); - if (promptId === undefined) return record; - const promptSkill = skillByPrompt.get(promptId); - return { - ...record, - prompt_id: promptId, - ...(promptSkill === undefined ? {} : { prompt_skill: promptSkill }), - }; - }); - } -} - -export function createClaudeCodeTranscriptAccumulator(): TranscriptLineAccumulator { - return new ClaudeCodeTranscriptAccumulator(); -} - -/** The `(content: string) => records[]` shape task 1.4 asks for, and what a fixture-driven - * test targets directly. The adapter instead streams `createClaudeCodeTranscriptAccumulator` - * one line at a time, so a large transcript is never held whole in memory — this is a - * convenience wrapper around the same per-line logic, not a second implementation of it. */ -export function mapClaudeCodeTranscriptToSinkRecords( - content: string -): readonly LocalCostCandidateRecord[] { - const accumulator = createClaudeCodeTranscriptAccumulator(); - for (const line of content.split("\n")) accumulator.push(line); - return accumulator.build(); -} - -function matchesMainTranscript(segments: readonly string[], sessionId: string): boolean { - return segments.length === 2 && segments[1] === `${sessionId}.jsonl`; -} - -function matchesSubagentTranscript(segments: readonly string[], sessionId: string): boolean { - return ( - segments.length === 4 && - segments[1] === sessionId && - segments[2] === "subagents" && - segments[3].endsWith(".jsonl") - ); -} - -export const CLAUDE_CODE_TRANSCRIPT_LOCATION: TranscriptLocation = { - root: (homeDir) => `${homeDir}${sep}.claude${sep}projects`, - matches: (relativePath, sessionId) => { - const segments = relativePath.split(sep); - return ( - matchesMainTranscript(segments, sessionId) || matchesSubagentTranscript(segments, sessionId) - ); - }, -}; diff --git a/cli/src/domain/formats/claude-root-path-rewrite.ts b/cli/src/domain/formats/claude-root-path-rewrite.ts deleted file mode 100644 index 8d001b30c..000000000 --- a/cli/src/domain/formats/claude-root-path-rewrite.ts +++ /dev/null @@ -1,54 +0,0 @@ -/** - * Pure helper that rewrites ${CLAUDE_PLUGIN_ROOT}/ → ./ in every - * string value of an arbitrary parsed JSON structure (spec §"Hooks" and §"MCP"). - * - * Recurses through arrays and objects. Rewrites only string VALUES, never keys, - * so that key names containing the pattern are left untouched (spec §M-v2.4 risk note). - * - * No I/O, no path math — plain string prefix substitution. - */ - -// Written as a split literal to avoid biome's noTemplateCurlyInString warning. -const CLAUDE_ROOT_PREFIX = "$" + "{CLAUDE_PLUGIN_ROOT}/"; -const DEFAULT_RELATIVE_PREFIX = "./"; - -/** - * Rewrites ${CLAUDE_PLUGIN_ROOT}/ in every string value of a parsed JSON - * structure. The optional `substitute` function receives the suffix (the part after - * the prefix) and returns the replacement string. - * - * Defaults to `(s) => "./" + s` (Mode A behaviour). - */ -export function rewriteClaudeRootInJson( - parsed: unknown, - substitute?: (suffix: string) => string -): unknown { - if (typeof parsed === "string") return rewriteStringValue(parsed, substitute); - if (Array.isArray(parsed)) return parsed.map((item) => rewriteClaudeRootInJson(item, substitute)); - if (parsed !== null && typeof parsed === "object") - return rewriteObject(parsed as Record, substitute); - return parsed; -} - -function rewriteStringValue(value: string, substitute?: (suffix: string) => string): string { - if (!value.includes(CLAUDE_ROOT_PREFIX)) return value; - if (!substitute) return value.replaceAll(CLAUDE_ROOT_PREFIX, DEFAULT_RELATIVE_PREFIX); - return value.split(CLAUDE_ROOT_PREFIX).reduce((acc, segment, i) => { - if (i === 0) return segment; - const spaceIdx = segment.search(/[\s"'<>]/); - const suffix = spaceIdx === -1 ? segment : segment.slice(0, spaceIdx); - const rest = spaceIdx === -1 ? "" : segment.slice(spaceIdx); - return acc + substitute(suffix) + rest; - }, ""); -} - -function rewriteObject( - obj: Record, - substitute?: (suffix: string) => string -): Record { - const result: Record = {}; - for (const [key, value] of Object.entries(obj)) { - result[key] = rewriteClaudeRootInJson(value, substitute); - } - return result; -} diff --git a/cli/src/domain/formats/codex-agent-toml.ts b/cli/src/domain/formats/codex-agent-toml.ts deleted file mode 100644 index 07b79ac6c..000000000 --- a/cli/src/domain/formats/codex-agent-toml.ts +++ /dev/null @@ -1,62 +0,0 @@ -import { parseFrontmatter } from "./markdown.js"; -import { stringifyToml } from "./toml.js"; - -/** - * Converts a Claude-format agent markdown file (frontmatter + body) into a - * Codex subagent TOML string. - * - * TOML schema mapping (D-14, D-15, D-16): - * name — when prefixName=false: fm.name when present, else "-" - * when prefixName=true: always "-" (flat mode) - * description — fm.description when present (string) - * model — omitted in MVP1 (D-5): no known Codex model id set - * developer_instructions — verbatim body, no rewrite (D-4) - * - * Key insertion order is fixed for deterministic output (D-15). - * - * prefixName=true is used in flat mode where all plugins share one .codex/agents/ directory; - * the plugin prefix prevents name collisions between agents from different plugins. - * prefixName=false is used in marketplace mode where each plugin has its own subdirectory. - * - * No inverse: codexAgentMarkdownToToml is lossy — the model field is intentionally - * omitted (D-5) and the TOML schema diverges from markdown frontmatter, making a - * lossless round-trip technically impossible. - */ -export function codexAgentMarkdownToToml( - content: string, - pluginName: string, - fileBaseName: string, - prefixName = false -): string { - const { frontmatter, body } = parseFrontmatter(content); - const name = resolveName(frontmatter, pluginName, fileBaseName, prefixName); - const obj = buildTomlObject(name, frontmatter, body); - return stringifyToml(obj); -} - -function resolveName( - frontmatter: Record, - pluginName: string, - fileBaseName: string, - prefixName: boolean -): string { - const basename = fileBaseName.replace(/\.md$/, ""); - if (!prefixName && typeof frontmatter.name === "string" && frontmatter.name.length > 0) { - return frontmatter.name; - } - return `${pluginName}-${basename}`; -} - -function buildTomlObject( - name: string, - frontmatter: Record, - body: string -): Record { - const obj: Record = {}; - obj.name = name; - // description is a required subagent key; default to "" when absent. - obj.description = typeof frontmatter.description === "string" ? frontmatter.description : ""; - // model is intentionally omitted in MVP1 (D-5): no known Codex model id set. - obj.developer_instructions = body; - return obj; -} diff --git a/cli/src/domain/formats/codex-marketplace.ts b/cli/src/domain/formats/codex-marketplace.ts deleted file mode 100644 index 43859cde5..000000000 --- a/cli/src/domain/formats/codex-marketplace.ts +++ /dev/null @@ -1,76 +0,0 @@ -/** - * Codex marketplace format adapter — pure parser, no I/O. - * - * Codex supports a multi-plugin marketplace catalog at `.agents/plugins/marketplace.json` - * (repo-scoped) or `~/.agents/plugins/marketplace.json` (personal scope). - * This adapter targets the repo-scoped path, treated as a multi-entry catalog. - * - * Documented fields (per https://developers.openai.com/codex/plugins/build): - * name (required), version (required by spec), description (required by spec) - * + author, homepage, repository, license, keywords, skills, mcpServers, - * apps, hooks, interface — ignored for NormalizedPlugin extraction. - * - * Marketplace catalog shape: { name?, plugins: [{ name, version?, description? }] } - * Mirrors Cursor's shape (multi-plugin array), not Copilot's (single-plugin manifest). - */ - -import { ForeignSchemaValidationError } from "../errors.js"; -import type { NormalizedCatalog, NormalizedPlugin } from "../models/normalized-plugin.js"; - -const SOURCE = "codex"; - -export function parseCodexMarketplace(rawJson: string): NormalizedCatalog { - const parsed = parseJson(rawJson); - const plugins = extractPlugins(parsed); - return { source: SOURCE, plugins }; -} - -function parseJson(rawJson: string): unknown { - try { - return JSON.parse(rawJson); - } catch { - throw new ForeignSchemaValidationError(SOURCE, "marketplace.json is not valid JSON"); - } -} - -function extractPlugins(parsed: unknown): readonly NormalizedPlugin[] { - if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) { - throw new ForeignSchemaValidationError(SOURCE, "marketplace.json must be a JSON object"); - } - const obj = parsed as Record; - if (!Array.isArray(obj.plugins)) { - throw new ForeignSchemaValidationError(SOURCE, '"plugins" must be an array'); - } - return obj.plugins.map((entry, i) => parseEntry(entry, i)); -} - -function parseEntry(raw: unknown, index: number): NormalizedPlugin { - if (raw === null || typeof raw !== "object" || Array.isArray(raw)) { - throw new ForeignSchemaValidationError(SOURCE, `plugins[${index}] must be an object`); - } - const obj = raw as Record; - if (typeof obj.name !== "string" || obj.name.length === 0) { - throw new ForeignSchemaValidationError( - SOURCE, - `plugins[${index}].name must be a non-empty string` - ); - } - return withOptionalFields({ name: obj.name, source: SOURCE }, obj); -} - -function withOptionalFields( - plugin: NormalizedPlugin, - obj: Record -): NormalizedPlugin { - if (typeof obj.version === "string" && obj.version.length > 0) { - return { - ...plugin, - version: obj.version, - ...(typeof obj.description === "string" ? { description: obj.description } : {}), - }; - } - if (typeof obj.description === "string") { - return { ...plugin, description: obj.description }; - } - return plugin; -} diff --git a/cli/src/domain/formats/codex-paths.ts b/cli/src/domain/formats/codex-paths.ts deleted file mode 100644 index 66a4bf0f9..000000000 --- a/cli/src/domain/formats/codex-paths.ts +++ /dev/null @@ -1,22 +0,0 @@ -/** - * Codex build output path constants. - * - * These constants are intentionally distinct from their source-side equivalents - * even when the literal values coincide (e.g. OUTPUT_CODEX_MARKETPLACE_RELATIVE - * mirrors SOURCE_MARKETPLACE_RELATIVE). Future changes to either side must not - * collapse them. - */ - -/** Relative path for the Codex-native plugin manifest inside each plugin output directory. */ -export const OUTPUT_CODEX_MANIFEST_RELATIVE = ".codex-plugin/plugin.json"; - -/** - * Relative path for the Codex-native marketplace catalog in the codex output tree. - * This is the official repo-scoped path `codex plugin marketplace add owner/repo` - * discovers (https://developers.openai.com/codex/plugins/build). The legacy - * `.claude-plugin/marketplace.json` fallback is intentionally not emitted. - */ -export const OUTPUT_CODEX_MARKETPLACE_RELATIVE = ".agents/plugins/marketplace.json"; - -/** Subdirectory name inside each plugin output for staged Codex agent TOML files. */ -export const OUTPUT_CODEX_AGENTS_DIR = "codex-agents"; diff --git a/cli/src/domain/formats/codex-rollout.ts b/cli/src/domain/formats/codex-rollout.ts deleted file mode 100644 index 6525f5f24..000000000 --- a/cli/src/domain/formats/codex-rollout.ts +++ /dev/null @@ -1,256 +0,0 @@ -import { sep } from "node:path"; -import type { TranscriptLocation } from "../capabilities/telemetry-capability.js"; -import type { - LocalCostCandidateRecord, - TranscriptLineAccumulator, -} from "../ports/session-cost-reader.js"; - -// Measured 2026-08-20 against two real rollouts on Codex CLI 0.145.0-alpha.27: -// ~/.codex/sessions/2026/07/29/rollout-*-019fae6f-....jsonl (a resumed session, where -// `session_meta.id` and `session_id` differ) and .../2026/07/16/rollout-*-019f69d0-....jsonl -// (that resumed session's own parent, a fresh session where the two agree). If Codex moves -// any of these field names, tests/domain/formats/codex-rollout.unit.test.ts turns red -// against the captured fixtures before a zero could be stored in the moved field's place. -// -// A `token_count` event's `info` carries `total_token_usage` (cumulative for the whole -// rollout) and `last_token_usage` (this call's own increment) — summing the totals across -// calls double-counts every call after the first. `info` carries no model and no request -// id at all; those live on the `turn_context` event that precedes the run of `token_count` -// events belonging to one turn, keyed by `turn_id`. And `last_token_usage.input_tokens` is -// *inclusive* of `cached_input_tokens` (OpenAI's Responses API convention), unlike Claude -// Code's `usage.input_tokens`, which is exclusive of its own cache figure — subtracting -// `cached_input_tokens` here is what keeps `input_tokens` meaning the same thing across -// tools. `reasoning_output_tokens` is a subset of `output_tokens`, not a sibling of it, so -// it is never added to it. -const VENDOR_FIELD = "session_meta.id"; -const TURN_FIELD = "turn_id"; - -interface CodexTokenUsage { - readonly input_tokens?: unknown; - readonly cached_input_tokens?: unknown; - readonly cache_write_input_tokens?: unknown; - readonly output_tokens?: unknown; -} - -interface CodexLine { - readonly type?: unknown; - readonly timestamp?: unknown; - readonly payload?: { - readonly id?: unknown; - readonly turn_id?: unknown; - readonly model?: unknown; - readonly effort?: unknown; - readonly type?: unknown; - readonly info?: { - readonly last_token_usage?: CodexTokenUsage; - readonly total_token_usage?: CodexTokenUsage; - }; - }; -} - -interface PendingTurn { - readonly turnId: string; - readonly model?: string; - readonly effort?: string; - readonly at?: string; - inputTokens?: number; - outputTokens?: number; - cacheReadTokens?: number; - cacheCreationTokens?: number; -} - -function asNumber(value: unknown): number | undefined { - return typeof value === "number" ? value : undefined; -} - -function asString(value: unknown): string | undefined { - return typeof value === "string" ? value : undefined; -} - -function parseLine(line: string): CodexLine | null { - const trimmed = line.trim(); - if (!trimmed) return null; - try { - return JSON.parse(trimmed) as CodexLine; - } catch { - return null; - } -} - -// `at` is the turn's own start, taken from the `turn_context` line rather than from any -// counted event: a record here covers a whole turn, so a moment inside it would claim a -// precision the record does not have. It is what a step interval is matched against. -function startTurn( - payload: NonNullable, - at: string | undefined -): PendingTurn | null { - const turnId = asString(payload.turn_id); - if (turnId === undefined) return null; - return { turnId, model: asString(payload.model), effort: asString(payload.effort), at }; -} - -/** Adds this event's own increment to the turn's running sums — never the cumulative - * `total_token_usage`. A metric absent from every event in the turn (Codex sometimes omits - * `cache_write_input_tokens` entirely rather than sending zero) stays unset rather than - * being summed into a fabricated zero. */ -function addUsage(pending: PendingTurn, usage: CodexTokenUsage): void { - const rawInput = asNumber(usage.input_tokens); - const cached = asNumber(usage.cached_input_tokens); - const cacheWrite = asNumber(usage.cache_write_input_tokens); - const output = asNumber(usage.output_tokens); - if (rawInput !== undefined) { - pending.inputTokens = (pending.inputTokens ?? 0) + (rawInput - (cached ?? 0)); - } - if (cached !== undefined) pending.cacheReadTokens = (pending.cacheReadTokens ?? 0) + cached; - if (cacheWrite !== undefined) { - pending.cacheCreationTokens = (pending.cacheCreationTokens ?? 0) + cacheWrite; - } - if (output !== undefined) pending.outputTokens = (pending.outputTokens ?? 0) + output; -} - -/** The cumulative figure as a comparable tuple, or null when this event carries none. - * - * Codex re-emits the last `token_count` of a turn verbatim: the same `last_token_usage` - * arrives twice while `total_token_usage` does not move. Measured on 400 real rollouts, 291 - * of 16,415 events (1.8%) across 38 rollouts, inflating a summed reading of this tool by - * ~0.9% on input and cache-read and ~1.2% on output. A cumulative that has not moved cannot - * carry consumption that was billed: that is what the cumulative means, so this is a - * consequence of the format rather than a guess about it. - * - * `null` when the event states no cumulative at all — then the increment is counted, because - * an absent figure is not evidence that nothing happened. */ -function cumulativeKey(usage: CodexTokenUsage | undefined): string | null { - if (!usage) return null; - const parts = [ - asNumber(usage.input_tokens), - asNumber(usage.cached_input_tokens), - asNumber(usage.cache_write_input_tokens), - asNumber(usage.output_tokens), - ]; - if (parts.every((part) => part === undefined)) return null; - return parts.map((part) => (part === undefined ? "" : String(part))).join("/"); -} - -function hasCounters(pending: PendingTurn): boolean { - return ( - pending.inputTokens !== undefined || - pending.outputTokens !== undefined || - pending.cacheReadTokens !== undefined || - pending.cacheCreationTokens !== undefined - ); -} - -function buildRecord(vendorId: string, pending: PendingTurn): LocalCostCandidateRecord { - return { - kind: "request", - vendor_id: vendorId, - vendor_field: VENDOR_FIELD, - turn_id: pending.turnId, - turn_field: TURN_FIELD, - ...(pending.model !== undefined ? { model: pending.model } : {}), - ...(pending.effort !== undefined ? { effort: pending.effort } : {}), - ...(pending.at !== undefined ? { event_timestamp: pending.at } : {}), - ...(pending.inputTokens !== undefined ? { input_tokens: pending.inputTokens } : {}), - ...(pending.outputTokens !== undefined ? { output_tokens: pending.outputTokens } : {}), - ...(pending.cacheReadTokens !== undefined - ? { cache_read_tokens: pending.cacheReadTokens } - : {}), - ...(pending.cacheCreationTokens !== undefined - ? { cache_creation_tokens: pending.cacheCreationTokens } - : {}), - }; -} - -/** Pairs each `turn_context` with the `token_count` events that follow it, up to the next - * `turn_context` (or end of file), and emits one record per turn — never per line, since a - * `token_count` event alone carries no model, no request id, and only a cumulative figure. - * - * `build()`'s own final `flush()` cannot tell "the session ended here" from "the session is - * still running and this turn is not done yet" apart — a rollout has no line that says a - * turn, or the session, is finished; a turn is closed only by the *next* `turn_context`, and - * a still-running session's last turn has none. This module does not attempt that - * distinction itself: it always emits whatever the counters sum to so far, and does not need - * to say anything more than what it counted. Whether a later read's own record for the same - * `turn_id` is a genuine correction is decided downstream, in `read-local-cost-use-case.ts`'s - * `storeNewCandidates` — by comparing it against what is already stored, never by asking - * whether the session "should" be finished by now: a candidate whose counters are strictly - * larger than what is stored is itself the only proof this module's own file can ever offer - * that an earlier reading was not the last word. */ -class CodexRolloutAccumulator implements TranscriptLineAccumulator { - private vendorId: string | undefined; - private pending: PendingTurn | undefined; - /** The cumulative last counted, so a verbatim re-emission of a turn's final - * `token_count` is not added a second time. See `cumulativeKey`. */ - private lastCumulative: string | undefined; - private readonly records: LocalCostCandidateRecord[] = []; - - push(line: string): void { - const parsed = parseLine(line); - if (!parsed?.payload) return; - if (parsed.type === "session_meta") this.vendorId = asString(parsed.payload.id); - else if (parsed.type === "turn_context") this.startNewTurn(parsed.payload, parsed.timestamp); - else if (parsed.type === "event_msg" && parsed.payload.type === "token_count") { - this.applyTokenCount( - parsed.payload.info?.last_token_usage, - parsed.payload.info?.total_token_usage - ); - } - } - - build(): readonly LocalCostCandidateRecord[] { - this.flush(); - return this.records; - } - - private startNewTurn(payload: NonNullable, timestamp: unknown): void { - this.flush(); - this.pending = startTurn(payload, asString(timestamp)) ?? undefined; - } - - private applyTokenCount( - usage: CodexTokenUsage | undefined, - cumulative: CodexTokenUsage | undefined - ): void { - if (!this.pending || !usage) return; - const key = cumulativeKey(cumulative); - if (key !== null && key === this.lastCumulative) return; - if (key !== null) this.lastCumulative = key; - addUsage(this.pending, usage); - } - - private flush(): void { - if (this.pending && this.vendorId !== undefined && hasCounters(this.pending)) { - this.records.push(buildRecord(this.vendorId, this.pending)); - } - this.pending = undefined; - } -} - -export function createCodexRolloutAccumulator(): TranscriptLineAccumulator { - return new CodexRolloutAccumulator(); -} - -/** The `(content: string) => records[]` shape task 1.4 asks for, and what a fixture-driven - * test targets directly. The adapter instead streams `createCodexRolloutAccumulator` one - * line at a time, so a large rollout is never held whole in memory. */ -export function mapCodexRolloutToSinkRecords(content: string): readonly LocalCostCandidateRecord[] { - const accumulator = createCodexRolloutAccumulator(); - for (const line of content.split("\n")) accumulator.push(line); - return accumulator.build(); -} - -/** - * Resolving Codex's session by `session_meta.id`, not `session_meta.session_id`, is - * task 3's whole point: on a fresh session the two hold the same value, so a reader keyed - * on the wrong one still passes every test written against a fresh session. This location's - * `matches` relies on the filename instead of opening the file to check — measured across - * every rollout on disk, a file's own trailing UUID always equals its `session_meta.id`, - * including on the resumed session captured above, where it does not equal `session_id`. - */ -export const CODEX_ROLLOUT_LOCATION: TranscriptLocation = { - root: (homeDir) => `${homeDir}${sep}.codex${sep}sessions`, - matches: (relativePath, sessionId) => { - const base = relativePath.split(sep).pop() ?? relativePath; - return base.startsWith("rollout-") && base.endsWith(`-${sessionId}.jsonl`); - }, -}; diff --git a/cli/src/domain/formats/command.ts b/cli/src/domain/formats/command.ts deleted file mode 100644 index d69568b63..000000000 --- a/cli/src/domain/formats/command.ts +++ /dev/null @@ -1,79 +0,0 @@ -import type { UserFileSection, UserFileSectionKey } from "../tools/contracts.js"; - -export function stripToolSuffix(suffix: string, fileName: string): string { - const basename = fileName.split("/").at(-1) ?? fileName; - if (!basename.endsWith(suffix)) return fileName; - const dir = fileName.slice(0, fileName.length - basename.length); - const stripped = `${basename.slice(0, -suffix.length)}.md`; - return `${dir}${stripped}`; -} - -function buildCommandName(fm: Record, relativeFileName: string): string { - const phase = relativeFileName.split("/")[0]?.match(/^(\d+)/)?.[1]; - const baseName = String(fm.name ?? ""); - return phase ? `aidd:${phase}:${baseName}` : baseName; -} - -function stripCommandNamePrefix(fm: Record): string { - const rawName = String(fm.name ?? ""); - const match = /^aidd:\d+:(.+)$/.exec(rawName); - return match ? match[1] : rawName; -} - -export function convertCommandFrontmatter( - fm: Record, - relativeFileName: string -): Record { - const name = buildCommandName(fm, relativeFileName); - const result: Record = { name, description: fm.description }; - if (fm["argument-hint"] !== undefined) result["argument-hint"] = fm["argument-hint"]; - return result; -} - -export function convertCommandFrontmatterNoHint( - fm: Record, - relativeFileName: string -): Record { - const name = buildCommandName(fm, relativeFileName); - return { name, description: fm.description }; -} - -export function reverseConvertCommandFrontmatter( - fm: Record -): Record { - const name = stripCommandNamePrefix(fm); - const result: Record = { name, description: fm.description }; - if (fm["argument-hint"] !== undefined) result["argument-hint"] = fm["argument-hint"]; - return result; -} - -export function reverseConvertCommandFrontmatterNoHint( - fm: Record -): Record { - const name = stripCommandNamePrefix(fm); - return { name, description: fm.description }; -} - -export function buildAiddCommandFilePath(dir: string, fileName: string): string { - const slashIdx = fileName.indexOf("/"); - if (slashIdx !== -1) { - const phaseDir = fileName.slice(0, slashIdx); - const baseName = fileName.slice(slashIdx + 1); - const phase = phaseDir.match(/^(\d+)/)?.[1]; - if (phase) { - return `${dir}commands/aidd/${phase}/${baseName}`; - } - } - const baseName = fileName.split("/").at(-1) ?? fileName; - return `${dir}commands/aidd/${baseName}`; -} - -export function detectSectionKeyFromPrefixes( - relativePath: string, - prefixes: [string, UserFileSection][] -): UserFileSectionKey | null { - for (const [prefix, section] of prefixes) { - if (relativePath.startsWith(prefix)) return { section, key: relativePath.slice(prefix.length) }; - } - return null; -} diff --git a/cli/src/domain/formats/commit-session-trailer.ts b/cli/src/domain/formats/commit-session-trailer.ts deleted file mode 100644 index 7aee16115..000000000 --- a/cli/src/domain/formats/commit-session-trailer.ts +++ /dev/null @@ -1,132 +0,0 @@ -/** - * The one link between a commit and the session that produced it. - * - * Every other link in the chain a record can be read along already exists: a request names - * its turn, a turn names its session, a session names the task folder it declared. What - * nothing named was the commit — so "this backlog item cost X" could be answered, and "this - * commit cost X" could not, though the two are one query apart. - * - * A commit carries `AIDD-Session-Id: ` and the join closes. The value is - * whatever `session-anchor.ts` resolves — never a second identifier minted for this, which - * would be a second identity for one thing. - * - * **How far that join is measured, by host, because it is not the same distance for both.** - * - * On Claude Code it is measured: `CLAUDE_CODE_SESSION_ID` is the transcript filename, and - * `claude-code-transcript.ts`'s own `matchesMainTranscript` resolves a session by - * `.jsonl`, so the variable and the record's `vendor_id` are the same string. - * - * On Codex it is measured too, on 2026-09-02, against two real sessions: - * - * fresh session CODEX_THREAD_ID = 01a06041… = session_meta.id = the rollout's filename - * resumed session CODEX_THREAD_ID = 01a06041… — and no second rollout was written at all - * - * The resume is the half that mattered. This module used to warn that "a thread spans - * several rollouts, so a commit made in a later one joins to nothing"; `codex exec resume - * --last` disproved it, appending to the same rollout file rather than opening a new one. - * `CODEX_THREAD_ID` tracks the rollout, and the rollout's uuid is exactly the `vendor_id` - * both the hook and the reader join on. So the trailer's value equals the records' own. - * - * What that leaves is narrow, named, and bounded. 89 of the 418 rollouts on the machine this - * was measured on carry `thread_source: "subagent"`, where `session_meta.id` is the - * subagent's own and `session_meta.session_id` is the parent's. No capture yet says which of - * the two a subagent's `CODEX_THREAD_ID` carries, so a commit authored from inside a Codex - * subagent is the one case not measured. - * - * The bound is what makes it liveable rather than open-ended: those two identifiers are the - * subagent and the thread that delegated to it, so the trailer names one or the other and - * both are the same piece of work. The failure mode is a commit attributed to the parent - * thread instead of the delegated turn inside it — coarser than intended, never somebody - * else's session and never a different tree. And where the named rollout has no records - * read, the result is the ordinary "a join that finds no records on the other side", which - * this contract already calls a normal outcome. - * - * Settling it takes one Codex session that delegates, with `CODEX_THREAD_ID` read from - * inside the subagent and compared against the rollout that subagent wrote. Nothing forces a - * delegation from the command line, so it waits for one that happens anyway rather than for - * a run bought to provoke it. - * - * Every ordinary session, fresh or resumed, joins exactly. - */ - -/** Git's own trailer token. Capitalised the way `Co-authored-by` and `Signed-off-by` are, - * because `git interpret-trailers` matches a token case-insensitively but writes back what - * it was given, and a repository whose history spells one trailer three ways is one nobody - * can grep. */ -export const SESSION_TRAILER_TOKEN = "AIDD-Session-Id"; - -/** The delegate's own filename, beside the hook that calls it rather than inside it: a - * repository that already runs a `prepare-commit-msg` hook (lefthook, husky, a hand-written - * one) keeps it, and gains one line calling this. Overwriting theirs to install ours would - * be the kind of silent theft a measurement tool has no business committing. */ -export const SESSION_TRAILER_DELEGATE_FILE = "aidd-session-trailer.sh"; - -/** What a `prepare-commit-msg` written from scratch starts with — and, read back, the one - * line that does not count as somebody else's content. Exported rather than spelled at each - * site: `git-adapter.ts` writes it when a repository has no hook and reads it again when - * deciding whether a hook holds anything but ours, and a third spelling there would make a - * freshly installed hook report as "somebody else's too". */ -export const SESSION_TRAILER_HOOK_HEADER = "#!/bin/sh"; - -/** The line appended to `prepare-commit-msg`, and the marker read back to tell an install - * that already happened from one that has not. `"$@"` forwards git's own three arguments — - * the message file, where the message came from, and the commit being amended — because the - * delegate reads the first two and a hook that dropped them would trailer a merge. - * - * The separators are forced to `/`, which is the whole of what makes this work on Windows. - * A hook is shell, run by the `sh` Git for Windows ships, and that shell does not resolve - * `C:\Users\…`: inside double quotes a backslash is an ordinary character, so the path - * arrives literally and names nothing. `C:/Users/…` it resolves fine. Node's own `resolve` - * hands back backslashes there, so this is the seam where a filesystem path becomes shell - * text and has to stop being one. On POSIX the replacement matches nothing and the string is - * unchanged. - * - * Both sides go through here — `installCommitMessageDelegate` writes this line and - * `removeCommitMessageDelegate` looks for it — so the two can never disagree about the - * spelling, whatever the platform. */ -export function sessionTrailerHookLine(delegatePath: string): string { - return `sh "${delegatePath.replace(/\\/gu, "/")}" "$@"`; -} - -/** - * The delegate itself: POSIX `sh`, no Node, no dependency on this CLI still being installed. - * A hook that fails is a commit that fails, so every path here ends in `exit 0` — measurement - * is never allowed to stand between a person and their own commit. - * - * `CODEX_THREAD_ID` is read before `CLAUDE_CODE_SESSION_ID`, the same precedence and for the - * same measured reason as `session-anchor.ts`: a Codex process nested inside a Claude Code - * session inherits the outer session's variable, and trailering the enclosing session would - * name work it did not do. Neither variable set means no AI session made this commit, and - * the commit gets no trailer at all — an unknown is never a guess. - * - * A merge or a squash is skipped: neither is a person authoring work, and a merge commit - * carrying a session id would attribute every commit it brings in to that one session. - */ -export function sessionTrailerDelegateScript(): string { - return `#!/bin/sh -# Installed by \`aidd telemetry on\`, removed by \`aidd telemetry off\`. -# -# Names the AI session that authored this commit, so what a session cost can be read -# per commit. Writes nothing when no session made the commit. -set -u - -message_file="\${1:-}" -message_source="\${2:-}" - -[ -n "$message_file" ] || exit 0 - -# A merge or a squash is not a person authoring work. -case "$message_source" in - merge | squash) exit 0 ;; -esac - -session_id="\${CODEX_THREAD_ID:-\${CLAUDE_CODE_SESSION_ID:-}}" -[ -n "$session_id" ] || exit 0 - -# --if-exists doNothing keeps an amend, or a second run of this hook, from writing it twice. -git interpret-trailers --in-place --if-exists doNothing \\ - --trailer "${SESSION_TRAILER_TOKEN}=$session_id" "$message_file" || exit 0 - -exit 0 -`; -} diff --git a/cli/src/domain/formats/copilot-events.ts b/cli/src/domain/formats/copilot-events.ts deleted file mode 100644 index 913d0f280..000000000 --- a/cli/src/domain/formats/copilot-events.ts +++ /dev/null @@ -1,136 +0,0 @@ -import type { LocalCostCandidateRecord } from "../ports/session-cost-reader.js"; - -// Measured 2026-08-21/22 against real files on `@github/copilot@1.0.80`: -// ~/.copilot/session-state//events.jsonl. `session.shutdown` fires once, at the end of -// the session — never per turn — and its own `tokenDetails` is the four-counter breakdown -// this reader carries. Confirmed arithmetically against the same capture: -// `tokenDetails.input.tokenCount` (10) + `tokenDetails.cache_write.tokenCount` (21070) = -// `modelMetrics..usage.inputTokens` (21080) — the `usage` object is *inclusive* of -// the cache-write figure, `tokenDetails` already exclusive, matching every other reader's -// convention here. **Confirmed the same way for `cache_read` on 1.0.82, 2026-09-06** -// (tests/fixtures/local-cost/.copilot/session-state/55555555-.../events.jsonl): 9 + 42038 + -// 21404 = 63451 = `usage.inputTokens`, with Copilot's own terminal line reading -// `↑ 63.5k (42.0k cached, 21.4k written)`. That is the capture the earlier one could not -// give: at `cache_read: 0` an inclusive and an exclusive `input` produce the same number, -// at 42038 they do not — an inclusive one would read 63451, not 9. `modelMetrics..requests.cost` (and its session-level twin, -// `totalPremiumRequests`) is a count times a per-model multiplier, invariant to -// consumption — measured across fourteen local sessions, it read `0.33` for every -// single-request `claude-haiku-4.5` session regardless of tokens spent — so neither is ever -// read as `cost_usd`. No `model` is stamped either: `currentModel` names only the last -// model a session used, and `session.model_change` is a real, captured event, so -// attributing a whole session's tokens to it would repeat the sticky-attribution mistake -// this codebase already corrected for `skill.name`. -// -// The session id is never read off the file's own content — `session.shutdown` carries -// none, and reading it from a preceding `session.start` line would give the file's own -// answer rather than the session the caller already asked for, the one case where the two -// could disagree (a truncated capture, a copy missing its first line). The directory this -// file lives in already names the session; `CopilotCostReaderAdapter` reads that name once -// and hands it straight through. -const VENDOR_FIELD = "sessionId"; -const TURN_FIELD = "id"; - -interface CopilotTokenCount { - readonly tokenCount?: unknown; -} - -interface CopilotShutdownData { - readonly tokenDetails?: { - readonly input?: CopilotTokenCount; - readonly output?: CopilotTokenCount; - readonly cache_read?: CopilotTokenCount; - readonly cache_write?: CopilotTokenCount; - }; -} - -interface CopilotEventLine { - readonly type?: unknown; - readonly id?: unknown; - readonly timestamp?: unknown; - readonly data?: CopilotShutdownData; -} - -interface CopilotCounters { - readonly input_tokens: number; - readonly output_tokens: number; - readonly cache_read_tokens: number; - readonly cache_creation_tokens: number; -} - -function asNumber(value: unknown): number | undefined { - return typeof value === "number" ? value : undefined; -} - -function asString(value: unknown): string | undefined { - return typeof value === "string" ? value : undefined; -} - -/** All four or none — every real capture reports them together, and a shape this file has - * not been taught (a renamed field, a `tokenDetails` present but empty) must yield no - * record rather than one silently missing every counter. */ -function readCounters(details: CopilotShutdownData["tokenDetails"]): CopilotCounters | null { - const input = asNumber(details?.input?.tokenCount); - const output = asNumber(details?.output?.tokenCount); - const cacheRead = asNumber(details?.cache_read?.tokenCount); - const cacheWrite = asNumber(details?.cache_write?.tokenCount); - if (input === undefined || output === undefined) return null; - if (cacheRead === undefined || cacheWrite === undefined) return null; - return { - input_tokens: input, - output_tokens: output, - cache_read_tokens: cacheRead, - cache_creation_tokens: cacheWrite, - }; -} - -function buildRecord( - line: CopilotEventLine, - vendorId: string, - counters: CopilotCounters -): LocalCostCandidateRecord { - const turnId = asString(line.id); - const timestamp = asString(line.timestamp); - return { - kind: "session", - vendor_id: vendorId, - vendor_field: VENDOR_FIELD, - ...(turnId !== undefined ? { turn_id: turnId, turn_field: TURN_FIELD } : {}), - ...(timestamp !== undefined ? { event_timestamp: timestamp } : {}), - ...counters, - }; -} - -function parseLine(line: string): CopilotEventLine | null { - const trimmed = line.trim(); - if (!trimmed) return null; - try { - return JSON.parse(trimmed) as CopilotEventLine; - } catch { - return null; - } -} - -/** - * One record at most, from `session.shutdown`'s own `tokenDetails` — never per turn, since - * no per-request figure exists on this tool's file at all. A session that never - * shut down, or one that shut down with no billed request (no `tokenDetails` at all, - * `modelMetrics: {}`), yields nothing: a session held and found empty, never a record of - * zeros. Only the first matching line is kept — `session.shutdown` fires once. - * - * `vendorId` is the caller's own — never re-derived from the file, see the header comment - * above for why. Pure and synchronous: the one part of this reader allowed to open a file - * is `CopilotCostReaderAdapter`, which calls this with what it read. - */ -export function mapCopilotEventsToSinkRecords( - content: string, - vendorId: string -): readonly LocalCostCandidateRecord[] { - for (const raw of content.split("\n")) { - const parsed = parseLine(raw); - if (parsed?.type !== "session.shutdown") continue; - const counters = readCounters(parsed.data?.tokenDetails); - if (counters === null) continue; - return [buildRecord(parsed, vendorId, counters)]; - } - return []; -} diff --git a/cli/src/domain/formats/copilot-marketplace.ts b/cli/src/domain/formats/copilot-marketplace.ts deleted file mode 100644 index 974cbc76c..000000000 --- a/cli/src/domain/formats/copilot-marketplace.ts +++ /dev/null @@ -1,58 +0,0 @@ -/** - * Copilot marketplace format adapter — pure parser, no I/O. - * - * Copilot has no multi-plugin catalog convention. The "marketplace" is a Git - * repository where each repo publishes exactly ONE plugin via the manifest at - * `.github/plugin/plugin.json`. This adapter treats that single-plugin manifest - * as a degenerate one-entry catalog. - * - * Documented fields (per https://code.visualstudio.com/docs/copilot/customization/agent-plugins): - * name (required, kebab-case ≤64 chars), description, version, author.name - * + agents, skills, hooks, mcpServers — ignored for NormalizedPlugin extraction. - * - * NOTE: The task plan suggested `.github/agents/` as the manifest path based on - * earlier Part 2 research. Primary-source evidence from the actual Copilot docs - * and the github/awesome-copilot repo confirms `.github/plugin/plugin.json` as - * the canonical location. This overrides the prior assumption. - */ - -import { ForeignSchemaValidationError } from "../errors.js"; -import type { NormalizedCatalog, NormalizedPlugin } from "../models/normalized-plugin.js"; - -const SOURCE = "copilot"; - -export function parseCopilotMarketplace(rawJson: string): NormalizedCatalog { - const parsed = parseJson(rawJson); - const plugin = parsePlugin(parsed); - return { source: SOURCE, plugins: [plugin] }; -} - -function parseJson(rawJson: string): unknown { - try { - return JSON.parse(rawJson); - } catch { - throw new ForeignSchemaValidationError(SOURCE, "plugin.json is not valid JSON"); - } -} - -function parsePlugin(parsed: unknown): NormalizedPlugin { - if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) { - throw new ForeignSchemaValidationError(SOURCE, "plugin.json must be a JSON object"); - } - const obj = parsed as Record; - if (typeof obj.name !== "string" || obj.name.length === 0) { - throw new ForeignSchemaValidationError(SOURCE, '"name" must be a non-empty string'); - } - const plugin: NormalizedPlugin = { name: obj.name, source: SOURCE }; - if (typeof obj.version === "string" && obj.version.length > 0) { - return { - ...plugin, - version: obj.version, - ...(typeof obj.description === "string" ? { description: obj.description } : {}), - }; - } - if (typeof obj.description === "string") { - return { ...plugin, description: obj.description }; - } - return plugin; -} diff --git a/cli/src/domain/formats/cursor-hooks-project-merge.ts b/cli/src/domain/formats/cursor-hooks-project-merge.ts deleted file mode 100644 index c75b5da28..000000000 --- a/cli/src/domain/formats/cursor-hooks-project-merge.ts +++ /dev/null @@ -1,98 +0,0 @@ -/** - * A Cursor plugin's own `hooks/hooks.json` never fires from the plugin-scope - * directory Cursor's native install writes it to (measured — see - * aidd_docs/tasks/2026_08/2026_08_22_telemetry-every-tool/measurements.md, Phase 4). - * Only a project-scope `.cursor/hooks.json`, the same file `aidd framework build - * --target cursor --flat` already writes, is ever observed running. This module - * gives `aidd plugin install` that same destination for one plugin at a time. - */ - -import { rewriteClaudeRootInJson } from "./claude-root-path-rewrite.js"; -import { mergeCursorFlatHooks } from "./flat-hooks-merge.js"; -import { genericFlatHooksScriptPath } from "./flat-paths.js"; - -const HOOKS_PREFIX = "hooks/"; -const CURSOR_HOOKS_DIR = ".cursor/hooks/"; - -interface CursorHooksFile { - version?: number; - hooks?: Record>; -} - -/** - * Rewrites a plugin's raw `hooks/hooks.json` (Claude nested shape, - * `${CLAUDE_PLUGIN_ROOT}`-relative commands) to the paths its scripts land at once - * copied to `.cursor/hooks//`, then merges it into the project's own - * `.cursor/hooks.json`. `existingJson` is the current file content, or null. - * - * Strips this plugin's own prior contribution first: `mergeCursorFlatHooks` itself - * only appends, so a second install of the same plugin would otherwise double every - * command it owns. Every entry this route ever writes names its own script under - * `.cursor/hooks//` (see `cursorProjectHooksScriptPath`), which is a - * plugin-unique substring — no persisted "what did I contribute last time" map is - * needed the way OpenCode's mcp merge carries one. - */ -export function mergeCursorProjectHooksJson( - existingJson: string | null, - pluginHooksJson: string, - pluginName: string -): { content: string; warnings: readonly string[] } { - const rewritten = rewritePluginRootTokens(pluginHooksJson, pluginName); - const deduped = - existingJson === null ? null : serialize(stripPluginEntries(existingJson, pluginName)); - return mergeCursorFlatHooks(deduped, rewritten); -} - -/** Removes one plugin's entries from `.cursor/hooks.json`, leaving every other - * plugin's untouched — the install-time counterpart to `mergeCursorProjectHooksJson`, - * used by `plugin remove` to unmerge what an install merged. */ -export function unmergeCursorProjectHooksJson(existingJson: string, pluginName: string): string { - return serialize(stripPluginEntries(existingJson, pluginName)); -} - -/** Where a hook script (everything under `hooks/` but its own manifest) lands once - * copied into the project, given its path relative to the plugin root. */ -export function cursorProjectHooksScriptPath( - pluginName: string, - hooksRelativePath: string -): string { - const rest = hooksRelativePath.startsWith(HOOKS_PREFIX) - ? hooksRelativePath.slice(HOOKS_PREFIX.length) - : hooksRelativePath; - return genericFlatHooksScriptPath(CURSOR_HOOKS_DIR, pluginName, rest); -} - -/** The directory a plugin's copied hook scripts live under — nothing else writes here, - * so `plugin remove` can delete it whole once the plugin's `.cursor/hooks.json` entries - * are stripped. */ -export function cursorProjectHooksScriptDir(pluginName: string): string { - return `${CURSOR_HOOKS_DIR}${pluginName}/`; -} - -function stripPluginEntries(existingJson: string, pluginName: string): CursorHooksFile { - const parsed = JSON.parse(existingJson) as CursorHooksFile; - const marker = cursorProjectHooksScriptDir(pluginName); - const hooks: Record> = {}; - for (const [event, entries] of Object.entries(parsed.hooks ?? {})) { - const kept = entries.filter((entry) => !entry.command.includes(marker)); - if (kept.length > 0) hooks[event] = kept; - } - return { version: 1, hooks }; -} - -function serialize(cursor: CursorHooksFile): string { - return `${JSON.stringify(cursor, null, 2)}\n`; -} - -function rewritePluginRootTokens(pluginHooksJson: string, pluginName: string): string { - const parsed = JSON.parse(pluginHooksJson) as unknown; - const rewritten = rewriteClaudeRootInJson(parsed, (suffix) => resolveSuffix(suffix, pluginName)); - return JSON.stringify(rewritten); -} - -// A hooks.json command only ever names a path under its own hooks/ — unlike the -// framework-build route, this never needs an agents/ or skills/ branch too. -function resolveSuffix(suffix: string, pluginName: string): string { - if (!suffix.startsWith(HOOKS_PREFIX)) return suffix; - return `./${cursorProjectHooksScriptPath(pluginName, suffix)}`; -} diff --git a/cli/src/domain/formats/cursor-marketplace.ts b/cli/src/domain/formats/cursor-marketplace.ts deleted file mode 100644 index f12e4f226..000000000 --- a/cli/src/domain/formats/cursor-marketplace.ts +++ /dev/null @@ -1,70 +0,0 @@ -/** - * Cursor marketplace format adapter — pure parser, no I/O. - * - * Cursor's marketplace.json schema is undocumented as of 2026-05-06. - * The reference page (https://cursor.com/docs/reference/plugins.md) returns 404. - * Documented plugin.json fields (per https://cursor.com/docs/plugins): - * name (required), description, version, author.name - * - * The marketplace.json shape mirrors Claude's existing catalog format - * { plugins: [{ name, version?, description? }] } — lowest-risk default, - * easily extended when Cursor publishes their schema. - * - * Cursor plugins use `.cursor-plugin/` as the manifest directory instead of - * `.claude-plugin/`, and `.mdc` extension for rules. - */ - -import { ForeignSchemaValidationError } from "../errors.js"; -import type { NormalizedCatalog, NormalizedPlugin } from "../models/normalized-plugin.js"; - -const SOURCE = "cursor"; - -export function parseCursorMarketplace(rawJson: string): NormalizedCatalog { - const parsed = parseJson(rawJson); - const plugins = extractPlugins(parsed); - return { source: SOURCE, plugins }; -} - -function parseJson(rawJson: string): unknown { - try { - return JSON.parse(rawJson); - } catch { - throw new ForeignSchemaValidationError(SOURCE, "marketplace.json is not valid JSON"); - } -} - -function extractPlugins(parsed: unknown): readonly NormalizedPlugin[] { - if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) { - throw new ForeignSchemaValidationError(SOURCE, "marketplace.json must be a JSON object"); - } - const obj = parsed as Record; - if (!Array.isArray(obj.plugins)) { - throw new ForeignSchemaValidationError(SOURCE, '"plugins" must be an array'); - } - return obj.plugins.map((entry, i) => parseEntry(entry, i)); -} - -function parseEntry(raw: unknown, index: number): NormalizedPlugin { - if (raw === null || typeof raw !== "object" || Array.isArray(raw)) { - throw new ForeignSchemaValidationError(SOURCE, `plugins[${index}] must be an object`); - } - const obj = raw as Record; - if (typeof obj.name !== "string" || obj.name.length === 0) { - throw new ForeignSchemaValidationError( - SOURCE, - `plugins[${index}].name must be a non-empty string` - ); - } - const plugin: NormalizedPlugin = { name: obj.name, source: SOURCE }; - if (typeof obj.version === "string" && obj.version.length > 0) { - return { - ...plugin, - version: obj.version, - ...(typeof obj.description === "string" ? { description: obj.description } : {}), - }; - } - if (typeof obj.description === "string") { - return { ...plugin, description: obj.description }; - } - return plugin; -} diff --git a/cli/src/domain/formats/cursor-paths.ts b/cli/src/domain/formats/cursor-paths.ts deleted file mode 100644 index 6621c2299..000000000 --- a/cli/src/domain/formats/cursor-paths.ts +++ /dev/null @@ -1,13 +0,0 @@ -/** - * Cursor build output path constants. - * - * These constants are intentionally distinct from their source-side equivalents - * even when the literal values coincide. Future changes to either side must not - * collapse them. - */ - -/** Relative path for the Cursor-native plugin manifest inside each plugin output directory. */ -export const OUTPUT_CURSOR_MANIFEST_RELATIVE = ".cursor-plugin/plugin.json"; - -/** Relative path for the Cursor marketplace catalog in the cursor output tree. */ -export const OUTPUT_CURSOR_MARKETPLACE_RELATIVE = ".cursor-plugin/marketplace.json"; diff --git a/cli/src/domain/formats/flat-hooks-merge.ts b/cli/src/domain/formats/flat-hooks-merge.ts deleted file mode 100644 index e8ec09d39..000000000 --- a/cli/src/domain/formats/flat-hooks-merge.ts +++ /dev/null @@ -1,324 +0,0 @@ -/** - * Pure shape-transform and merge helpers for per-tool flat hook config registration. - * - * Each function handles the structural difference between how Claude (framework source - * format), Cursor, Copilot, and Codex expect hooks to be registered in flat mode. - * - * All functions are pure (no I/O). They receive and return JSON-serialisable values. - * - * Claude event names (source) are PascalCase; Cursor maps supported events to camelCase. - */ - -import { asPlainObject } from "./plain-object.js"; - -// ── Types ───────────────────────────────────────────────────────────────────── - -type ClaudeHookItem = { type?: string; command?: string; [key: string]: unknown }; -type ClaudeMatcherGroup = { matcher?: string; hooks: ClaudeHookItem[] }; -type ClaudeHooksShape = { hooks?: Record }; - -type FlatHookEntry = { type: string; command: string; timeout?: number }; -type CopilotFlatShape = { version: 1; hooks?: Record }; - -type CursorHookEntry = { command: string }; -type CursorFlatShape = { version: 1; hooks: Record }; - -type CodexHookEntry = { - matcher?: string; - hooks: Array<{ type: string; command: string; timeout?: number; statusMessage?: string }>; -}; -type CodexHooksShape = { hooks?: Record }; - -// ── Event mapping ───────────────────────────────────────────────────────────── - -// `Stop` fans out to two Cursor events, not one: measured (2026-08-22, see -// measurements.md Phase 6) interactive sessions fire `stop` and headless sessions -// fire `sessionEnd` instead — never both from the same run, but which one depends -// on how the session ends, so both are subscribed. A run file already tolerates -// more than one `turn_end` line (two real `stop` firings, one interactive session, -// Phase 4 addendum), so a session that happens to fire both is not a problem. -const CURSOR_EVENT_MAP: Record = { - SessionStart: ["sessionStart"], - UserPromptSubmit: ["beforeSubmitPrompt"], - PreToolUse: ["preToolUse"], - PostToolUse: ["postToolUse"], - Stop: ["stop", "sessionEnd"], - SubagentStop: ["subagentStop"], -}; - -// Codex keeps Claude's event names, with one exception it does not have: there is no -// `Stop`. Its vocabulary, read out of the 0.149.0 binary itself, is PreToolUse, -// PermissionRequest, PostToolUse, PreCompact, PostCompact, SessionStart, SessionEnd, -// SubagentStart, SubagentStop - and a live probe confirmed it: a `codex exec` run with all -// four subscribed fired SessionStart and SessionEnd and never Stop, so a turn was never -// closed and every Codex session journalled a session_start with nothing after it. -// -// SessionEnd is coarser than Stop by nature: it bounds the session, not each turn. For -// `codex exec` the two coincide, and for an interactive session one turn_end bounding the -// whole session is the honest answer rather than none at all. The journal already tolerates -// more than one turn_end line, so nothing downstream depends on there being exactly one. -const CODEX_EVENT_MAP: Record = { - Stop: ["SessionEnd"], -}; - -/** - * Renames a plugin hooks.json's events to the ones Codex delivers, without merging. - * - * Codex is installed two ways - a built marketplace tree and a merged project config - and - * the two have drifted apart three times. Both call this, so the rename cannot land on one - * route and not the other, which is exactly how a Codex session came to journal a - * session_start with nothing after it. - */ -export function renameCodexHookEvents(pluginHooksJson: string): string { - const parsed = JSON.parse(pluginHooksJson) as ClaudeHooksShape; - if (!parsed.hooks) return pluginHooksJson; - const renamed: Record = {}; - for (const [event, matchers] of Object.entries(parsed.hooks)) { - for (const codexEvent of CODEX_EVENT_MAP[event] ?? [event]) { - renamed[codexEvent] = [...(renamed[codexEvent] ?? []), ...matchers]; - } - } - return `${JSON.stringify({ ...parsed, hooks: renamed }, null, 2)}\n`; -} - -// ── Claude: merge hooks into .claude/settings.json ──────────────────────────── - -/** - * Merges a plugin's hooks (Claude nested shape) additively into the top-level - * `hooks` key of `.claude/settings.json`. Preserves all other settings keys. - * - * @param existingSettings - Current file content, or null if absent. - * @param pluginHooksJson - Path-rewritten plugin hooks.json content (Claude nested shape). - * @returns { content: new settings.json content, warnings: [] } - */ -export function mergeClaudeSettingsHooks( - existingSettings: string | null, - pluginHooksJson: string -): { content: string; warnings: readonly string[] } { - const settings = existingSettings - ? (JSON.parse(existingSettings) as Record) - : {}; - const plugin = JSON.parse(pluginHooksJson) as ClaudeHooksShape; - const pluginHooks = plugin.hooks ?? {}; - const existing = (settings.hooks as Record) ?? {}; - const merged = appendHooksEntries(existing, pluginHooks); - return { - content: `${JSON.stringify({ ...settings, hooks: merged }, null, 2)}\n`, - warnings: [], - }; -} - -function appendHooksEntries( - existing: Record, - incoming: Record -): Record { - const result: Record = { ...existing }; - for (const [event, matchers] of Object.entries(incoming)) { - result[event] = [...(result[event] ?? []), ...matchers]; - } - return result; -} - -// ── Copilot: flatten nested hooks shape ─────────────────────────────────────── - -/** - * Flattens the Claude nested matcher-group shape into Copilot's expected flat shape: - * `hooks.EVENT[]` of `{type, command, timeout?}`. - * - * @param pluginHooksJson - Raw plugin hooks.json in Claude nested shape. - * @returns New flat-shape hooks JSON string. - */ -export function flattenCopilotHooksShape(pluginHooksJson: string): string { - const parsed = JSON.parse(pluginHooksJson) as ClaudeHooksShape; - const claudeHooks = parsed.hooks ?? {}; - const flat: Record = {}; - - for (const [event, matchers] of Object.entries(claudeHooks)) { - const entries = flattenMatcherGroups(matchers); - if (entries.length > 0) flat[event] = entries; - } - - const output: CopilotFlatShape = { version: 1 }; - if (Object.keys(flat).length > 0) output.hooks = flat; - return `${JSON.stringify(output, null, 2)}\n`; -} - -function flattenMatcherGroups(matchers: ClaudeMatcherGroup[]): FlatHookEntry[] { - const entries: FlatHookEntry[] = []; - for (const group of matchers) { - for (const item of group.hooks ?? []) { - if (typeof item.command !== "string") continue; - const entry: FlatHookEntry = { type: item.type ?? "command", command: item.command }; - if (typeof item.timeout === "number") entry.timeout = item.timeout; - entries.push(entry); - } - } - return entries; -} - -// ── Cursor: event-mapped merge into single .cursor/hooks.json ───────────────── - -/** - * Merges a plugin's hooks (Claude nested shape) into the accumulated `.cursor/hooks.json` - * with version:1, event-mapped keys, and flat `{command}` entries. - * - * Unmapped events are skipped and reported in the returned warnings list. - * - * @param existingCursorJson - Current .cursor/hooks.json content, or null. - * @param pluginHooksJson - Path-rewritten plugin hooks.json in Claude nested shape. - * @returns { content, warnings } - */ -export function mergeCursorFlatHooks( - existingCursorJson: string | null, - pluginHooksJson: string -): { content: string; warnings: readonly string[] } { - const cursor = parseCursorHooks(existingCursorJson); - const plugin = JSON.parse(pluginHooksJson) as ClaudeHooksShape; - const pluginHooks = plugin.hooks ?? {}; - const warnings: string[] = []; - - for (const [claudeEvent, matchers] of Object.entries(pluginHooks)) { - const cursorEvents = CURSOR_EVENT_MAP[claudeEvent]; - if (!cursorEvents) { - warnings.push(`cursor: unmapped event '${claudeEvent}' skipped`); - continue; - } - const entries = extractCursorEntries(matchers); - for (const cursorEvent of cursorEvents) { - cursor.hooks[cursorEvent] = [...(cursor.hooks[cursorEvent] ?? []), ...entries]; - } - } - - return { content: `${JSON.stringify(cursor, null, 2)}\n`, warnings }; -} - -function parseCursorHooks(content: string | null): CursorFlatShape { - if (!content) return { version: 1, hooks: {} }; - const parsed = JSON.parse(content) as Partial; - return { version: 1, hooks: parsed.hooks ?? {} }; -} - -function extractCursorEntries(matchers: ClaudeMatcherGroup[]): CursorHookEntry[] { - const entries: CursorHookEntry[] = []; - for (const group of matchers) { - for (const item of group.hooks ?? []) { - if (typeof item.command === "string") entries.push({ command: item.command }); - } - } - return entries; -} - -// ── Codex: framework plugin hooks into .codex/hooks.json ───────────────────── - -/** - * Merges a plugin's hooks (Claude nested shape) into `.codex/hooks.json` using - * Codex's nested shape WITH top-level `hooks` wrapper. - * - * Does NOT emit the install-mode memory hook (node .aidd/scripts/update_memory.cjs). - * That hook belongs to HooksCapability.mergeFn (mergeCodexHooksJson) in install mode. - * - * @param existingJson - Current .codex/hooks.json content, or null. - * @param pluginHooksJson - Path-rewritten plugin hooks.json in Claude nested shape. - * @returns { content, warnings: [] } - */ -export function mergeCodexFrameworkHooksJson( - existingJson: string | null, - pluginHooksJson: string -): { content: string; warnings: readonly string[] } { - const codex = parseCodexHooks(existingJson); - const plugin = JSON.parse(pluginHooksJson) as ClaudeHooksShape; - const pluginHooks = plugin.hooks ?? {}; - - for (const [event, matchers] of Object.entries(pluginHooks)) { - for (const codexEvent of CODEX_EVENT_MAP[event] ?? [event]) { - codex.hooks[codexEvent] = [ - ...(codex.hooks[codexEvent] ?? []), - ...convertToCodexEntries(matchers), - ]; - } - } - - return { - content: `${JSON.stringify({ hooks: codex.hooks }, null, 2)}\n`, - warnings: [], - }; -} - -function parseCodexHooks( - content: string | null -): CodexHooksShape & { hooks: Record } { - if (!content) return { hooks: {} }; - const parsed = JSON.parse(content) as CodexHooksShape; - return { hooks: parsed.hooks ?? {} }; -} - -function convertToCodexEntries(matchers: ClaudeMatcherGroup[]): CodexHookEntry[] { - return matchers.map((group) => ({ - ...(group.matcher !== undefined ? { matcher: group.matcher } : {}), - hooks: group.hooks - .filter((item) => typeof item.command === "string") - .map((item) => buildCodexHookItem(item)), - })); -} - -function buildCodexHookItem(item: ClaudeHookItem): { - type: string; - command: string; - timeout?: number; - statusMessage?: string; -} { - const entry: { type: string; command: string; timeout?: number; statusMessage?: string } = { - type: item.type ?? "command", - command: item.command as string, - }; - if (typeof item.timeout === "number") entry.timeout = item.timeout; - if (typeof item.statusMessage === "string") entry.statusMessage = item.statusMessage; - return entry; -} - -// ── Detection: does an already-written hooks file register a command? ───────── - -/** - * Every `command` string registered for `claudeEvent` in a hooks file already written in - * any of the four shapes this module writes — Claude/Codex's nested matcher groups, or - * Copilot/Cursor's flat `{command}` entries — plus whatever alias `CURSOR_EVENT_MAP` maps - * `claudeEvent` to (Cursor renames `SessionStart` to `sessionStart`; Codex and Copilot - * keep it as written). Malformed content, or a shape none of the four writers produce, - * answers `[]` rather than throwing: a shape this module does not recognise is not - * evidence of anything. - * - * The one shape-parsing routine a *reader* needs for "did a hooks block ask for this - * command" — `telemetry-evidence-adapter.ts`'s recorder-declaration check calls this - * rather than restating the four shapes' knowledge, so a fifth shape recognised here is - * recognised there too. - */ -export function hookCommandsForEvent(hooksFileContent: string, claudeEvent: string): string[] { - let parsed: unknown; - try { - parsed = JSON.parse(hooksFileContent); - } catch { - return []; - } - const hooks = asPlainObject(asPlainObject(parsed)?.hooks); - if (hooks === null) return []; - const commands: string[] = []; - for (const eventName of [claudeEvent, ...(CURSOR_EVENT_MAP[claudeEvent] ?? [])]) { - const entries = hooks[eventName]; - if (Array.isArray(entries)) for (const entry of entries) collectCommands(entry, commands); - } - return commands; -} - -// Handles both known entry depths in one walk: a nested group (`{ hooks: [...] }`, -// Claude/Codex) recurses one level into its own `hooks` array; a flat entry -// (`{ command }` or `{ type, command }`, Copilot/Cursor) has none to recurse into and -// contributes its own command directly. -function collectCommands(entry: unknown, out: string[]): void { - const record = asPlainObject(entry); - if (record === null) return; - if (Array.isArray(record.hooks)) { - for (const nested of record.hooks) collectCommands(nested, out); - return; - } - if (typeof record.command === "string") out.push(record.command); -} diff --git a/cli/src/domain/formats/flat-paths.ts b/cli/src/domain/formats/flat-paths.ts deleted file mode 100644 index 8fa793926..000000000 --- a/cli/src/domain/formats/flat-paths.ts +++ /dev/null @@ -1,132 +0,0 @@ -/** - * Tool-agnostic flat path primitives. - * - * All functions are pure (no I/O). Parameterized by a primary-dir prefix so that - * claude, cursor, copilot, opencode, and codex all share the same path derivation - * logic with different prefixes/extensions. - */ - -/** - * Returns the flat-output path for an agent file. - * The source `.md` extension is stripped and replaced with `outputExt`. - * - * Flat mode is PLUGIN-PREFIXED single level: no `/` directory segment, - * but the plugin name is prepended to the leaf filename with a hyphen so tools - * can discover the files and the plugin origin is preserved in the name. - * - * @param agentsPrefix - Full prefix for agents dir (e.g. ".github/agents/", ".claude/agents/") - * @param plugin - Plugin name (prepended to the output filename) - * @param agentBaseName - Source basename without path (e.g. "implementer.md") - * @param outputExt - Output extension (e.g. ".agent.md", ".md") - */ -export function genericFlatAgentPath( - agentsPrefix: string, - plugin: string, - agentBaseName: string, - outputExt: string -): string { - const withoutMd = agentBaseName.endsWith(".md") ? agentBaseName.slice(0, -3) : agentBaseName; - return `${agentsPrefix}${plugin}-${withoutMd}${outputExt}`; -} - -/** - * Returns the flat-output path for a skill file (preserves the skill's internal subtree). - * - * Flat mode is PLUGIN-PREFIXED single level: no `/` directory segment, - * but the plugin name is prepended to the first path segment (the skill folder) - * with a hyphen. Tools can discover skills at the expected depth and the plugin - * origin is preserved in the folder name. - * - * Assumes every immediate child of `skills/` IS a self-contained skill folder — the - * hyphen lands on that child's own name, so a non-skill sibling (a shared helper - * directory, a manifest file) gets renamed exactly like one, breaking any relative - * path that reaches it by its original name. True today for the four callers still on - * this function (claude, cursor, codex, copilot's flat contracts in tool-contracts.ts); - * false for OpenCode's aidd-telemetry, which is why opencode's flat contract uses - * `genericFlatSkillTreePath` instead (#defect fixed alongside this comment). - * - * @param skillsPrefix - Full prefix for skills dir (e.g. ".github/skills/", ".claude/skills/") - * @param plugin - Plugin name (prepended to the skill folder segment) - * @param skillRelPath - Path relative to the plugin's skills/ directory - */ -export function genericFlatSkillPath( - skillsPrefix: string, - plugin: string, - skillRelPath: string -): string { - return `${skillsPrefix}${plugin}-${skillRelPath}`; -} - -/** - * Returns the flat-output path for a skill file, nesting the plugin's ENTIRE skills/ - * subtree under one `/` directory segment instead of hyphenating each - * immediate child independently (contrast `genericFlatSkillPath`). - * - * Nothing below `skillsPrefix` is renamed: one segment is added in front of the tree and - * every name under it survives. `genericFlatSkillPath` renames each immediate child instead, - * and a script's `require()` is never rewritten (see plugin-content-translator.ts's - * `TranslatedFile.verbatim` doc), so any path crossing a renamed name stops resolving there. - * - * This is the shape OpenCode installs today, and the one `genericFlatHooksScriptPath` uses - * for a hook's own subtree. It does not license sharing code between skills: the four other - * contracts do rename per child, and a plugin that relies on the tree staying intact is - * installable by one tool out of five. - * - * @param skillsPrefix - Full prefix for skills dir (e.g. ".opencode/skills/") - * @param plugin - Plugin name (used as the nesting directory) - * @param skillRelPath - Path relative to the plugin's skills/ directory - */ -export function genericFlatSkillTreePath( - skillsPrefix: string, - plugin: string, - skillRelPath: string -): string { - return `${skillsPrefix}${plugin}/${skillRelPath}`; -} - -/** - * Returns the flat-output path for the per-plugin hooks JSON file. - * - * @param hooksPrefix - Full prefix for hooks dir (e.g. ".github/hooks/") - * @param plugin - Plugin name - */ -export function genericFlatHooksFile(hooksPrefix: string, plugin: string): string { - return `${hooksPrefix}${plugin}.hooks.json`; -} - -/** - * Returns the flat-output path for a sibling hooks script file. - * - * @param hooksPrefix - Full prefix for hooks dir - * @param plugin - Plugin name - * @param scriptRelPath - Path relative to the plugin's hooks/ directory - */ -export function genericFlatHooksScriptPath( - hooksPrefix: string, - plugin: string, - scriptRelPath: string -): string { - return `${hooksPrefix}${plugin}/${scriptRelPath}`; -} - -/** - * Returns the key prefix used when merging a plugin's MCP servers. - * Includes trailing dash. - */ -export function flatMcpKeyPrefix(plugin: string): string { - return `${plugin}-`; -} - -/** - * Returns the flat-output path for a hook file under a shared, non-namespaced - * `flatHooksDir` — a loader that scans one directory for its own runtime module - * (opencode's `.opencode/plugin/`), not a per-plugin subtree. No plugin segment is - * added: two plugins delivering the same filename there collide by design, the same - * way the tool's own loader would see them. - * - * @param flatHooksDir - The tool's declared flat hooks directory, trailing slash included - * @param hooksRelativePath - A hook component's path, e.g. "hooks/journal.cjs" - */ -export function flatHooksSharedDirPath(flatHooksDir: string, hooksRelativePath: string): string { - return `${flatHooksDir}${hooksRelativePath.replace(/^hooks\//, "")}`; -} diff --git a/cli/src/domain/formats/marketplace-json.ts b/cli/src/domain/formats/marketplace-json.ts deleted file mode 100644 index f8e3c0ab4..000000000 --- a/cli/src/domain/formats/marketplace-json.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { MarketplaceEntryAlreadyExistsError } from "../errors.js"; - -export interface MarketplaceLocalEntry { - name: string; - version: string; - source: string; - description: string; - recommended: boolean; - strict: boolean; -} - -interface MarketplaceJson { - plugins?: MarketplaceLocalEntry[]; - [key: string]: unknown; -} - -export function appendPluginToMarketplace(json: string, entry: MarketplaceLocalEntry): string { - const parsed = JSON.parse(json) as MarketplaceJson; - const plugins = parsed.plugins ?? []; - - const collision = plugins.findIndex((p) => p.name === entry.name); - if (collision !== -1) { - throw new MarketplaceEntryAlreadyExistsError(entry.name, collision, "(marketplace.json)"); - } - - const updated: MarketplaceJson = { - ...parsed, - plugins: [...plugins, entry], - }; - return `${JSON.stringify(updated, null, 2)}\n`; -} diff --git a/cli/src/domain/formats/opencode-export.ts b/cli/src/domain/formats/opencode-export.ts deleted file mode 100644 index 24772ce6c..000000000 --- a/cli/src/domain/formats/opencode-export.ts +++ /dev/null @@ -1,164 +0,0 @@ -import type { LocalCostCandidateRecord } from "../ports/session-cost-reader.js"; - -// Measured 2026-08-20 on opencode 1.14.20, providerID "anthropic": `opencode export -// --sanitize` answers `{info, messages}` on stdout, and a counted message's own -// `info` carries `tokens` (`{total, input, output, reasoning, cache:{read, write}}`), -// `modelID` and a stable `id`. `total == input + output + cache.read + cache.write` on every -// message captured that session (`reasoning` was `0` throughout, so it never entered the sum). -// `info.cost` is deliberately never read here: it is `0` in every message captured, its -// denomination (which currency, computed vs billed) has never been established, and a -// figure whose meaning is unknown is worse than an absent one. -// `info.providerID` (e.g. "anthropic", sitting right next to `modelID`) is deliberately -// never read either: the stored record has no provider field — `model` everywhere else in -// this codebase already holds a bare model id, not a `provider/model` pair — and inventing -// one here would introduce OpenCode's own vocabulary for something no other reader names. -// -// Re-probed 2026-08-24 against a second, genuinely different provider obtained on this -// machine — `opencode run --model opencode/big-pickle ...` (providerID "opencode", an -// `@ai-sdk/openai-compatible` backend). Its `total == input + output + cache.read + -// cache.write + reasoning` reconciled too (`14072 == 13926 + 13 + 128 + 0 + 5`), but that -// did not settle the question then: a second, continued turn in that session showed -// `cache.read: 0, cache.write: 0` throughout — the backend never exercised its cache — so -// no capture put a large `cache.read` beside `input` for a non-Anthropic provider, which -// is the one comparison that shows `input` failing to shrink if it already counted the -// cached tokens. -// -// **Captured 2026-09-06** (providerID "opencode", modelID "ling-3.0-flash-fin-free" — -// tests/fixtures/telemetry-sink/opencode-export-non-anthropic-cache.json): three billed -// turns of one session with the cache genuinely exercised. `input` falls 28242 → 269 → 196 -// as `cache.read` climbs 640 → 28928 → 29184, and `total == input + output + reasoning + -// cache.read + cache.write` holds on all three (29089, 29356, 29438). An `input` inclusive -// of the cached tokens could neither shrink that way nor leave that identity standing, so -// the counters are disjoint for this provider too. Anthropic's own exclusivity is -// independent of this (the documented behaviour of its Messages API, corroborated elsewhere -// in this repo against Claude Code's own `/usage`). What stays open is narrower than it -// was: a provider that reports prompt tokens *inclusive* of cached ones, the way native -// OpenAI's Chat Completions usage does, has still never been captured here. -// -// That open question does not reopen the choice above to never read `info.providerID`: a -// per-record check would need a provider field on the stored record to hang a per-provider -// caveat off of, which is the schema change the comment above already declines, for a -// reason unrelated to this one. The declaration this limit needs instead is static — the -// same shape as Cursor's `not covered`, just narrower — and `opencode.ts`'s -// `telemetryLocalRead.limitation` carries it. -const VENDOR_FIELD = "sessionID"; -const TURN_FIELD = "id"; - -interface OpencodeTokenCounts { - readonly total?: unknown; - readonly input?: unknown; - readonly output?: unknown; - readonly cache?: { readonly read?: unknown; readonly write?: unknown }; -} - -interface OpencodeMessageInfo { - readonly id?: unknown; - readonly modelID?: unknown; - readonly tokens?: OpencodeTokenCounts; - readonly time?: { readonly created?: unknown; readonly completed?: unknown }; -} - -interface OpencodeExportPayload { - readonly messages?: readonly { readonly info?: OpencodeMessageInfo }[]; -} - -function asNumber(value: unknown): number | undefined { - return typeof value === "number" ? value : undefined; -} - -function asString(value: unknown): string | undefined { - return typeof value === "string" ? value : undefined; -} - -// `time.created`, not `time.completed`: created is on every counted message measured, while -// completed is absent on some, and a record that sometimes means "started" and sometimes -// means "finished" is worse than one that always means the same thing. Epoch milliseconds. -function isoFromEpochMillis(value: unknown): string | undefined { - const millis = asNumber(value); - if (millis === undefined || millis <= 0) return undefined; - const at = new Date(millis); - return Number.isNaN(at.getTime()) ? undefined : at.toISOString(); -} - -function buildIdentity( - info: OpencodeMessageInfo, - sessionId: string -): Pick { - const turnId = asString(info.id); - return { - vendor_id: sessionId, - vendor_field: VENDOR_FIELD, - ...(turnId !== undefined ? { turn_id: turnId, turn_field: TURN_FIELD } : {}), - }; -} - -// `cache.read`/`cache.write` are the same quantities the other tools already call -// cache-read and cache-creation — mapped onto those field names, not OpenCode's own. -function buildCounters( - tokens: OpencodeTokenCounts -): Pick< - LocalCostCandidateRecord, - "input_tokens" | "output_tokens" | "cache_read_tokens" | "cache_creation_tokens" -> { - const input = asNumber(tokens.input); - const output = asNumber(tokens.output); - const cacheRead = asNumber(tokens.cache?.read); - const cacheWrite = asNumber(tokens.cache?.write); - return { - ...(input !== undefined ? { input_tokens: input } : {}), - ...(output !== undefined ? { output_tokens: output } : {}), - ...(cacheRead !== undefined ? { cache_read_tokens: cacheRead } : {}), - ...(cacheWrite !== undefined ? { cache_creation_tokens: cacheWrite } : {}), - }; -} - -// A message OpenCode created but never billed carries `tokens` with every counter at `0` -// and no `total` key at all — reproduced on this machine 2026-08-24 by SIGINT-ing an -// `opencode run` mid-response and exporting the session: the interrupted assistant message -// has `time.created` but no `time.completed`, no `finish`, and a `tokens` object with no -// `total` — the same shape as this repo's own fixture's fourth assistant message. -// anomalyco/opencode#33687 confirms the mechanism: a message that halts on abort is not -// reliably given a `finish` either, so `total`'s absence is the signal, not something to -// wait on being fixed. A message that completed with genuinely zero usage still carries a -// `total` (`0`), and still yields a record: that is an observation, not a call that never -// happened. -function wasBilled(tokens: OpencodeTokenCounts): boolean { - return asNumber(tokens.total) !== undefined; -} - -function buildRecord( - info: OpencodeMessageInfo, - sessionId: string -): LocalCostCandidateRecord | null { - if (info.tokens === undefined) return null; - if (!wasBilled(info.tokens)) return null; - const model = asString(info.modelID); - const at = isoFromEpochMillis(info.time?.created); - return { - kind: "request", - ...buildIdentity(info, sessionId), - ...(model !== undefined ? { model } : {}), - ...(at !== undefined ? { event_timestamp: at } : {}), - ...buildCounters(info.tokens), - }; -} - -/** Every billed message in a captured `opencode export --sanitize` payload, mapped onto the - * stored record's own field names. A message whose `info.tokens` is absent — every user turn, - * and any turn OpenCode never measured — yields no record: never an invented zero. Nor does one - * whose `tokens` carries no `total`: that is a message OpenCode created but never billed (see - * `wasBilled`), and counting it would inflate the request count with a call that never happened. - * `sessionId` is trusted as given, matching every other local reader's contract; it is not - * re-derived from `payload.info.id`. */ -export function mapOpencodeExportToSinkRecords( - payload: unknown, - sessionId: string -): readonly LocalCostCandidateRecord[] { - const messages = (payload as OpencodeExportPayload)?.messages ?? []; - const records: LocalCostCandidateRecord[] = []; - for (const message of messages) { - const record = buildRecord(message?.info ?? {}, sessionId); - if (record) records.push(record); - } - return records; -} diff --git a/cli/src/domain/formats/opencode-marketplace.ts b/cli/src/domain/formats/opencode-marketplace.ts deleted file mode 100644 index 2f3071f40..000000000 --- a/cli/src/domain/formats/opencode-marketplace.ts +++ /dev/null @@ -1,78 +0,0 @@ -/** - * OpenCode marketplace format adapter — pure parser, no I/O. - * - * OpenCode has no dedicated marketplace.json or per-project plugin manifest - * convention. Plugins are referenced by npm package name (or local file path) - * in the project-level `opencode.json` config file under a `plugin` array. - * Each entry is either a bare string specifier or a [specifier, options] tuple. - * - * This adapter treats the `plugin` array in `opencode.json` as a plugin catalog. - * A missing or empty `plugin` field yields an empty catalog (it is optional per - * the OpenCode config schema). No version or description is available at this - * layer; those fields are always omitted from the NormalizedPlugin output. - * - * Documented fields (per https://opencode.ai/docs/config and packages/opencode/src/config/plugin.ts): - * plugin: (string | [string, Record])[] — optional array - * - * Probe path: `opencode.json` (strict JSON, project root — the public convention). - * The `.opencode/opencode.jsonc` variant used in the OpenCode repo itself is JSONC - * and requires a separate parser; `opencode.json` is sufficient for catalog detection. - */ - -import { ForeignSchemaValidationError } from "../errors.js"; -import type { NormalizedCatalog, NormalizedPlugin } from "../models/normalized-plugin.js"; - -const SOURCE = "opencode"; - -export function parseOpencodeMarketplace(rawJson: string): NormalizedCatalog { - const parsed = parseJson(rawJson); - const plugins = extractPlugins(parsed); - return { source: SOURCE, plugins }; -} - -function parseJson(rawJson: string): unknown { - try { - return JSON.parse(rawJson); - } catch { - throw new ForeignSchemaValidationError(SOURCE, "opencode.json is not valid JSON"); - } -} - -function extractPlugins(parsed: unknown): readonly NormalizedPlugin[] { - if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) { - throw new ForeignSchemaValidationError(SOURCE, "opencode.json must be a JSON object"); - } - const obj = parsed as Record; - if (!("plugin" in obj) || obj.plugin === undefined) { - return []; - } - if (!Array.isArray(obj.plugin)) { - throw new ForeignSchemaValidationError(SOURCE, '"plugin" must be an array'); - } - return obj.plugin.map((entry, i) => parseEntry(entry, i)); -} - -function parseEntry(raw: unknown, index: number): NormalizedPlugin { - const spec = extractSpec(raw, index); - if (typeof spec !== "string" || spec.length === 0) { - throw new ForeignSchemaValidationError( - SOURCE, - `plugin[${index}] specifier must be a non-empty string` - ); - } - return { name: spec, source: SOURCE }; -} - -function extractSpec(raw: unknown, index: number): unknown { - if (typeof raw === "string") return raw; - if (Array.isArray(raw)) { - if (raw.length === 0) { - throw new ForeignSchemaValidationError(SOURCE, `plugin[${index}] tuple must not be empty`); - } - return raw[0]; - } - throw new ForeignSchemaValidationError( - SOURCE, - `plugin[${index}] must be a string or [string, options] tuple` - ); -} diff --git a/cli/src/domain/formats/opencode-mcp-merge.ts b/cli/src/domain/formats/opencode-mcp-merge.ts deleted file mode 100644 index f678275d4..000000000 --- a/cli/src/domain/formats/opencode-mcp-merge.ts +++ /dev/null @@ -1,148 +0,0 @@ -import type { Hasher } from "../ports/hasher.js"; -import { stripJsonComments } from "./jsonc.js"; - -interface OpencodeMcpSection { - mcp?: Record; -} - -const MCP_COLLISION_REASON = - "server already exists in opencode.json (user-owned); plugin entry skipped"; - -/** - * Merges incoming OpenCode-format MCP servers (already transformed via transformMcpToOpencode) - * into the existing opencode.json content. - * - * - Strips keys previously contributed by this plugin (previousEntriesForThisPlugin) before merging. - * - Preserves user-owned servers (keys not in previousEntriesForThisPlugin and not in incoming). - * - Incoming servers replace previous entries owned by this plugin (idempotent re-install). - * - Incoming servers that collide with user-owned keys are skipped and returned as collisions. - * - * Both `existingContent` and `incomingTransformed` must be valid JSON strings produced by - * `JSON.stringify(_, null, 2)` (the same serialization as transformMcpToOpencode). - */ -export function mergeOpencodeMcp( - existingContent: string | null, - incomingTransformed: string, - previousEntriesForThisPlugin: ReadonlyMap, - hasher: Hasher -): { - mergedContent: string; - contributedEntries: ReadonlyMap; - collisions: ReadonlyArray; -} { - const { full, mcp } = parseExisting(existingContent); - const incoming = parseIncoming(incomingTransformed); - const cleaned = stripPreviousEntries(mcp, previousEntriesForThisPlugin); - return applyIncoming(full, cleaned, incoming, previousEntriesForThisPlugin, hasher); -} - -/** - * Builds the opencode.json emitted by the flat framework build. - * - * Written unconditionally (even with zero MCP servers), so the archive always - * ships a config — matching every sibling flat target (codex config.toml, - * claude settings.json). - * - * The framework-owned keys ($schema, instructions) come from `baseConfig`: the - * same bundled `assets/configs/opencode/opencode.json` the install path writes, - * so both paths emit an identical shape from a single source of truth. - * - * - `baseConfig` keys are framework-owned and win over stale existing copies. - * - Any other top-level keys in an existing config are preserved (user-owned). - * - Incoming prefixed MCP servers merge into `mcp` (incoming wins); the `mcp` - * key is omitted entirely when neither existing nor incoming contribute one. - * - Malformed `existing` content throws — no silent discard. - * - * @param baseConfig - Canonical base config (bundled opencode.json asset), as JSON - * @param existing - Raw content of the existing opencode.json (or null if absent) - * @param incoming - Already-prefixed MCP server entries to merge in - */ -export function buildOpencodeFlatConfig( - baseConfig: string, - existing: string | null, - incoming: Record -): string { - const base = JSON.parse(baseConfig) as Record; - const { full, mcp } = parseExisting(existing); - const userKeys = { ...full }; - for (const key of Object.keys(base)) delete userKeys[key]; - delete userKeys.mcp; - const mergedMcp = { ...mcp, ...incoming }; - const result: Record = { ...base, ...userKeys }; - delete result.mcp; - if (Object.keys(mergedMcp).length > 0) result.mcp = mergedMcp; - return JSON.stringify(result, null, 2); -} - -/** - * Removes servers previously contributed by a plugin from the opencode.json mcp section. - * Keys not present in `entries` are preserved untouched. - */ -export function unmergeOpencodeMcp( - existingContent: string, - entries: ReadonlyMap -): string { - const parsed = JSON.parse(stripJsonComments(existingContent)) as OpencodeMcpSection; - const mcp = { ...(parsed.mcp ?? {}) }; - for (const name of entries.keys()) { - delete mcp[name]; - } - return JSON.stringify({ ...parsed, mcp }, null, 2); -} - -// ── Private helpers ────────────────────────────────────────────────────────── - -function parseExisting(content: string | null): { - full: Record; - mcp: Record; -} { - if (content === null) return { full: {}, mcp: {} }; - // opencode.json is user-owned and may be JSONC (comments / trailing commas). - const parsed = JSON.parse(stripJsonComments(content)) as OpencodeMcpSection; - return { - full: parsed as Record, - mcp: (parsed.mcp as Record) ?? {}, - }; -} - -function parseIncoming(transformed: string): Record { - const parsed = JSON.parse(transformed) as OpencodeMcpSection; - return (parsed.mcp as Record) ?? {}; -} - -function stripPreviousEntries( - existing: Record, - previous: ReadonlyMap -): Record { - const result = { ...existing }; - for (const name of previous.keys()) { - delete result[name]; - } - return result; -} - -function applyIncoming( - full: Record, - cleanedMcp: Record, - incoming: Record, - previous: ReadonlyMap, - hasher: Hasher -): { - mergedContent: string; - contributedEntries: ReadonlyMap; - collisions: ReadonlyArray; -} { - const mcp = { ...cleanedMcp }; - const contributed = new Map(); - const collisions: string[] = []; - for (const [name, server] of Object.entries(incoming)) { - if (name in cleanedMcp && !previous.has(name)) { - collisions.push(`${name}: ${MCP_COLLISION_REASON}`); - continue; - } - mcp[name] = server; - contributed.set(name, hasher.hash(JSON.stringify(server)).value); - } - const mergedContent = JSON.stringify({ ...full, mcp }, null, 2); - return { mergedContent, contributedEntries: contributed, collisions }; -} diff --git a/cli/src/domain/formats/placeholders.ts b/cli/src/domain/formats/placeholders.ts deleted file mode 100644 index 14a56801f..000000000 --- a/cli/src/domain/formats/placeholders.ts +++ /dev/null @@ -1,16 +0,0 @@ -// Placeholder substitution removed in marketplace-only architecture. -// Plugin content is tool-agnostic with relative paths and hardcoded aidd_docs. -// Kept as identity for backward compat with existing callers; will be removed -// when capability classes drop docsDir threading. - -export function baseRewriteContent(content: string, _directory: string, _docsDir: string): string { - return content; -} - -export function baseReverseRewriteContent( - content: string, - _directory: string, - _docsDir: string -): string { - return content; -} diff --git a/cli/src/domain/formats/plain-object.ts b/cli/src/domain/formats/plain-object.ts deleted file mode 100644 index f7247a788..000000000 --- a/cli/src/domain/formats/plain-object.ts +++ /dev/null @@ -1,9 +0,0 @@ -/** Narrows a value fresh out of `JSON.parse` to a plain object, `null` for anything else — - * an array, a primitive, or `null` itself. Every reader that parses a settings or config - * file needs this same one step before it can look up a key by name, and duplicating it - * per adapter is exactly the kind of drift `no duplication` exists to catch. */ -export function asPlainObject(value: unknown): Record | null { - return value !== null && typeof value === "object" && !Array.isArray(value) - ? (value as Record) - : null; -} diff --git a/cli/src/domain/formats/plugin-root-token-rewrite.ts b/cli/src/domain/formats/plugin-root-token-rewrite.ts deleted file mode 100644 index 142689abc..000000000 --- a/cli/src/domain/formats/plugin-root-token-rewrite.ts +++ /dev/null @@ -1,32 +0,0 @@ -/** - * Pure helper that substitutes the native marketplace plugin-root token. - * - * In a marketplace plugin bundle, hook/mcp path strings are authored with - * ${CLAUDE_PLUGIN_ROOT} as the canonical source token. Which token a tool - * expands is declared on that tool, as `plugins.pluginRootToken`; the constants - * below are the vocabulary those declarations pick from. - * - * This helper replaces ALL occurrences of the source token in the content - * string with the provided target token. For claude the target equals the - * source, making this a transparent no-op. - * - * Only the literal path token is rewritten. Every other ${...} variable - * (e.g. ${ISSUE_NUMBER}, ${CLAUDE_SESSION_ID}) is left untouched. - * - * No I/O, no JSON parsing — pure string substitution. - */ - -// Split literals to avoid biome's noTemplateCurlyInString warning. -export const CLAUDE_PLUGIN_ROOT_TOKEN = "$" + "{CLAUDE_PLUGIN_ROOT}"; -export const CURSOR_PLUGIN_ROOT_TOKEN = "$" + "{CURSOR_PLUGIN_ROOT}"; -export const PLUGIN_ROOT_TOKEN = "$" + "{PLUGIN_ROOT}"; - -/** - * Replace every occurrence of ${CLAUDE_PLUGIN_ROOT} in `content` with - * `targetToken`. Returns `content` unchanged when targetToken equals the - * source token (claude no-op path). - */ -export function rewritePluginRootToken(content: string, targetToken: string): string { - if (targetToken === CLAUDE_PLUGIN_ROOT_TOKEN) return content; - return content.replaceAll(CLAUDE_PLUGIN_ROOT_TOKEN, targetToken); -} diff --git a/cli/src/domain/formats/relative-link-rewrite.ts b/cli/src/domain/formats/relative-link-rewrite.ts deleted file mode 100644 index 6868d4550..000000000 --- a/cli/src/domain/formats/relative-link-rewrite.ts +++ /dev/null @@ -1,58 +0,0 @@ -import { basename, dirname, posix } from "node:path"; - -// Matches the same non-whitespace, non-quote, non-bracket character class used -// in copilot.ts::rewriteCopilotContent for consistency. -const REFERENCE_CHAR_CLASS = "[^\\s`'\">,]+"; - -const RELATIVE_CURRENT_RE = new RegExp(`@\\.\\/(${REFERENCE_CHAR_CLASS})`, "g"); -const RELATIVE_PARENT_RE = new RegExp(`@\\.\\.\\/(${REFERENCE_CHAR_CLASS})`, "g"); - -// Matches @${CLAUDE_PLUGIN_ROOT}/ — only when prefixed with @ (spec C-v2.2). -const CLAUDE_ROOT_RE = new RegExp(`@\\$\\{CLAUDE_PLUGIN_ROOT\\}\\/(${REFERENCE_CHAR_CLASS})`, "g"); - -export interface RewriteRelativeLinksOptions { - readonly currentFilePluginRelative: string; - /** - * Optional override for how @${CLAUDE_PLUGIN_ROOT}/ target paths are - * resolved before computing the markdown link. Receives the plugin-relative - * path (e.g. "agents/foo.md") and returns the path to use in the link. - * Defaults to identity (Mode A behaviour — relative path from current file). - */ - readonly resolveTargetPath?: (pluginRelPath: string) => string; -} - -/** - * Rewrites @./X → [X](./X), @../X → [X](../X), and - * @${CLAUDE_PLUGIN_ROOT}/ → a markdown link with a relative path computed - * from the current file's plugin-relative location (spec §"Content rewrite" rules 1–3). - * - * Does NOT rewrite @{{TOOLS}}/... — callers must detect and halt on that pattern. - * Does NOT touch bare ${CLAUDE_PLUGIN_ROOT} without a leading @ (spec C-v2.2). - * - * No inverse: rewriteRelativeLinks is a one-way expansion — @-shorthand is - * replaced with full markdown links that cannot be unambiguously reversed to the - * original @-syntax (the label and path are indistinguishable from user-authored links). - */ -export function rewriteRelativeLinks( - content: string, - options: RewriteRelativeLinksOptions -): string { - const afterParent = content.replace(RELATIVE_PARENT_RE, "[$1](../$1)"); - const afterCurrent = afterParent.replace(RELATIVE_CURRENT_RE, "[$1](./$1)"); - return afterCurrent.replace(CLAUDE_ROOT_RE, (_match, rel: string) => - rewriteClaudeRootRef(rel, options.currentFilePluginRelative, options.resolveTargetPath) - ); -} - -function rewriteClaudeRootRef( - targetPluginRel: string, - currentFilePluginRelative: string, - resolveTargetPath?: (pluginRelPath: string) => string -): string { - const resolved = resolveTargetPath ? resolveTargetPath(targetPluginRel) : targetPluginRel; - const currentDirPluginRel = dirname(currentFilePluginRelative); - let linkPath = posix.relative(currentDirPluginRel, resolved); - if (!linkPath.startsWith(".")) linkPath = `./${linkPath}`; - const label = basename(targetPluginRel); - return `[${label}](${linkPath})`; -} diff --git a/cli/src/domain/formats/vscode-mcp-merge.ts b/cli/src/domain/formats/vscode-mcp-merge.ts deleted file mode 100644 index 22c353a2f..000000000 --- a/cli/src/domain/formats/vscode-mcp-merge.ts +++ /dev/null @@ -1,80 +0,0 @@ -/** - * Purely additive merge helper for workspace MCP config files. - * - * Distinct from opencode-mcp-merge (which is manifest-driven and strips previous - * plugin-owned entries before merging). This helper has NO manifest and NO strip - * step — flat mode is fire-and-forget (spec §Out of scope). - * - * Contract: - * - Read existing servers object from the target file (or start empty). - * - For each incoming key: if already present and force === false, record a collision - * and skip; if force === true, overwrite. - * - Keys in `incoming` are pre-prefixed by the caller (flatMcpKeyPrefix from - * flat-paths.ts). This helper is agnostic to plugin identity. - * - User-owned servers (any key not in incoming) are always preserved. - * - Returns merged JSON (2-space indent, trailing newline) and collisions list. - * - * The `serversKey` parameter controls which JSON property holds the servers map: - * - "servers" for .vscode/mcp.json (Copilot/VS Code format) - * - "mcpServers" for .mcp.json (Claude) or .cursor/mcp.json (Cursor) - */ - -/** - * Merges incoming MCP server entries (pre-prefixed with plugin name) into the - * existing workspace MCP config content. - * - * @param existing - Current file contents, or null if file does not exist. - * @param incoming - Pre-prefixed server entries to add (keys like "-"). - * @param force - When true, overwrite colliding entries instead of recording them. - * @param serversKey - JSON property name for the servers map (default: "servers"). - * @returns mergedContent (the new file content) and collisions (keys that were skipped - * because they already existed and force was false). - * - * No inverse: mergeVscodeMcp is a fire-and-forget additive merge — flat mode has no - * plugin manifest to track what was contributed, so there is no strip/unmerge operation. - */ -export function mergeVscodeMcp( - existing: string | null, - incoming: Record, - force: boolean, - serversKey = "servers" -): { mergedContent: string; collisions: ReadonlyArray } { - const { full, servers } = parseExisting(existing, serversKey); - const { servers: mergedServers, collisions } = applyIncoming(servers, incoming, force); - const merged: Record = { ...full, [serversKey]: mergedServers }; - return { mergedContent: `${JSON.stringify(merged, null, 2)}\n`, collisions }; -} - -// ── Private helpers ────────────────────────────────────────────────────────── - -function parseExisting( - content: string | null, - serversKey: string -): { - full: Record; - servers: Record; -} { - if (content === null) return { full: {}, servers: {} }; - const parsed = JSON.parse(content) as Record; - return { - full: parsed, - servers: (parsed[serversKey] as Record) ?? {}, - }; -} - -function applyIncoming( - existingServers: Record, - incoming: Record, - force: boolean -): { servers: Record; collisions: ReadonlyArray } { - const servers = { ...existingServers }; - const collisions: string[] = []; - for (const [key, value] of Object.entries(incoming)) { - if (key in servers && !force) { - collisions.push(key); - continue; - } - servers[key] = value; - } - return { servers, collisions }; -} diff --git a/cli/src/domain/models/.gitkeep b/cli/src/domain/models/.gitkeep deleted file mode 100644 index e69de29bb..000000000 diff --git a/cli/src/domain/models/config-capability.ts b/cli/src/domain/models/config-capability.ts deleted file mode 100644 index f710f637d..000000000 --- a/cli/src/domain/models/config-capability.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { HooksCapability } from "../capabilities/hooks-capability.js"; -import { McpCapability } from "../capabilities/mcp-capability.js"; -import { SettingsCapability } from "../capabilities/settings-capability.js"; -import type { ToolConfig } from "../tools/registry.js"; - -export type ConfigCapability = McpCapability | HooksCapability | SettingsCapability; - -export function extractConfigCapabilities(config: ToolConfig): ConfigCapability[] { - const result: ConfigCapability[] = []; - - // IDE tools expose settings directly - if ("settings" in config) { - const s = (config as { settings: unknown }).settings; - if (s instanceof SettingsCapability) result.push(s); - else if (Array.isArray(s)) result.push(...(s as SettingsCapability[])); - } - - // AI tools expose capabilities bag - if (config.kind === "ai") { - const aiCaps = config.capabilities as Record; - if (typeof aiCaps === "object" && aiCaps !== null) { - if (aiCaps.mcp instanceof McpCapability) result.push(aiCaps.mcp); - if (aiCaps.hooks instanceof HooksCapability) result.push(aiCaps.hooks); - if (aiCaps.settings instanceof SettingsCapability) result.push(aiCaps.settings); - if (Array.isArray(aiCaps.settings)) result.push(...(aiCaps.settings as SettingsCapability[])); - } - } - - return result; -} diff --git a/cli/src/domain/models/cost-report-envelope.ts b/cli/src/domain/models/cost-report-envelope.ts deleted file mode 100644 index 4ead22ebc..000000000 --- a/cli/src/domain/models/cost-report-envelope.ts +++ /dev/null @@ -1,541 +0,0 @@ -import type { TelemetryRouteSupply } from "../capabilities/telemetry-capability.js"; -import type { - AgentAttributionSource, - CostReport, - CostReportEmptySelection, - CostReportFilters, - CostReportToolCoverage, - CostTotals, - PersonIdentityUnusableCause, -} from "./cost-report.js"; -import type { FlowAttributionSource } from "./flow-attribution.js"; -import type { PersonResolution } from "./person-resolution.js"; -import type { StepAttributionSource } from "./step-attribution.js"; -import type { TaskAttributionSource, TaskUnattributedReason } from "./task-attribution.js"; -import type { AiToolId } from "./tool-ids.js"; - -/** Bumped when a consumer that understood the previous shape would misread this one. - * - * A version exists so a consumer can refuse rather than guess — the same reason - * `sink_schema_version` exists on a stored line. Adding a field a consumer may ignore is - * not a bump; changing what an existing field means is. - * - * Bumped to 13: `by_prompt` is a new top-level breakdown, and a consumer summing every - * breakdown's `requests` against `totals.requests` to check nothing was dropped now has one - * more to include. It is the only breakdown no host limit can empty: every other depends on - * a capture that may not have happened, this one on a field the transcript reader resolves - * for itself. That is not the same as complete, and `CostReportPromptRow` carries the - * measurement — 845 of 30,714 records of this machine's own sink carry no `prompt_id`, all - * but one of them stored by a reader that predates the resolution. - * - * Bumped to 12: `by_task`'s `attribution` stops being always `"declared"`. A record no - * declaration covers, in a session whose journal witnessed it and that wrote into exactly - * one task folder, is now named after that folder and marked `"inferred"` - so one task can - * hold two rows, one per route, the same `(name x attribution)` shape `by_step` already has. - * A consumer that read `attribution` as constant, or `by_task` as one row per task, - * misreads this version. Measured: on the one session with a complete journal, 1045 of 1073 - * records fell inside a declared interval and the remaining 27 sat between `session_start` - * and the first declaration, 38 minutes of work before the flow named its ticket. - * - * Bumped to 11: a `by_task` or `by_backlog` row with no task gains a fourth possible - * `reason`, `no-journal`. A consumer that switched exhaustively on the three before it meets - * a value it has no case for, which is a misread rather than a field it may ignore. It - * separates a fact about the read from a fact about the work: a session with no journal read - * for it used to be given `no-declaration`, which asserts the session declared no task. - * Measured 2026-09-04 - a report run from a subdirectory put 100% of a period into that row - * while every journal sat one directory up, unread. - * - * Bumped to 10: `by_agent` is a new top-level breakdown, and a consumer summing every - * breakdown's `requests` against `totals.requests` to check nothing was dropped now has one - * more to include. It exists because that is where the spend is: on a live session, ten - * subagent files held 432M of 466M tokens, every one of their lines naming its agent and - * almost none its skill. - * - * Bumped to 9: `attribution` gains a fourth value, `prompt-matched`. A consumer that - * understood the three before it — mapping them to labels, or switching exhaustively — meets - * a value it has no case for, which is a misread rather than a field it may ignore. It ranks - * above `journal-interval` and below `tool-stated`: an identifier two sources independently - * name is stronger than an inference from moments, weaker than the tool saying it outright. - * - * Bumped to 8: `by_flow` is a new top-level breakdown - a consumer summing every - * breakdown's `requests` against `totals.requests` to check nothing was dropped now has a - * seventh breakdown to include, the same reasoning that bumped `by_backlog` in. Grouped - * from the journal's own step sequence, read between whichever skills the domain declares - * as orchestrating (`flow-attribution.ts`'s `ORCHESTRATING_SKILLS`) - never a second - * capture, and never a new hook line. A row with no `flow` is the work that fell in no - * flow interval at all; unlike `by_task` and `by_backlog`, it carries no `reason` breaking - * that remainder down further; a flow is read from the same sequence whichever way a - * record misses it, so there is only ever one fact to state. - * - * Bumped to 7: `by_backlog` is a new top-level breakdown - a consumer summing every - * breakdown's `requests` against `totals.requests` to check nothing was dropped now has a - * sixth breakdown to include, the same reasoning that bumped `by_task` in. Groups the same - * per-record task membership `by_task` already computes one level higher, by what each - * task's own folder declares (`aidd_docs/tasks//backlog-link.json`) - never a second - * notion of which task a record belongs to. A row with no `backlog` carries `declaration` - * (`"none"` for a task known to declare nothing, `"unreadable"` for one whose declaration - * could not be parsed) or `reason` (the same three values `by_task` gives a record in no - * task at all) - never both, and never neither. - * - * Bumped to 6: the row `by_task` gives what fell in no declared interval can now be up to - * three rows instead of always exactly one - `reason` names which of three distinct facts - * applies, and a consumer that read "the one row with no `task`" as a single, whole-period - * fact would now silently sum, or read, only part of it. Summing every row's `totals` - * still reconciles to the period total exactly as before; only the count and identity of - * rows with no `task` changes. - * - * Bumped to 5: `by_task` is a new top-level breakdown - a consumer summing every - * breakdown's requests against `totals.requests` to check nothing was dropped now has a - * fifth breakdown to include, the same reasoning that bumped `by_project`, `by_day` and - * `by_person` in. Grouped from the same closed intervals the pre-existing `--task` filter - * already reads, never a second notion of when a task was running; unrelated to - * `task_attribution`, which still exists only alongside a `--task` filter. - * - * Bumped to 4: `by_person` is a new top-level breakdown, and `read` gained - * `identity_unusable` - a consumer summing every breakdown's requests against - * `totals.requests` to check nothing was dropped now has a fourth breakdown to include, - * the same reasoning that bumped `by_project` and `by_day` in. `identity_unusable` itself - * was reshaped from a boolean into a named cause before this version ever shipped - see - * the identity-is-the-person rework - so no second bump announces that change. - * - * Bumped to 3: `by_model`'s `model` is now absent on the row for a record neither reader - * that permits a model-less request could name - a consumer that read it as always a - * string on every prior version would misread this one, the same reasoning that bumped - * `by_project`'s `project` to optional back when that row was added. - * - * Bumped to 2: `by_project` and `by_day` are new top-level breakdowns. */ -export const COST_REPORT_ENVELOPE_VERSION = 15; - -/** Money as whole micro-dollars, the way the report carries it: an integer, so a consumer - * summing several reports gets the same answer this one did. Divide by 1,000,000 for - * dollars, and only at the moment of display. */ -export interface CostReportEnvelopeTotals { - readonly requests: number; - readonly cost_micro_usd?: number; - readonly input_tokens?: number; - readonly output_tokens?: number; - readonly cache_read_tokens?: number; - readonly cache_creation_tokens?: number; -} - -export interface CostReportEnvelopeStepRow { - readonly step?: string; - readonly attribution: StepAttributionSource; - readonly totals: CostReportEnvelopeTotals; -} - -/** One model's figures, largest first, plus one row for what named none - `model` absent - * there, the same convention `CostReportEnvelopeProjectRow` uses for what named none. */ -export interface CostReportEnvelopeModelRow { - readonly model?: string; - readonly totals: CostReportEnvelopeTotals; -} - -/** What a route was measured to supply, `null` where the tool declares no such route at - * all — a different fact from a declared route that supplies nothing. */ -export interface CostReportEnvelopeRouteSupply { - readonly token_counters: boolean; - readonly amount: boolean; - readonly tool_stated_step: boolean; - /** The route names the agent a record belongs to, and so also says when one is the main - * thread's own. Without it `by_agent` reads this tool's records as stating no agent, never - * as the main thread. */ - readonly agent_name: boolean; -} - -export interface CostReportEnvelopeCapability { - readonly local_read: CostReportEnvelopeRouteSupply | null; - readonly export: CostReportEnvelopeRouteSupply | null; - /** False means the run journal never names this tool's sessions: no step can be derived - * from an interval, and a read that sweeps the journal never reaches one of its sessions - * at all. A consumer seeing a readable tool with no figures should look here before - * concluding it did no work. */ - readonly journal_attributable: boolean; - readonly task_attributable: boolean; -} - -export interface CostReportEnvelopeToolRow { - readonly tool: AiToolId; - readonly coverage: CostReportToolCoverage; - readonly reason?: string; - /** Read this rather than inferring from whether a figure is present. A tool that cannot - * supply an amount and a session that cost nothing look identical in the numbers. */ - readonly capability: CostReportEnvelopeCapability; - readonly totals: CostReportEnvelopeTotals; - /** A local-read `kind: "session"` total, present only for a tool whose own file yields - * one already-complete session figure rather than per-request records - today, only - * Copilot. Never folded into `totals`, which counts billed requests alone. */ - readonly session_totals?: CostReportEnvelopeTotals; -} - -export interface CostReportEnvelopeAttributionRow { - readonly attribution: StepAttributionSource; - readonly totals: CostReportEnvelopeTotals; -} - -/** The same idea, one axis over: how much of a `--task` report's total came from a - * declared interval versus a written file. */ -export interface CostReportEnvelopeTaskAttributionRow { - readonly attribution: TaskAttributionSource; - readonly totals: CostReportEnvelopeTotals; -} - -/** One project's figures, largest first, plus one row for what named none — `project` - * absent there, the same convention the step row uses for `unattributed`. */ -export interface CostReportEnvelopeProjectRow { - readonly project?: string; - readonly totals: CostReportEnvelopeTotals; -} - -/** One framework task's figures, keyed on the closed interval a record's own moment falls - * in - see `CostReportTaskRow`. `attribution` is present only alongside `task`, and says - * which route named it: `"declared"` where a `task_declared` interval covers the record, - * `"inferred"` where the session wrote into exactly one task folder and no declaration - * covered it. One task can therefore carry two rows, one per route; a row for what fell in no declared interval carries `reason` instead, - * naming which distinct fact applies - never both, and never neither. */ -export interface CostReportEnvelopeTaskRow { - readonly task?: string; - readonly attribution?: TaskAttributionSource; - readonly reason?: TaskUnattributedReason; - readonly totals: CostReportEnvelopeTotals; -} - -/** One backlog item's figures — see `CostReportBacklogRow`. `declaration` and `reason` are - * never both present, and never neither, on a row with no `backlog`. */ -export interface CostReportEnvelopeBacklogRow { - readonly backlog?: string; - readonly declaration?: "none" | "unreadable"; - readonly reason?: TaskUnattributedReason; - readonly totals: CostReportEnvelopeTotals; -} - -/** One agent's figures — see `CostReportAgentRow`. `agent` names it and is present exactly - * when `attribution` is `tool-stated`. The two rows that name none are different facts and - * never merged: `main-thread` is a tool that names agents saying this record belongs to - * none of them, `not-stated` is a tool whose route never names one, where reading a main - * thread would assert something nothing observed. */ -export interface CostReportEnvelopeAgentRow { - readonly agent?: string; - readonly attribution: AgentAttributionSource; - readonly totals: CostReportEnvelopeTotals; -} - -/** `started_at` is the earliest moment in the prompt, and only a named prompt carries one: - * the row for records that named no prompt is drawn from many turns, so a start moment there - * would assert a unit that never existed. */ -export interface CostReportEnvelopePromptRow { - readonly prompt?: string; - readonly started_at?: string; - readonly totals: CostReportEnvelopeTotals; -} - -/** `attribution` says how this flow came to be known, the same three-way shape `by_step` - * carries: `journal-interval` for a flow the journal opened and closed, `tool-stated` for - * one only a record's own tool named, `unattributed` for the row of work that joined - * neither. A `tool-stated` row carries no `started_at` - it is a bucket drawn from however - * many runs of that skill the tool named, and a name is not a run. */ -export interface CostReportEnvelopeFlowRow { - readonly flow?: string; - readonly attribution: FlowAttributionSource; - readonly started_at?: string; - readonly totals: CostReportEnvelopeTotals; -} - -/** One UTC day's figures. Every day the period spans, in order, whether or not a record - * landed on it — a day with nothing is a row of zeros, never an omitted row. */ -export interface CostReportEnvelopeDayRow { - readonly day: string; - readonly totals: CostReportEnvelopeTotals; -} - -/** One person's figures — `person` is the canonical identifier, present only where - * `resolution` is `"mapped"`; an unresolved row's raw identifier lives in `identities` - * instead. `identities` always carries what produced the row, so a person line is - * traceable back to its evidence without a second lookup against the mapping. */ -export interface CostReportEnvelopePersonRow { - readonly resolution: PersonResolution; - readonly person?: string; - readonly display_name?: string; - readonly identities: readonly string[]; - readonly totals: CostReportEnvelopeTotals; -} - -/** What the read could not do, travelling with what it did. A total assembled from a - * partial read is indistinguishable from a complete one unless these come with it. */ -export interface CostReportEnvelopeRead { - readonly undated_records: number; - readonly unreadable_lines: number; - /** Which of the two possible causes made this machine's own identity unusable for - * resolving records - `"unreadable"` for a declared identity file that could not be - * read back, `"absent"` for no identity declared at all. Absent from this envelope - * entirely when the identity was read back fine; `by_person` already shows a resolved - * identity's own effect on its own. */ - readonly identity_unusable?: PersonIdentityUnusableCause; -} - -/** - * One period's report, in the shape a program reads. - * - * Field names are snake_case, matching the stored record a consumer may already parse. - * Every counter is optional for the same reason it is optional there: an absent counter - * means never observed, which is a different fact from zero, and a tool whose files carry - * no amount has an unknown cost rather than a free one. - */ -export interface CostReportEnvelope { - readonly cost_report_version: number; - /** The period as it resolved, absolutely — never as it was asked for. */ - readonly period: { readonly from_day: string; readonly to_day: string }; - /** Whether the project switch is on right now, from `CostReport.measurementEnabled` - - * carried here so `--json` and `--axis` can say what the terminal rendering already - * does. Never derives "was this really measured" from a figure being zero: a switch off - * and a genuinely empty period read identically in every count below, and only this - * field tells them apart. Not a `cost_report_version` bump - a consumer that never reads - * it sees every field it already understood mean exactly what it always meant. */ - readonly measurement_enabled: boolean; - readonly task?: string; - /** Only the generic filters actually given - `task` above keeps its own field, - * unchanged. Absent for an unfiltered period. */ - readonly filters?: CostReportFilters; - /** Present only when a filter, never the period itself, is what emptied this - * selection - naming which one, and whether its value was ever known at all. */ - readonly empty_selection?: CostReportEmptySelection; - readonly sessions: number; - readonly totals: CostReportEnvelopeTotals; - /** Per session, and never broken down by step: no active-time measure on any tool - * carries a step attribute. Absent when no record carried it. */ - readonly active_time_s?: number; - readonly by_step: readonly CostReportEnvelopeStepRow[]; - readonly by_model: readonly CostReportEnvelopeModelRow[]; - readonly by_tool: readonly CostReportEnvelopeToolRow[]; - readonly by_project: readonly CostReportEnvelopeProjectRow[]; - readonly by_task: readonly CostReportEnvelopeTaskRow[]; - readonly by_backlog: readonly CostReportEnvelopeBacklogRow[]; - readonly by_flow: readonly CostReportEnvelopeFlowRow[]; - /** One row per agent that ran, `agent` absent on the main thread's own row. */ - readonly by_agent: readonly CostReportEnvelopeAgentRow[]; - /** One row per prompt that caused work, largest first, plus the row for records that - * named none. The one breakdown no host limit can empty. */ - readonly by_prompt: readonly CostReportEnvelopePromptRow[]; - /** Every day the period spans, always — a long period stays readable by how the text - * rendering chooses to show it, never by what this envelope omits. */ - readonly by_day: readonly CostReportEnvelopeDayRow[]; - /** Mapped people first, then every unplaced identity, then the one row for records - * carrying none at all - see `CostReport["byPeople"]`. */ - readonly by_person: readonly CostReportEnvelopePersonRow[]; - /** All three strengths, always, strongest first. */ - readonly attribution: readonly CostReportEnvelopeAttributionRow[]; - /** Present only alongside `task`: an unfiltered period carries no per-record task - * identity to break down. */ - readonly task_attribution?: readonly CostReportEnvelopeTaskAttributionRow[]; - readonly read: CostReportEnvelopeRead; -} - -function supply(from: TelemetryRouteSupply | null): CostReportEnvelopeRouteSupply | null { - return from === null - ? null - : { - token_counters: from.tokenCounters, - amount: from.amount, - tool_stated_step: from.toolStatedStep, - agent_name: from.agentName, - }; -} - -function capability(from: CostReport["byTools"][number]["capability"]) { - return { - local_read: supply(from.localRead), - export: supply(from.export), - journal_attributable: from.journalAttributable, - task_attributable: from.taskAttributable, - }; -} - -function toolRow(row: CostReport["byTools"][number]): CostReportEnvelopeToolRow { - return { - tool: row.tool, - coverage: row.coverage, - ...(row.reason === undefined ? {} : { reason: row.reason }), - capability: capability(row.capability), - totals: totals(row.totals), - ...(row.sessionTotals === undefined ? {} : { session_totals: totals(row.sessionTotals) }), - }; -} - -function stepRow(row: CostReport["bySteps"][number]): CostReportEnvelopeStepRow { - return { - ...(row.step === undefined ? {} : { step: row.step }), - attribution: row.attribution, - totals: totals(row.totals), - }; -} - -function totals(from: CostTotals): CostReportEnvelopeTotals { - return { - requests: from.requests, - ...(from.costMicroUsd === undefined ? {} : { cost_micro_usd: from.costMicroUsd }), - ...(from.inputTokens === undefined ? {} : { input_tokens: from.inputTokens }), - ...(from.outputTokens === undefined ? {} : { output_tokens: from.outputTokens }), - ...(from.cacheReadTokens === undefined ? {} : { cache_read_tokens: from.cacheReadTokens }), - ...(from.cacheCreationTokens === undefined - ? {} - : { cache_creation_tokens: from.cacheCreationTokens }), - }; -} - -function projectRow(row: CostReport["byProjects"][number]): CostReportEnvelopeProjectRow { - return { - ...(row.project === undefined ? {} : { project: row.project }), - totals: totals(row.totals), - }; -} - -function modelRow(row: CostReport["byModels"][number]): CostReportEnvelopeModelRow { - return { - ...(row.model === undefined ? {} : { model: row.model }), - totals: totals(row.totals), - }; -} - -function taskRow(row: CostReport["byTasks"][number]): CostReportEnvelopeTaskRow { - return { - ...(row.task === undefined ? {} : { task: row.task }), - ...(row.attribution === undefined ? {} : { attribution: row.attribution }), - ...(row.reason === undefined ? {} : { reason: row.reason }), - totals: totals(row.totals), - }; -} - -function backlogRow(row: CostReport["byBacklog"][number]): CostReportEnvelopeBacklogRow { - return { - ...(row.backlog === undefined ? {} : { backlog: row.backlog }), - ...(row.declaration === undefined ? {} : { declaration: row.declaration }), - ...(row.reason === undefined ? {} : { reason: row.reason }), - totals: totals(row.totals), - }; -} - -function agentRow(row: CostReport["byAgents"][number]): CostReportEnvelopeAgentRow { - return { - ...(row.agent === undefined ? {} : { agent: row.agent }), - attribution: row.attribution, - totals: totals(row.totals), - }; -} - -function promptRow(row: CostReport["byPrompts"][number]): CostReportEnvelopePromptRow { - return { - ...(row.prompt === undefined ? {} : { prompt: row.prompt }), - ...(row.startedAt === undefined ? {} : { started_at: row.startedAt }), - totals: totals(row.totals), - }; -} - -function flowRow(row: CostReport["byFlows"][number]): CostReportEnvelopeFlowRow { - return { - ...(row.flow === undefined ? {} : { flow: row.flow }), - attribution: row.attribution, - ...(row.startedAt === undefined ? {} : { started_at: row.startedAt }), - totals: totals(row.totals), - }; -} - -function personRow(row: CostReport["byPeople"][number]): CostReportEnvelopePersonRow { - return { - resolution: row.resolution, - ...(row.person === undefined ? {} : { person: row.person }), - ...(row.displayName === undefined ? {} : { display_name: row.displayName }), - identities: row.identities, - totals: totals(row.totals), - }; -} - -function attributionRow( - row: CostReport["attributionMix"][number] -): CostReportEnvelopeAttributionRow { - return { attribution: row.attribution, totals: totals(row.totals) }; -} - -/** Present only alongside `task`: an unfiltered period carries no per-record task identity - * to break down (see metrics-contract.md's "Attributing records to a task"). */ -function taskAttribution( - taskAttributionMix: CostReport["taskAttributionMix"] -): Pick { - if (taskAttributionMix === undefined) return {}; - return { - task_attribution: taskAttributionMix.map((row) => ({ - attribution: row.attribution, - totals: totals(row.totals), - })), - }; -} - -function readSummary(report: CostReport): CostReportEnvelopeRead { - return { - undated_records: report.undatedRecords, - unreadable_lines: report.unreadableLines, - ...(report.identityUnusableCause === undefined - ? {} - : { identity_unusable: report.identityUnusableCause }), - }; -} - -/** - * The same report a person reads, rendered for a program. - * - * A rendering, never a second computation: every figure here comes from the `CostReport` - * it is handed, and nothing is derived on the way through. Two ways of computing one - * number is how they start disagreeing. - * - * Pure — no clock, no filesystem, no printing. - */ -/** Every `by_*` breakdown together - pulled out on its own so `toCostReportEnvelope` reads - * as one shape assembled from its own reads, not a wall of field-by-field assignments (the - * same reason `cost-report.ts`'s own `breakdownFields` exists). */ -function breakdownFields( - report: CostReport -): Pick< - CostReportEnvelope, - | "by_step" - | "by_model" - | "by_tool" - | "by_project" - | "by_task" - | "by_backlog" - | "by_flow" - | "by_agent" - | "by_prompt" - | "by_day" - | "by_person" -> { - return { - by_step: report.bySteps.map(stepRow), - by_model: report.byModels.map(modelRow), - by_tool: report.byTools.map(toolRow), - by_project: report.byProjects.map(projectRow), - by_task: report.byTasks.map(taskRow), - by_backlog: report.byBacklog.map(backlogRow), - by_flow: report.byFlows.map(flowRow), - by_agent: report.byAgents.map(agentRow), - by_prompt: report.byPrompts.map(promptRow), - by_day: report.byDays.map((row) => ({ day: row.day, totals: totals(row.totals) })), - by_person: report.byPeople.map(personRow), - }; -} - -export function toCostReportEnvelope(report: CostReport): CostReportEnvelope { - return { - cost_report_version: COST_REPORT_ENVELOPE_VERSION, - period: { from_day: report.fromDay, to_day: report.toDay }, - measurement_enabled: report.measurementEnabled, - ...(report.task === undefined ? {} : { task: report.task }), - ...(report.filters === undefined ? {} : { filters: report.filters }), - ...(report.emptySelection === undefined ? {} : { empty_selection: report.emptySelection }), - sessions: report.sessions, - totals: totals(report.totals), - ...(report.activeTimeSeconds === undefined ? {} : { active_time_s: report.activeTimeSeconds }), - ...breakdownFields(report), - attribution: report.attributionMix.map(attributionRow), - ...taskAttribution(report.taskAttributionMix), - read: readSummary(report), - }; -} diff --git a/cli/src/domain/models/cost-report.ts b/cli/src/domain/models/cost-report.ts deleted file mode 100644 index a53c7bc28..000000000 --- a/cli/src/domain/models/cost-report.ts +++ /dev/null @@ -1,2067 +0,0 @@ -import type { TelemetryRouteSupply } from "../capabilities/telemetry-capability.js"; -import type { PersonIdentity } from "../ports/person-identity-reader.js"; -import type { FlowAttributionSource, FlowInterval } from "./flow-attribution.js"; -import { ORCHESTRATING_SKILLS } from "./flow-attribution.js"; -import { type PersonResolution, type ResolvedPerson, resolvePerson } from "./person-resolution.js"; -import { STEP_ATTRIBUTION_SOURCES, type StepAttributionSource } from "./step-attribution.js"; -import { - momentFallsWithin, - TASK_ATTRIBUTION_SOURCES, - TASK_UNATTRIBUTED_REASONS, - type TaskAttributionSource, - type TaskInterval, - type TaskUnattributedReason, - taskUnattributedReason, -} from "./task-attribution.js"; -import type { TaskBacklogDeclaration } from "./task-backlog-link.js"; -import { - type TaskIdentity, - taskIdentitiesFromWrittenPaths, - taskIdentityFromWrittenPath, -} from "./task-identity.js"; -import { type TelemetrySinkRecord, telemetrySinkRecordDayKey } from "./telemetry-sink-record.js"; -import type { AiToolId } from "./tool-ids.js"; - -const MS_PER_DAY = 24 * 60 * 60 * 1000; - -/** Money is carried as whole micro-dollars, never as the floating amount a record stores. - * - * The report's whole claim is that its parts add up: the per-step figures plus the - * unattributed one equal the total, exactly. Floating addition does not have that property - * - the same amounts summed in two groupings differ in the last bits - so a reconciliation - * test over floats either fails on noise or is written loosely enough to pass over a real - * error. Rounding each amount once, on the way in, makes every sum after it exact. The - * cost is at most half a micro-dollar per record, which no report prints. */ -const MICRO_USD_PER_USD = 1e6; - -export function toMicroUsd(costUsd: number): number { - return Math.round(costUsd * MICRO_USD_PER_USD); -} - -export function fromMicroUsd(microUsd: number): number { - return microUsd / MICRO_USD_PER_USD; -} - -/** A group's figures. Every counter is optional and an absent one means *never observed*, - * which is a different fact from zero: a tool whose files carry no amount has an unknown - * cost, not a free one, and printing the two alike is how a session reads as free. - * `requests` alone is never absent - it counts records, and a group exists because records - * are in it. */ -export interface CostTotals { - readonly requests: number; - readonly costMicroUsd?: number; - readonly inputTokens?: number; - readonly outputTokens?: number; - readonly cacheReadTokens?: number; - readonly cacheCreationTokens?: number; -} - -/** One row of the step breakdown. Keyed by the step *and* the strength of its attribution, - * never by the step alone: the same skill reached once from the tool's own statement and - * once from a journal interval is two different claims, and merging them presents an - * inference as a measurement. `step` is absent exactly when `attribution` is - * `"unattributed"` - which names what nothing could say, and never says work ran outside - * every step. */ -export interface CostReportStepRow { - readonly step?: string; - readonly attribution: StepAttributionSource; - readonly totals: CostTotals; -} - -/** One model's figures, largest first, plus one row for what named none - `model` absent - * there, the same convention `CostReportProjectRow` uses for what named no project. Both - * the Codex and OpenCode readers permit a request record with no model, so this row is what - * lets `byModels` keep reconciling to the total exactly the way `bySteps`'s `unattributed` - * and `byProjects`'s unknown row already do. */ -export interface CostReportModelRow { - readonly model?: string; - readonly totals: CostTotals; -} - -/** How a record's agent came to be known, so a row that names none says which of the two - * silences it is. `main-thread` is a measurement — the tool names agents and said this - * record belongs to none of them; `not-stated` is the absence of one, from a tool whose - * route never names an agent at all, and reading it as a main thread would assert a fact - * nothing observed. */ -export type AgentAttributionSource = "tool-stated" | "main-thread" | "not-stated"; - -/** One agent's own share of a period, `agent` absent unless `attribution` is `tool-stated`. - * - * Where the spend actually is: measured on a live session, ten subagent files held 432M of - * its 466M tokens, and every one of their lines names its agent where almost none names a - * skill (100% against 2.7%). `by_step` reads a few percent not because the reader drops - * anything but because the host names a skill on the main thread alone. - * - * **The limit this axis still lives with.** A line marked as a subagent's that carries no - * agent name reads as the main thread, because nothing on the stored record separates the - * two. Measured 2026-09-05 across 1,852 transcripts: 157 of 122,637 subagent lines name no - * agent, 0.07%. Closing it means a new field on the record, which no record already stored - * could ever gain (see `storeNewCandidates`), so it is stated rather than captured. */ -export interface CostReportAgentRow { - readonly agent?: string; - readonly attribution: AgentAttributionSource; - readonly totals: CostTotals; -} - -/** One prompt's own share of a period, `prompt` absent on the row for records that named - * none. - * - * The one breakdown no host limit can empty — never one that is complete. Every other - * depends on a capture that may not have happened — a journal, an identity file, a - * declaration, a host that names a skill. This one depends on a field the transcript reader - * resolves for itself, by walking `parentUuid` back to the line that named the prompt. - * - * The reader is very nearly complete and the sink it fills is not, and the difference is - * worth stating where the figure is read. Measured 2026-09-05 on this machine's own sink: - * 845 of 30,714 records carry no `prompt_id`, 2.75%. Exactly one of them was written by the - * current reader — an assistant line whose `parentUuid` chain reaches no line naming a - * prompt, out of 29,607 in that session. The other 844 were stored by earlier readers: 34 - * before the CLI stamped a version at all, and 810 in one session before this resolution - * shipped. - * - * **Those 844 stay unnamed however often the sink is read again.** `storeNewCandidates` - * fixes a record's field set the first time it sees the turn, so a re-read that would now - * resolve the prompt stores nothing — the turn is already stored and its counters have not - * grown. Re-reading is not the repair either: of the 811 whose sessions were measured, 720 - * name a request no transcript on disk still holds, so roughly 90 records in 30,714 are all - * a retroactive pass could ever recover. Which is why this states a limit rather than - * carrying machinery to close it. - * - * `startedAt` is the earliest moment in the group, and only a named prompt gets one: the - * row for records that named no prompt is a bucket drawn from many turns, so a start moment - * there would assert a unit that never existed. */ -export interface CostReportPromptRow { - readonly prompt?: string; - readonly startedAt?: string; - readonly totals: CostTotals; -} - -/** Why a tool contributes nothing, when it contributes nothing. `covered` with no records - * is a tool that could have been read and did nothing in this period; `not-covered` is a - * tool nothing here can read at all. A consumer prints the second as its reason, never as - * a zero. */ -export type CostReportToolCoverage = "covered" | "not-covered"; - -/** What a tool was measured to be able to supply, gathered from its own declarations and - * carried through untouched. It travels beside the figures so a consumer branches on a - * declared capability rather than on whether a number happened to be present — the - * inference that turns a limit into a zero. `null` means the route is not declared at all, - * which is a different fact from a declared route that supplies nothing. */ -export interface CostReportToolCapability { - readonly localRead: TelemetryRouteSupply | null; - readonly export: TelemetryRouteSupply | null; - /** Whether the run journal ever names this tool's sessions. False means two things at - * once, and both matter: no step can be derived from an interval, and a read that sweeps - * the journal will never reach one of its sessions at all — so a tool can be perfectly - * readable and still report nothing until someone names a session by hand. Without this, - * that limit is indistinguishable from a tool that did no work. */ - readonly journalAttributable: boolean; - readonly taskAttributable: boolean; -} - -export interface CostReportToolDeclaration { - readonly tool: AiToolId; - readonly coverage: CostReportToolCoverage; - /** Why it is not covered, or what a covered tool's figures cannot be used for. Comes - * from the tool's own declaration; this module never writes one. */ - readonly reason?: string; - readonly capability: CostReportToolCapability; -} - -export interface CostReportToolRow { - readonly tool: AiToolId; - readonly coverage: CostReportToolCoverage; - readonly reason?: string; - readonly capability: CostReportToolCapability; - readonly totals: CostTotals; - /** A local-read `kind: "session"` total, present only for a tool whose own file yields a - * one-shot, already-complete session figure rather than per-request records — today, - * only Copilot. Never folded into `totals`: it answers "what did this session - * report" where `totals` answers "what did billed requests sum to", and the two-kinds - * rule forbids treating one as the other. */ - readonly sessionTotals?: CostTotals; -} - -/** How much of the broken-down total each strength accounts for. Printed as three figures - * rather than as a sentence saying attribution is approximate: three numbers that sum to - * the total say strictly more, and unlike the sentence they can be asserted. */ -export interface CostReportAttributionRow { - readonly attribution: StepAttributionSource; - readonly totals: CostTotals; -} - -/** The same idea as `CostReportAttributionRow`, one axis over: how much of a `--task` - * report's total came from a declared interval versus a written file. */ -export interface CostReportTaskAttributionRow { - readonly attribution: TaskAttributionSource; - readonly totals: CostTotals; -} - -/** One project's figures, largest first, plus one row for what named none — `project` - * absent there, the same convention `CostReportStepRow` uses for `unattributed`. Never - * folded into a neighbour: that would place a figure that was never placed. */ -export interface CostReportProjectRow { - readonly project?: string; - readonly totals: CostTotals; -} - -/** One framework task's figures, keyed on the declared interval a record's own moment - * falls in - never on a session's whole-session written-path inference, which is the - * `--task` filter's own, separate route and would let one session's records land in more - * than one row. `attribution` is always `"declared"` where `task` is present, since a - * closed interval is the only route this breakdown reads; it travels anyway so a consumer - * never has to assume a strength this object does not state. - * - * A record that fell in no declared interval carries `reason` instead of `task` - - * `TaskUnattributedReason` names which distinct fact applies, never one label standing in - * for all of them: no usable journal reached that record's session at all; no usable task - * declaration exists in a session whose journal was read; a - * task was declared but this record precedes it (whether every declaration, or the gap a `turn_end` - * leaves before the next one); or a task was declared and the journal's own declared - * coverage runs out before this record's moment. Never for a written file this breakdown - * does not consult, and never split from a declaration the journal simply could not read: - * the journal records a `task_declared` line or it does not, and those two read as - * `"no-declaration"` alike. Up to four such rows can appear in one period, one per reason - * actually present - never collapsed into one, since two different gaps are not one gap. - * Sorted apart from every other breakdown: largest first among named tasks, with every - * reason row last, in `TASK_UNATTRIBUTED_REASONS`' own fixed order, so a reader sees tasks - * before the remainder and the remainder in the same order every time. */ -export interface CostReportTaskRow { - readonly task?: TaskIdentity; - readonly attribution?: TaskAttributionSource; - readonly reason?: TaskUnattributedReason; - readonly totals: CostTotals; -} - -/** One backlog item's figures — grouped one level above `CostReportTaskRow`: every task - * declaring the same item lands in one row, which is the whole point of this axis (`--task` - * still answers "this task cost X"; this answers "this backlog item cost X"). Composes on - * the same per-record task membership `byTasks` already computes, resolved once per task - * folder rather than per record - see `report-cost-use-case.ts`. - * - * `backlog` is present only for a record whose task declares an item. Where it is absent, - * exactly one of `declaration` or `reason` says why, and never both: - * - * - `declaration: "none"` — the record's task exists and is known, but its folder declares - * no backlog item. A normal state, its own row, distinct from a record belonging to no - * task at all. - * - `declaration: "unreadable"` — the record's task folder's declaration exists but could - * not be parsed. Its own row, costing that row's resolution and no figure: the record is - * still counted, here and in every other breakdown, exactly as `by_task` counts a record - * whose declaration could not be read. - * - `reason` — the record belongs to no task at all, carrying the same - * `TaskUnattributedReason` `CostReportTaskRow` gives it; up to five such rows, one per - * reason actually present, never collapsed into one. */ -export interface CostReportBacklogRow { - readonly backlog?: string; - readonly declaration?: "none" | "unreadable"; - readonly reason?: TaskUnattributedReason; - readonly totals: CostTotals; -} - -/** One orchestrated run's figures - keyed on the closed `FlowInterval` a record's own - * moment falls inside, never on `flow` (the orchestrating skill's name) alone: a session - * running the same orchestrating skill twice must stay two rows, not one row merged by - * name (see `flow-attribution.ts`'s own doc on `buildFlowIntervals`). `startedAt` is the - * flow's own opening moment, carried beside `flow` so two rows that do share a name are - * still told apart - the same reason `CostReportStepRow` carries `attribution` beside - * `step`. - * - * Absent on the one row for every record whose own moment falls in no flow interval at - * all - a normal state, its own row, never folded into a named one and never split by a - * reason the way `CostReportTaskRow`'s remainder is: nothing about *why* a record sits - * outside every flow needs telling apart the way "no declaration" and "the journal falls - * silent" do for a task, since a flow is read from the same sequence either way. - * - * **The limit stated where this figure is read:** a skill a person runs by hand while a - * flow is open still counts inside it. The journal cannot tell a hand-run skill from one - * the orchestrator itself invoked - both write the identical `step_start` line - so - * neither can this breakdown. */ -export interface CostReportFlowRow { - readonly flow?: string; - readonly attribution: FlowAttributionSource; - readonly startedAt?: string; - readonly totals: CostTotals; -} - -/** One person's figures — a mapped person, one unplaced identity, or the records that - * carried none at all. `person` is the canonical `personId`, present only when `resolution` - * is `"mapped"`; the raw identifier that produced an `"unresolved"` row lives in - * `identities` instead, since that row was never claimed by anyone to have a canonical form. - * `identities` always carries what produced the row — every raw identifier behind a mapped - * person, including their own canonical one, or the single raw identifier behind an - * unresolved row — so a report line naming a person is traceable back to its evidence - * without a second lookup against the identity. */ -export interface CostReportPersonRow { - readonly resolution: PersonResolution; - readonly person?: string; - readonly displayName?: string; - readonly identities: readonly string[]; - readonly totals: CostTotals; -} - -/** One UTC day's figures, in chronological order — every day the period spans, whether or - * not a record landed on it. A day with nothing is a row of zeros: the one place in this - * report a zero is the measurement rather than the false reading this layer exists to - * refuse, because an omitted row would read as continuity a gap is not. */ -export interface CostReportDayRow { - readonly day: string; - readonly totals: CostTotals; -} - -/** One session's journal, reduced to what a report needs. Assembling it from the run - * journal is the caller's job; this module never opens a file - `taskIntervals` comes - * straight from `buildTaskIntervals`, already built once per session rather than re-derived - * per record. */ -export interface CostReportSessionJournal { - readonly vendorId: string; - readonly tool: string; - readonly projectId?: string; - readonly writtenPaths: readonly string[]; - readonly taskIntervals: readonly TaskInterval[]; - /** Straight from `buildFlowIntervals`, the same way `taskIntervals` comes from - * `buildTaskIntervals` - built once per session, never re-derived per record. */ - readonly flowIntervals: readonly FlowInterval[]; - /** The first and last moment this journal actually witnessed, from its own lines - the - * bound the written-file route infers inside and never outside. - * - * A journal witnesses only the time it was open for, which is not the time its session - * produced records: a journal lost and recreated mid-session witnesses the minutes since, - * while the sink still holds that session's records from days before. Measured on a live - * machine - one session's journal began at 09:54 while its own records ran back a week - - * and without this bound the written-file route would have attributed all seven days to a - * task folder that session touched today. - * - * Absent when no line in the journal carried a moment this reader could parse: nothing - * was witnessed, so nothing can be inferred. */ - readonly witnessed?: { readonly fromMs: number; readonly toMs: number }; -} - -/** The four dimensions that narrow on an equal record field - `task` keeps its own route - * and its own top-level field, exactly as before this type existed. Every one composes - * with the others, and with `task`, by `and`: two given narrow to their intersection, - * never their union. */ -export interface CostReportFilters { - readonly project?: string; - readonly step?: string; - readonly model?: string; - readonly tool?: string; -} - -export type CostReportFilterName = keyof CostReportFilters | "task"; - -/** Every value a filterable field has carried, anywhere the caller looked - not only in - * the period this report answers. What lets an empty selection tell a value nobody ever - * recorded apart from one that simply had no work here. */ -export interface CostReportKnownValues { - readonly projects: ReadonlySet; - readonly steps: ReadonlySet; - readonly models: ReadonlySet; -} - -/** The filter that narrowed a non-empty selection down to nothing - never the period - * itself, which is an honest zero rather than a filter's doing. `known` says whether the - * value was ever seen anywhere this call could look; `combination` is present only when - * the value matched something before any generic filter ran, so the emptiness comes from - * its intersection with a filter already applied rather than from the value alone. */ -export interface CostReportEmptySelection { - readonly filter: CostReportFilterName; - readonly value: string; - readonly known: boolean; - readonly combination?: boolean; -} - -/** Why this machine's own identity could not be used to resolve records against - the - * two possible causes, named rather than folded into one boolean, so a program reading a - * report can tell "the file exists but could not be read" apart from "nobody declared - * one at all", exactly as a person reading the caveat can. */ -export type PersonIdentityUnusableCause = "unreadable" | "absent"; - -export interface CostReportInput { - readonly fromDay: string; - readonly toDay: string; - readonly records: readonly TelemetrySinkRecord[]; - readonly journals: readonly CostReportSessionJournal[]; - readonly declaredTools: readonly CostReportToolDeclaration[]; - /** Records carrying no moment at all - counted and named, never placed in the period. */ - readonly undatedRecords: number; - /** Lines the read could not parse. A report built from a partial read looks exactly like - * one built from a whole read unless this travels with it. */ - readonly unreadableLines: number; - /** Restrict to the sessions that wrote into this task. Absent means the whole period, - * which is the primary question: a task is a filter over a period, and work that touched - * no task folder is still fully reportable. */ - readonly task?: TaskIdentity; - /** Any of `project`, `step`, `model` and `tool`, each optional and composing with `task` - * and each other by `and`. */ - readonly filters?: CostReportFilters; - /** Every distinct task identity this period's records could fall inside, resolved once - * each to its own folder's declaration - never read here, and never re-resolved per - * record. Gathering this is `ReportCostUseCase`'s job, exactly like `journals` and - * `identity`: the domain stays free of the filesystem `TaskBacklogReader` reads from. - * Absent from a task this map cannot name reads as `{ kind: "none" }` - see - * `backlogKeyOf`'s own doc for why a missing entry must never drop a record rather than - * merely being unreachable through this input's one production caller. */ - readonly taskBacklogDeclarations?: ReadonlyMap; - /** Where a generic filter's value has ever been seen - absent when the caller has none - * to offer, which reads the same as a filter never matching it elsewhere. */ - readonly knownValues?: CostReportKnownValues; - /** This machine's own identity, arriving as data rather than read from a module - the - * domain stays free of where the identity file lives. Absent or `null` both mean no - * identity was declared, which resolves every identifier as `unresolved` rather than - * failing the report - the same reading `identityUnusableCause: "absent"` names below. */ - readonly identity?: PersonIdentity | null; - /** Which of the two possible reasons the identity above could not be used to resolve - * records - `"unreadable"` for a declared identity file that could not be read back, - * `"absent"` for no identity declared at all. Either way costs the resolution alone: - * every record is still counted, with every identifier reported as `unresolved` and - * this cause saying why, the same way `unreadableLines` says why a total came from a - * partial read. This field itself is absent only when the identity was read back fine. */ - readonly identityUnusableCause?: PersonIdentityUnusableCause; - /** Whether the project switch is on right now, as data rather than a read this pure - * function performs itself - the same reasoning `identity` above documents. Required, - * not defaulted: `ReportCostUseCase` is this function's one production caller and always - * has a concrete answer, since it is the one thing that reads the switch. A default here - * would be reachable only from a test that never bothered to ask - which is exactly the - * silent "on" this field exists to rule out. */ - readonly measurementEnabled: boolean; -} - -export interface CostReport { - readonly fromDay: string; - readonly toDay: string; - readonly task?: TaskIdentity; - /** Only the generic filters actually given, in a fixed order - `task` keeps its own - * field above, unchanged. Absent for an unfiltered period. */ - readonly filters?: CostReportFilters; - /** Present only when a filter - never the period itself - is what emptied this - * selection. */ - readonly emptySelection?: CostReportEmptySelection; - readonly sessions: number; - readonly totals: CostTotals; - /** Per session, from `kind: "session"` records alone, and never broken down by step: no - * active-time measure on any tool carries a step attribute, so any share in a per-step - * breakdown is cost, never time. Absent when no record carried it. */ - readonly activeTimeSeconds?: number; - readonly bySteps: readonly CostReportStepRow[]; - readonly byModels: readonly CostReportModelRow[]; - readonly byAgents: readonly CostReportAgentRow[]; - /** One row per prompt that caused work, largest first, plus the row for records that - * named none — see `CostReportPromptRow`. */ - readonly byPrompts: readonly CostReportPromptRow[]; - readonly byTools: readonly CostReportToolRow[]; - readonly byProjects: readonly CostReportProjectRow[]; - readonly byTasks: readonly CostReportTaskRow[]; - /** Every task's records regrouped by what its folder declares - see - * `CostReportBacklogRow`. Sums to `totals` exactly like every other breakdown. */ - readonly byBacklog: readonly CostReportBacklogRow[]; - /** One row per orchestrated run the journal's own sequence names, plus the one row for - * work that ran outside every flow - see `CostReportFlowRow`. Sums to `totals` exactly - * like every other breakdown. */ - readonly byFlows: readonly CostReportFlowRow[]; - readonly byDays: readonly CostReportDayRow[]; - /** Mapped people first, then every unplaced identity, then the one row for records - * carrying none at all - a reader sees people before gaps. Within the mapped and the - * unresolved groups, largest first; never merged across the three. */ - readonly byPeople: readonly CostReportPersonRow[]; - readonly attributionMix: readonly CostReportAttributionRow[]; - /** Present only alongside `task`: an unfiltered period carries no per-record task identity - * to break down (see metrics-contract.md's "Attributing records to a task"). */ - readonly taskAttributionMix?: readonly CostReportTaskAttributionRow[]; - readonly undatedRecords: number; - readonly unreadableLines: number; - /** Which cause made this machine's own identity unusable for resolving records - see - * `CostReportInput`'s own field of the same name. Absent when the identity was read - * back fine; distinguishing a resolved identity from an absent or unreadable one is - * `byPeople`'s job, not this field's. */ - readonly identityUnusableCause?: PersonIdentityUnusableCause; - /** Whether the project switch is on right now - never inferred from whether any record - * was found, since an empty period and a switched-off one are different facts a reader - * must not conflate. Always concrete here, unlike the optional input field it is resolved - * from: every report has an answer to this, even the ones that default it. */ - readonly measurementEnabled: boolean; -} - -// Declared as the list first and the type derived from it, rather than the other way -// round: reading the keys back off the table would have to assert their type, and an -// assertion is exactly what stops holding the day the table and the type disagree. -const COUNTER_FIELDS = [ - "inputTokens", - "outputTokens", - "cacheReadTokens", - "cacheCreationTokens", -] as const; - -type CounterField = (typeof COUNTER_FIELDS)[number]; - -const COUNTER_SOURCE: Readonly> = { - inputTokens: "input_tokens", - outputTokens: "output_tokens", - cacheReadTokens: "cache_read_tokens", - cacheCreationTokens: "cache_creation_tokens", -}; - -/** Accumulates a group while keeping "never observed" distinct from "observed as zero". - * A field stays absent until some record in the group carries it. */ -class TotalsAccumulator { - private requests = 0; - private costMicroUsd: number | undefined; - private readonly counters = new Map(); - - add(record: TelemetrySinkRecord): void { - this.requests += 1; - // Gated the same way every token counter is gated below, not on `!== undefined`: a - // record read off disk is never guaranteed to hold the type its own field declares, and - // `JSON.stringify(NaN)` is `null` - which is `!== undefined` and would have read as a - // known, free cost rather than the unknown one this layer exists to keep distinct. - if (typeof record.cost_usd === "number") { - this.costMicroUsd = (this.costMicroUsd ?? 0) + toMicroUsd(record.cost_usd); - } - this.addTokensOnly(record); - } - - /** Never touches `requests` or `cost_usd`: a `kind: "session"` local-read total is not a - * billed request, and the tool never states a cost for one. */ - addTokensOnly(record: TelemetrySinkRecord): void { - for (const field of COUNTER_FIELDS) { - const value = record[COUNTER_SOURCE[field]]; - if (typeof value === "number") { - this.counters.set(field, (this.counters.get(field) ?? 0) + value); - } - } - } - - build(): CostTotals { - const counters: Partial> = {}; - for (const field of COUNTER_FIELDS) { - const value = this.counters.get(field); - if (value !== undefined) counters[field] = value; - } - return { - requests: this.requests, - ...(this.costMicroUsd === undefined ? {} : { costMicroUsd: this.costMicroUsd }), - ...counters, - }; - } -} - -function accumulateInto( - groups: Map, - key: K, - record: TelemetrySinkRecord, - apply: (accumulator: TotalsAccumulator) => void = (accumulator) => accumulator.add(record) -): void { - const existing = groups.get(key); - if (existing) { - apply(existing); - return; - } - const created = new TotalsAccumulator(); - apply(created); - groups.set(key, created); -} - -/** Every token a row counted, across all four disjoint counters. The weight `bySize` falls - * back to for a costless row - never `inputTokens + outputTokens` alone: every tool this - * report has ever seen runs at 90%-plus cache, so a weight blind to the two cache counters - * would order a costless breakdown by the sliver of its volume nobody reads it for, and - * invert the order a reader actually wants. It is also the same sum the report already - * prints beside a costless row - weighing by anything else would sort a row by a number the - * report never shows. */ -function tokensOf(totals: CostTotals): number { - return ( - (totals.inputTokens ?? 0) + - (totals.outputTokens ?? 0) + - (totals.cacheReadTokens ?? 0) + - (totals.cacheCreationTokens ?? 0) - ); -} - -/** Largest first, so the biggest thing is the first thing read. Weighted by amount where - * one exists and by tokens where none does, since a tool with no amount would otherwise - * sort as if it had cost nothing. Ties fall back to the row's own key, so the same records - * always produce the same report. */ -function bySize( - rows: readonly T[], - totalsOf: (row: T) => CostTotals, - keyOf: (row: T) => string -): T[] { - const weight = (row: T): number => { - const totals = totalsOf(row); - return totals.costMicroUsd ?? tokensOf(totals); - }; - return [...rows].sort( - (left, right) => weight(right) - weight(left) || keyOf(left).localeCompare(keyOf(right)) - ); -} - -// A single space cannot occur in a `step_attribution` value, so it separates the two parts -// of the key unambiguously even though a skill name could contain almost anything. The -// group keeps the two parts beside its counters rather than parsing them back out of the -// key: reading a type back out of a string is an assertion, and this needs none. -const STEP_ROW_SEPARATOR = " "; - -interface StepGroup { - readonly attribution: StepAttributionSource; - readonly step?: string; - readonly totals: TotalsAccumulator; -} - -function stepRowKey(record: TelemetrySinkRecord): string { - return `${record.step_attribution}${STEP_ROW_SEPARATOR}${record.step ?? ""}`; -} - -function addToStepGroup(groups: Map, record: TelemetrySinkRecord): void { - const key = stepRowKey(record); - const existing = groups.get(key); - if (existing) { - existing.totals.add(record); - return; - } - const created: StepGroup = { - attribution: record.step_attribution, - ...(record.step === undefined ? {} : { step: record.step }), - totals: new TotalsAccumulator(), - }; - created.totals.add(record); - groups.set(key, created); -} - -// A record with no project is its own group, never folded into one that was actually -// placed. A symbol can never equal a real `project_id` string, so it is a safe Map key -// for "unknown" beside every value a record might actually carry. -const NO_KNOWN_PROJECT = Symbol("no known project"); -type ProjectKey = string | typeof NO_KNOWN_PROJECT; - -// An empty string is not a name - it is what a tool writes when it has none to give, and -// treating it as its own project would print a nameless row a person cannot act on. The -// `typeof` guard is there for a second reason: a record read off disk carries whatever its -// own line actually held, not what this field's type declares. -function projectKeyOf(record: TelemetrySinkRecord): ProjectKey { - return typeof record.project_id === "string" && record.project_id !== "" - ? record.project_id - : NO_KNOWN_PROJECT; -} - -// The same idea, one dimension over: a record with no model is its own group, never -// dropped. `bySteps` has `unattributed` and `byProjects` has the row above for exactly this -// reason - both the Codex and OpenCode readers permit a request record with no model, so -// without this row `byModels` would stop reconciling to its own total with nothing naming -// the gap. Deliberately narrower than `projectKeyOf`: nothing measured so far ever writes -// an empty-string `model`, so unlike `project_id` this stays an `undefined` check rather -// than also folding in `""` - a rule this module has no evidence for yet. -const NO_KNOWN_MODEL = Symbol("no known model"); -type ModelKey = string | typeof NO_KNOWN_MODEL; - -// The main thread's own row, and the row for a tool that could never have named one. Symbols -// for the same reason `NO_KNOWN_MODEL` is one: an agent really can be named anything, so no -// string is safe to reserve. -const MAIN_THREAD = Symbol("the main thread"); -const AGENT_NOT_STATED = Symbol("a tool whose route never names an agent"); -type AgentKey = string | typeof MAIN_THREAD | typeof AGENT_NOT_STATED; - -/** Which of the three rows a record joins. `agent_name` present is the tool's own statement - * and needs nothing else; absent means one of two different things, and only the tool's - * declaration tells them apart. - * - * This axis used to answer `NO_AGENT` for every record with no `agent_name`, whatever the - * tool. Only Claude Code's reader ever sets the field, so on Codex, Copilot and OpenCode - * every record was reported as the main thread — 100% of the axis, on no evidence. The - * declaration is read rather than the record because the record cannot carry the absence: - * a tool that never names an agent writes exactly what a main-thread line writes. */ -function agentKeyOf( - record: TelemetrySinkRecord, - namesAgents: (tool: AiToolId) => boolean -): AgentKey { - if (record.agent_name !== undefined) return record.agent_name; - return namesAgents(record.tool) ? MAIN_THREAD : AGENT_NOT_STATED; -} - -/** Whether a tool's own declared route names agents, answered from `declaredTools` alone. - * A tool with no declared local read supplies nothing, so it names no agent either — the - * same reading `NO_CAPABILITY` gives every other supply. */ -function agentNamingTools( - declaredTools: readonly CostReportToolDeclaration[] -): (tool: AiToolId) => boolean { - const naming = new Set( - declaredTools - .filter((declaration) => declaration.capability.localRead?.agentName === true) - .map((declaration) => declaration.tool) - ); - return (tool) => naming.has(tool); -} - -// The row for what named no prompt. A symbol for the same reason `NO_AGENT` is one: a prompt -// id is opaque and host-assigned, so no string is safe to reserve against it. -const NO_PROMPT = Symbol("no prompt named"); -type PromptKey = string | typeof NO_PROMPT; - -function promptKeyOf(record: TelemetrySinkRecord): PromptKey { - return record.prompt_id === undefined ? NO_PROMPT : record.prompt_id; -} - -function modelKeyOf(record: TelemetrySinkRecord): ModelKey { - return record.model === undefined ? NO_KNOWN_MODEL : record.model; -} - -// The same idea, for the task a record's own moment fell inside - a record whose session -// never declared one, whose moment falls before a declaration, or whose moment the -// journal's own declared coverage has run out before - and one whose session no usable -// journal ever reached - is its own group, keyed on *which* of those this record is, never -// dropped and never collapsed into one bucket. A plain -// string, unlike `NO_KNOWN_PROJECT` and `NO_KNOWN_MODEL`: `TaskIdentity` is always -// `${month}/${name}`, which a reason string never is, so the two can never collide. -type TaskRowKey = TaskIdentity | TaskUnattributedReason; - -/** How a record came to belong to a task, or why it belongs to none - the value every task - * axis keys on, computed once per record. A named membership carries `attribution` beside - * the identity rather than only the identity, because the same task holds records from both - * routes: on the session this route was measured against, 1045 records fell inside a - * declared interval and 27 preceded the first declaration entirely. One row carrying the - * weaker attribution would state something false about the 1045. */ -interface TaskGroup { - readonly task?: TaskIdentity; - readonly attribution?: TaskAttributionSource; - readonly reason?: TaskUnattributedReason; - readonly totals: TotalsAccumulator; -} - -/** Mirrors `addToStepGroup`, which folds that axis' own pairs the same way. */ -function addToTaskGroup( - groups: Map, - row: TaskRow, - record: TelemetrySinkRecord -): void { - const key = taskRowKeyOf(row); - const existing = groups.get(key); - if (existing) { - existing.totals.add(record); - return; - } - const created: TaskGroup = - typeof row === "string" - ? { reason: row, totals: new TotalsAccumulator() } - : { task: row.task, attribution: row.attribution, totals: new TotalsAccumulator() }; - created.totals.add(record); - groups.set(key, created); -} - -interface TaskMembershipRow { - readonly task: TaskIdentity; - readonly attribution: TaskAttributionSource; -} - -type TaskRow = TaskMembershipRow | TaskUnattributedReason; - -const TASK_ROW_SEPARATOR = " "; - -/** Mirrors `stepRowKey`, which keys that axis' own `(name x attribution)` pairs the same - * way, rather than inventing a second way to key a pair. A `TaskIdentity` is always - * `${month}/${name}` and an attribution is never one, so a named key can never collide with - * a reason key. */ -function taskRowKeyOf(row: TaskRow): string { - return typeof row === "string" ? row : `${row.attribution}${TASK_ROW_SEPARATOR}${row.task}`; -} - -/** The one task a session's written files name, when they name exactly one. - * - * Two written folders infer nothing: two candidates and no reason to choose between them. - * That refusal is what answers the objection that kept written paths out of this breakdown - * until now - the `--task` filter's own inferred route attributes a whole session, which can - * place one session under two task rows at once. Refusing is not a fallback here, it is the - * bound that makes the route sound. */ -function soleWrittenTaskOf(journal: CostReportSessionJournal | undefined): TaskIdentity | null { - if (journal === undefined) return null; - const identities = new Set(taskIdentitiesFromWrittenPaths(journal.writtenPaths)); - if (identities.size !== 1) return null; - const [only] = identities; - return only ?? null; -} - -/** Whether this journal witnessed `momentIso` at all - never an unbounded yes for a journal - * that carries no readable moment. */ -function witnessed( - journal: CostReportSessionJournal | undefined, - momentIso: string | undefined -): boolean { - const span = journal?.witnessed; - if (span === undefined || momentIso === undefined) return false; - const momentMs = Date.parse(momentIso); - if (Number.isNaN(momentMs)) return false; - return momentMs >= span.fromMs && momentMs <= span.toMs; -} - -// The same idea, one level above a task: a task whose folder declares no backlog item, or -// whose declaration exists but could not be read, is its own group - never folded into -// each other, and never folded into a named item. Symbols, the same reason `NO_KNOWN_PROJECT` -// and `NO_KNOWN_MODEL` are: a backlog item is a free-form string on either support (a forge -// reference or a project-relative path), so nothing here can rule out a real item colliding -// with a string sentinel the way a plain string could. -const NO_BACKLOG_DECLARED = Symbol("task declares no backlog item"); -const UNREADABLE_BACKLOG_DECLARATION = Symbol("task's backlog declaration could not be read"); -type BacklogRowKey = - | string - | typeof NO_BACKLOG_DECLARED - | typeof UNREADABLE_BACKLOG_DECLARATION - | TaskUnattributedReason; - -/** Every session's own closed intervals, keyed by vendor id - built once from - * `buildTaskIntervals`'s own output, never a second notion of when a task was running. - * Unlike `declaredIntervalsForTask`, this keeps every task a session ever declared, not - * only one: `byTasks` groups by whichever task a record's moment falls in, not by - * membership in a single task asked for. - * - * **Every journal gets an entry, including one that declared nothing.** The empty list and - * the absent key are two different facts and `taskRowOf` reads them as two: a key - * mapped to `[]` is a journal that was read and declared nothing, an absent key is a - * session no journal was read for at all. Skipping the empty ones - which this did until - * 2026-09-04 - collapsed both into `"no-declaration"`, so a report that never found the - * runs directory announced that the work had declared no task. */ -function allTaskIntervalsByVendorId( - journals: readonly CostReportSessionJournal[] -): ReadonlyMap { - const byVendorId = new Map(); - for (const journal of journals) byVendorId.set(journal.vendorId, journal.taskIntervals); - return byVendorId; -} - -/** Which task a record's own moment falls inside, among *all* of its session's declared - * intervals - `taskUnattributedReason` for a record whose moment falls in none. Intervals - * within one session are closed and never overlap (`buildTaskIntervals`), so at most one - * ever matches - this never has to choose between two. - * - * `interval.path` failing to resolve here is unreachable for every interval this codebase's - * own wiring ever produces, not merely untested: `buildTaskIntervals` already refuses to - * emit a `TaskInterval` for a declared path `taskIdentityFromWrittenPath` cannot turn into - * an identity (a literal `..` path segment, say). It is not unreachable in the type this - * function actually takes - `CostReportSessionJournal.taskIntervals` is a plain input - * field, so a caller (a test, most concretely) can still hand this a `TaskInterval` literal - * whose `path` resolves to nothing, which is exactly why the fallback stays rather than - * being deleted as dead code. Reading such a moment the same as no interval covering it at - * all is deliberate, not an invented fourth reason: a path this layer cannot turn into an - * identity names no task a person could act on by name either. */ -function taskRowOf( - record: TelemetrySinkRecord, - intervalsByVendorId: ReadonlyMap, - journalsByVendorId: ReadonlyMap -): TaskRow { - const intervals = intervalsByVendorId.get(record.vendor_id); - // No entry at all means no journal was read for this session - never that it declared - // nothing. `allTaskIntervalsByVendorId` gives every journal it read an entry, so the two - // cases are distinguishable here and nowhere else. - if (intervals === undefined) return "no-journal"; - const interval = intervals.find((candidate) => - momentFallsWithin([candidate], record.event_timestamp) - ); - const declared = interval && taskIdentityFromWrittenPath(interval.path); - if (declared) return { task: declared, attribution: "declared" }; - // Only now the weaker route, and only inside what this journal witnessed: a declaration - // that covers the record always wins, so this never overrides a stated fact with an - // inferred one. - const journal = journalsByVendorId.get(record.vendor_id); - const inferred = soleWrittenTaskOf(journal); - if (inferred !== null && witnessed(journal, record.event_timestamp)) { - return { task: inferred, attribution: "inferred" }; - } - // The journal's own earliest witnessed moment, so a record older than everything this - // session saw is named for that rather than for declaring late - the distinction 96.2% of - // a real period turns on. Absent for a journal with no readable moment, which then makes - // no coverage claim at all. - return taskUnattributedReason(intervals, record.event_timestamp, journal?.witnessed?.fromMs); -} - -/** Which `byBacklog` row a record's own task-row key belongs in - built from - * `taskRowOf`'s own output, never a second notion of which task a record fell - * inside. A reason (the record belongs to no task at all) passes straight through - * unchanged, exactly as `by_task` gives it; a named task looks up its folder's declaration - * once, in the map `ReportCostUseCase` already resolved for every distinct task identity - * this period's records could name. - * - * A named task missing from `declarations` is unreachable through this module's one - * production caller - `report-cost-use-case.ts` resolves every task identity `byTasks` can - * ever key on before this ever runs - but is read as `{ kind: "none" }` rather than - * throwing or dropping the record, the same defensive default `taskRowOf`'s own - * `interval.path` fallback documents: a caller a test can still construct must never lose a - * record's figures to a gap in wiring this module cannot see from here. */ -function backlogKeyOf( - taskRow: TaskRow, - declarations: ReadonlyMap | undefined -): BacklogRowKey { - if (typeof taskRow === "string") return taskRow; - const declaration = declarations?.get(taskRow.task) ?? { kind: "none" as const }; - if (declaration.kind === "none") return NO_BACKLOG_DECLARED; - if (declaration.kind === "unreadable") return UNREADABLE_BACKLOG_DECLARATION; - return declaration.link.backlog; -} - -// A record falling in no flow interval at all is its own group, keyed on this symbol - -// never a plain string sentinel: a `FlowInterval` is never itself a valid key value here -// (see `FlowRowKey` below), so nothing about a real interval could ever collide with it, -// unlike `NO_BACKLOG_DECLARED`'s own worry about a free-form backlog string. -const OUTSIDE_EVERY_FLOW = Symbol("record falls outside every flow interval"); - -// Keyed on the closed `FlowInterval` object itself, by reference, never on `skill` alone: -// two orchestrated runs of the same skill in one session are two distinct `FlowInterval` -// objects (`buildFlowIntervals`'s own doc comment), and a `Map` keyed on object identity -// keeps them two rows without needing a synthesized composite string key. This also gives -// `byFlows` for free the property `phase-1.md` asks of it: a record outside every flow can -// never collide with one inside, since `OUTSIDE_EVERY_FLOW` is a symbol no interval object -// can ever equal. -type FlowRowKey = FlowInterval | string | typeof OUTSIDE_EVERY_FLOW; - -/** Every session's own closed flow intervals, keyed by vendor id - the same shape - * `allTaskIntervalsByVendorId` gives task intervals, one layer wider. */ -function allFlowIntervalsByVendorId( - journals: readonly CostReportSessionJournal[] -): ReadonlyMap { - const byVendorId = new Map(); - for (const journal of journals) { - if (journal.flowIntervals.length > 0) byVendorId.set(journal.vendorId, journal.flowIntervals); - } - return byVendorId; -} - -/** Which flow interval a record's own moment falls inside, among all of its session's - * orchestrated runs - `OUTSIDE_EVERY_FLOW` for a record whose moment falls in none, the - * same "no reason taxonomy" spec's own hard constraint gives this axis: unlike a task's - * three distinct gaps, nothing here needs telling apart *why* a record sits outside every - * flow, since a flow is read from the same sequence either way. Intervals within one - * session are closed and never overlap (`buildFlowIntervals`), so at most one ever - * matches. */ -function flowKeyOf( - record: TelemetrySinkRecord, - intervalsByVendorId: ReadonlyMap -): FlowRowKey { - const intervals = intervalsByVendorId.get(record.vendor_id) ?? []; - const interval = intervals.find((candidate) => - momentFallsWithin([candidate], record.event_timestamp) - ); - return interval ?? flowTheToolNamed(record) ?? OUTSIDE_EVERY_FLOW; -} - -/** The orchestrating skill a record's own tool named, for a record no interval covers - - * the skill name itself as the key, which no `FlowInterval` object and no symbol can ever - * equal, so the two row kinds never collide. - * - * Only `tool-stated`. A `journal-interval` step is an inference from a moment, and the - * intervals it was inferred from are the very ones just checked; a `prompt-matched` one - * names the step a prompt opened, which is a step and not an orchestration. Neither says a - * flow was orchestrated, and reading either as one would put work inside a flow on the - * strength of the reader's own guess. - * - * Why this capture exists at all: a session resumed after its context was compacted invokes - * nothing again, so no `step_start` hook fires and its journal opens no flow, while the - * transcript goes on stating the step on every record it produces. Measured on this - * machine - one such session, six `step_end` lines, no `step_start`, and 2,220 records in a - * 30-day period that `by_flow` placed outside every flow while `by_step` named the very - * skill they ran under. */ -function flowTheToolNamed(record: TelemetrySinkRecord): string | undefined { - if (record.step_attribution !== "tool-stated") return undefined; - return record.step !== undefined && ORCHESTRATING_SKILLS.has(record.step) - ? record.step - : undefined; -} - -// A record with no identifier is its own row, keyed on a symbol the same way -// `NO_KNOWN_PROJECT` keys the row for no known project - never folded into an unresolved -// row, which the spec's own three-way shape (`PersonResolution`) requires stay distinct. -const NO_KNOWN_PERSON = Symbol("no known person"); -type PersonRowKey = string | typeof NO_KNOWN_PERSON; - -// An empty string reads the same as absent, the same reading `projectKeyOf` already gives -// an empty `project_id` - a tool writing `person_id: ""` has stated nothing, not named an -// identity nobody could ever claim. -function personRawIdOf(record: TelemetrySinkRecord): string | undefined { - return typeof record.person_id === "string" && record.person_id !== "" - ? record.person_id - : undefined; -} - -/** One resolved person's group - keyed once, on whichever field makes two records the same - * row: a mapped record's canonical `personId`, so two raw identities one person declared - * merge; an unresolved record's own raw identifier, so two unplaced identities never merge - * into each other; or the shared `NO_KNOWN_PERSON` symbol for a record with none. */ -interface PersonGroup { - readonly resolved: ResolvedPerson; - readonly totals: TotalsAccumulator; -} - -function personGroupKey(resolved: ResolvedPerson): PersonRowKey { - if (resolved.resolution === "mapped" && resolved.personId !== undefined) { - return resolved.personId; - } - if (resolved.resolution === "unresolved") { - const [rawId] = resolved.identities; - if (rawId !== undefined) return rawId; - } - return NO_KNOWN_PERSON; -} - -function addToPersonGroup( - groups: Map, - record: TelemetrySinkRecord, - resolved: ResolvedPerson -): void { - const key = personGroupKey(resolved); - const existing = groups.get(key); - if (existing) { - existing.totals.add(record); - return; - } - const created: PersonGroup = { resolved, totals: new TotalsAccumulator() }; - created.totals.add(record); - groups.set(key, created); -} - -/** A prompt's running totals plus the earliest moment seen in it. The moment is tracked - * here rather than read back off the records because the pass over them happens once, and - * because a sink is append-ordered by when it was read, never by when a turn began. */ -interface PromptGroup { - readonly totals: TotalsAccumulator; - earliestMs?: number; -} - -function addToPromptGroup(groups: Map, record: TelemetrySinkRecord): void { - const key = promptKeyOf(record); - const group = groups.get(key) ?? { totals: new TotalsAccumulator() }; - group.totals.add(record); - const atMs = - record.event_timestamp === undefined ? Number.NaN : Date.parse(record.event_timestamp); - if (!Number.isNaN(atMs) && (group.earliestMs === undefined || atMs < group.earliestMs)) { - group.earliestMs = atMs; - } - groups.set(key, group); -} - -/** Every UTC day from `fromDay` to `toDay`, inclusive — the full period, whether or not a - * record ever lands on a given day. A day with nothing is still a row: a gap in a series - * reads as continuity, so the row has to exist to be a zero. */ -function dayRange(fromDay: string, toDay: string): readonly string[] { - const days: string[] = []; - const end = Date.parse(`${toDay}T00:00:00Z`); - for (let at = Date.parse(`${fromDay}T00:00:00Z`); at <= end; at += MS_PER_DAY) { - days.push(new Date(at).toISOString().slice(0, 10)); - } - return days; -} - -/** The vendor ids whose sessions wrote into `task` at some point - unchanged from before a - * task could be declared at all, and deliberately still whole-session: nothing about the - * existing per-file attribution changes for a tool that already has it. */ -function inferredVendorIdsForTask( - journals: readonly CostReportSessionJournal[], - task: TaskIdentity -): ReadonlySet { - const vendorIds = new Set(); - for (const journal of journals) { - if (taskIdentitiesFromWrittenPaths(journal.writtenPaths).includes(task)) { - vendorIds.add(journal.vendorId); - } - } - return vendorIds; -} - -/** Every session's own declared intervals that name `task`, keyed by vendor id so a - * record's session is a lookup rather than a walk of every journal again. A session that - * never declared this task carries no entry - what makes an undeclared session read as - * belonging to none, never to the last one seen. */ -function declaredIntervalsForTask( - journals: readonly CostReportSessionJournal[], - task: TaskIdentity -): ReadonlyMap { - const byVendorId = new Map(); - for (const journal of journals) { - const intervals = journal.taskIntervals.filter( - (interval) => taskIdentityFromWrittenPath(interval.path) === task - ); - if (intervals.length > 0) byVendorId.set(journal.vendorId, intervals); - } - return byVendorId; -} - -/** Both routes to `task`, kept apart rather than merged into one vendor-id set: a declared - * interval decides per record, at the precision `buildTaskIntervals` bounds it to, while a - * written file decides for a session's records as a whole, exactly as it always has. - * Merging them would let a session's own zero-width or long-closed declaration - real, but - * covering no record - drag in records a written file never touched either. */ -interface TaskMembership { - readonly declaredIntervalsByVendorId: ReadonlyMap; - readonly inferredVendorIds: ReadonlySet; -} - -function taskMembership( - journals: readonly CostReportSessionJournal[], - task: TaskIdentity -): TaskMembership { - return { - declaredIntervalsByVendorId: declaredIntervalsForTask(journals, task), - inferredVendorIds: inferredVendorIdsForTask(journals, task), - }; -} - -/** How, if at all, one record belongs to the task `membership` was built for - `undefined` - * for neither route, which is what excludes it from a `--task` report entirely. A record - * whose own moment falls in a declared interval is `"declared"` even when its session also - * wrote into the folder; only a record a declaration does not cover falls back to whether - * its whole session did. */ -function taskAttributionOf( - record: TelemetrySinkRecord, - membership: TaskMembership -): TaskAttributionSource | undefined { - const intervals = membership.declaredIntervalsByVendorId.get(record.vendor_id); - if (intervals && momentFallsWithin(intervals, record.event_timestamp)) return "declared"; - return membership.inferredVendorIds.has(record.vendor_id) ? "inferred" : undefined; -} - -// The field a generic filter narrows on, and the fixed order they are applied in - after -// `task`, which already existed and uses its own membership route rather than an equality -// check. Fixed so two people asking for the same selection always see the same filter -// named as the one that emptied it. -const GENERIC_FILTER_FIELDS: Readonly> = - { - project: "project_id", - step: "step", - model: "model", - tool: "tool", - }; -const GENERIC_FILTER_ORDER: readonly (keyof CostReportFilters)[] = [ - "project", - "step", - "model", - "tool", -]; - -interface SelectionStage { - readonly name: CostReportFilterName | undefined; - readonly value: string | undefined; - readonly records: readonly TelemetrySinkRecord[]; -} - -/** One stage per active filter, each narrowing what the stage before it kept. Filters - * compose by `and` and nothing else: every stage only ever removes records the one before - * it was already going to keep, never adds one back. */ -function selectionStages( - records: readonly TelemetrySinkRecord[], - input: CostReportInput, - membership: TaskMembership | null -): readonly SelectionStage[] { - const stages: SelectionStage[] = [{ name: undefined, value: undefined, records }]; - if (membership !== null) { - const kept = records.filter((r) => taskAttributionOf(r, membership) !== undefined); - stages.push({ name: "task", value: input.task, records: kept }); - } - for (const name of GENERIC_FILTER_ORDER) { - const value = input.filters?.[name]; - if (value === undefined) continue; - const field = GENERIC_FILTER_FIELDS[name]; - const previous = stages[stages.length - 1]?.records ?? []; - stages.push({ name, value, records: previous.filter((r) => r[field] === value) }); - } - return stages; -} - -/** Whether a filter's own value is known at all - anywhere this call can see, not only in - * this selection. `task` reads the same membership `buildCostReport` already computed; - * `tool` reads the declared list, a closed set no read is needed for; the rest read - * `knownValues`, gathered once across every day file the caller looked at, not only the - * period's own records. */ -function isKnownFilterValue( - name: CostReportFilterName, - value: string, - input: CostReportInput, - membership: TaskMembership | null -): boolean { - if (name === "task") { - return ( - (membership?.declaredIntervalsByVendorId.size ?? 0) > 0 || - (membership?.inferredVendorIds.size ?? 0) > 0 - ); - } - if (name === "tool") return input.declaredTools.some((tool) => tool.tool === value); - const known = input.knownValues ?? { projects: new Set(), steps: new Set(), models: new Set() }; - const set = { project: known.projects, step: known.steps, model: known.models }[name]; - return set?.has(value) ?? false; -} - -/** True when the culprit filter's own value matched something before any generic filter - * ran, meaning the emptiness comes from its intersection with a filter already applied - * rather than from this value alone. `task` has no "alone" reading - it is the only route - * to a task, not one of several composed equality checks. */ -function isCombinationCulprit( - stages: readonly SelectionStage[], - membership: TaskMembership | null, - culprit: SelectionStage -): boolean { - if (culprit.name === undefined || culprit.name === "task") return false; - const field = GENERIC_FILTER_FIELDS[culprit.name]; - const baseline = stages[membership === null ? 0 : 1]?.records ?? []; - return baseline.some((r) => r[field] === culprit.value); -} - -/** The first filter that narrowed a non-empty selection down to nothing - never the - * period itself, which is an honest zero rather than a filter's doing. Stages only ever - * shrink, so the first empty one is the whole answer to "which filter emptied it". */ -function emptySelectionOf( - stages: readonly SelectionStage[], - input: CostReportInput, - membership: TaskMembership | null -): CostReportEmptySelection | undefined { - if ((stages[0]?.records.length ?? 0) === 0) return undefined; - const culprit = stages.find((stage) => stage.records.length === 0); - if (!culprit || culprit.name === undefined || culprit.value === undefined) return undefined; - const known = isKnownFilterValue(culprit.name, culprit.value, input, membership); - const combination = isCombinationCulprit(stages, membership, culprit); - return { - filter: culprit.name, - value: culprit.value, - known, - ...(combination ? { combination: true } : {}), - }; -} - -/** Which of the four generic filters were actually given, in the same fixed order - never - * `task`, which keeps its own top-level field unchanged. `undefined` when none were, so - * an unfiltered period carries no empty object. */ -function activeFilters(filters: CostReportFilters | undefined): CostReportFilters | undefined { - if (!filters) return undefined; - const given = GENERIC_FILTER_ORDER.filter((name) => filters[name] !== undefined); - if (given.length === 0) return undefined; - return Object.fromEntries(given.map((name) => [name, filters[name]])); -} - -/** `by_tool` is a breakdown of every *declared* tool, not only the ones a record touched - - * that is what lets an unreadable one show its own reason instead of a false zero. A - * `--tool` filter has to narrow that same list, or every tool it excluded would still - * print a row reading "nothing in this period" - indistinguishable from one genuinely - * measured idle, exactly the lie a filter's whole point is to remove. */ -function declaredToolsInScope( - declaredTools: readonly CostReportToolDeclaration[], - filters: CostReportFilters | undefined -): readonly CostReportToolDeclaration[] { - const wanted = filters?.tool; - return wanted === undefined - ? declaredTools - : declaredTools.filter((tool) => tool.tool === wanted); -} - -/** Every declared tool gets a row, in the declared order, whether or not it contributed - - * a tool absent from the output is a tool a reader assumes did nothing, and for an - * unreadable one that assumption is exactly the false zero this layer exists to prevent. */ -function buildToolRows( - declaredTools: readonly CostReportToolDeclaration[], - measured: ReadonlyMap, - sessionTotals: ReadonlyMap -): readonly CostReportToolRow[] { - return declaredTools.map((declaration) => { - const session = sessionTotals.get(declaration.tool); - return { - tool: declaration.tool, - coverage: declaration.coverage, - ...(declaration.reason === undefined ? {} : { reason: declaration.reason }), - capability: declaration.capability, - totals: measured.get(declaration.tool)?.build() ?? { requests: 0 }, - ...(session === undefined ? {} : { sessionTotals: session.build() }), - }; - }); -} - -/** - * One period's records and journals, reduced to a report whose every breakdown sums to the - * total it belongs to. - * - * Pure: everything it needs arrives as data, including which tools are covered - so this - * module names no tool and no skill, and a fifth tool changes a declaration rather than - * this file. The two rules it exists to enforce come from - * `aidd_docs/product/metrics-contract.md`, and this is the first thing in the codebase - * that could break either: money and the four token counters come from `kind: "request"` - * records alone, and active time from `kind: "session"` records alone. Summing across the - * two kinds counts the same tokens twice and produces a total that looks right. - */ -/** Every group one pass over the records fills. Kept together so the pass reads as one - * decision per record rather than as five parallel loops over the same list. */ -interface Groups { - readonly totals: TotalsAccumulator; - readonly steps: Map; - readonly models: Map; - readonly agents: Map; - readonly prompts: Map; - readonly tools: Map; - readonly toolSessionTotals: Map; - readonly attributions: Map; - readonly taskAttributions: Map; - readonly projects: Map; - readonly tasks: Map; - readonly backlog: Map; - readonly flows: Map; - readonly people: Map; - readonly days: Map; - activeTimeSeconds?: number; -} - -function emptyGroups(fromDay: string, toDay: string): Groups { - const days = new Map(); - for (const day of dayRange(fromDay, toDay)) days.set(day, new TotalsAccumulator()); - return { - totals: new TotalsAccumulator(), - steps: new Map(), - models: new Map(), - agents: new Map(), - prompts: new Map(), - tools: new Map(), - toolSessionTotals: new Map(), - attributions: new Map(), - taskAttributions: new Map(), - projects: new Map(), - tasks: new Map(), - backlog: new Map(), - flows: new Map(), - people: new Map(), - days, - }; -} - -/** Active time is the one quantity taken from the `"session"` kind, and the only one: no - * `"request"` record on any tool measured so far carries it, and no `"session"` record's - * money or tokens are ever added to a total, since they are a flush window's own delta of - * quantities the request records already report in full. */ -// An export-route "session" record is one periodic flush's own delta - never safe to show -// as if it were the whole session, and left untouched exactly as before. A local-read -// "session" record is different in kind, not degree: nothing reads a tool's own file this -// way except a one-shot, already-complete total (see Copilot), so it is never at risk -// of being summed with a later flush of the same quantity. Kept off `totals`, `bySteps` and -// `byDays` regardless - the two-kinds rule forbids summing it with request lines. -function accumulateSessionRecord(groups: Groups, record: TelemetrySinkRecord): void { - // `typeof`, not `!== undefined`, for the same reason `TotalsAccumulator` guards every - // counter that way: `parseTelemetrySinkLine` validates `sink_schema_version` and casts - // the rest, so a field's declared type is a claim about what this system writes, never - // about what a line on disk holds. This one was the exception until it was not — `null` - // is `!== undefined` and would have read as an observed zero, and a string would have - // concatenated into the running total and reached the terminal as `NaN` minutes. - if (typeof record.active_time_s === "number") { - groups.activeTimeSeconds = (groups.activeTimeSeconds ?? 0) + record.active_time_s; - } - if (record.provenance === "local-read") { - accumulateInto(groups.toolSessionTotals, record.tool, record, (accumulator) => - accumulator.addTokensOnly(record) - ); - } -} - -/** Everything one record needs to be placed on every axis, resolved once per report rather - * than once per record. Gathered into a shape because the list had grown past what a - * positional signature reads as: nine parameters in a fixed order is a call nobody can check - * by eye, and every one of them is the same for every record in the run. */ -interface RecordContext { - readonly membership: TaskMembership | null; - readonly taskIntervalsByVendorId: ReadonlyMap; - readonly flowIntervalsByVendorId: ReadonlyMap; - readonly journalsByVendorId: ReadonlyMap; - readonly identity: PersonIdentity | null; - readonly taskBacklogDeclarations: ReadonlyMap | undefined; - readonly namesAgents: (tool: AiToolId) => boolean; -} - -function accumulateRequestRecord( - groups: Groups, - record: TelemetrySinkRecord, - context: RecordContext -): void { - groups.totals.add(record); - addToStepGroup(groups.steps, record); - accumulateInto(groups.attributions, record.step_attribution, record); - accumulateInto(groups.tools, record.tool, record); - accumulateInto(groups.models, modelKeyOf(record), record); - accumulateInto(groups.agents, agentKeyOf(record, context.namesAgents), record); - addToPromptGroup(groups.prompts, record); - accumulateInto(groups.projects, projectKeyOf(record), record); - const taskRow = taskRowOf(record, context.taskIntervalsByVendorId, context.journalsByVendorId); - addToTaskGroup(groups.tasks, taskRow, record); - accumulateInto(groups.backlog, backlogKeyOf(taskRow, context.taskBacklogDeclarations), record); - accumulateInto(groups.flows, flowKeyOf(record, context.flowIntervalsByVendorId), record); - addToPersonGroup(groups.people, record, resolvePerson(context.identity, personRawIdOf(record))); - addToDayGroup(groups.days, record); - const { membership } = context; - const attribution = membership === null ? undefined : taskAttributionOf(record, membership); - if (attribution !== undefined) accumulateInto(groups.taskAttributions, attribution, record); -} - -/** Only a day the period itself spans: `emptyGroups` seeded every one of them, so a record - * dated outside the period joins nothing rather than adding a day the report never claimed - * to cover. */ -function addToDayGroup(days: Map, record: TelemetrySinkRecord): void { - const day = telemetrySinkRecordDayKey(record); - if (day !== undefined && days.has(day)) days.get(day)?.add(record); -} - -function accumulate( - records: readonly TelemetrySinkRecord[], - fromDay: string, - toDay: string, - membership: TaskMembership | null, - journals: readonly CostReportSessionJournal[], - identity: PersonIdentity | null, - taskBacklogDeclarations: ReadonlyMap | undefined, - declaredTools: readonly CostReportToolDeclaration[] -): Groups { - const groups = emptyGroups(fromDay, toDay); - const context: RecordContext = { - membership, - taskIntervalsByVendorId: allTaskIntervalsByVendorId(journals), - flowIntervalsByVendorId: allFlowIntervalsByVendorId(journals), - journalsByVendorId: new Map(journals.map((journal) => [journal.vendorId, journal])), - identity, - taskBacklogDeclarations, - namesAgents: agentNamingTools(declaredTools), - }; - for (const record of records) { - if (record.kind === "session") accumulateSessionRecord(groups, record); - else accumulateRequestRecord(groups, record, context); - } - return groups; -} - -/** All three, always, in the declared order. - * - * A strength that accounted for nothing is the one place in this report where a zero is - * the measurement rather than an absence: the total is known, and none of it came from - * that source. Dropping the row would leave a consumer handling one to three rows in an - * order it cannot predict, and unable to tell "no records were attributed this way" from - * "this report does not carry that field". */ -function attributionRows( - attributions: ReadonlyMap -): readonly CostReportAttributionRow[] { - return STEP_ATTRIBUTION_SOURCES.map((attribution) => ({ - attribution, - totals: attributions.get(attribution)?.build() ?? { requests: 0 }, - })); -} - -/** Both sources, always - the same reason `attributionRows` always gives every one of its - * own: a - * source that accounted for nothing is still a fact about this task, not an absent field. */ -function taskAttributionRows( - taskAttributions: ReadonlyMap -): readonly CostReportTaskAttributionRow[] { - return TASK_ATTRIBUTION_SOURCES.map((attribution) => ({ - attribution, - totals: taskAttributions.get(attribution)?.build() ?? { requests: 0 }, - })); -} - -function stepRows(steps: ReadonlyMap): readonly CostReportStepRow[] { - const rows: CostReportStepRow[] = [...steps.values()].map((group) => ({ - attribution: group.attribution, - ...(group.step === undefined ? {} : { step: group.step }), - totals: group.totals.build(), - })); - return bySize( - rows, - (row) => row.totals, - (row) => `${row.step ?? ""}/${row.attribution}` - ); -} - -/** Every project a record named, largest first, plus one row for what named none. */ -function projectRows( - projects: ReadonlyMap -): readonly CostReportProjectRow[] { - const rows: CostReportProjectRow[] = [...projects].map(([key, accumulator]) => ({ - ...(key === NO_KNOWN_PROJECT ? {} : { project: key }), - totals: accumulator.build(), - })); - return bySize( - rows, - (row) => row.totals, - (row) => row.project ?? "" - ); -} - -// Typed over `string | symbol`, wider than either `TaskRowKey` or `BacklogRowKey` alone, -// so `taskRows` and `backlogRows` share one check rather than each carrying its own copy - -// safe because every reason is a plain string and a symbol key never equals one. -function isTaskUnattributedReason(key: string | symbol): key is TaskUnattributedReason { - return typeof key === "string" && (TASK_UNATTRIBUTED_REASONS as readonly string[]).includes(key); -} - -/** Every task a record's own moment fell inside, largest first, then one row per reason - * actually present for what fell in none - `TASK_UNATTRIBUTED_REASONS`' own fixed order, - * always after every named task regardless of size, the same convention `personRows` gives - * its own `none` row. Up to four such rows, never fewer than the reasons present: two - * different gaps collapsed into one row is the fault this breakdown exists to avoid. */ -function taskRows(tasks: ReadonlyMap): readonly CostReportTaskRow[] { - const named: CostReportTaskRow[] = []; - const byReason = new Map(); - for (const group of tasks.values()) { - const totals = group.totals.build(); - if (group.reason !== undefined) { - byReason.set(group.reason, { reason: group.reason, totals }); - continue; - } - if (group.task === undefined || group.attribution === undefined) continue; - named.push({ task: group.task, attribution: group.attribution, totals }); - } - // Tie-broken on the pair, not on the task alone: one task can hold both a declared row and - // an inferred one, and a tie-break blind to the attribution would order them arbitrarily. - const sorted = bySize( - named, - (row) => row.totals, - (row) => `${row.task ?? ""}/${row.attribution ?? ""}` - ); - const reasonRows = TASK_UNATTRIBUTED_REASONS.map((reason) => byReason.get(reason)).filter( - (row): row is CostReportTaskRow => row !== undefined - ); - return [...sorted, ...reasonRows]; -} - -/** Every backlog item a task declared, largest first, then the two rows for a known task - * that named none or could not be read, then one row per reason a record fell in no task at - * all - `TASK_UNATTRIBUTED_REASONS`' own fixed order, the same tail convention `taskRows` - * uses. Two tasks declaring the same item merge here by construction: `backlogKeyOf` keys - * both on the identical `backlog` string, so `accumulateInto` folds them into one - * accumulator before this ever runs - never a second merge step that could disagree with - * how every other axis already reconciles. */ -interface BacklogGroups { - readonly named: readonly CostReportBacklogRow[]; - readonly byReason: ReadonlyMap; - readonly none: CostReportBacklogRow | undefined; - readonly unreadable: CostReportBacklogRow | undefined; -} - -// Split from `backlogRows` purely to stay under this codebase's own line-per-function limit -// - one pass classifying every key into the four shapes a row can be, nothing sorted yet. -function classifyBacklogGroups( - backlog: ReadonlyMap -): BacklogGroups { - const named: CostReportBacklogRow[] = []; - const byReason = new Map(); - let none: CostReportBacklogRow | undefined; - let unreadable: CostReportBacklogRow | undefined; - for (const [key, accumulator] of backlog) { - if (isTaskUnattributedReason(key)) { - byReason.set(key, { reason: key, totals: accumulator.build() }); - } else if (key === NO_BACKLOG_DECLARED) { - none = { declaration: "none", totals: accumulator.build() }; - } else if (key === UNREADABLE_BACKLOG_DECLARATION) { - unreadable = { declaration: "unreadable", totals: accumulator.build() }; - } else { - named.push({ backlog: key, totals: accumulator.build() }); - } - } - return { named, byReason, none, unreadable }; -} - -function backlogRows( - backlog: ReadonlyMap -): readonly CostReportBacklogRow[] { - const { named, byReason, none, unreadable } = classifyBacklogGroups(backlog); - const sorted = bySize( - named, - (row) => row.totals, - (row) => row.backlog ?? "" - ); - const reasonRows = TASK_UNATTRIBUTED_REASONS.map((reason) => byReason.get(reason)).filter( - (row): row is CostReportBacklogRow => row !== undefined - ); - return [...sorted, ...(none ? [none] : []), ...(unreadable ? [unreadable] : []), ...reasonRows]; -} - -// Second precision, no milliseconds - the same spelling `record.cjs`'s own `nowIso` writes -// to the journal's `at` field. `startMs` here always comes from `Date.parse`-ing one such -// value, so its own milliseconds are already zero; this only strips the ".000" `toISOString` -// would otherwise append, so a row's `startedAt` string-matches the journal line it opened -// on rather than looking like a different moment. -function isoSecondsFromMs(ms: number): string { - return new Date(ms).toISOString().replace(/\.\d{3}Z$/u, "Z"); -} - -/** Every orchestrated run the period's journals name, largest first, then the one row for - * work that fell in no flow interval at all - see `CostReportFlowRow`. No reason taxonomy - * the way `by_task`'s and `by_backlog`'s own remainders carry one: a flow is read from the - * same sequence either way, so there is only one fact to state about falling outside every - * one of them, never three. - * - * The remainder is pinned last rather than sorted with the named rows, the same tail - * convention `taskRows` and `backlogRows` already keep. Sorting it by size put it first - * whenever work outside every flow outweighed each single run - which is the ordinary case, - * not a corner one - so the axis led with its own remainder while the two axes beside it - * led with their largest named row. One breakdown that orders itself differently from its - * neighbours is read as a different kind of answer, and it is not one. */ -function flowRows(flows: ReadonlyMap): readonly CostReportFlowRow[] { - const named: CostReportFlowRow[] = []; - let outsideEveryFlow: CostReportFlowRow | undefined; - for (const [key, accumulator] of flows) { - if (key === OUTSIDE_EVERY_FLOW) { - outsideEveryFlow = { attribution: "unattributed", totals: accumulator.build() }; - continue; - } - // A name is not a run. The tool-stated row is a bucket drawn from however many runs of - // that skill the tool named, so it carries no `startedAt` - the same reason the row for - // records that named no prompt carries none. - if (typeof key === "string") { - named.push({ flow: key, attribution: "tool-stated", totals: accumulator.build() }); - continue; - } - named.push({ - flow: key.skill, - attribution: "journal-interval", - startedAt: isoSecondsFromMs(key.startMs), - totals: accumulator.build(), - }); - } - const sorted = bySize( - named, - (row) => row.totals, - (row) => `${row.flow ?? ""}@${row.attribution}@${row.startedAt ?? ""}` - ); - return outsideEveryFlow === undefined ? sorted : [...sorted, outsideEveryFlow]; -} - -/** Every day in the period, in order — never sorted by size, unlike every other breakdown - * here. A series read out of order is not a series. */ -function dayRows(days: ReadonlyMap): readonly CostReportDayRow[] { - return [...days].map(([day, accumulator]) => ({ day, totals: accumulator.build() })); -} - -/** Every agent that ran, largest first, plus one row for the main thread. */ -function agentRows( - agents: ReadonlyMap -): readonly CostReportAgentRow[] { - const rows: CostReportAgentRow[] = [...agents].map(([key, accumulator]) => { - if (key === MAIN_THREAD) return { attribution: "main-thread", totals: accumulator.build() }; - if (key === AGENT_NOT_STATED) return { attribution: "not-stated", totals: accumulator.build() }; - return { agent: key, attribution: "tool-stated", totals: accumulator.build() }; - }); - return bySize( - rows, - (row) => row.totals, - (row) => `${row.agent ?? ""}@${row.attribution}` - ); -} - -/** Every prompt that caused work, largest first, plus one row for what named none. - * Largest first and not chronological: unlike `by_day` this is a ranking, and a ranking has - * no continuity to break by reordering. The row for what named none is placed last rather - * than ranked among them - it is a remainder drawn from many turns, not a turn, so its size - * is not comparable to theirs. `by_flow` places its own remainder the same way. */ -function promptRows(prompts: ReadonlyMap): readonly CostReportPromptRow[] { - const named: CostReportPromptRow[] = []; - let namedNone: CostReportPromptRow | undefined; - for (const [key, group] of prompts) { - const totals = group.totals.build(); - if (key === NO_PROMPT) { - namedNone = { totals }; - continue; - } - named.push({ - prompt: key, - ...(group.earliestMs === undefined ? {} : { startedAt: isoSecondsFromMs(group.earliestMs) }), - totals, - }); - } - const sorted = bySize( - named, - (row) => row.totals, - (row) => row.prompt ?? "" - ); - return namedNone === undefined ? sorted : [...sorted, namedNone]; -} - -/** Every model a record named, largest first, plus one row for what named none. */ -function modelRows( - models: ReadonlyMap -): readonly CostReportModelRow[] { - const rows: CostReportModelRow[] = [...models].map(([key, accumulator]) => ({ - ...(key === NO_KNOWN_MODEL ? {} : { model: key }), - totals: accumulator.build(), - })); - return bySize( - rows, - (row) => row.totals, - (row) => row.model ?? "" - ); -} - -function personRowOf(group: PersonGroup): CostReportPersonRow { - const { resolved } = group; - return { - resolution: resolved.resolution, - ...(resolved.personId === undefined ? {} : { person: resolved.personId }), - ...(resolved.displayName === undefined ? {} : { displayName: resolved.displayName }), - identities: resolved.identities, - totals: group.totals.build(), - }; -} - -/** The order every `by_person` breakdown is read in, strongest claim first: a person the - * record itself named, then the one this machine's identity claims for records that named - * nobody, then every unplaced identity, then the one no-identifier row. - * - * A `Record` over the whole union rather than a filter per group, because a filter per - * group silently *drops* whatever it does not name - which is what happened when - * `"this-machine"` was added on 2026-09-04: the rows existed, summed into no group, and - * vanished from the breakdown while the totals they belonged to stayed. This shape makes - * that a compile error. */ -const PERSON_ROW_ORDER: Record = { - mapped: 0, - "this-machine": 1, - unresolved: 2, - none: 3, -}; - -/** Grouped in `PERSON_ROW_ORDER`, largest first within each group - `bySize` alone cannot - * give this order, since it sorts purely on weight and a large unresolved row would - * otherwise outrank a small mapped one. Sorting inside the single-row groups - * (`"this-machine"`, `"none"`, at most one each) costs nothing and needs no exception. */ -function personRows( - people: ReadonlyMap -): readonly CostReportPersonRow[] { - const rows = [...people.values()].map(personRowOf); - const keyOf = (row: CostReportPersonRow) => row.person ?? row.identities[0] ?? ""; - return Object.keys(PERSON_ROW_ORDER) - .sort( - (a, b) => PERSON_ROW_ORDER[a as PersonResolution] - PERSON_ROW_ORDER[b as PersonResolution] - ) - .flatMap((resolution) => - bySize( - rows.filter((row) => row.resolution === resolution), - (row) => row.totals, - keyOf - ) - ); -} - -/** - * One period's records and journals, reduced to a report whose every breakdown sums to the - * total it belongs to. - * - * Pure: everything it needs arrives as data, including which tools are covered - so this - * module names no tool and no skill, and a fifth tool changes a declaration rather than - * this file. The two rules it exists to enforce come from - * `aidd_docs/product/metrics-contract.md`, and this is the first thing in the codebase - * that could break either: money and the four token counters come from `kind: "request"` - * records alone, and active time from `kind: "session"` records alone. Summing across the - * two kinds counts the same tokens twice and produces a total that looks right. - */ -/** `task`, `filters` and `emptySelection` together - the selection this report answered, - * as opposed to the figures it answered with. Pulled out on its own so the object literal - * below reads as one shape, not a wall of conditional spreads. */ -function selectionFields( - input: CostReportInput, - emptySelection: CostReportEmptySelection | undefined -): Pick { - const filters = activeFilters(input.filters); - return { - ...(input.task === undefined ? {} : { task: input.task }), - ...(filters === undefined ? {} : { filters }), - ...(emptySelection === undefined ? {} : { emptySelection }), - }; -} - -function toolRowsInScope(input: CostReportInput, groups: Groups): readonly CostReportToolRow[] { - return buildToolRows( - declaredToolsInScope(input.declaredTools, input.filters), - groups.tools, - groups.toolSessionTotals - ); -} - -/** `undatedRecords`, `unreadableLines` and `identityUnusableCause` together - what the - * read could not do, pulled out on its own for the same reason `selectionFields` is: the - * object literal below reads as one shape, not a wall of field-by-field assignments. */ -function readFields( - input: CostReportInput -): Pick< - CostReport, - "undatedRecords" | "unreadableLines" | "identityUnusableCause" | "measurementEnabled" -> { - return { - undatedRecords: input.undatedRecords, - unreadableLines: input.unreadableLines, - measurementEnabled: input.measurementEnabled, - ...(input.identityUnusableCause === undefined - ? {} - : { identityUnusableCause: input.identityUnusableCause }), - }; -} - -/** Every `by*` breakdown together - pulled out on its own for the same reason - * `selectionFields` and `readFields` are: the object literal below reads as one shape, - * not a wall of field-by-field assignments. */ -function breakdownFields( - input: CostReportInput, - groups: Groups -): Pick< - CostReport, - | "bySteps" - | "byModels" - | "byAgents" - | "byPrompts" - | "byTools" - | "byProjects" - | "byTasks" - | "byBacklog" - | "byFlows" - | "byDays" - | "byPeople" -> { - return { - bySteps: stepRows(groups.steps), - byModels: modelRows(groups.models), - byAgents: agentRows(groups.agents), - byPrompts: promptRows(groups.prompts), - byTools: toolRowsInScope(input, groups), - byProjects: projectRows(groups.projects), - byTasks: taskRows(groups.tasks), - byBacklog: backlogRows(groups.backlog), - byFlows: flowRows(groups.flows), - byDays: dayRows(groups.days), - byPeople: personRows(groups.people), - }; -} - -function assembleCostReport( - input: CostReportInput, - inScope: readonly TelemetrySinkRecord[], - groups: Groups, - membership: TaskMembership | null, - emptySelection: CostReportEmptySelection | undefined -): CostReport { - return { - fromDay: input.fromDay, - toDay: input.toDay, - ...selectionFields(input, emptySelection), - sessions: new Set(inScope.map((record) => record.vendor_id)).size, - totals: groups.totals.build(), - ...(groups.activeTimeSeconds === undefined - ? {} - : { activeTimeSeconds: groups.activeTimeSeconds }), - ...breakdownFields(input, groups), - attributionMix: attributionRows(groups.attributions), - ...(membership === null - ? {} - : { taskAttributionMix: taskAttributionRows(groups.taskAttributions) }), - ...readFields(input), - }; -} - -/** A group key only for a `kind: "request"`, `provenance: "local-read"` record carrying a - * `turn_id` — the shape a local re-read of a still-running turn produces more than one of - * (see `read-local-cost-use-case.ts`'s `storeNewCandidates`, and metrics-contract.md's "The - * other way to double count"). Restricted to `kind: "request"`: a `kind: "session"` record - * can carry a `turn_id` too — Copilot's shutdown total is keyed on the shutdown event's own - * id — but it is a one-shot cumulative figure with no provisional reading to collapse, - * grouping it here would treat a whole-session total as one more corrigible turn. - * Restricted to `provenance: "local-read"`: on the export route the same field is a prompt - * id several billed calls share (see `billedRequestKey` below), so the identical key there - * would merge distinct calls instead of two readings of one. */ -function localReadTurnKey(record: TelemetrySinkRecord): string | null { - if (record.kind !== "request" || record.provenance !== "local-read") return null; - return record.turn_id === undefined - ? null - : `${record.tool} ${record.vendor_id} ${record.turn_id}`; -} - -/** How much of a group a record accounts for, used only to pick the largest of several - * readings of the same still-growing turn — never stored, never itself summed into a - * total. */ -function counterWeight(record: TelemetrySinkRecord): number { - return COUNTER_FIELDS.reduce((sum, field) => { - const value = record[COUNTER_SOURCE[field]]; - return sum + (typeof value === "number" ? value : 0); - }, 0); -} - -/** How many of the four counters a record states at all, whether zero or not — the - * tie-break `mergeSupersededTurnGroup` needs beyond `counterWeight` alone, since an - * *observed* zero (Codex sometimes reports `cache_write_input_tokens: 0` once a later - * event states it) and a counter never mentioned both add zero to the weight, and only - * this distinguishes them. Preferring the record that states more never risks preferring a - * shrink: `strictlyImprovesOn`'s write-time guard already refused any candidate that would - * have dropped a counter the stored one had, so within one group nothing here ever loses - * a counter a heavier-weighted sibling also states. */ -function definedCounterCount(record: TelemetrySinkRecord): number { - return COUNTER_FIELDS.reduce( - (count, field) => count + (typeof record[COUNTER_SOURCE[field]] === "number" ? 1 : 0), - 0 - ); -} - -/** - * One Codex-shaped turn, read more than once while it was still open, collapsed to the one - * record carrying the most complete counters. Never done at write time: the sink is - * append-only, so an earlier, partial reading of a turn is never edited in place — only - * reconciled by whatever reads it back, which is here, the same way `mergeBilledRequestGroup` - * reconciles two routes seeing one call. - * - * Unlike that merge, every record in this group came from the *same* route reading the - * *same* file at different moments, so the survivor is simply whichever carries the largest - * counters — never a blend of two, which would state a combination of token counts the - * tool's own file never actually reported together. A later record that reads smaller than - * an earlier one (a shrink, not a correction) is never picked over the larger one this way, - * whatever order the two arrived in. */ -function mergeSupersededTurnGroup(group: readonly TelemetrySinkRecord[]): TelemetrySinkRecord { - if (group.length === 1) return group[0]; - const heaviest = Math.max(...group.map(counterWeight)); - const largest = group.filter((record) => counterWeight(record) === heaviest); - const mostDefined = Math.max(...largest.map(definedCounterCount)); - return pickDeterministically( - largest.filter((record) => definedCounterCount(record) === mostDefined) - ); -} - -/** Every other kind and route passes through untouched — see `localReadTurnKey`. */ -function collapseSupersededTurns( - records: readonly TelemetrySinkRecord[] -): readonly TelemetrySinkRecord[] { - const groups = new Map(); - const rest: TelemetrySinkRecord[] = []; - for (const record of records) { - const key = localReadTurnKey(record); - if (key === null) { - rest.push(record); - continue; - } - const bucket = groups.get(key); - if (bucket) bucket.push(record); - else groups.set(key, [record]); - } - return [...rest, ...[...groups.values()].map(mergeSupersededTurnGroup)]; -} - -/** A group key only where `billed_request_id` is present — the one field measured so far - * to be a stable, cross-route identifier for a single billed call (unlike `turn_id`, which - * a main-agent request and its subagent share). A record with none joins nothing and is - * left exactly as it arrived, the same rule an unmatched `turn_id` already follows for a - * local re-read. */ -function billedRequestKey(record: TelemetrySinkRecord): string | null { - return record.billed_request_id === undefined - ? null - : `${record.tool}\u0000${record.vendor_id}\u0000${record.billed_request_id}`; -} - -/** The same group, from any starting order, always answers the same record — the same - * property `accumulate` already guarantees for the records it is handed (see "the same - * records, however they arrive" below). A group's own order is never guaranteed: OTLP - * redelivery can duplicate an export record, and a re-read joins a session's already-stored - * records in whatever order the day files listed them, not the order they were billed in. - * Picking `group[0]` would make the survivor depend on that accident; sorting on each - * candidate's own serialized content does not. */ -function pickDeterministically(candidates: readonly TelemetrySinkRecord[]): TelemetrySinkRecord { - return [...candidates].sort((a, b) => JSON.stringify(a).localeCompare(JSON.stringify(b)))[0]; -} - -/** Borrows `step_attribution`/`step`/`step_plugin` from a sibling that resolved one, when - * `base`'s own is `"unattributed"` — the export route never states a step at all, so - * leaving it as the survivor by default would throw away the one thing the local-read - * route in the same group did know, preferring a tool-stated step over a journal-interval - * one where both exist. */ -function withStepBackfill( - base: TelemetrySinkRecord, - group: readonly TelemetrySinkRecord[] -): TelemetrySinkRecord { - if (base.step_attribution !== "unattributed") return base; - const stepDonors = group.filter( - (record) => record !== base && record.step_attribution !== "unattributed" - ); - if (stepDonors.length === 0) return base; - const toolStated = stepDonors.filter((record) => record.step_attribution === "tool-stated"); - const donor = pickDeterministically(toolStated.length > 0 ? toolStated : stepDonors); - return { - ...base, - step_attribution: donor.step_attribution, - step: donor.step, - step_plugin: donor.step_plugin, - }; -} - -/** `person_id` and `person_display_name`, backfilled onto `base` from the group as a pair — - * never one field from each — the day a person-scoped view was added, discharging the note - * this function's own doc comment used to carry: "nothing in `CostReport` groups or filters - * on person, so a survivor without them loses no figure this report shows. Revisit this the - * day a person-scoped view is added." That day is `byPeople`. A local-read record and its - * export-route sibling can share one `billed_request_id` (Claude Code's own `requestId`, - * stated by both routes — see `telemetry-sink-record.ts`), and only the local-read side - * ever carries a person; leaving the survivor without it whenever `pickDeterministically` - * happened to keep the export side would silently report a mapped person's own work as - * `"none"`, the exact false reading this feature exists to refuse. Independent of - * `withStepBackfill`, never chained after it: that helper returns early the moment a step - * is already resolved, and person still has to be checked even then. */ -function withPersonBackfill( - base: TelemetrySinkRecord, - group: readonly TelemetrySinkRecord[] -): TelemetrySinkRecord { - if (base.person_id !== undefined) return base; - const donors = group.filter((record) => record.person_id !== undefined); - if (donors.length === 0) return base; - const donor = pickDeterministically(donors); - return { - ...base, - person_id: donor.person_id, - ...(donor.person_display_name === undefined - ? {} - : { person_display_name: donor.person_display_name }), - }; -} - -/** One billed call, seen once by each of two live routes, collapsed to the one record a - * report may safely sum. Never done at write time: the sink is append-only (see - * metrics-contract.md, "Where records live"), so a record already stored can never be - * corrected in place — only reconciled by whatever reads it back, which is here. - * - * The survivor keeps whichever record carries `cost_usd` — on every tool measured so far, - * that is also the one whose four token counters are complete for the call - * (metrics-contract.md, "Cost and token counters"), so nothing about the group's money is - * ever summed from more than one record. `withStepBackfill` and `withPersonBackfill` then - * each independently fill in what the survivor itself lacks from a sibling that has it. */ -function mergeBilledRequestGroup(group: readonly TelemetrySinkRecord[]): TelemetrySinkRecord { - if (group.length === 1) return group[0]; - const costBearing = group.filter((record) => record.cost_usd !== undefined); - const base = pickDeterministically(costBearing.length > 0 ? costBearing : group); - return withPersonBackfill(withStepBackfill(base, group), group); -} - -/** `kind: "session"` records are never part of a billed-call group — no metric datapoint - * measured so far carries `billed_request_id` at all — so this only ever touches - * `kind: "request"` records, and only ones that carry the field. */ -function collapseBilledRequests( - records: readonly TelemetrySinkRecord[] -): readonly TelemetrySinkRecord[] { - const groups = new Map(); - const rest: TelemetrySinkRecord[] = []; - for (const record of records) { - const key = record.kind === "request" ? billedRequestKey(record) : null; - if (key === null) { - rest.push(record); - continue; - } - const bucket = groups.get(key); - if (bucket) bucket.push(record); - else groups.set(key, [record]); - } - return [...rest, ...[...groups.values()].map(mergeBilledRequestGroup)]; -} - -export function buildCostReport(input: CostReportInput): CostReport { - // Turn-supersede first, billed-request-collapse second: the first reconciles two readings - // of one local-read record before the second ever has to reconcile two routes seeing one - // call, so a still-open Codex turn is already down to one record by the time a billed-call - // group is formed. Order between them is otherwise inert — the two key on disjoint fields. - const records = collapseBilledRequests(collapseSupersededTurns(input.records)); - const membership = input.task === undefined ? null : taskMembership(input.journals, input.task); - const stages = selectionStages(records, input, membership); - const emptySelection = emptySelectionOf(stages, input, membership); - const inScope = stages[stages.length - 1]?.records ?? []; - const identity = input.identity ?? null; - const groups = accumulate( - inScope, - input.fromDay, - input.toDay, - membership, - input.journals, - identity, - input.taskBacklogDeclarations, - input.declaredTools - ); - - return assembleCostReport(input, inScope, groups, membership, emptySelection); -} diff --git a/cli/src/domain/models/doctor.ts b/cli/src/domain/models/doctor.ts deleted file mode 100644 index b8e5cd976..000000000 --- a/cli/src/domain/models/doctor.ts +++ /dev/null @@ -1,31 +0,0 @@ -import type { AiToolId, ToolId } from "./tool-ids.js"; - -export type IssueSeverity = "info" | "warning" | "error"; - -export interface DoctorIssue { - severity: IssueSeverity; - message: string; - fix: string; -} - -export interface ToolHealth { - toolId: ToolId; - fileCount: number; - mergeFileCount: number; -} - -export type PluginIssueKind = "missing" | "hash-mismatch"; - -export interface PluginIssueEntry { - toolId: AiToolId; - pluginName: string; - issue: PluginIssueKind; - filePath: string; -} - -export interface DoctorReport { - healthy: boolean; - toolHealth: ToolHealth[]; - issues: DoctorIssue[]; - pluginIssues: PluginIssueEntry[]; -} diff --git a/cli/src/domain/models/file.ts b/cli/src/domain/models/file.ts deleted file mode 100644 index a8e139ba9..000000000 --- a/cli/src/domain/models/file.ts +++ /dev/null @@ -1,71 +0,0 @@ -import { ManifestValidationError } from "../errors.js"; -import { GITKEEP_FILE } from "./framework.js"; -import type { MergeStrategy } from "./merge.js"; - -// ── FileHash ────────────────────────────────────────────────────────────────── - -const MD5_PATTERN = /^[0-9a-f]{32}$/; - -export class FileHash { - readonly value: string; - - constructor(value: string) { - if (!MD5_PATTERN.test(value)) { - throw new ManifestValidationError( - `Invalid MD5 hash: "${value}". Expected 32 lowercase hex characters.` - ); - } - this.value = value; - } - - equals(other: FileHash): boolean { - return this.value === other.value; - } -} - -// ── InstallationFile ────────────────────────────────────────────────────────── - -export class InstallationFile { - readonly relativePath: string; - readonly content: string; - readonly hash: FileHash; - readonly mergeStrategy: MergeStrategy; - readonly frameworkPath?: string; - - constructor(params: { - relativePath: string; - content: string; - hash: FileHash; - mergeStrategy?: MergeStrategy; - frameworkPath?: string; - }) { - this.relativePath = params.relativePath; - this.content = params.content; - this.hash = params.hash; - this.mergeStrategy = params.mergeStrategy ?? "none"; - this.frameworkPath = params.frameworkPath; - } -} - -// ── FileDiff ────────────────────────────────────────────────────────────────── - -export type FileDiffKind = "added" | "removed" | "changed" | "unchanged"; - -export interface FileDiff { - readonly relativePath: string; - readonly kind: FileDiffKind; - readonly conflict?: boolean; -} - -export function removeRedundantGitkeeps(files: InstallationFile[]): InstallationFile[] { - const nonEmptyDirs = new Set( - files - .filter((f) => !f.relativePath.endsWith(`/${GITKEEP_FILE}`)) - .map((f) => f.relativePath.split("/").slice(0, -1).join("/")) - ); - return files.filter((f) => { - if (!f.relativePath.endsWith(`/${GITKEEP_FILE}`)) return true; - const dir = f.relativePath.split("/").slice(0, -1).join("/"); - return !nonEmptyDirs.has(dir); - }); -} diff --git a/cli/src/domain/models/flow-attribution.ts b/cli/src/domain/models/flow-attribution.ts deleted file mode 100644 index 833fe7082..000000000 --- a/cli/src/domain/models/flow-attribution.ts +++ /dev/null @@ -1,160 +0,0 @@ -import type { - RunJournal, - RunJournalBoundary, - RunJournalFileWritten, - RunJournalStepStart, - RunJournalTaskDeclared, -} from "../ports/run-journal-reader.js"; -import { - buildClosedIntervals, - type ClosedInterval, - type IntervalClosure, -} from "./journal-intervals.js"; -import { namesTheSameSkill } from "./skill-name.js"; - -/** - * Which skills open a flow when their own `step_start` fires - declared here, once, rather - * than matched from a plugin name in passing. - * - * No skill's `SKILL.md` frontmatter says it orchestrates - `name`, `description` and - * `argument-hint` are the whole of what it carries - and `aidd-orchestrator` alone holds - * three skills that plausibly do (`00-async-dev`, `01-sdlc`, `02-backlog`). Matching the - * plugin name itself as a string prefix would be exactly the tool-name branching this - * repository already carries as a debt elsewhere (`host.cjs`, issue #683): a name that - * happens to look right is not a declaration that it orchestrates. A declared set is the - * one place this fact lives, and a project extending the framework with its own - * orchestrator adds to this set and nothing else - no hook changes, no report code changes. - * - * Every skill below is named twice, deliberately, not by oversight: `skill-detection.cjs` - * has two capture routes, and each writes a different spelling to the journal. - * `skillNameFromArgument` (Claude Code, Copilot) hands over the host's own argument, - * `aidd-orchestrator:01-sdlc`. `skillNameFromSkillFileRead` (Cursor, Codex) has no such - * argument to read and falls back to the bare directory name a `SKILL.md` path names, - * `01-sdlc` - `sanitizeSkillName` keeps `:` on the way to the journal, so neither spelling - * is altered before it lands there. A set holding only the prefixed form would silently - * open no flow at all on Cursor or Codex. - * - * The three bare names carry a real cost, stated rather than argued away: a skill of the - * reader's own project named `00-async-dev`, `01-sdlc` or `02-backlog` opens a flow here, - * and nothing in the journal separates it from the orchestrator's own. The limit is printed - * with the figures - see `flowLimits` in `cost-report-artefact.ts` - because it cannot be - * removed at an acceptable price. Qualifying the name at capture was measured and does not - * work: the plugin directory sits at a different depth on every host, so no fixed offset - * names it. Installed 2026-09-01 by `aidd setup`, for the one skill `01-sdlc`: - * - * Claude Code ~/.claude/plugins/cache/aidd-framework/aidd-orchestrator/2.2.1/skills/01-sdlc/ - * Codex ~/.codex/plugins/cache/aidd-framework/aidd-orchestrator/2.2.1/skills/01-sdlc/ - * Cursor ~/.cursor/plugins/local/aidd-orchestrator/skills/01-sdlc/ - * - * Two segments above `skills/` on the first two, one on the third. An earlier version of - * this comment claimed the bare names are "verified unique across every plugin's own - * `skills/` directory in this framework", and concluded from it that they "never risk" - * opening a flow on an unrelated skill. Both halves stand, and the conclusion does not - * follow from them: this code runs against a reader's project, which is not the population - * that was checked. - * - * OpenCode names no skill at all on any route (`opencode.cjs`'s own `stepStart: null` - the - * same limit `bySteps` already lives with), so no third spelling exists to add. - */ -/** How a record's flow came to be known - the same three-way shape `by_step` carries, and - * for the same reason: a flow an interval placed a record inside and one the record's own - * tool named are two different claims, and merging them presents an inference as a - * measurement. - * - * Narrower than `StepAttributionSource`, deliberately. `prompt-matched` resolves a step - * from the prompt a `step_start` line was opened under, which names a step and never a - * flow; carrying a value nothing here can produce would invite a consumer to handle a case - * that cannot arise. */ -export type FlowAttributionSource = "journal-interval" | "tool-stated" | "unattributed"; - -export const ORCHESTRATING_SKILLS: ReadonlySet = new Set([ - "aidd-orchestrator:00-async-dev", - "00-async-dev", - "aidd-orchestrator:01-sdlc", - "01-sdlc", - "aidd-orchestrator:02-backlog", - "02-backlog", -]); - -/** The unqualified spellings among them - every entry carrying no `plugin:` prefix. These - * are the ones a reader's own project can collide with, so these are the ones the flow axis - * names when it states that limit (`flowLimits`, `cost-report-artefact.ts`). - * - * Derived rather than written out a second time. The set above promises that a project - * adding its own orchestrator "adds to this set and nothing else - no hook changes, no - * report code changes"; a sentence listing three names by hand would have broken that - * promise the moment a fourth was added, and gone on printing three. Sorted so the sentence - * reads the same on every run, whatever order the set was written in. */ -export function bareOrchestratingSkillNames( - skills: ReadonlySet = ORCHESTRATING_SKILLS -): readonly string[] { - return [...skills].filter((skill) => !skill.includes(":")).sort(); -} - -/** One closed flow interval: from an orchestrating skill's own `step_start` to whichever of - * a `step_end` naming that same skill or the next orchestrating `step_start` comes first, - * or - unclosed - the journal's own last witnessed moment. - * - * **A `turn_end` stopped closing one on 2026-09-04**, for the reason `task-attribution.ts` - * already gives for a declared task: it is a pause, not the end of an orchestration. On the - * one orchestrated session measured, the flow opened at 05:56:27, the first pause fell at - * 06:02:34, and the same orchestration went on writing into the same task folder until - * 09:27:21 - six minutes named against three and a half hours not. The step axis inside it - * named 2,220 requests for the same skill while the flow, the wider concept, named 56. Read from exactly the same journal a declared task - * interval already reads (`task-attribution.ts`), one layer wider: no boundary is added for - * this, and none is captured that was not captured already - see `phase-1.md`'s own "why an - * axis, not a capture". A non-orchestrating `step_start` neither opens nor closes one of - * these; it belongs to whichever flow interval its own moment already falls inside, or to - * none at all. */ -export interface FlowInterval extends ClosedInterval { - readonly skill: string; - /** Whether `endMs` is a moment this journal witnessed or the cap standing in for one it - * never did. Carried because `buildStepIntervals` composes these into the step axis and - * reads it there; no flow row of its own is printed differently for it. */ - readonly closedBy: IntervalClosure; -} - -/** - * Journal lines in, closed flow intervals out - the same merge and the same "journal's own - * last witnessed moment" cap `buildTaskIntervals` uses, run through the one shared walk - * (`buildClosedIntervals`) rather than a second copy of it. An orchestrating `step_start` - * opens an interval; a `step_end` naming that same skill, or the next orchestrating - * `step_start`, closes it; every other boundary - a non-orchestrating `step_start`, a - * `step_end` naming another skill, a `turn_end`, a `file_written`, a `task_declared` - - * neither opens nor closes one, and only ever contributes its own moment toward the - * journal's last witnessed one for an interval nothing ever closed. - * - * A `step_end` naming a *different* skill is never a closer, the same rule - * `buildStepIntervals` follows: a step run inside the orchestration finishing is not the - * orchestration finishing, and truncating there is exactly the fault naming the skill - * exists to prevent. Only `aidd-dev:01-plan` emits the marker today, so most flows still - * close on the next orchestrating `step_start` or the journal's own last witnessed moment; - * that fallback is the cost of this rule, stated rather than hidden. - * - * Two orchestrated runs of the very same skill in one session yield two distinct - * `FlowInterval` objects here, never one merged by name: `cost-report.ts`'s own grouping - * keys a record's flow membership on the interval it actually fell inside, by reference, - * not on `skill` alone - the fact this function's own two-row acceptance criterion rests - * on. Never open-ended, for the same reason `buildTaskIntervals` never is: a journal that - * ends without the orchestrating skill ever saying it was done exposes nothing about when - * it finished, so a boundless interval would attribute everything a long-running session - * goes on to do afterward to the first orchestrating step it ever saw. - */ -export function buildFlowIntervals( - journal: RunJournal, - periodEndMs?: number -): readonly FlowInterval[] { - return buildClosedIntervals< - RunJournalBoundary | RunJournalTaskDeclared | RunJournalFileWritten, - RunJournalStepStart, - FlowInterval - >( - [...journal.boundaries, ...journal.taskDeclarations, ...journal.filesWritten], - periodEndMs, - (boundary): boundary is RunJournalStepStart => - boundary.type === "step_start" && ORCHESTRATING_SKILLS.has(boundary.skill), - (boundary, opener) => - boundary.type === "step_end" && namesTheSameSkill(boundary.skill, opener.skill), - (opener, startMs, endMs, closedBy) => ({ skill: opener.skill, startMs, endMs, closedBy }) - ); -} diff --git a/cli/src/domain/models/framework-build.ts b/cli/src/domain/models/framework-build.ts deleted file mode 100644 index 0d06ab2b0..000000000 --- a/cli/src/domain/models/framework-build.ts +++ /dev/null @@ -1,93 +0,0 @@ -import { COPILOT_VSCODE_MCP_PATH, COPILOT_WORKSPACE_DIR } from "../tools/ai/copilot-paths.js"; - -/** Build target: supported tool identifiers for framework build. */ -export type FrameworkBuildTarget = "claude" | "cursor" | "copilot" | "codex" | "opencode"; - -/** Output layout discriminant: marketplace dist (Mode A) vs direct workspace inject (Mode B flat). */ -export type FrameworkBuildMode = "marketplace" | "flat"; - -export interface FrameworkBuildTargetMode { - readonly target: FrameworkBuildTarget; - readonly mode: FrameworkBuildMode; -} - -/** - * Every target/mode combination the build pipeline supports — the single source of truth - * for "which target:mode pairs exist". Infrastructure wiring (deps.ts's build registry) - * must not diverge from this list; commands read it here, not through infrastructure. - */ -export const FRAMEWORK_BUILD_TARGET_MODES: readonly FrameworkBuildTargetMode[] = [ - { target: "claude", mode: "marketplace" }, - { target: "claude", mode: "flat" }, - { target: "cursor", mode: "marketplace" }, - { target: "cursor", mode: "flat" }, - { target: "copilot", mode: "marketplace" }, - { target: "copilot", mode: "flat" }, - { target: "codex", mode: "marketplace" }, - { target: "codex", mode: "flat" }, - { target: "opencode", mode: "flat" }, -]; - -/** Every target with at least one supported build mode, derived from FRAMEWORK_BUILD_TARGET_MODES. */ -export const SUPPORTED_BUILD_TARGETS: readonly FrameworkBuildTarget[] = [ - ...new Set(FRAMEWORK_BUILD_TARGET_MODES.map((entry) => entry.target)), -]; - -export interface FrameworkBuildOptions { - readonly sourceDir: string; - readonly outDir: string; - readonly target: FrameworkBuildTarget; - /** Output layout. Defaults to "marketplace" (Mode A) when absent. */ - readonly mode?: FrameworkBuildMode; -} - -export interface BuildPluginResult { - readonly name: string; - readonly filesWritten: number; - readonly skippedSections: readonly string[]; -} - -export interface FrameworkBuildResult { - readonly outDir: string; - readonly plugins: readonly BuildPluginResult[]; - readonly totalFiles: number; -} - -// --- Path constants --- - -/** Path to the source (Claude-format) plugin manifest inside each plugin directory. */ -export const SOURCE_PLUGIN_MANIFEST_RELATIVE = ".claude-plugin/plugin.json"; - -/** Path where the synthesized OpenPlugin-format plugin manifest is written. */ -export const OUTPUT_PLUGIN_MANIFEST_RELATIVE = ".plugin/plugin.json"; - -/** Path to the source (Claude-format) marketplace catalog. */ -export const SOURCE_MARKETPLACE_RELATIVE = ".claude-plugin/marketplace.json"; - -/** Path where the synthesized OpenPlugin-format marketplace catalog is written. */ -export const OUTPUT_MARKETPLACE_RELATIVE = ".plugin/marketplace.json"; - -export const PLUGIN_HOOKS_RELATIVE = "hooks/hooks.json"; -export const PLUGIN_MCP_RELATIVE = ".mcp.json"; -export const PLUGIN_AGENT_INPUT_EXT = ".md"; -export const PLUGIN_SKILL_ENTRY_FILE = "SKILL.md"; - -/** Subdirectory names that are out-of-scope for MVP1 and receive a warn+skip. */ -export const OUT_OF_SCOPE_PLUGIN_SECTIONS: readonly ["commands", "rules"] = ["commands", "rules"]; - -// --- Flat-mode canonical path prefixes --- - -/** Output prefix for agents in flat mode: .github/agents//.agent.md */ -export const FLAT_GITHUB_AGENTS_PREFIX = `${COPILOT_WORKSPACE_DIR}agents/`; - -/** Output prefix for skills in flat mode: .github/skills/// */ -export const FLAT_GITHUB_SKILLS_PREFIX = `${COPILOT_WORKSPACE_DIR}skills/`; - -/** Output prefix for hooks in flat mode: .github/hooks/.hooks.json */ -export const FLAT_GITHUB_HOOKS_PREFIX = `${COPILOT_WORKSPACE_DIR}hooks/`; - -/** Path to the VS Code workspace MCP config merged in flat mode. */ -export const FLAT_VSCODE_MCP_PATH = COPILOT_VSCODE_MCP_PATH; - -/** File extension for agent files in flat output (workspace canonical). */ -export const FLAT_AGENT_OUTPUT_EXT = ".agent.md"; diff --git a/cli/src/domain/models/framework.ts b/cli/src/domain/models/framework.ts deleted file mode 100644 index b4a15e484..000000000 --- a/cli/src/domain/models/framework.ts +++ /dev/null @@ -1,63 +0,0 @@ -import type { IdeToolId } from "./tool-ids.js"; - -export const TOOLS_PLACEHOLDER = "{{TOOLS}}/"; -export const DOCS_PLACEHOLDER = "{{DOCS}}/"; -export const AT_TOOLS_PLACEHOLDER = "@{{TOOLS}}/"; -export const AT_DOCS_PLACEHOLDER = "@{{DOCS}}/"; - -export const CONFIG_MCP = "mcp"; -export const CONFIG_VSCODE_SETTINGS = "vscodeSettings"; -export const CONFIG_VSCODE_EXTENSIONS = "vscodeExtensions"; -export const CONFIG_VSCODE_KEYBINDINGS = "vscodeKeybindings"; -export const CONFIG_OPENCODE = "opencode"; - -export const GITKEEP_FILE = ".gitkeep"; -export const FRAMEWORK_CONFIG_PREFIX = "config/"; - -export interface ContentSection { - readonly name: string; - readonly directory: string; - readonly entryFile: string | null; -} - -export interface TemplateRef { - readonly name: string; - readonly path: string; -} - -export interface ConfigRef { - readonly name: string; - readonly path: string; - readonly requiredIdeId?: IdeToolId; -} - -export class FrameworkDescriptor { - readonly version: string; - readonly contentSections: readonly ContentSection[]; - readonly templateRefs: readonly TemplateRef[]; - readonly configRefs: readonly ConfigRef[]; - - constructor(params: { - version: string; - contentSections: ContentSection[]; - templateRefs: TemplateRef[]; - configRefs: ConfigRef[]; - }) { - this.version = params.version; - this.contentSections = Object.freeze([...params.contentSections]); - this.templateRefs = Object.freeze([...params.templateRefs]); - this.configRefs = Object.freeze([...params.configRefs]); - } - - getContentSection(name: string): ContentSection | undefined { - return this.contentSections.find((s) => s.name === name); - } - - getTemplate(name: string): TemplateRef | undefined { - return this.templateRefs.find((t) => t.name === name); - } - - getConfig(name: string): ConfigRef | undefined { - return this.configRefs.find((c) => c.name === name); - } -} diff --git a/cli/src/domain/models/installed-rule.ts b/cli/src/domain/models/installed-rule.ts deleted file mode 100644 index 7877d8192..000000000 --- a/cli/src/domain/models/installed-rule.ts +++ /dev/null @@ -1,75 +0,0 @@ -import { parseFrontmatter } from "../formats/markdown.js"; -import type { AiToolId } from "../models/tool-ids.js"; - -/** - * One rule as it sits installed in a project, read back rather than generated. - * - * The shape is the one the plugin script this replaced emitted, field for field, so the - * skill that consumes it did not have to change what it reads. What changed is where the - * rows come from: the script carried its own table of four tool directories and their - * extensions, and `RulesCapability.installedLocation()` now answers that per tool, from the - * installer itself. - */ -export interface InstalledRule { - readonly tool: AiToolId; - /** Project-relative, `/`-separated, exactly as the scan found it. */ - readonly path: string; - /** The file's own name with the installed extension removed — never a frontmatter field. - * A rule's identity is where it sits: two rules may state the same `name` and still be - * two rules, and one that states none is still named. */ - readonly name: string; - /** What the rule says it governs, empty where it says nothing. Empty rather than absent: - * every tool's rule may carry one, so a missing description is a rule that stated none, - * not a tool that cannot. */ - readonly description: string; - /** Every glob the rule scopes itself to, absent when it names none — which means it - * applies everywhere, a different statement from an empty list. */ - readonly paths?: readonly string[]; -} - -/** Each tool names the scope field differently: `paths` for Claude Code and Codex, `globs` - * for Cursor, `applyTo` for Copilot. Read all three and merge, rather than branch on the - * tool: a file converted from one tool to another carries whichever its source used, and a - * reader asking one question should not have to know which tool answered. */ -const SCOPE_FIELDS = ["paths", "globs", "applyTo"] as const; - -/** A scope stated as one string may hold several globs: `tool-paths.md` tells a generator - * to comma-join them for Cursor and Copilot. Split, so a rule governing two trees reads as - * two and not as one glob containing a comma. */ -function globsIn(value: unknown): readonly string[] { - if (Array.isArray(value)) return value.filter((item): item is string => typeof item === "string"); - if (typeof value !== "string") return []; - return value - .split(",") - .map((glob) => glob.trim()) - .filter((glob) => glob !== ""); -} - -function scopeOf(frontmatter: Record): readonly string[] { - return SCOPE_FIELDS.flatMap((field) => globsIn(frontmatter[field])); -} - -/** The installed extension, whole. Trimming at the last dot would leave `.instructions` - * glued to every Copilot rule's name, since what it installs is `.instructions.md`. */ -function nameOf(path: string, extension: string): string { - const basename = path.split("/").at(-1) ?? path; - return basename.endsWith(extension) ? basename.slice(0, -extension.length) : basename; -} - -export function toInstalledRule( - tool: AiToolId, - path: string, - extension: string, - content: string -): InstalledRule { - const { frontmatter } = parseFrontmatter(content); - const description = frontmatter.description; - const paths = scopeOf(frontmatter); - return { - tool, - path, - name: nameOf(path, extension), - description: typeof description === "string" ? description : "", - ...(paths.length === 0 ? {} : { paths }), - }; -} diff --git a/cli/src/domain/models/journal-intervals.ts b/cli/src/domain/models/journal-intervals.ts deleted file mode 100644 index e7af8c069..000000000 --- a/cli/src/domain/models/journal-intervals.ts +++ /dev/null @@ -1,165 +0,0 @@ -/** The one walk that turns run-journal lines into closed intervals - shared by - * `task-attribution.ts`'s declared task intervals and `flow-attribution.ts`'s orchestrated - * flow intervals, which differ only in what opens an interval, what else closes it, and - * what payload the opener carries into the built interval. Pulled out once both needed the - * identical merge-sort-walk shape: same three-array merge, same "journal's own last - * witnessed moment" cap, same closer walk - duplicating it a second time is exactly what - * this codebase's own duplication gate refuses. */ - -/** One boundary-like value, paired with the millisecond moment its own `at` parses to. */ -export interface TimedBoundary { - readonly atMs: number; - readonly boundary: T; -} - -/** Every `at`-bearing value, timed and sorted - dropping one whose own `at` cannot be - * parsed before any pairing happens, never leaving it in as a mid-list gap. Left in, an - * unparseable boundary would vanish from a caller's view while still occupying a list - * index, silently widening the interval before it. */ -export function timed( - boundaries: readonly T[] -): readonly TimedBoundary[] { - return boundaries - .map((boundary) => ({ atMs: Date.parse(boundary.at), boundary })) - .filter(({ atMs }) => !Number.isNaN(atMs)) - .sort((left, right) => left.atMs - right.atMs); -} - -/** The journal's own last recorded moment, capped at `periodEndMs` when one is given - so a - * clock-skewed future moment (`file_written` dated `9999-12-31`, say) never widens an - * unclosed interval past what a report could ever place a record in anyway. `timed()` only - * refuses a moment it cannot parse at all; this is what refuses one that parses but is - * absurd, without a second, weaker notion of "too far in the future". - * - * Takes the moment itself and returns a moment. It used to take the whole list and answer - * `number | undefined` for an empty one, which made the walk below end an unclosed interval - * at `?? lastMs ?? startMs` - a second fallback no input could reach, since a walk that - * reaches an opener has by definition witnessed one. A default standing in for a case that - * cannot arise is where a wrong number hides, so the emptiness is now handled once, where - * it is real, and this answers with a moment. */ -function cappedLastMoment(witnessedLastMs: number, periodEndMs: number | undefined): number { - return periodEndMs === undefined ? witnessedLastMs : Math.min(witnessedLastMs, periodEndMs); -} - -/** What ended an interval. `boundary` is a moment the journal actually witnessed - a - * closer, or a later opener; `journal-end` is the fallback, meaning nothing in the journal - * ever said this interval was over and it was capped at the last moment the journal - * witnessed at all. - * - * The distinction is a caller's to act on, not this walk's: an interval capped at the - * journal's end is a bound, and reading it as a measured extent is how a step that opened - * shortly before a long session went quiet comes to be credited with everything that - * followed. `step-attribution.ts` is the caller that reads it today. - * - * Two values and not three. "Closed by its own `step_end`" and "closed by a later opener" - * are genuinely different strengths of evidence, and both were considered; both are - * nevertheless a moment the journal witnessed, and no caller distinguishes them, so a third - * value would be structure nothing reads. */ -export type IntervalClosure = "boundary" | "journal-end"; - -/** The two facts every closed interval this module builds actually needs — `path` - * (`TaskInterval`) or `skill` (`FlowInterval`) rides beside these, never inside this shape - * itself. */ -export interface ClosedInterval { - readonly startMs: number; - readonly endMs: number; -} - -/** Whether a record's own moment falls inside one of `intervals` - never true for a record - * with no moment, or one earlier than every interval, which is what keeps an interval from - * being read backward onto work that happened before it ever opened. Generic over - * `ClosedInterval` rather than either concrete interval shape, since the check itself - * never reads `path` or `skill`. */ -export function momentFallsWithin( - intervals: readonly ClosedInterval[], - momentIso: string | undefined -): boolean { - if (momentIso === undefined) return false; - const momentMs = Date.parse(momentIso); - if (Number.isNaN(momentMs)) return false; - return intervals.some((interval) => momentMs >= interval.startMs && momentMs < interval.endMs); -} - -/** - * Journal lines in, closed intervals out - the one walk both `buildTaskIntervals` and - * `buildFlowIntervals` run. `boundaryLike` is the same three-array merge either caller - * builds (`journal.boundaries`, `journal.taskDeclarations`, `journal.filesWritten`), - * already carrying every moment either interval kind might need to cap an unclosed one at. - * - * `isOpener` names which boundary starts an interval; `isCloser` names every *other* - * boundary that can end one early, and is asked about the open interval's own opener as - * well as the candidate (a `task_declared` interval also closes on the next - * `task_declared`, which `isOpener` already covers - an interval ends at the first later - * boundary either predicate accepts, not at the first `isCloser` alone). An opener the walk reaches with no later opener or - * closer ends at the journal's own last witnessed moment - itself at the earliest, since - * the opener is one of those moments - never left open-ended: no - * boundary here exposes when an interval's own work actually finishes, so an unbounded - * interval would go on attributing everything a long-running session does afterward to the - * first opener it ever saw. - * - * `toInterval` turns one opener plus its resolved bounds, and how those bounds were - * reached (`IntervalClosure`), into the caller's own interval shape, or `null` to close the interval without emitting a row for it - - * `buildTaskIntervals` uses this to skip a declared path `taskIdentityFromWrittenPath` - * cannot resolve while still letting it close whatever interval came before it; - * `buildFlowIntervals` never returns `null`, since every orchestrating `step_start` names a - * skill outright. - */ -export function buildClosedIntervals< - TBoundary extends { readonly at: string }, - TOpener extends TBoundary, - TInterval, ->( - boundaryLike: readonly TBoundary[], - periodEndMs: number | undefined, - isOpener: (boundary: TBoundary) => boundary is TOpener, - isCloser: (boundary: TBoundary, opener: TOpener) => boolean, - toInterval: ( - opener: TOpener, - startMs: number, - endMs: number, - closedBy: IntervalClosure - ) => TInterval | null -): readonly TInterval[] { - const everyWitnessedMoment = timed(boundaryLike); - // Not one readable moment in the whole journal: no interval either, and nothing below - // would find an opener to walk. Returning here is also what makes `lastMs` a moment - // rather than a maybe-moment for the rest of this function. - if (everyWitnessedMoment.length === 0) return []; - const lastMs = cappedLastMoment( - everyWitnessedMoment[everyWitnessedMoment.length - 1].atMs, - periodEndMs - ); - const intervals: TInterval[] = []; - for (let i = 0; i < everyWitnessedMoment.length; i++) { - const { atMs: startMs, boundary } = everyWitnessedMoment[i]; - if (!isOpener(boundary)) continue; - const closerMs = firstCloserAfter(everyWitnessedMoment, i, boundary, isOpener, isCloser); - const interval = - closerMs === undefined - ? toInterval(boundary, startMs, lastMs, "journal-end") - : toInterval(boundary, startMs, closerMs, "boundary"); - if (interval !== null) intervals.push(interval); - } - return intervals; -} - -/** The moment the interval opened at `from` ends, or `undefined` when nothing closes it. - * - * Scanned forward from the opener rather than filtered once for the whole journal, because - * `isCloser` is asked about the pair: a `step_end` closes the flow whose skill it names and - * no other, which a single pre-filtered list of closers cannot express. For an `isCloser` - * that ignores its opener - `buildTaskIntervals` passes one - this answers exactly what the - * pre-filtered walk answered. */ -function firstCloserAfter( - everyWitnessedMoment: readonly TimedBoundary[], - from: number, - opener: TOpener, - isOpener: (boundary: TBoundary) => boundary is TOpener, - isCloser: (boundary: TBoundary, opener: TOpener) => boolean -): number | undefined { - for (let i = from + 1; i < everyWitnessedMoment.length; i++) { - const { atMs, boundary } = everyWitnessedMoment[i]; - if (isOpener(boundary) || isCloser(boundary, opener)) return atMs; - } - return undefined; -} diff --git a/cli/src/domain/models/manifest.ts b/cli/src/domain/models/manifest.ts deleted file mode 100644 index bab13b1e7..000000000 --- a/cli/src/domain/models/manifest.ts +++ /dev/null @@ -1,529 +0,0 @@ -import { - DuplicatePluginError, - InvalidManifestDataError, - InvalidManifestToolIdError, - PluginNotFoundError, - ToolNotInManifestError, -} from "../errors.js"; -import { FileHash, type InstallationFile } from "./file.js"; -import { type McpExclusion, mcpExclusionEquals } from "./mcp-exclusion.js"; -import type { MergeFileEntry } from "./merge.js"; -import { Plugin, type PluginEntryData } from "./plugin.js"; -import { type ToolId, VALID_TOOL_IDS } from "./tool-ids.js"; - -const MANIFEST_VERSION = 6; - -// VSCode file paths that were tracked under "copilot" in manifest v1. -// Used exclusively by migrateV1toV2 to move them to the "vscode" tool entry. -// It can only be removed when the manifest version is bumped again and v1 support is explicitly dropped. -const VSCODE_MIGRATION_PATHS = new Set([ - ".vscode/extensions.json", - ".vscode/keybindings.json", - ".vscode/settings.json", -]); - -interface TrackedFile { - readonly relativePath: string; - readonly hash: FileHash; - readonly frameworkPath?: string; -} - -// Retained for legacy manifest round-trip and isFileTracked coverage. -interface ScriptsEntry { - readonly version: string; - readonly files: readonly TrackedFile[]; -} - -// Retained for legacy manifest round-trip and isFileTracked coverage. -interface PluginsEntry { - readonly version: string; - readonly files: readonly TrackedFile[]; -} - -interface ToolEntry { - readonly toolId: ToolId; - readonly version: string; - readonly files: readonly TrackedFile[]; - readonly mergeFiles: readonly MergeFileEntry[]; - readonly excludedMcp: readonly McpExclusion[]; - readonly plugins: readonly Plugin[]; -} - -// Kept for legacy manifest round-trip: v3/v4 manifests may carry these sections until migrate runs. -interface ScriptsEntryData { - version: string; - files: TrackedFileData[]; -} - -interface PluginsSectionData { - version: string; - files: TrackedFileData[]; -} - -interface ManifestData { - version: 6; - tools: Record; -} - -interface MergeFileEntryData { - relativePath: string; - sectionKey: string | null; - entries: Record; -} - -interface ToolEntryData { - toolId: string; - version: string; - files: TrackedFileData[]; - mergeFiles?: MergeFileEntryData[]; - excludedMcp?: Array<{ configPath: string; entryKey: string }>; - plugins?: PluginEntryData[]; -} - -interface TrackedFileData { - relativePath: string; - hash: string; - frameworkPath?: string; -} - -// This migration block must remain until all users have upgraded past v1. -// Removing it would corrupt manifests that still have VSCode files tracked under "copilot". -function migrateV1toV2(raw: Record): void { - const tools = raw.tools as Record | undefined; - if (!tools) return; - - const copilot = tools.copilot; - if (!copilot) return; - - const vscodeFiles = copilot.files.filter((f) => VSCODE_MIGRATION_PATHS.has(f.relativePath)); - if (vscodeFiles.length === 0) return; - - copilot.files = copilot.files.filter((f) => !VSCODE_MIGRATION_PATHS.has(f.relativePath)); - - if (!tools.vscode) { - tools.vscode = { - toolId: "vscode", - version: copilot.version, - files: [], - mergeFiles: [], - }; - } - const existingPaths = new Set(tools.vscode.files.map((f) => f.relativePath)); - const deduped = vscodeFiles.filter((f) => !existingPaths.has(f.relativePath)); - tools.vscode.files = [...tools.vscode.files, ...deduped]; -} - -function migrateV2toV3(raw: Record): void { - const tools = raw.tools as Record | undefined; - if (!tools) return; - for (const entry of Object.values(tools)) { - entry.plugins ??= []; - } -} - -function migrateV3toV4(raw: Record): void { - if (!("mode" in raw)) raw.mode = "local"; - if (!("plugins" in raw)) raw.plugins = null; -} - -// Strips dead top-level fields: docs, mode, repo, docsDir, scripts, plugins. -// The legacy scripts/plugins file lists are parsed separately (parseLegacySections) -// before this strip, so removing them here during the round-trip is safe. -function migrateV4toV5(raw: Record): void { - delete raw.docs; - delete raw.mode; - delete raw.repo; - delete raw.docsDir; - delete raw.scripts; - delete raw.plugins; - if (!("marketplaces" in raw)) raw.marketplaces = {}; -} - -// Strips the dead marketplaces aggregate. The actual marketplace registry now lives -// exclusively in .aidd/marketplaces.json (managed by MarketplaceRegistryAdapter). -function migrateV5toV6(raw: Record): void { - delete raw.marketplaces; -} - -export class Manifest { - private readonly _tools: Map; - // Legacy _scripts/_plugins file lists retained so isFileTracked still recognises files - // written by pre-v6 manifests (the fields themselves are stripped from serialized output). - private _scripts: ScriptsEntry | null; - private _plugins: PluginsEntry | null; - - private constructor(params: { - tools: Map; - scripts: ScriptsEntry | null; - plugins: PluginsEntry | null; - }) { - this._tools = new Map(params.tools); - this._scripts = params.scripts; - this._plugins = params.plugins; - } - - static create(): Manifest { - return new Manifest({ - tools: new Map(), - scripts: null, - plugins: null, - }); - } - - addTool( - toolId: ToolId, - version: string, - files: InstallationFile[], - mergeFiles: MergeFileEntry[] = [], - excludedMcp: McpExclusion[] = [] - ): void { - const existing = this._tools.get(toolId); - this._tools.set(toolId, { - toolId, - version, - files: this.toTrackedFiles(files), - mergeFiles, - excludedMcp, - plugins: existing?.plugins ?? [], - }); - } - - /** Returns true when the loaded JSON carried a legacy scripts section. Used by isFileTracked. */ - hasScripts(): boolean { - return this._scripts !== null; - } - - /** Returns true when the loaded JSON carried a legacy top-level plugins section. Used by isFileTracked. */ - hasPlugins(): boolean { - return this._plugins !== null; - } - - private toTrackedFiles(files: InstallationFile[]): TrackedFile[] { - return files.map((f) => ({ - relativePath: f.relativePath, - hash: f.hash, - ...(f.frameworkPath !== undefined && { frameworkPath: f.frameworkPath }), - })); - } - - getInstalledToolIds(): ToolId[] { - return [...this._tools.keys()]; - } - - getToolFiles( - toolId: ToolId - ): ReadonlyArray<{ relativePath: string; hash: FileHash; frameworkPath?: string }> { - return this._tools.get(toolId)?.files ?? []; - } - - getMergeFiles(toolId: ToolId): readonly MergeFileEntry[] { - return this._tools.get(toolId)?.mergeFiles ?? []; - } - - /** Returns all tracked paths (files + merge files + plugin files) across all tools that start with the given directory prefix. */ - getTrackedPathsInDirectory(dir: string): Set { - const tracked = new Set(); - for (const [, entry] of this._tools) { - for (const f of entry.files) { - if (f.relativePath.startsWith(dir)) tracked.add(f.relativePath); - } - for (const m of entry.mergeFiles) { - if (m.relativePath.startsWith(dir)) tracked.add(m.relativePath); - } - for (const plugin of entry.plugins) { - for (const relPath of plugin.files.keys()) { - if (relPath.startsWith(dir)) tracked.add(relPath); - } - } - } - return tracked; - } - - getExcludedMcp(toolId: ToolId): readonly McpExclusion[] { - return this._tools.get(toolId)?.excludedMcp ?? []; - } - - addExcludedMcp(toolId: ToolId, exclusions: McpExclusion[]): void { - const entry = this._tools.get(toolId); - if (!entry) throw new ToolNotInManifestError(toolId); - const existing = [...entry.excludedMcp]; - for (const excl of exclusions) { - if (!existing.some((e) => mcpExclusionEquals(e, excl))) { - existing.push(excl); - } - } - this._tools.set(toolId, { ...entry, excludedMcp: existing }); - } - - removeExcludedMcp(toolId: ToolId, exclusions: McpExclusion[]): void { - const entry = this._tools.get(toolId); - if (!entry) throw new ToolNotInManifestError(toolId); - const filtered = entry.excludedMcp.filter( - (e) => !exclusions.some((r) => mcpExclusionEquals(e, r)) - ); - this._tools.set(toolId, { ...entry, excludedMcp: filtered }); - } - - clearExcludedMcp(toolId: ToolId): void { - const entry = this._tools.get(toolId); - if (!entry) throw new ToolNotInManifestError(toolId); - this._tools.set(toolId, { ...entry, excludedMcp: [] }); - } - - updateTrackedFileHash(toolId: ToolId, relativePath: string, hash: FileHash): void { - const entry = this._tools.get(toolId); - if (!entry) return; - const existing = entry.files.find((f) => f.relativePath === relativePath); - const updatedFiles = existing - ? entry.files.map((f) => (f.relativePath === relativePath ? { ...f, hash } : f)) - : [...entry.files, { relativePath, hash }]; - this._tools.set(toolId, { ...entry, files: updatedFiles }); - } - - updateToolMergeFiles( - toolId: ToolId, - mergeFiles: MergeFileEntry[], - excludedMcp?: McpExclusion[] - ): void { - const entry = this._tools.get(toolId); - if (!entry) throw new ToolNotInManifestError(toolId); - this._tools.set(toolId, { - ...entry, - mergeFiles, - ...(excludedMcp !== undefined && { excludedMcp }), - }); - } - - removeTool(toolId: ToolId): void { - if (!this._tools.has(toolId)) { - throw new ToolNotInManifestError(toolId); - } - this._tools.delete(toolId); - } - - hasTool(toolId: ToolId): boolean { - return this._tools.has(toolId); - } - - getPlugins(toolId: ToolId): readonly Plugin[] { - return this._tools.get(toolId)?.plugins ?? []; - } - - addPlugin(toolId: ToolId, plugin: Plugin): void { - const entry = this._tools.get(toolId); - if (!entry) throw new ToolNotInManifestError(toolId); - if (entry.plugins.some((p) => p.name === plugin.name)) { - throw new DuplicatePluginError(plugin.name); - } - this._tools.set(toolId, { ...entry, plugins: [...entry.plugins, plugin] }); - } - - removePlugin(toolId: ToolId, name: string): void { - const entry = this._tools.get(toolId); - if (!entry) throw new ToolNotInManifestError(toolId); - if (!entry.plugins.some((p) => p.name === name)) { - throw new PluginNotFoundError(name); - } - this._tools.set(toolId, { ...entry, plugins: entry.plugins.filter((p) => p.name !== name) }); - } - - updatePlugin(toolId: ToolId, plugin: Plugin): void { - const entry = this._tools.get(toolId); - if (!entry) throw new ToolNotInManifestError(toolId); - if (!entry.plugins.some((p) => p.name === plugin.name)) { - throw new PluginNotFoundError(plugin.name); - } - this._tools.set(toolId, { - ...entry, - plugins: entry.plugins.map((p) => (p.name === plugin.name ? plugin : p)), - }); - } - - isFileTracked(relativePath: string): boolean { - for (const entry of this._tools.values()) { - if (entry.files.some((f) => f.relativePath === relativePath)) return true; - if (entry.mergeFiles.some((m) => m.relativePath === relativePath)) return true; - if (this.isFileTrackedInPlugins(entry.plugins, relativePath)) return true; - } - if (this._scripts?.files.some((f) => f.relativePath === relativePath)) return true; - if (this._plugins?.files.some((f) => f.relativePath === relativePath)) return true; - return false; - } - - private isFileTrackedInPlugins(plugins: readonly Plugin[], relativePath: string): boolean { - for (const plugin of plugins) { - if (plugin.isFileTracked(relativePath)) return true; - } - return false; - } - - getToolVersion(toolId: ToolId): string | undefined { - return this._tools.get(toolId)?.version; - } - - getInstalledDirectories(): Set { - const dirs = new Set(); - for (const entry of this._tools.values()) { - for (const file of entry.files) { - dirs.add(`${file.relativePath.split("/")[0]}/`); - } - } - return dirs; - } - - // --- Serialization --- - - toJSON(): ManifestData { - const tools = this.serializeTools(); - return { version: MANIFEST_VERSION as 6, tools }; - } - - private serializeTools(): Record { - const tools: Record = {}; - for (const [toolId, entry] of this._tools.entries()) { - tools[toolId] = { - toolId: entry.toolId, - version: entry.version, - files: this.toTrackedFileData(entry.files), - mergeFiles: this.toMergeFileEntryData(entry.mergeFiles), - ...(entry.excludedMcp.length > 0 && { - excludedMcp: entry.excludedMcp.map((e) => ({ - configPath: e.configPath, - entryKey: e.entryKey, - })), - }), - ...(entry.plugins.length > 0 && { - plugins: entry.plugins.map((p) => p.toJSON()), - }), - }; - } - return tools; - } - - private toTrackedFileData(files: readonly TrackedFile[]): TrackedFileData[] { - return files.map((f) => ({ - relativePath: f.relativePath, - hash: f.hash.value, - ...(f.frameworkPath !== undefined && { frameworkPath: f.frameworkPath }), - })); - } - - private static parseTrackedFiles(files: TrackedFileData[]): TrackedFile[] { - return files.map((f) => ({ - relativePath: f.relativePath, - hash: new FileHash(f.hash), - ...(f.frameworkPath !== undefined && { frameworkPath: f.frameworkPath }), - })); - } - - private toMergeFileEntryData(mergeFiles: readonly MergeFileEntry[]): MergeFileEntryData[] { - return mergeFiles.map((m) => { - const entries: Record = {}; - for (const [key, hash] of Object.entries(m.entries)) { - entries[key] = hash.value; - } - return { - relativePath: m.relativePath, - sectionKey: m.sectionKey, - entries, - }; - }); - } - - private static parseMergeFileEntries(data: MergeFileEntryData[]): MergeFileEntry[] { - return data.map((m) => { - const entries: Record = {}; - for (const [key, hash] of Object.entries(m.entries)) { - entries[key] = new FileHash(hash); - } - return { - relativePath: m.relativePath, - sectionKey: m.sectionKey, - entries, - }; - }); - } - - static fromJSON(data: unknown): Manifest { - if (data === null || typeof data !== "object") { - throw new InvalidManifestDataError("expected an object."); - } - const raw = data as Record; - Manifest.applyMigrations(raw); - const tools = Manifest.parseTools(raw); - const { scripts, plugins } = Manifest.parseLegacySections(raw); - return new Manifest({ tools, scripts, plugins }); - } - - private static applyMigrations(raw: Record): void { - const version = raw.version; - if (version === 6) return; - if (typeof version !== "number" || version < 1 || version > 6) { - throw new InvalidManifestDataError( - `Unsupported manifest version: ${String(version)}. Expected ${MANIFEST_VERSION}.` - ); - } - const migrations: ((r: Record) => void)[] = [ - migrateV1toV2, - migrateV2toV3, - migrateV3toV4, - migrateV4toV5, - migrateV5toV6, - ]; - for (const migrate of migrations.slice(version - 1)) { - migrate(raw); - } - } - - private static parseTools(raw: Record): Map { - const tools = new Map(); - if (raw.tools === null || typeof raw.tools !== "object") return tools; - - for (const [key, value] of Object.entries(raw.tools as Record)) { - const toolId = key as ToolId; - if (!VALID_TOOL_IDS.includes(toolId)) { - throw new InvalidManifestToolIdError(key); - } - const entry = value as ToolEntryData; - tools.set(toolId, { - toolId, - version: entry.version, - files: Manifest.parseTrackedFiles(entry.files), - mergeFiles: Manifest.parseMergeFileEntries(entry.mergeFiles ?? []), - excludedMcp: - entry.excludedMcp?.map((e) => ({ configPath: e.configPath, entryKey: e.entryKey })) ?? [], - plugins: Manifest.parsePluginEntries(entry.plugins ?? []), - }); - } - return tools; - } - - // Parse legacy scripts/plugins file lists for backward-compatible file tracking of pre-v6 manifests. - private static parseLegacySections(raw: Record): { - scripts: ScriptsEntry | null; - plugins: PluginsEntry | null; - } { - let scripts: ScriptsEntry | null = null; - if (raw.scripts !== null && raw.scripts !== undefined && typeof raw.scripts === "object") { - const scriptsRaw = raw.scripts as ScriptsEntryData; - scripts = { - version: scriptsRaw.version, - files: Manifest.parseTrackedFiles(scriptsRaw.files), - }; - } - - let plugins: PluginsEntry | null = null; - if (raw.plugins !== null && raw.plugins !== undefined && typeof raw.plugins === "object") { - const pluginsRaw = raw.plugins as PluginsSectionData; - plugins = { - version: pluginsRaw.version, - files: Manifest.parseTrackedFiles(pluginsRaw.files), - }; - } - return { scripts, plugins }; - } - - private static parsePluginEntries(data: PluginEntryData[]): Plugin[] { - return data.map((p) => Plugin.fromJSON(p)); - } -} diff --git a/cli/src/domain/models/marketplace-cache-entry.ts b/cli/src/domain/models/marketplace-cache-entry.ts deleted file mode 100644 index 69fcc179d..000000000 --- a/cli/src/domain/models/marketplace-cache-entry.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { EmptyMarketplaceCacheNameError } from "../errors.js"; - -const MIN_NAME_LENGTH = 1; - -export interface MarketplaceCacheEntryParams { - name: string; - path: string; - sizeBytes: number; - lastFetchedAt: Date | null; -} - -export class MarketplaceCacheEntry { - readonly name: string; - readonly path: string; - readonly sizeBytes: number; - readonly lastFetchedAt: Date | null; - - constructor(params: MarketplaceCacheEntryParams) { - if (params.name.trim().length < MIN_NAME_LENGTH) { - throw new EmptyMarketplaceCacheNameError(); - } - this.name = params.name; - this.path = params.path; - this.sizeBytes = params.sizeBytes; - this.lastFetchedAt = params.lastFetchedAt; - } - - equals(other: MarketplaceCacheEntry): boolean { - return this.name === other.name && this.path === other.path; - } -} diff --git a/cli/src/domain/models/marketplace-entry.ts b/cli/src/domain/models/marketplace-entry.ts deleted file mode 100644 index d6397f973..000000000 --- a/cli/src/domain/models/marketplace-entry.ts +++ /dev/null @@ -1,103 +0,0 @@ -import { - InvalidMarketplaceNameError, - InvalidMarketplaceScopeError, - MarketplaceAlreadyRegisteredError, -} from "../errors.js"; -import { MARKETPLACE_NAME_REGEX, type MarketplaceScope } from "./marketplace.js"; -import { type PluginSource, parsePluginSource, serializePluginSource } from "./plugin-source.js"; - -export interface MarketplaceEntryData { - name: string; - source: Record; - scope: MarketplaceScope; - lastRefreshAt?: string; - version?: string; -} - -export class MarketplaceEntry { - readonly name: string; - readonly source: PluginSource; - readonly scope: MarketplaceScope; - readonly lastRefreshAt?: string; - readonly version?: string; - - private constructor(params: { - name: string; - source: PluginSource; - scope: MarketplaceScope; - lastRefreshAt?: string; - version?: string; - }) { - this.name = params.name; - this.source = params.source; - this.scope = params.scope; - this.lastRefreshAt = params.lastRefreshAt; - this.version = params.version; - } - - static create(params: { - name: string; - source: PluginSource; - scope: MarketplaceScope; - }): MarketplaceEntry { - if (!MARKETPLACE_NAME_REGEX.test(params.name)) { - throw new InvalidMarketplaceNameError(params.name); - } - if (params.scope !== "project" && params.scope !== "user") { - throw new InvalidMarketplaceScopeError(String(params.scope)); - } - return new MarketplaceEntry(params); - } - - static deserialize(data: MarketplaceEntryData): MarketplaceEntry { - if (!MARKETPLACE_NAME_REGEX.test(data.name)) { - throw new InvalidMarketplaceNameError(data.name); - } - if (data.scope !== "project" && data.scope !== "user") { - throw new InvalidMarketplaceScopeError(String(data.scope)); - } - const source = parsePluginSource(data.source); - return new MarketplaceEntry({ - name: data.name, - source, - scope: data.scope, - lastRefreshAt: data.lastRefreshAt, - version: data.version, - }); - } - - serialize(): MarketplaceEntryData { - const data: MarketplaceEntryData = { - name: this.name, - source: serializePluginSource(this.source), - scope: this.scope, - }; - if (this.lastRefreshAt !== undefined) data.lastRefreshAt = this.lastRefreshAt; - if (this.version !== undefined) data.version = this.version; - return data; - } - - withVersion(version: string): MarketplaceEntry { - return new MarketplaceEntry({ - name: this.name, - source: this.source, - scope: this.scope, - lastRefreshAt: this.lastRefreshAt, - version, - }); - } - - equals(other: MarketplaceEntry): boolean { - return ( - this.name === other.name && - this.scope === other.scope && - this.lastRefreshAt === other.lastRefreshAt && - this.version === other.version && - JSON.stringify(serializePluginSource(this.source)) === - JSON.stringify(serializePluginSource(other.source)) - ); - } -} - -// Re-export error for convenience in aggregate -export { MarketplaceAlreadyRegisteredError }; diff --git a/cli/src/domain/models/marketplace.ts b/cli/src/domain/models/marketplace.ts deleted file mode 100644 index b68fe2105..000000000 --- a/cli/src/domain/models/marketplace.ts +++ /dev/null @@ -1,135 +0,0 @@ -import { InvalidMarketplaceNameError, InvalidMarketplaceScopeError } from "../errors.js"; -import { type PluginSource, parsePluginSource, serializePluginSource } from "./plugin-source.js"; - -export const MARKETPLACE_NAME_REGEX = /^[a-z0-9]+(-[a-z0-9]+)*$/; -export const FRAMEWORK_MARKETPLACE_NAME = "aidd-framework"; -export const STALE_MAX_DAYS_DEFAULT = 7; -const MS_PER_DAY = 24 * 60 * 60 * 1000; - -export type MarketplaceScope = "project" | "user"; - -export interface MarketplaceData { - name: string; - source: Record; - scope: MarketplaceScope; - addedAt: string; - lastFetched?: string; - version?: string; -} - -/** - * A marketplace record as it comes off disk: `scope` is whatever the file said, which is - * exactly why fromJSON checks it. Saying so lets a caller hand over an unchecked value - * without pretending it is already one of the two the domain accepts. - */ -export type StoredMarketplaceData = Omit & { scope: string }; - -function isMarketplaceScope(value: string): value is MarketplaceScope { - return value === "project" || value === "user"; -} - -export class Marketplace { - readonly name: string; - readonly source: PluginSource; - readonly scope: MarketplaceScope; - readonly addedAt: string; - readonly lastFetched?: string; - readonly version?: string; - - private constructor(params: { - name: string; - source: PluginSource; - scope: MarketplaceScope; - addedAt: string; - lastFetched?: string; - version?: string; - }) { - this.name = params.name; - this.source = params.source; - this.scope = params.scope; - this.addedAt = params.addedAt; - this.lastFetched = params.lastFetched; - this.version = params.version; - } - - static create(params: { - name: string; - source: PluginSource; - scope: MarketplaceScope; - addedAt: string; - }): Marketplace { - return Marketplace.fromJSON({ - name: params.name, - source: serializePluginSource(params.source), - scope: params.scope, - addedAt: params.addedAt, - }); - } - - static fromJSON(data: StoredMarketplaceData): Marketplace { - if (!MARKETPLACE_NAME_REGEX.test(data.name)) { - throw new InvalidMarketplaceNameError(data.name); - } - if (!isMarketplaceScope(data.scope)) { - throw new InvalidMarketplaceScopeError(String(data.scope)); - } - const source = parsePluginSource(data.source); - return new Marketplace({ - name: data.name, - source, - scope: data.scope, - addedAt: data.addedAt, - lastFetched: data.lastFetched, - version: data.version, - }); - } - - toJSON(): MarketplaceData { - const data: MarketplaceData = { - name: this.name, - source: serializePluginSource(this.source), - scope: this.scope, - addedAt: this.addedAt, - }; - if (this.lastFetched !== undefined) data.lastFetched = this.lastFetched; - if (this.version !== undefined) data.version = this.version; - return data; - } - - withLastFetched(when: string): Marketplace { - return new Marketplace({ - name: this.name, - source: this.source, - scope: this.scope, - addedAt: this.addedAt, - lastFetched: when, - version: this.version, - }); - } - - withVersion(version: string): Marketplace { - return new Marketplace({ - name: this.name, - source: this.source, - scope: this.scope, - addedAt: this.addedAt, - lastFetched: this.lastFetched, - version, - }); - } - - isFramework(): boolean { - return this.name === FRAMEWORK_MARKETPLACE_NAME; - } -} - -export function isMarketplaceStale( - marketplace: Marketplace, - now: number, - maxDays: number -): boolean { - if (!marketplace.lastFetched) return true; - const lastMs = Date.parse(marketplace.lastFetched); - if (Number.isNaN(lastMs)) return true; - return now - lastMs > maxDays * MS_PER_DAY; -} diff --git a/cli/src/domain/models/mcp-exclusion.ts b/cli/src/domain/models/mcp-exclusion.ts deleted file mode 100644 index dc1733819..000000000 --- a/cli/src/domain/models/mcp-exclusion.ts +++ /dev/null @@ -1,185 +0,0 @@ -import type { Hasher } from "../ports/hasher.js"; -import { InstallationFile } from "./file.js"; -import type { MergeFileEntry } from "./merge.js"; -import { parseEntryKeys } from "./merge.js"; - -// ── Win32 platform transform ───────────────────────────────────────────────── - -interface McpServerWin32 { - command?: string; - args?: string[]; - [key: string]: unknown; -} - -interface McpConfigWin32 { - mcpServers?: Record; - [key: string]: unknown; -} - -function transformMcpForWin32(content: string): string { - const config = JSON.parse(content) as McpConfigWin32; - if (!config.mcpServers) return JSON.stringify(config, null, 2); - for (const server of Object.values(config.mcpServers)) { - if (server.command === "npx") { - server.args = ["/c", "npx", ...(server.args ?? [])]; - server.command = "cmd"; - } else if (server.command === "uvx") { - server.command = "uvx.exe"; - } else if (server.command === "uv") { - server.command = "uv.exe"; - } - } - return JSON.stringify(config, null, 2); -} - -export function transformFor(platform: string): ((content: string) => string) | undefined { - return platform === "win32" ? transformMcpForWin32 : undefined; -} - -// ── McpExclusion VO ────────────────────────────────────────────────────────── - -export interface McpExclusion { - readonly configPath: string; - readonly entryKey: string; -} - -export function mcpExclusionEquals(a: McpExclusion, b: McpExclusion): boolean { - return a.configPath === b.configPath && a.entryKey === b.entryKey; -} - -// ── MCP server key extraction and filtering ────────────────────────────────── - -/** Returns a map of file relative path → available MCP server keys for each MCP-capable merge file. */ -export function extractMcpKeys( - generated: InstallationFile[], - getEntrySection: (frameworkPath: string) => string | null -): Map { - const result = new Map(); - forEachMcpFile(generated, getEntrySection, (file, sectionKey) => { - const keys = parseEntryKeys(file.content, sectionKey); - if (keys.length > 0) result.set(file.relativePath, keys); - }); - return result; -} - -/** Filters MCP entries from generated file content, removing entries listed in exclusions. */ -export function filterMcpExclusions( - generated: InstallationFile[], - getEntrySection: (frameworkPath: string) => string | null, - exclusions: readonly McpExclusion[], - hasher: Hasher -): InstallationFile[] { - if (exclusions.length === 0) return generated; - return generated.map((file) => { - if (file.mergeStrategy === "none") return file; - const sectionKey = resolveSectionKey(file, getEntrySection); - if (sectionKey === null) return file; - const fileExclusions = exclusions.filter((e) => e.configPath === file.relativePath); - if (fileExclusions.length === 0) return file; - return filterFileContent( - file, - sectionKey, - new Set(fileExclusions.map((e) => e.entryKey)), - hasher - ); - }); -} - -/** Returns exclusions for server keys present in generated files but absent from selectedKeys. */ -export function computeMcpExclusions( - generated: InstallationFile[], - getEntrySection: (frameworkPath: string) => string | null, - selectedKeys: Set -): McpExclusion[] { - const exclusions: McpExclusion[] = []; - forEachMcpFile(generated, getEntrySection, (file, sectionKey) => { - for (const key of parseEntryKeys(file.content, sectionKey)) { - if (!selectedKeys.has(key)) exclusions.push({ configPath: file.relativePath, entryKey: key }); - } - }); - return exclusions; -} - -/** Returns MCP entries present in generated files but not tracked in known entries and not already excluded. */ -export function detectNewMcpEntries( - generated: InstallationFile[], - getEntrySection: (frameworkPath: string) => string | null, - knownEntries: readonly MergeFileEntry[], - excluded: readonly McpExclusion[] -): McpExclusion[] { - const newEntries: McpExclusion[] = []; - forEachMcpFile(generated, getEntrySection, (file, sectionKey) => { - const known = findKnownEntries(knownEntries, file.relativePath, sectionKey); - const excludedKeys = excludedKeysFor(excluded, file.relativePath); - for (const key of parseEntryKeys(file.content, sectionKey)) { - if (known.has(key) || excludedKeys.has(key)) continue; - newEntries.push({ configPath: file.relativePath, entryKey: key }); - } - }); - return newEntries; -} - -function findKnownEntries( - knownEntries: readonly MergeFileEntry[], - relativePath: string, - sectionKey: string -): Set { - const match = knownEntries.find( - (e) => e.relativePath === relativePath && e.sectionKey === sectionKey - ); - return new Set(match ? Object.keys(match.entries) : []); -} - -function excludedKeysFor(excluded: readonly McpExclusion[], relativePath: string): Set { - return new Set(excluded.filter((e) => e.configPath === relativePath).map((e) => e.entryKey)); -} - -// ── Helpers ────────────────────────────────────────────────────────────────── - -function forEachMcpFile( - generated: InstallationFile[], - getEntrySection: (frameworkPath: string) => string | null, - callback: (file: InstallationFile, sectionKey: string) => void -): void { - for (const file of generated) { - if (file.mergeStrategy === "none") continue; - const sectionKey = resolveSectionKey(file, getEntrySection); - if (sectionKey !== null) callback(file, sectionKey); - } -} - -function resolveSectionKey( - file: InstallationFile, - getEntrySection: (frameworkPath: string) => string | null -): string | null { - if (!file.frameworkPath) return null; - return getEntrySection(file.frameworkPath); -} - -function filterFileContent( - file: InstallationFile, - sectionKey: string, - excludedKeys: Set, - hasher: Hasher -): InstallationFile { - try { - const parsed = JSON.parse(file.content) as Record; - const section = parsed[sectionKey] as Record | undefined; - if (!section || typeof section !== "object") return file; - const kept: Record = {}; - for (const [key, value] of Object.entries(section)) { - if (!excludedKeys.has(key)) kept[key] = value; - } - parsed[sectionKey] = kept; - const content = JSON.stringify(parsed, null, 2); - return new InstallationFile({ - relativePath: file.relativePath, - content, - hash: hasher.hash(content), - mergeStrategy: file.mergeStrategy, - frameworkPath: file.frameworkPath, - }); - } catch { - return file; - } -} diff --git a/cli/src/domain/models/merge.ts b/cli/src/domain/models/merge.ts deleted file mode 100644 index caefe50de..000000000 --- a/cli/src/domain/models/merge.ts +++ /dev/null @@ -1,130 +0,0 @@ -import { stripJsonComments } from "../formats/jsonc.js"; -import type { Hasher } from "../ports/hasher.js"; -import type { FileHash, InstallationFile } from "./file.js"; - -// ── MergeStrategy ──────────────────────────────────────────────────────────── - -export type PerKeyMergeStrategy = { - default: "framework-prime" | "user-prime"; - /** Keys where framework always wins, overriding the default strategy. */ - frameworkOverrideKeys: readonly string[]; -}; - -export type MergeStrategy = "none" | "framework-prime" | "user-prime" | PerKeyMergeStrategy; - -export function isPerKeyMergeStrategy(s: MergeStrategy): s is PerKeyMergeStrategy { - return typeof s === "object" && s !== null; -} - -// ── ConflictDecision ───────────────────────────────────────────────────────── - -export type ConflictDecision = "overwrite" | "skip" | "backup"; - -// ── MergeFileEntry ─────────────────────────────────────────────────────────── - -export interface MergeFileEntry { - readonly relativePath: string; - readonly sectionKey: string | null; - readonly entries: Readonly>; -} - -export function extractMergeEntries( - jsonContent: string, - sectionKey: string | null, - hasher: Hasher -): Record { - let parsed: Record; - try { - parsed = JSON.parse(stripJsonComments(jsonContent)) as Record; - } catch { - return {}; - } - const container = resolveContainer(parsed, sectionKey); - if (container === null || typeof container !== "object" || Array.isArray(container)) return {}; - return hashJsonEntries(container as Record, hasher); -} - -/** Hashes each top-level value of a JSON-serialisable object, one entry per key. */ -export function hashJsonEntries( - entries: Record, - hasher: Hasher -): Record { - const result: Record = {}; - for (const [key, value] of Object.entries(entries)) { - result[key] = hasher.hash(JSON.stringify(value)); - } - return result; -} - -function resolveContainer(parsed: Record, sectionKey: string | null): unknown { - if (sectionKey === null) return parsed; - return parsed[sectionKey] ?? null; -} - -export function parseEntryKeys(content: string, sectionKey: string): string[] { - try { - const parsed = JSON.parse(content) as Record; - const section = parsed[sectionKey]; - if (section === null || typeof section !== "object" || Array.isArray(section)) return []; - return Object.keys(section as Record); - } catch { - return []; - } -} - -export function removeEntriesFromJson( - content: string, - sectionKey: string | null, - keysToRemove: string[] -): string { - const parsed = JSON.parse(content) as Record; - if (sectionKey === null) { - for (const key of keysToRemove) delete parsed[key]; - return JSON.stringify(parsed, null, 2); - } - const container = (parsed[sectionKey] as Record | undefined) ?? {}; - for (const key of keysToRemove) delete container[key]; - // A section we emptied out must vanish, not linger as `{}` — a settings file that - // shares its top level with unrelated keys (Claude's settings.json, permissions and - // all) must come back byte-identical once every key we own is gone. - if (Object.keys(container).length === 0) { - delete parsed[sectionKey]; - } else { - parsed[sectionKey] = container; - } - return JSON.stringify(parsed, null, 2); -} - -export function isMergeContentEmpty(content: string, sectionKey: string | null): boolean { - try { - const parsed = JSON.parse(content) as Record; - if (sectionKey === null) return Object.keys(parsed).length === 0; - const otherKeys = Object.keys(parsed).filter((k) => k !== sectionKey); - if (otherKeys.length > 0) return false; - const section = parsed[sectionKey] as Record | undefined; - return !section || Object.keys(section).length === 0; - } catch { - return false; - } -} - -export function buildMergeFileEntries( - distribution: InstallationFile[], - getEntrySection: (frameworkPath: string) => string | null, - hasher: Hasher -): MergeFileEntry[] { - const grouped = new Map(); - for (const file of distribution) { - if (file.mergeStrategy === "none") continue; - const sectionKey = file.frameworkPath ? getEntrySection(file.frameworkPath) : null; - const hashes = extractMergeEntries(file.content, sectionKey, hasher); - const key = `${file.relativePath}::${sectionKey ?? ""}`; - const previous = grouped.get(key); - grouped.set(key, { - relativePath: file.relativePath, - sectionKey, - entries: { ...(previous?.entries ?? {}), ...hashes }, - }); - } - return [...grouped.values()]; -} diff --git a/cli/src/domain/models/normalized-plugin.ts b/cli/src/domain/models/normalized-plugin.ts deleted file mode 100644 index 9149be11d..000000000 --- a/cli/src/domain/models/normalized-plugin.ts +++ /dev/null @@ -1,27 +0,0 @@ -/** - * NormalizedPlugin — internal AST for foreign-marketplace catalog entries. - * - * A marketplace catalog lists plugin entries with metadata and a source pointer. - * It does NOT inline capability content (commands, rules, skills) — that is - * resolved later by the existing PluginDistributionReaderAdapter pipeline after - * the plugin source is fetched. - * - * This type is intentionally minimal (Phase A). Capability fields are deferred - * to Phase B/C when concrete content-level parsing from foreign formats is needed. - * - * NOT versioned — internal type only, no schema versioning. - */ - -export type ForeignMarketplaceSource = "cursor" | "copilot" | "codex" | "opencode"; - -export interface NormalizedPlugin { - readonly name: string; - readonly version?: string; - readonly description?: string; - readonly source: ForeignMarketplaceSource; -} - -export interface NormalizedCatalog { - readonly source: ForeignMarketplaceSource; - readonly plugins: readonly NormalizedPlugin[]; -} diff --git a/cli/src/domain/models/paths.ts b/cli/src/domain/models/paths.ts deleted file mode 100644 index 741e7a06e..000000000 --- a/cli/src/domain/models/paths.ts +++ /dev/null @@ -1,56 +0,0 @@ -import { join } from "node:path"; - -export const AIDD_DIR = ".aidd"; -export const AIDD_CONFIG_FILENAME = "config.json"; -/** The project-scope marketplace registry, named once: `MarketplaceRegistryAdapter` writes - * it and `CleanUseCase` removes it, and a second spelling is how one of them forgets. */ -export const AIDD_MARKETPLACES_FILENAME = "marketplaces.json"; -export const DOCS_DIR = "aidd_docs" as const; -export const RUNS_SUBDIR = "runs" as const; -export const PLUGIN_CACHE_SUBDIR = join(AIDD_DIR, "plugin-cache"); -export const MARKETPLACE_CACHE_SUBDIR = join(AIDD_DIR, "cache", "marketplaces"); -export const BUILT_CACHE_SUBDIR = join(AIDD_DIR, "cache", "built"); - -// The one spelling of "the run journal's directory, as a gitignore/pathspec entry" - -// `telemetry-on-use-case.ts`'s `protectRunsDir` and `forget-telemetry-use-case.ts`'s -// history check both ask `VersionControl.listTrackedFiles` about exactly this path; a -// second literal of the same string would be a second way of asking the same question. -export const RUNS_ENTRY = `${DOCS_DIR}/${RUNS_SUBDIR}/`; - -export function marketplaceCacheDir(projectRoot: string, marketplaceName: string): string { - return join(projectRoot, MARKETPLACE_CACHE_SUBDIR, marketplaceName); -} - -export function builtMarketplaceDir( - projectRoot: string, - marketplaceName: string, - target: string -): string { - return join(projectRoot, BUILT_CACHE_SUBDIR, marketplaceName, target); -} - -// One directory is the other, or contains it. Two callers guard on this - a build refusing -// to write into the tree it reads from, and the cache-rebuild path deciding whether it -// needs the temp-dir detour - and each spelled the comparison itself with a hardcoded "/", -// so on Windows neither ever recognised real nesting. Named once here so the -// question has an answer to point at rather than a habit to repeat. Both sides are -// expected already resolved: this compares spelling, it does not resolve. -export function pathContainsOrEquals(outer: string, inner: string): boolean { - const normalizedOuter = outer.replace(/\\/g, "/"); - const normalizedInner = inner.replace(/\\/g, "/"); - return normalizedOuter === normalizedInner || normalizedInner.startsWith(`${normalizedOuter}/`); -} - -/** Either direction: neither may sit inside the other. */ -export function pathsOverlap(a: string, b: string): boolean { - return pathContainsOrEquals(a, b) || pathContainsOrEquals(b, a); -} - -/** The manifest's filename, in the domain because two adapters name that file and a - * diagnostic prints both. `telemetry-evidence-adapter.ts` scans it for a declaration while - * `manifest-repository-adapter.ts` loads and validates it, and `aidd telemetry check` prints - * a row from each — so a person reads two sentences about one file. They agreed by two - * matching literals until this existed, which is agreement by coincidence: renaming the file - * at one site would have made the rows contradict each other again, which is the defect that - * pairing was introduced to close. */ -export const MANIFEST_FILENAME = "manifest.json"; diff --git a/cli/src/domain/models/person-resolution.ts b/cli/src/domain/models/person-resolution.ts deleted file mode 100644 index 5aa76fdcd..000000000 --- a/cli/src/domain/models/person-resolution.ts +++ /dev/null @@ -1,132 +0,0 @@ -import type { PersonIdentity } from "../ports/person-identity-reader.js"; - -/** - * The same three-way reading `stepAttribution` already gives an unknown: never a zero, and - * it says its own strength. - * - * - `"mapped"` — the identifier is this machine's own person: their `personId`, or one of - * the identifiers listed in `alsoMe`. - * - `"unresolved"` — the identifier is real, but nobody's identity covers it. - * - `"this-machine"` — the record carried no identifier of its own, and this machine has - * declared an identity. Not folded into `"mapped"`, which is the record naming a person - * this identity claims; this is the identity claiming a record that named nobody. - * - * It exists because `person_id` is stamped when a record is *stored* - * (`stampProvenanceAndTool`), so whether a record carries one depends on when the identity - * was declared relative to when that record was read - never on the work itself. Two reads - * of one sink, with one `identity.json`, answered differently depending on the order those - * two things happened in. Measured on a live machine: 29,207 requests, every one read by - * that machine's own `aidd`, and `by_person` could name none of them. - * - * Sound because the sink has exactly one writer - `read-local-cost-use-case.ts` is the - * only caller of `TelemetrySink.appendRecord`, and every line it writes carries - * `provenance: "local-read"`. A record in this sink was read by this machine's own reader, - * and this machine has said who that is. If a second writer is ever added, this branch is - * the one that stops being true. - * - `"none"` — there was no identifier to resolve **and** no identity declared, a different - * fact from one nobody claimed: it says nobody opted in, while an unresolved one says - * somebody did, on a machine or under a tool this identity has not heard of yet. - */ -export type PersonResolution = "mapped" | "unresolved" | "none" | "this-machine"; - -/** What resolving one raw identifier against an identity answers. `identities` always - * carries what produced the row — including the canonical `personId` when mapped, and the - * raw identifier itself when unresolved — so a caller can show a person line's own evidence - * without going back to the identity a second time. */ -export interface ResolvedPerson { - readonly resolution: PersonResolution; - readonly personId?: string; - readonly displayName?: string; - readonly identities: readonly string[]; -} - -function matches(identity: PersonIdentity, rawId: string): boolean { - return identity.personId === rawId || identity.alsoMe.includes(rawId); -} - -/** One identity, as the person a row names and the evidence behind it — shared by the two - * routes that end at this machine's own person so they cannot describe them differently. - * - * `alsoMe` can no longer contain `identity.personId` itself: `link` treats an identifier - * equal to the person's own as already listed and refuses to append it - * (`PersonIdentityUseCase.link`). No runtime check replaces that guard here — do not - * reintroduce a branch for a shape `link` no longer lets anyone write. */ -function claimedBy(identity: PersonIdentity): Omit { - return { - personId: identity.personId, - ...(identity.displayName === undefined ? {} : { displayName: identity.displayName }), - identities: [identity.personId, ...identity.alsoMe], - }; -} - -/** - * Resolves one raw identifier against this machine's own identity — `identity` is `null` - * for no identity declared at all, which resolves every identifier as `unresolved` exactly - * as an identity that simply does not cover it would, since a report must show every - * figure the same way whether the gap is "no identity" or "an identity that does not know - * this identifier". - * - * `rawId` undefined or empty answers `"this-machine"` when an identity is declared, and - * `"none"` with no identities when none is - nobody opted in is not a failure to resolve, - * and neither is ever conflated with an identifier this identity failed to place. The - * fallback is reached only when the record named nobody: an identifier it did carry is - * resolved on its own merits, mapped or unresolved, and this never overrules it. - * - * There is no shape here for two people claiming one identifier — `PersonIdentity` - * describes exactly one machine's own user, so that ambiguity cannot be constructed, let - * alone resolved. The type is the guard; no runtime check replaces it. - */ -export function resolvePerson( - identity: PersonIdentity | null, - rawId: string | undefined -): ResolvedPerson { - if (rawId === undefined || rawId === "") { - return identity === null - ? { resolution: "none", identities: [] } - : { ...claimedBy(identity), resolution: "this-machine" }; - } - if (identity === null || !matches(identity, rawId)) { - return { resolution: "unresolved", identities: [rawId] }; - } - return { ...claimedBy(identity), resolution: "mapped" }; -} - -/** `identity` with `value` added to `alsoMe`, deduplicated, and never the person's own - * `personId` — the one place this rule is written, so the real adapter and its in-memory - * test double share it rather than each reimplementing the same merge. - * - * The `personId` half is the invariant `resolvePerson` relies on rather than re-checking: - * a person's own identifier is not an identifier *added onto* them. Both writers go - * through here, so the shape cannot be written at all. */ -export function withAlsoMeAdded(identity: PersonIdentity, value: string): PersonIdentity { - return identity.alsoMe.includes(value) || value === identity.personId - ? identity - : { ...identity, alsoMe: [...identity.alsoMe, value] }; -} - -/** `current` re-anchored on `personId`, taken from another machine rather than generated - * here — keeping whatever was already declared, minus `personId` itself. - * - * That subtraction is the whole reason this exists instead of a literal in the adapter. - * Adding an identifier and then adopting it is an ordinary sequence (`link X`, later - * `use X`), and without it the person's own identifier lands in the list of identifiers - * added onto them: `status` prints it as added onto themselves, and `resolvePerson` names - * it twice as the evidence behind one row. Refused where it would be written, so nothing - * downstream has to filter it where it is read. */ -export function withPersonIdAdopted( - current: PersonIdentity | null, - personId: string -): PersonIdentity { - return { - personId, - origin: "adopted", - alsoMe: (current?.alsoMe ?? []).filter((raw) => raw !== personId), - ...(current?.displayName === undefined ? {} : { displayName: current.displayName }), - }; -} - -/** `identity` with `value` withdrawn from `alsoMe`, wherever it is — an identifier not - * listed leaves `alsoMe` unchanged rather than failing. */ -export function withAlsoMeRemoved(identity: PersonIdentity, value: string): PersonIdentity { - return { ...identity, alsoMe: identity.alsoMe.filter((raw) => raw !== value) }; -} diff --git a/cli/src/domain/models/plugin-catalog.ts b/cli/src/domain/models/plugin-catalog.ts deleted file mode 100644 index 6f02b920c..000000000 --- a/cli/src/domain/models/plugin-catalog.ts +++ /dev/null @@ -1,70 +0,0 @@ -import { isAbsolute } from "node:path"; -import { InvalidPluginManifestError } from "../errors.js"; -import { type PluginSource, parsePluginSource } from "./plugin-source.js"; - -export interface PluginCatalogEntry { - name: string; - source: PluginSource; - description?: string; - version?: string; - recommended: boolean; - strict: boolean; -} - -export interface PluginCatalog { - name?: string; - version?: string; - plugins: readonly PluginCatalogEntry[]; -} - -function parseEntry(raw: unknown, index: number): PluginCatalogEntry { - if (raw === null || typeof raw !== "object" || Array.isArray(raw)) { - throw new InvalidPluginManifestError(`plugins[${index}] must be an object`); - } - const obj = raw as Record; - - if (typeof obj.name !== "string" || obj.name.length === 0) { - throw new InvalidPluginManifestError(`plugins[${index}].name must be a non-empty string`); - } - - if (obj.source === undefined) { - throw new InvalidPluginManifestError(`plugins[${index}].source is required`); - } - - const source = parsePluginSource(obj.source); - - const entry: PluginCatalogEntry = { - name: obj.name, - source, - recommended: typeof obj.recommended === "boolean" ? obj.recommended : false, - strict: typeof obj.strict === "boolean" ? obj.strict : false, - }; - - if (typeof obj.description === "string") entry.description = obj.description; - if (typeof obj.version === "string") entry.version = obj.version; - - return entry; -} - -export function hasRelativePluginSources(catalog: PluginCatalog): boolean { - return catalog.plugins.some( - (entry) => entry.source.kind === "local" && !isAbsolute(entry.source.path) - ); -} - -export function parsePluginCatalog(raw: unknown): PluginCatalog { - if (raw === null || typeof raw !== "object" || Array.isArray(raw)) { - throw new InvalidPluginManifestError("marketplace.json must be a JSON object"); - } - const obj = raw as Record; - - if (!Array.isArray(obj.plugins)) { - throw new InvalidPluginManifestError('"plugins" must be an array'); - } - - const plugins = obj.plugins.map((entry, i) => parseEntry(entry, i)); - const catalog: PluginCatalog = { plugins }; - if (typeof obj.name === "string" && obj.name.length > 0) catalog.name = obj.name; - if (typeof obj.version === "string" && obj.version.length > 0) catalog.version = obj.version; - return catalog; -} diff --git a/cli/src/domain/models/plugin-component-kind.ts b/cli/src/domain/models/plugin-component-kind.ts deleted file mode 100644 index 9ca8df538..000000000 --- a/cli/src/domain/models/plugin-component-kind.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { InvalidPluginComponentKindError } from "../errors.js"; - -export type PluginComponentKind = "skills" | "agents" | "hooks" | "mcp" | "full"; - -const VALID_KINDS: readonly PluginComponentKind[] = ["skills", "agents", "hooks", "mcp", "full"]; - -export function parsePluginComponentKind(s: string): PluginComponentKind { - if ((VALID_KINDS as readonly string[]).includes(s)) { - return s as PluginComponentKind; - } - throw new InvalidPluginComponentKindError(s); -} diff --git a/cli/src/domain/models/plugin-content-translator.ts b/cli/src/domain/models/plugin-content-translator.ts deleted file mode 100644 index 850afc8e1..000000000 --- a/cli/src/domain/models/plugin-content-translator.ts +++ /dev/null @@ -1,438 +0,0 @@ -import { convertHooksFormat } from "../formats/cursor-hooks.js"; -import { flatHooksSharedDirPath } from "../formats/flat-paths.js"; -import { parseFrontmatter, serializeFrontmatter } from "../formats/markdown.js"; -import { rewritePluginRootToken } from "../formats/plugin-root-token-rewrite.js"; -import type { Hasher } from "../ports/hasher.js"; -import type { AiTool, HasAgents, HasCommands, HasPlugins, HasSkills } from "../tools/contracts.js"; -import { hasRules } from "../tools/contracts.js"; -import type { ToolConfig } from "../tools/registry.js"; -import { isAiTool } from "../tools/registry.js"; -import { InstallationFile } from "./file.js"; -import type { PluginComponentFile, PluginDistribution } from "./plugin-distribution.js"; -import type { PluginInstallNotice, ReadonlyNoticeList } from "./plugin-install-notice.js"; -import type { PluginTranslationSkip, ReadonlySkipList } from "./plugin-translation-skip.js"; - -const PLUGIN_MANIFEST_PATHS: readonly string[] = [ - ".claude-plugin/plugin.json", - ".cursor-plugin/plugin.json", - ".codex-plugin/plugin.json", - "plugin.json", -]; - -interface TranslatedFile { - relativePath: string; - content: string; - /** An artefact, not prose: copied byte for byte, with no frontmatter round-trip and no - * path rewriting. A skill's `scripts/` and a hook's `lib/` hold executable files, and - * rewriting a path inside one silently corrupts it — measured: Codex's and Copilot's - * rewrites change a bundled script by six and one bytes respectively, which is a file - * that no longer parses. Prose is translated; artefacts are carried. */ - verbatim?: true; -} - -interface MarkdownCap { - buildInstallPath: (fileName: string) => string | null; - convertFrontmatter: (fm: Record, fileName: string) => Record; - serialize: (fm: Record, body: string) => string; -} - -interface SkillCap { - convertFrontmatter: (fm: Record) => Record; - serialize: (fm: Record, body: string) => string; -} - -const PLUGIN_HOOKS_DIR = "hooks"; -const MARKDOWN_EXTENSION = ".md"; - -function parentDirOf(path: string): string { - return path.split("/").slice(0, -1).join("/"); -} - -// A hook script requires its siblings relative to itself, so the tree below hooks/ has to -// survive translation intact; flattening it breaks every such require. -function pathBelow(dir: string, path: string): string { - return path.startsWith(`${dir}/`) ? path.slice(dir.length + 1) : path; -} - -export class PluginContentTranslator { - constructor(private readonly hasher: Hasher) {} - - translate(dist: PluginDistribution, toolConfig: ToolConfig, docsDir: string): InstallationFile[] { - return this.translateWithComponentPaths(dist, toolConfig, docsDir).files; - } - - translateWithComponentPaths( - dist: PluginDistribution, - toolConfig: ToolConfig, - docsDir: string - ): { - files: InstallationFile[]; - componentPaths: ReadonlyMap; - skipped: ReadonlySkipList; - notices: ReadonlyNoticeList; - } { - const tool = asPluginTool(toolConfig); - if (tool === null) return { files: [], componentPaths: new Map(), skipped: [], notices: [] }; - const { mode } = tool.capabilities.plugins; - if (mode === "native") return this.translateNativeWithPaths(dist, tool, docsDir); - if (mode === "flat") { - const { files, skipped } = this.translateFlat(dist, tool, docsDir); - return { files, componentPaths: new Map(), skipped, notices: [] }; - } - return { files: [], componentPaths: new Map(), skipped: [], notices: [] }; - } - - detectFlatCollisions( - dists: PluginDistribution[], - toolConfig: ToolConfig - ): Array<{ plugin: string; path: string }> { - const tool = asPluginTool(toolConfig); - if (tool === null) return []; - if (tool.capabilities.plugins.mode !== "flat") return []; - const seen = new Map(); - const collisions: Array<{ plugin: string; path: string }> = []; - for (const dist of dists) { - for (const file of this.translate(dist, toolConfig, "")) { - if (seen.has(file.relativePath)) { - collisions.push({ plugin: dist.manifest.name, path: file.relativePath }); - } else { - seen.set(file.relativePath, dist.manifest.name); - } - } - } - return collisions; - } - - private translateNativeWithPaths( - dist: PluginDistribution, - tool: AiTool, - docsDir: string - ): { - files: InstallationFile[]; - componentPaths: ReadonlyMap; - skipped: ReadonlySkipList; - notices: ReadonlyNoticeList; - } { - const { pluginsDir } = tool.capabilities.plugins; - if (pluginsDir === null) { - return { files: [], componentPaths: new Map(), skipped: [], notices: [] }; - } - const pluginRoot = `${pluginsDir}${dist.manifest.name}/`; - const { files, componentPaths } = this.buildNativeFiles(dist, tool, docsDir, pluginRoot); - const notices = this.collectHooksTrustNotices(dist, tool); - return { files, componentPaths, skipped: [], notices }; - } - - private buildNativeFiles( - dist: PluginDistribution, - tool: AiTool, - docsDir: string, - pluginRoot: string - ): { files: InstallationFile[]; componentPaths: ReadonlyMap } { - const result: InstallationFile[] = []; - const componentPaths = new Map(); - for (const file of dist.files) { - const translated = this.translateFile(file, tool); - if (translated === null) continue; - const hooked = this.maybeConvertHooks(file.relativePath, translated.content, tool); - const content = translated.verbatim ? hooked : this.rewriteProse(hooked, tool, docsDir); - const installedPath = `${pluginRoot}${translated.relativePath}`; - result.push(this.makeFile(installedPath, content)); - if (isComponentFile(file.relativePath)) componentPaths.set(installedPath, file.relativePath); - } - this.appendManifestFile(dist, tool, pluginRoot, result); - return { files: result, componentPaths }; - } - - private appendManifestFile( - dist: PluginDistribution, - tool: AiTool, - pluginRoot: string, - result: InstallationFile[] - ): void { - const { pluginManifestRelativePath } = tool.capabilities.plugins; - if (pluginManifestRelativePath === null) return; - const sourceManifest = findSourceManifestContent(dist); - if (sourceManifest === null) return; - result.push(this.makeFile(`${pluginRoot}${pluginManifestRelativePath}`, sourceManifest)); - } - - // A delivered hook is not a skip: `hooksTrustNotice` names what a person still has to do - // before it runs, and only applies when this plugin actually ships one. - private collectHooksTrustNotices( - dist: PluginDistribution, - tool: AiTool - ): ReadonlyNoticeList { - if (dist.components.hooks.length === 0) return []; - const { hooksTrustNotice } = tool.capabilities.plugins; - if (hooksTrustNotice === null) return []; - const entry: PluginInstallNotice = { - pluginName: dist.manifest.name, - component: "hooks", - toolId: tool.toolId, - message: hooksTrustNotice, - }; - return [entry]; - } - - /** A plugin is authored with one spelling of the plugin root and the installer translates - * it, exactly as prose is translated. A script carried verbatim keeps its own bytes. */ - private rewriteProse(content: string, tool: AiTool, docsDir: string): string { - const rewritten = tool.rewriteContent(content, docsDir); - const { pluginRootToken } = tool.capabilities.plugins; - if (pluginRootToken === null) return rewritten; - return rewritePluginRootToken(rewritten, pluginRootToken); - } - - private maybeConvertHooks(sourcePath: string, content: string, tool: AiTool): string { - if (sourcePath !== "hooks/hooks.json") return content; - return convertHooksFormat(content, tool.capabilities.plugins.hooksContentFormat); - } - - private translateFile( - file: PluginComponentFile, - tool: AiTool - ): TranslatedFile | null { - if (PLUGIN_MANIFEST_PATHS.includes(file.relativePath)) return null; - const cap = tool.capabilities.plugins; - if (file.relativePath === ".mcp.json") { - return cap.acceptsMcp ? { relativePath: cap.mcpRelativePath, content: file.content } : null; - } - if (file.relativePath.split("/")[0] === PLUGIN_HOOKS_DIR) { - if (!cap.acceptsHooks) return null; - if (file.relativePath === `${PLUGIN_HOOKS_DIR}/hooks.json`) { - return { relativePath: cap.hooksRelativePath, content: file.content }; - } - // Everything under `hooks/` but its own manifest is a script the host runs. It goes - // beside the manifest, and where the manifest sits at the plugin root it keeps its - // own directory — a script at the root would leave the command naming `hooks/` - // pointing at nothing. - const manifestDir = parentDirOf(cap.hooksRelativePath) || PLUGIN_HOOKS_DIR; - return { - relativePath: `${manifestDir}/${pathBelow(PLUGIN_HOOKS_DIR, file.relativePath)}`, - content: file.content, - verbatim: true, - }; - } - return this.translateComponent(file, tool); - } - - private translateComponent( - file: PluginComponentFile, - tool: AiTool - ): TranslatedFile | null { - const top = file.relativePath.split("/")[0]; - if (top === "commands" && hasCommands(tool)) { - return translateMarkdown(file, "commands/", tool.directory, tool.capabilities.commands); - } - if (top === "agents" && hasAgents(tool)) { - return translateMarkdown(file, "agents/", tool.directory, tool.capabilities.agents); - } - if (top === "rules" && hasRules(tool)) { - return translateMarkdown(file, "rules/", tool.directory, tool.capabilities.rules); - } - if (top === "skills" && hasSkills(tool)) { - return translateSkill(file, tool.capabilities.skills); - } - return null; - } - - private translateFlat( - dist: PluginDistribution, - tool: AiTool, - docsDir: string - ): { files: InstallationFile[]; skipped: ReadonlySkipList } { - const { flatNamespacePrefix } = tool.capabilities.plugins; - if (flatNamespacePrefix === null) return { files: [], skipped: [] }; - const result: InstallationFile[] = []; - for (const file of dist.components.commands) { - result.push( - this.flatCommandFile(file, dist.manifest.name, tool, flatNamespacePrefix, docsDir) - ); - } - for (const section of ["agents", "rules", "skills"] as const) { - for (const file of dist.components[section]) { - const f = this.flatSectionFile(file, section, dist.manifest.name, tool, docsDir); - if (f !== null) result.push(f); - } - } - result.push(...this.flatHooksFiles(dist, tool)); - const skipped = this.collectHooksSkips(dist, tool); - return { files: result, skipped }; - } - - // A flat-mode hook is a runtime module a loader scans for, not a manifest a merge - // reads — hooks/hooks.json describes the wrong shape for that and is never delivered; - // everything else under hooks/ (the module itself and whatever it requires beside it) - // is carried verbatim into flatHooksDir, exactly as native mode carries a hook script. - private flatHooksFiles(dist: PluginDistribution, tool: AiTool): InstallationFile[] { - const { flatHooksDir } = tool.capabilities.plugins; - if (flatHooksDir === null) return []; - return dist.components.hooks - .filter((file) => file.relativePath !== `${PLUGIN_HOOKS_DIR}/hooks.json`) - .map((file) => - this.makeFile(flatHooksSharedDirPath(flatHooksDir, file.relativePath), file.content) - ); - } - - private collectHooksSkips(dist: PluginDistribution, tool: AiTool): ReadonlySkipList { - if (dist.components.hooks.length === 0) return []; - const { acceptsHooks, hooksUnsupportedReason } = tool.capabilities.plugins; - if (acceptsHooks || hooksUnsupportedReason === null) return []; - const entry: PluginTranslationSkip = { - pluginName: dist.manifest.name, - component: "hooks", - toolId: tool.toolId, - reason: hooksUnsupportedReason, - }; - return [entry]; - } - - private flatCommandFile( - file: PluginComponentFile, - pluginName: string, - tool: AiTool, - prefix: string, - docsDir: string - ): InstallationFile { - const filename = basename(file.relativePath); - const raw = prefixCommandName(file.content, file.relativePath, prefix, pluginName); - const content = tool.rewriteContent(raw, docsDir); - return this.makeFile(`${tool.directory}commands/${pluginName}/${filename}`, content); - } - - private flatSectionFile( - file: PluginComponentFile, - section: "agents" | "rules" | "skills", - pluginName: string, - tool: AiTool, - docsDir: string - ): InstallationFile | null { - if (!sectionPresent(tool, section)) return null; - const sectionDir = `${section}/`; - const fileName = file.relativePath.slice(sectionDir.length); - // Same rule as the native path: prose is rewritten, an artefact is carried. A flat - // install rewrote every file it carried, so a script survived here only where a tool's - // own rewrite happened to leave it alone — which is luck, not a guarantee. - const content = isProse(file.relativePath) - ? tool.rewriteContent(file.content, docsDir) - : file.content; - return this.makeFile(`${tool.directory}${section}/${pluginName}/${fileName}`, content); - } - - private makeFile(relativePath: string, content: string): InstallationFile { - return new InstallationFile({ - relativePath, - content, - hash: this.hasher.hash(content), - }); - } -} - -function asPluginTool(config: ToolConfig): AiTool | null { - if (!isAiTool(config)) return null; - if (!hasPlugins(config)) return null; - return config; -} - -function hasPlugins(tool: AiTool): tool is AiTool { - return "plugins" in (tool.capabilities as object); -} - -function hasCommands(tool: AiTool): tool is AiTool { - return "commands" in (tool.capabilities as object); -} - -function hasAgents(tool: AiTool): tool is AiTool { - return "agents" in (tool.capabilities as object); -} - -function hasSkills(tool: AiTool): tool is AiTool { - return "skills" in (tool.capabilities as object); -} - -function sectionPresent(tool: AiTool, section: "agents" | "rules" | "skills"): boolean { - return section in (tool.capabilities as object); -} - -/** Prose is translated; anything else a plugin ships is an artefact, carried byte for - * byte. The extension is the whole test: a plugin's components are markdown by definition, - * and everything beside them — a script, a template, a fixture — is not. */ -function isProse(relativePath: string): boolean { - return relativePath.endsWith(MARKDOWN_EXTENSION); -} - -function isComponentFile(relativePath: string): boolean { - const top = relativePath.split("/")[0]; - return top === "agents" || top === "commands" || top === "rules" || top === "skills"; -} - -function findSourceManifestContent(dist: PluginDistribution): string | null { - for (const path of PLUGIN_MANIFEST_PATHS) { - const file = dist.files.find((f) => f.relativePath === path); - if (file !== undefined) return file.content; - } - return null; -} - -function basename(relativePath: string): string { - return relativePath.split("/").at(-1) ?? relativePath; -} - -function prefixCommandName( - content: string, - relativePath: string, - prefix: string, - pluginName: string -): string { - const { frontmatter, body } = parseFrontmatter(content); - const rawName = typeof frontmatter.name === "string" ? frontmatter.name : ""; - const simpleName = stripCommandPrefix(rawName) || basename(relativePath); - const newFrontmatter = { ...frontmatter, name: `${prefix}${pluginName}:${simpleName}` }; - return serializeFrontmatter(newFrontmatter, body); -} - -function stripCommandPrefix(name: string): string { - const match = /^aidd:\d+:(.+)$/.exec(name); - if (match) return match[1]; - const colonIdx = name.lastIndexOf(":"); - if (colonIdx !== -1) return name.slice(colonIdx + 1); - return name; -} - -function toPluginRelativePath(fullPath: string, toolDirectory: string): string { - const relative = fullPath.startsWith(toolDirectory) - ? fullPath.slice(toolDirectory.length) - : fullPath; - return relative.replace(/^([^/]+)\/aidd\//, "$1/"); -} - -function translateMarkdown( - file: PluginComponentFile, - sectionDir: string, - toolDirectory: string, - cap: MarkdownCap -): TranslatedFile | null { - const fileName = file.relativePath.slice(sectionDir.length); - const fullPath = cap.buildInstallPath(fileName); - if (fullPath === null) return null; - const relativePath = toPluginRelativePath(fullPath, toolDirectory); - const { frontmatter, body } = parseFrontmatter(file.content); - const newFm = cap.convertFrontmatter(frontmatter, fileName); - const content = cap.serialize(newFm, body); - return { relativePath, content }; -} - -/** A skill is prose with frontmatter; anything else under `skills/` is an asset the skill - * carries — a script it runs, a template it copies. Translating an asset would put it - * through a frontmatter round-trip and a path rewrite, neither of which is meaningful for - * a file that is not prose and both of which can damage it. */ -function translateSkill(file: PluginComponentFile, cap: SkillCap): TranslatedFile { - if (!isProse(file.relativePath)) { - return { relativePath: file.relativePath, content: file.content, verbatim: true }; - } - const { frontmatter, body } = parseFrontmatter(file.content); - const newFm = cap.convertFrontmatter(frontmatter); - const content = cap.serialize(newFm, body); - return { relativePath: file.relativePath, content }; -} diff --git a/cli/src/domain/models/plugin-format.ts b/cli/src/domain/models/plugin-format.ts deleted file mode 100644 index 77d8a569f..000000000 --- a/cli/src/domain/models/plugin-format.ts +++ /dev/null @@ -1,18 +0,0 @@ -export type PluginFormat = "claude" | "cursor" | "codex" | "copilot" | "opencode"; - -export const PLUGIN_MANIFEST_PROBES: readonly { format: PluginFormat; relativePath: string }[] = [ - { format: "claude", relativePath: ".claude-plugin/plugin.json" }, - { format: "cursor", relativePath: ".cursor-plugin/plugin.json" }, - { format: "codex", relativePath: ".codex-plugin/plugin.json" }, - { format: "copilot", relativePath: ".plugin/plugin.json" }, - { format: "copilot", relativePath: ".github/plugin/plugin.json" }, - { format: "copilot", relativePath: "plugin.json" }, -]; - -export const MARKETPLACE_PROBES: readonly { format: PluginFormat; relativePath: string }[] = [ - { format: "claude", relativePath: ".claude-plugin/marketplace.json" }, - { format: "cursor", relativePath: ".cursor-plugin/marketplace.json" }, - { format: "codex", relativePath: ".agents/plugins/marketplace.json" }, - { format: "copilot", relativePath: ".github/plugin/plugin.json" }, - { format: "opencode", relativePath: "opencode.json" }, -]; diff --git a/cli/src/domain/models/plugin-install-notice.ts b/cli/src/domain/models/plugin-install-notice.ts deleted file mode 100644 index 99bcc8fe5..000000000 --- a/cli/src/domain/models/plugin-install-notice.ts +++ /dev/null @@ -1,15 +0,0 @@ -import type { AiToolId } from "./tool-ids.js"; - -/** - * A component was delivered, not skipped, but only runs once a precondition outside the - * install is met — distinct from {@link import("./plugin-translation-skip.js").PluginTranslationSkip}, - * which names a component that was never delivered at all. - */ -export interface PluginInstallNotice { - readonly pluginName: string; - readonly component: "hooks"; - readonly toolId: AiToolId; - readonly message: string; -} - -export type ReadonlyNoticeList = readonly PluginInstallNotice[]; diff --git a/cli/src/domain/models/plugin-scaffold.ts b/cli/src/domain/models/plugin-scaffold.ts deleted file mode 100644 index ee7eed6b8..000000000 --- a/cli/src/domain/models/plugin-scaffold.ts +++ /dev/null @@ -1,86 +0,0 @@ -import type { PluginComponentKind } from "./plugin-component-kind.js"; - -export const GITKEEP_CONTENT = ""; - -export function manifestJsonContent(name: string, version: string, description: string): string { - const manifest = { - $schema: "https://json.schemastore.org/claude-code-plugin-manifest.json", - name, - version, - description, - }; - return `${JSON.stringify(manifest, null, 2)}\n`; -} - -export function readmeContent(name: string, description: string): string { - return `# ${name}\n\n${description}\n`; -} - -export function changelogContent(): string { - return `# Changelog\n\n## [0.1.0]\n\n- Initial scaffold.\n`; -} - -export function skillContent(skillName: string): string { - return `---\nname: ${skillName}\ndescription: TODO\n---\n\n# ${skillName}\n\n## Goal\n\nTODO\n`; -} - -export function agentContent(agentName: string): string { - return `---\nname: ${agentName}\ndescription: TODO\n---\n\n# ${agentName}\n\n## Goal\n\nTODO\n`; -} - -export function hooksJsonContent(): string { - return `${JSON.stringify({ hooks: {} }, null, 2)}\n`; -} - -export function mcpJsonContent(): string { - return `${JSON.stringify({ mcpServers: {} }, null, 2)}\n`; -} - -export function scenariosJsonContent(): string { - const content = { scenarios: [] as unknown[] }; - return `${JSON.stringify(content, null, 2)}\n`; -} - -export interface ScaffoldInput { - name: string; - kind: PluginComponentKind; - version: string; - description: string; -} - -export function buildScaffold(input: ScaffoldInput): ReadonlyMap { - const { name, kind, version, description } = input; - const files = new Map(); - - files.set(".claude-plugin/plugin.json", manifestJsonContent(name, version, description)); - files.set("README.md", readmeContent(name, description)); - files.set("CHANGELOG.md", changelogContent()); - - if (kind === "skills" || kind === "full") addSkillsFiles(files); - if (kind === "agents" || kind === "full") addAgentsFiles(files); - if (kind === "hooks" || kind === "full") addHooksFiles(files); - if (kind === "mcp" || kind === "full") addMcpFiles(files); - - return files; -} - -function addSkillsFiles(files: Map): void { - files.set("skills/00-example/SKILL.md", skillContent("00-example")); - files.set("skills/00-example/actions/.gitkeep", GITKEEP_CONTENT); - files.set("skills/00-example/references/.gitkeep", GITKEEP_CONTENT); - files.set("skills/00-example/evals/scenarios.json", scenariosJsonContent()); - files.set("skills/00-example/assets/.gitkeep", GITKEEP_CONTENT); -} - -function addAgentsFiles(files: Map): void { - files.set("agents/example.md", agentContent("example")); -} - -function addHooksFiles(files: Map): void { - files.set("hooks/hooks.json", hooksJsonContent()); - files.set("hooks/routing/.gitkeep", GITKEEP_CONTENT); -} - -function addMcpFiles(files: Map): void { - files.set(".mcp.json", mcpJsonContent()); -} diff --git a/cli/src/domain/models/plugin-source.ts b/cli/src/domain/models/plugin-source.ts deleted file mode 100644 index 774b9798d..000000000 --- a/cli/src/domain/models/plugin-source.ts +++ /dev/null @@ -1,257 +0,0 @@ -import { isAbsolute } from "node:path"; -import { InvalidPluginSourceError } from "../errors.js"; - -export const GITHUB_REPO_REGEX = /^[a-zA-Z0-9_.-]+\/[a-zA-Z0-9_.-]+$/; - -// npm package name grammar: unscoped my-pkg or scoped @scope/name. -// Leading `-` or `.` is forbidden (would be parsed as a pnpm flag or relative path). -export const NPM_PACKAGE_NAME_REGEX = /^(?:@[a-z0-9][a-z0-9-._]*\/)?[a-z0-9][a-z0-9-._]*$/; -export const SHA_REGEX = /^[a-f0-9]{40}$/; - -export interface PluginSourceGitHub { - kind: "github"; - repo: string; - ref?: string; - sha?: string; -} - -export interface PluginSourceUrl { - kind: "url"; - url: string; - ref?: string; - sha?: string; -} - -export interface PluginSourceGitSubdir { - kind: "git-subdir"; - url: string; - path: string; - ref?: string; - sha?: string; -} - -export interface PluginSourceNpm { - kind: "npm"; - package: string; - version?: string; - registry?: string; -} - -export interface PluginSourceLocal { - kind: "local"; - path: string; -} - -export type PluginSource = - | PluginSourceGitHub - | PluginSourceUrl - | PluginSourceGitSubdir - | PluginSourceNpm - | PluginSourceLocal; - -function assertString(value: unknown, field: string): string { - if (typeof value !== "string" || value.length === 0) { - throw new InvalidPluginSourceError(`"${field}" must be a non-empty string.`); - } - return value; -} - -function optionalString(raw: Record, field: string): string | undefined { - const value = raw[field]; - if (value === undefined) return undefined; - if (typeof value !== "string") { - throw new InvalidPluginSourceError(`"${field}" must be a string.`); - } - return value; -} - -function optionalSha(raw: Record): string | undefined { - const value = optionalString(raw, "sha"); - if (value !== undefined && !SHA_REGEX.test(value)) { - throw new InvalidPluginSourceError(`"sha" must be a 40-character lowercase hex string.`); - } - return value; -} - -function parseGitHub(raw: Record): PluginSourceGitHub { - const repo = assertString(raw.repo, "repo"); - if (!GITHUB_REPO_REGEX.test(repo)) { - throw new InvalidPluginSourceError(`"repo" must match owner/repo format.`); - } - return { - kind: "github", - repo, - ref: optionalString(raw, "ref"), - sha: optionalSha(raw), - }; -} - -function parseUrl(raw: Record): PluginSourceUrl { - return { - kind: "url", - url: assertString(raw.url, "url"), - ref: optionalString(raw, "ref"), - sha: optionalSha(raw), - }; -} - -function parseGitSubdir(raw: Record): PluginSourceGitSubdir { - return { - kind: "git-subdir", - url: assertString(raw.url, "url"), - path: assertString(raw.path, "path"), - ref: optionalString(raw, "ref"), - sha: optionalSha(raw), - }; -} - -function assertNpmPackageName(raw: Record): string { - const pkg = assertString(raw.package, "package"); - if (!NPM_PACKAGE_NAME_REGEX.test(pkg)) { - throw new InvalidPluginSourceError( - `"package" must be a valid npm package name (e.g. my-plugin or @scope/my-plugin). Got: "${pkg}"` - ); - } - return pkg; -} - -function parseNpm(raw: Record): PluginSourceNpm { - return { - kind: "npm", - package: assertNpmPackageName(raw), - version: optionalString(raw, "version"), - registry: optionalString(raw, "registry"), - }; -} - -function parseLocal(raw: Record): PluginSourceLocal { - return { - kind: "local", - path: assertString(raw.path, "path"), - }; -} - -export function parsePluginSource(raw: unknown): PluginSource { - if (typeof raw === "string") return parseStringPluginSource(raw); - if (raw === null || typeof raw !== "object" || Array.isArray(raw)) { - throw new InvalidPluginSourceError("expected an object."); - } - return parseObjectPluginSource(raw as Record); -} - -function parseStringPluginSource(raw: string): PluginSource { - // `isAbsolute` also catches a Windows-rooted path (`C:\...`, `\\server\share`), which - // starts with neither `/` nor `./`. - if (raw.startsWith("./") || isAbsolute(raw)) return { kind: "local", path: raw }; - if (GITHUB_REPO_REGEX.test(raw)) return { kind: "github", repo: raw }; - throw new InvalidPluginSourceError(`string source "${raw}" is not a recognized path or repo.`); -} - -function parseObjectPluginSource(obj: Record): PluginSource { - const kind = obj.kind; - switch (kind) { - case "github": - return parseGitHub(obj); - case "url": - return parseUrl(obj); - case "git-subdir": - return parseGitSubdir(obj); - case "npm": - return parseNpm(obj); - case "local": - return parseLocal(obj); - default: - throw new InvalidPluginSourceError( - `unknown kind "${String(kind)}". Expected: github, url, git-subdir, npm, local.` - ); - } -} - -const GITLAB_PREFIX = "gitlab:"; - -export function parsePluginSourceShorthand(raw: string): PluginSource { - if (raw.startsWith("https://") || raw.startsWith("http://")) return { kind: "url", url: raw }; - if (raw.startsWith("git@")) return { kind: "url", url: raw }; - // `isAbsolute` also catches a Windows-rooted path (`C:\...`, `\\server\share`), which - // starts with neither `/` nor `./`. - if (raw.startsWith("./") || isAbsolute(raw)) return { kind: "local", path: raw }; - if (raw.startsWith(GITLAB_PREFIX)) return parseGitLabShorthand(raw.slice(GITLAB_PREFIX.length)); - if (GITHUB_REPO_REGEX.test(raw)) return { kind: "github", repo: raw }; - const versioned = parseGitHubVersionedShorthand(raw); - if (versioned !== null) return versioned; - try { - return parsePluginSource(JSON.parse(raw)); - } catch (err) { - if (err instanceof InvalidPluginSourceError) throw err; - throw new InvalidPluginSourceError(`unrecognized source format: "${raw}"`); - } -} - -function parseGitHubVersionedShorthand(raw: string): PluginSourceGitHub | null { - const atIndex = raw.lastIndexOf("@"); - if (atIndex <= 0) return null; - const repo = raw.slice(0, atIndex); - const ref = raw.slice(atIndex + 1); - if (!GITHUB_REPO_REGEX.test(repo)) return null; - return { kind: "github", repo, ref }; -} - -function parseGitLabShorthand(raw: string): PluginSourceUrl { - const atIndex = raw.lastIndexOf("@"); - const repo = atIndex > 0 ? raw.slice(0, atIndex) : raw; - const ref = atIndex > 0 ? raw.slice(atIndex + 1) : undefined; - if (!GITHUB_REPO_REGEX.test(repo)) { - throw new InvalidPluginSourceError( - `"gitlab:${raw}" must match gitlab:owner/repo or gitlab:owner/repo@ref` - ); - } - const result: PluginSourceUrl = { kind: "url", url: `https://gitlab.com/${repo}.git` }; - if (ref !== undefined) result.ref = ref; - return result; -} - -export function describePluginSource(src: PluginSource): string { - switch (src.kind) { - case "github": - return `https://github.com/${src.repo}${src.ref ? `@${src.ref}` : ""}`; - case "url": - return src.url; - case "git-subdir": - return `${src.url}#${src.path}`; - case "npm": - return `npm:${src.package}${src.version ? `@${src.version}` : ""}`; - case "local": - return src.path; - } -} - -export function serializePluginSource(src: PluginSource): Record { - const result: Record = { kind: src.kind }; - switch (src.kind) { - case "github": - result.repo = src.repo; - if (src.ref !== undefined) result.ref = src.ref; - if (src.sha !== undefined) result.sha = src.sha; - break; - case "url": - result.url = src.url; - if (src.ref !== undefined) result.ref = src.ref; - if (src.sha !== undefined) result.sha = src.sha; - break; - case "git-subdir": - result.url = src.url; - result.path = src.path; - if (src.ref !== undefined) result.ref = src.ref; - if (src.sha !== undefined) result.sha = src.sha; - break; - case "npm": - result.package = src.package; - if (src.version !== undefined) result.version = src.version; - if (src.registry !== undefined) result.registry = src.registry; - break; - case "local": - result.path = src.path; - break; - } - return result; -} diff --git a/cli/src/domain/models/plugin-translation-mode.ts b/cli/src/domain/models/plugin-translation-mode.ts deleted file mode 100644 index d4cab29d4..000000000 --- a/cli/src/domain/models/plugin-translation-mode.ts +++ /dev/null @@ -1,6 +0,0 @@ -/** - * Discriminant for the two plugin translation strategies. - * - "marketplace": Mode A — register plugin reference in tool's native config (no file materialization). - * - "flat": Mode B — materialize plugin content as files on disk. - */ -export type PluginTranslationMode = "marketplace" | "flat"; diff --git a/cli/src/domain/models/plugin.ts b/cli/src/domain/models/plugin.ts deleted file mode 100644 index b87e114be..000000000 --- a/cli/src/domain/models/plugin.ts +++ /dev/null @@ -1,202 +0,0 @@ -import { InvalidPluginNameError, InvalidPluginVersionError } from "../errors.js"; -import type { InstallationFile } from "./file.js"; -import type { PluginDistribution } from "./plugin-distribution.js"; -import { type PluginSource, parsePluginSource, serializePluginSource } from "./plugin-source.js"; -import { isSemver } from "./semver.js"; - -export const PLUGIN_NAME_REGEX = /^[a-z0-9]+(-[a-z0-9]+)*$/; - -export function parsePluginSpec(arg: string): { name: string; version?: string } { - const at = arg.lastIndexOf("@"); - if (at <= 0) return { name: arg }; - return { name: arg.slice(0, at), version: arg.slice(at + 1) }; -} - -export interface PluginEntryData { - name: string; - source: Record; - version: string; - strict: boolean; - files: Record; - componentPaths?: Record; - mcpEntries?: Record; - marketplace?: string; -} - -export class Plugin { - readonly name: string; - readonly source: PluginSource; - readonly version: string; - readonly strict: boolean; - readonly files: ReadonlyMap; - /** Maps installedRelPath → plugin component path (e.g. rules/01-standards/naming.md) */ - readonly componentPaths: ReadonlyMap; - /** Maps MCP server name → MD5 hash of the contributed server JSON (OpenCode merge tracking). */ - readonly mcpEntries: ReadonlyMap; - readonly marketplace?: string; - - private constructor(params: { - name: string; - source: PluginSource; - version: string; - strict: boolean; - files: ReadonlyMap; - componentPaths: ReadonlyMap; - mcpEntries: ReadonlyMap; - marketplace?: string; - }) { - this.name = params.name; - this.source = params.source; - this.version = params.version; - this.strict = params.strict; - this.files = params.files; - this.componentPaths = params.componentPaths; - this.mcpEntries = params.mcpEntries; - this.marketplace = params.marketplace; - } - - static fromMetadata( - name: string, - version: string, - source: PluginSource, - strict: boolean, - marketplace?: string - ): Plugin { - const data: PluginEntryData = { - name, - source: serializePluginSource(source), - version, - strict, - files: {}, - }; - if (marketplace !== undefined) data.marketplace = marketplace; - return Plugin.fromJSON(data); - } - - static withMcpEntries(plugin: Plugin, mcpEntries: ReadonlyMap): Plugin { - return new Plugin({ - name: plugin.name, - source: plugin.source, - version: plugin.version, - strict: plugin.strict, - files: plugin.files, - componentPaths: plugin.componentPaths, - mcpEntries, - marketplace: plugin.marketplace, - }); - } - - static fromDistribution( - dist: PluginDistribution, - source: PluginSource, - files: InstallationFile[], - componentPaths?: ReadonlyMap, - marketplace?: string - ): Plugin { - const filesRecord: Record = {}; - for (const f of files) { - filesRecord[f.relativePath] = f.hash.value; - } - const componentPathsRecord: Record = {}; - if (componentPaths) { - for (const [k, v] of componentPaths) componentPathsRecord[k] = v; - } - const data: PluginEntryData = { - name: dist.manifest.name, - source: serializePluginSource(source), - version: dist.manifest.version, - strict: dist.manifest.strict ?? false, - files: filesRecord, - componentPaths: componentPathsRecord, - }; - if (marketplace !== undefined) data.marketplace = marketplace; - return Plugin.fromJSON(data); - } - - static fromDistributionWithMcp( - dist: PluginDistribution, - source: PluginSource, - files: InstallationFile[], - mcpEntries: ReadonlyMap, - componentPaths?: ReadonlyMap, - marketplace?: string - ): Plugin { - const base = Plugin.fromDistribution(dist, source, files, componentPaths, marketplace); - return Plugin.withMcpEntries(base, mcpEntries); - } - - static fromJSON(data: PluginEntryData): Plugin { - if (!PLUGIN_NAME_REGEX.test(data.name)) { - throw new InvalidPluginNameError(data.name); - } - if (!isSemver(data.version)) { - throw new InvalidPluginVersionError(data.version); - } - const source = parsePluginSource(data.source); - const files = new Map(Object.entries(data.files)); - const componentPaths = new Map(Object.entries(data.componentPaths ?? {})); - const mcpEntries = new Map(Object.entries(data.mcpEntries ?? {})); - return new Plugin({ - name: data.name, - source, - version: data.version, - strict: data.strict, - files, - componentPaths, - mcpEntries, - marketplace: data.marketplace, - }); - } - - toJSON(): PluginEntryData { - const data: PluginEntryData = { - name: this.name, - source: serializePluginSource(this.source), - version: this.version, - strict: this.strict, - files: mapToRecord(this.files), - }; - if (this.componentPaths.size > 0) data.componentPaths = mapToRecord(this.componentPaths); - if (this.mcpEntries.size > 0) data.mcpEntries = mapToRecord(this.mcpEntries); - if (this.marketplace !== undefined) data.marketplace = this.marketplace; - return data; - } - - isFileTracked(relPath: string): boolean { - return this.files.has(relPath); - } - - withVersion(v: string): Plugin { - return new Plugin({ - name: this.name, - source: this.source, - version: v, - strict: this.strict, - files: this.files, - componentPaths: this.componentPaths, - mcpEntries: this.mcpEntries, - marketplace: this.marketplace, - }); - } - - withFiles(f: ReadonlyMap): Plugin { - return new Plugin({ - name: this.name, - source: this.source, - version: this.version, - strict: this.strict, - files: f, - componentPaths: this.componentPaths, - mcpEntries: this.mcpEntries, - marketplace: this.marketplace, - }); - } -} - -function mapToRecord(map: ReadonlyMap): Record { - const record: Record = {}; - for (const [key, value] of map) { - record[key] = value; - } - return record; -} diff --git a/cli/src/domain/models/report-period.ts b/cli/src/domain/models/report-period.ts deleted file mode 100644 index 8411f178b..000000000 --- a/cli/src/domain/models/report-period.ts +++ /dev/null @@ -1,71 +0,0 @@ -import { InvalidReportDayError, InvalidReportSpanError } from "../errors.js"; - -/** The two UTC days a report covers, inclusive, as they resolved. - * - * A consumer stores this beside a figure. Reporting the period as it was *asked for* — "the - * last seven days" — would give two callers on two days the same words for two different - * measurements, and a figure nobody can reproduce is a figure nobody can cite. */ -export interface ResolvedReportPeriod { - readonly fromDay: string; - readonly toDay: string; -} - -/** What a caller asked for, in any of the three ways it can be said. */ -export interface ReportPeriodRequest { - readonly from?: string; - readonly to?: string; - readonly days?: string; -} - -const DAY_PATTERN = /^\d{4}-\d{2}-\d{2}$/u; -const DAY_KEY_LENGTH = "YYYY-MM-DD".length; -const MILLISECONDS_PER_DAY = 24 * 60 * 60 * 1000; - -/** A week: the span someone asks about after finishing a piece of work, and short enough - * that a first run answers instead of scanning a year of day files. */ -export const DEFAULT_REPORT_DAYS = 7; -const MAX_REPORT_DAYS = 3650; - -function parseDay(flag: string, value: string): string { - // The shape first, then the calendar: `2026-02-31` matches the pattern and is not a day, - // and `Date.parse` alone would accept a great deal that is not a day at all. - if (!DAY_PATTERN.test(value)) throw new InvalidReportDayError(flag, value); - const parsed = new Date(`${value}T00:00:00Z`); - if (Number.isNaN(parsed.getTime())) throw new InvalidReportDayError(flag, value); - if (dayKey(parsed) !== value) throw new InvalidReportDayError(flag, value); - return value; -} - -function parseSpan(value: string): number { - const days = Number(value); - if (!Number.isInteger(days) || days < 1 || days > MAX_REPORT_DAYS) { - throw new InvalidReportSpanError(value, MAX_REPORT_DAYS); - } - return days; -} - -function dayKey(at: Date): string { - return at.toISOString().slice(0, DAY_KEY_LENGTH); -} - -function daysBefore(day: string, count: number): string { - return dayKey(new Date(Date.parse(`${day}T00:00:00Z`) - count * MILLISECONDS_PER_DAY)); -} - -/** - * What was asked for, plus today, resolved once into two absolute days. - * - * Pure, and it never reads a clock: `today` is the caller's, so the same request resolves - * the same way twice — which is the whole point of the type. The two days come back in - * order however they were given, since a period asked for end-first is the same period. - */ -export function resolveReportPeriod( - request: ReportPeriodRequest, - today: Date -): ResolvedReportPeriod { - const span = request.days === undefined ? DEFAULT_REPORT_DAYS : parseSpan(request.days); - const toDay = request.to === undefined ? dayKey(today) : parseDay("--to", request.to); - const fromDay = - request.from === undefined ? daysBefore(toDay, span - 1) : parseDay("--from", request.from); - return fromDay <= toDay ? { fromDay, toDay } : { fromDay: toDay, toDay: fromDay }; -} diff --git a/cli/src/domain/models/semver.ts b/cli/src/domain/models/semver.ts deleted file mode 100644 index edc5db781..000000000 --- a/cli/src/domain/models/semver.ts +++ /dev/null @@ -1,18 +0,0 @@ -function parseSemver(v: string): [number, number, number] { - const match = v.match(/^v?(\d+)\.(\d+)\.(\d+)/); - if (!match) return [0, 0, 0]; - return [Number(match[1]), Number(match[2]), Number(match[3])]; -} - -export function isSemver(s: string): boolean { - return /^v?\d+\.\d+\.\d+/.test(s); -} - -export function compareSemver(a: string, b: string): -1 | 0 | 1 { - const [aMajor, aMinor, aPatch] = parseSemver(a); - const [bMajor, bMinor, bPatch] = parseSemver(b); - if (aMajor !== bMajor) return aMajor < bMajor ? -1 : 1; - if (aMinor !== bMinor) return aMinor < bMinor ? -1 : 1; - if (aPatch !== bPatch) return aPatch < bPatch ? -1 : 1; - return 0; -} diff --git a/cli/src/domain/models/session-anchor.ts b/cli/src/domain/models/session-anchor.ts deleted file mode 100644 index 2533a91b2..000000000 --- a/cli/src/domain/models/session-anchor.ts +++ /dev/null @@ -1,22 +0,0 @@ -/** - * Which environment variable, if any, names the session actually running this process — - * `aidd telemetry check`'s "hook fired" claim needs to tell a genuinely dead hook from one - * that simply predates this check. - * - * Which variables, and the order they are read in, were measured live against real Codex - * and Claude Code sessions in the plugin's own `session-anchor.cjs`. That file was deleted - * when the CLI took the read path, so what it measured is written out below rather than - * deferred to: a pointer is worth nothing once the thing it points at is gone. - * - * Codex's variable is checked first: a Codex process nested inside a Claude Code session - * inherits `CLAUDE_CODE_SESSION_ID` from its parent, a false anchor that would name the - * enclosing session rather than the one actually running. `CODEX_THREAD_ID` set at all - * means this is a Codex process, whatever else it inherited. - * - * No third variable is read here. Copilot and Cursor were not probed this way, so a host - * other than these two reads no anchor because nothing was measured for it, not because - * nothing exists. - */ -export function resolveSessionAnchor(env: NodeJS.ProcessEnv): string | undefined { - return env.CODEX_THREAD_ID || env.CLAUDE_CODE_SESSION_ID; -} diff --git a/cli/src/domain/models/session-project.ts b/cli/src/domain/models/session-project.ts deleted file mode 100644 index af3e32718..000000000 --- a/cli/src/domain/models/session-project.ts +++ /dev/null @@ -1,33 +0,0 @@ -import type { RunJournal } from "../ports/run-journal-reader.js"; - -/** Which of `session_start`'s two fields named the project. The same reason - * `vendor_field` exists on the identifier: `project_id` alone is a directory name that - * collides across machines, `project_remote` is absent without a remote, and a consumer - * has to be able to tell which one it got. */ -export type ProjectField = "project_id" | "project_remote"; - -export interface SessionProject { - readonly projectId: string; - readonly projectField: ProjectField; -} - -/** - * The project a journalled session ran in, one hop past `session_start` — which already - * resolved both fields and stops there. `project_remote` wins when present: it is the - * same value for every checkout of one repository, where `project_id` alone falls back to - * a directory name that does not carry that guarantee. - * - * A journal with no session, or a session naming neither field, answers `null` — no - * project is the honest reading, never a guess at the caller's own repository. - */ -export function resolveSessionProject(journal: RunJournal | null): SessionProject | null { - const session = journal?.session; - if (!session) return null; - if (session.project_remote !== undefined && session.project_remote !== "") { - return { projectId: session.project_remote, projectField: "project_remote" }; - } - if (session.project_id !== undefined && session.project_id !== "") { - return { projectId: session.project_id, projectField: "project_id" }; - } - return null; -} diff --git a/cli/src/domain/models/setup-flow.ts b/cli/src/domain/models/setup-flow.ts deleted file mode 100644 index 38b356b6b..000000000 --- a/cli/src/domain/models/setup-flow.ts +++ /dev/null @@ -1,99 +0,0 @@ -import { InvalidPluginModeConfigError, InvalidSetupToolIdError } from "../errors.js"; -import type { MarketplaceSourceMode } from "./marketplace-source-mode.js"; -import { type ToolId, VALID_TOOL_IDS } from "./tool-ids.js"; - -export type PluginInstallMode = "interactive" | "all" | "recommended" | "named" | "none"; - -export interface SetupFlowParams { - projectRoot: string; - source?: MarketplaceSourceMode; - aiTools?: readonly ToolId[]; - ideTools?: readonly ToolId[]; - pluginMode?: PluginInstallMode; - pluginNames?: readonly string[]; - interactive?: boolean; - force?: boolean; - registerDefaultMarketplace?: boolean; -} - -export class SetupFlow { - readonly projectRoot: string; - readonly source?: MarketplaceSourceMode; - readonly aiTools: readonly ToolId[]; - readonly ideTools: readonly ToolId[]; - readonly pluginMode: PluginInstallMode; - readonly pluginNames: readonly string[]; - readonly interactive: boolean; - readonly force: boolean; - readonly registerDefaultMarketplace: boolean; - - constructor(params: SetupFlowParams) { - this.validateToolIds(params.aiTools ?? [], params.ideTools ?? []); - this.validatePluginMode(params.pluginMode ?? "none", params.pluginNames ?? []); - this.projectRoot = params.projectRoot; - this.source = params.source; - this.aiTools = params.aiTools ?? []; - this.ideTools = params.ideTools ?? []; - this.pluginMode = params.pluginMode ?? "none"; - this.pluginNames = params.pluginNames ?? []; - this.interactive = params.interactive ?? false; - this.force = params.force ?? false; - this.registerDefaultMarketplace = params.registerDefaultMarketplace ?? true; - } - - private validateToolIds(aiTools: readonly ToolId[], ideTools: readonly ToolId[]): void { - const all = [...aiTools, ...ideTools]; - for (const id of all) { - if (!(VALID_TOOL_IDS as readonly string[]).includes(id)) { - throw new InvalidSetupToolIdError(id, VALID_TOOL_IDS); - } - } - } - - private validatePluginMode(mode: PluginInstallMode, names: readonly string[]): void { - if (mode === "named" && names.length === 0) { - throw new InvalidPluginModeConfigError( - 'Plugin mode "named" requires at least one plugin name.' - ); - } - if (mode !== "named" && names.length > 0) { - throw new InvalidPluginModeConfigError( - `Plugin names provided but mode is "${mode}" (expected "named").` - ); - } - } - - isScriptable(): boolean { - return !this.interactive; - } - - hasAnyTool(): boolean { - return this.aiTools.length > 0 || this.ideTools.length > 0; - } - - equals(other: SetupFlow): boolean { - return ( - this.projectRoot === other.projectRoot && - this.interactive === other.interactive && - this.force === other.force && - this.pluginMode === other.pluginMode && - arraysEqual(this.aiTools, other.aiTools) && - arraysEqual(this.ideTools, other.ideTools) && - arraysEqual(this.pluginNames, other.pluginNames) && - sourcesEqual(this.source, other.source) - ); - } -} - -function arraysEqual(a: readonly T[], b: readonly T[]): boolean { - return a.length === b.length && a.every((v, i) => v === b[i]); -} - -function sourcesEqual( - a: MarketplaceSourceMode | undefined, - b: MarketplaceSourceMode | undefined -): boolean { - if (a === undefined && b === undefined) return true; - if (a === undefined || b === undefined) return false; - return a.equals(b); -} diff --git a/cli/src/domain/models/skill-name.ts b/cli/src/domain/models/skill-name.ts deleted file mode 100644 index 14412b1a4..000000000 --- a/cli/src/domain/models/skill-name.ts +++ /dev/null @@ -1,39 +0,0 @@ -/** Whether two journal lines name the same skill, when the two hosts that wrote them do not - * spell it the same way. - * - * `skill-detection.cjs` has two capture routes and each writes a different spelling. - * `skillNameFromArgument` (Claude Code, Copilot) hands over the host's own argument, - * `aidd-dev:01-plan`. `skillNameFromSkillFileRead` (Cursor, Codex) has no such argument and - * falls back to the bare directory name a `SKILL.md` path names, `01-plan`. One session can - * hold both: the start is captured by whichever route the host allows, and the end is read - * out of the text a skill echoes, which always carries the plugin-qualified form because - * that is what the skill knows itself as. - * - * So an exact comparison makes a declared end close nothing at all on Cursor and Codex - - * `01-plan` opened, `aidd-dev:01-plan` ended, no match - and the interval falls back to the - * next opener as if the skill had never said it was done. - * - * Qualified against qualified is compared whole: `aidd-dev:01-plan` and `aidd-pm:01-plan` - * are two skills, and nothing may fold them together. Only when one side carries no plugin - * at all does the bare name decide, because that side has nothing else to offer. The cost is - * stated rather than hidden: an unqualified `01-plan` closes whichever `01-plan` is open, - * whatever plugin it came from. That is the same limit `ORCHESTRATING_SKILLS` already names - * for a project whose own skill shares a directory name with an orchestrator - the host - * threw the plugin away before this code ever saw the line, and no reader can put it back. - */ -export function namesTheSameSkill(one: string, other: string): boolean { - if (one === other) return true; - const oneQualified = one.includes(":"); - const otherQualified = other.includes(":"); - // Both spellings carry a plugin, and they disagree: two skills, never one. - if (oneQualified && otherQualified) return false; - return bareSkillName(one) === bareSkillName(other); -} - -/** The skill's own name with any `plugin:` prefix dropped - what a host that never saw the - * plugin would have written. The separator is the first colon, matching the one shape this - * domain has: `plugin:skill`, or a bare `skill`. */ -function bareSkillName(skill: string): string { - const separator = skill.indexOf(":"); - return separator === -1 ? skill : skill.slice(separator + 1); -} diff --git a/cli/src/domain/models/step-attribution.ts b/cli/src/domain/models/step-attribution.ts deleted file mode 100644 index 5877db6b7..000000000 --- a/cli/src/domain/models/step-attribution.ts +++ /dev/null @@ -1,246 +0,0 @@ -import type { - RunJournal, - RunJournalBoundary, - RunJournalFileWritten, - RunJournalStepStart, - RunJournalTaskDeclared, -} from "../ports/run-journal-reader.js"; -import { buildFlowIntervals, ORCHESTRATING_SKILLS } from "./flow-attribution.js"; -import { - buildClosedIntervals, - type ClosedInterval, - type IntervalClosure, -} from "./journal-intervals.js"; -import { namesTheSameSkill } from "./skill-name.js"; - -/** How a record's step came to be known. Never collapsed into one field with the step - * name itself: a name the tool stated and one taken from an interval answer differently - * when two skills interleave, and a consumer must be able to tell a measurement from an - * inference. `unattributed` is a value returned here, never the caller's own omission — - * an absent field would be read as "no step ran", which is the assertion nothing on a - * transcript or a journal can support. */ -export type StepAttributionSource = - | "tool-stated" - | "prompt-matched" - | "journal-interval" - | "unattributed"; - -/** Strongest first, and fixed: a consumer reading a report should find the three in the - * same order every time, whatever the records happened to contain. Ordering them by how - * much of a period each accounted for would make the order itself a measurement, which is - * the one thing a stable contract must not do. */ -export const STEP_ATTRIBUTION_SOURCES: readonly StepAttributionSource[] = [ - "tool-stated", - "prompt-matched", - "journal-interval", - "unattributed", -]; - -export interface StepAttribution { - readonly source: StepAttributionSource; - readonly step?: string; -} - -const UNATTRIBUTED: StepAttribution = { source: "unattributed" }; - -/** One `step_start`, closed by a `step_end` naming that same skill or by the next - * `step_start`, and - unclosed - by the journal's own last witnessed moment. `endMs` is - * exclusive, matching the half-open interval the run journal itself defines. - * - * **A `turn_end` stopped closing one on 2026-09-05.** It is a pause, not the end of a - * step, which is the rule `buildTaskIntervals` and `buildFlowIntervals` already read from - * this very journal; a step spanning three prompts was being credited with its first turn - * and nothing after. Measured on the one orchestrated session captured, 2026-09-04: four - * steps opened across four hours of continuous work, every one of them closed by the next - * pause, the last at 06:02:34 against a session that went on until 09:27:21. Of its 1,073 - * records, 69 fell inside a step interval; with a pause no longer closing one, 1,065 do. - * The same journal already gave the flow axis 1,052 records and this axis 1 - two walks - * over identical evidence disagreeing by three orders of magnitude, which is what this - * change removes. - * - * **Capped rather than left open, which reverses the choice this comment used to pin.** - * That choice rested on one premise: the cap "cannot be applied here" because this walk saw - * `boundaries` alone, while a task or flow interval also saw `filesWritten` and - * `taskDeclarations`, so it had later moments to cap at and this had none. The premise is - * now false by construction - `buildStepIntervals` reads the same three arrays they do. All - * that survives of it is the degenerate journal whose very last line is the opener, where - * the cap does give a zero-width interval covering nothing. Open is not the safer error - * there: one captured session carries a single `vendor_id` spanning 22 days, so - * "everything the session does afterward" is three weeks of unrelated work. - * - * `aidd telemetry check`'s `records-join` claim was said to depend on the open reading, and - * in that degenerate journal it genuinely does: `joinedVerdict` fails when *every* record is - * unattributed, so a session whose journal holds the opener and nothing else, and whose - * records carry no tool-stated step of their own, flips that claim from ok to fail. Found by - * running it, not reasoned about - `diagnose-telemetry-use-case.unit.test.ts` held exactly - * that journal. Failing there is the honest answer: nothing in such a journal says the step - * was still running, and a claim reading ok on the strength of an unbounded interval was - * asserting what it could not see. Every host that writes a pause is unaffected, which is - * Claude Code, Cursor and OpenCode by `journal.cjs`'s own `HOOK_EVENT_NAME_TO_CANONICAL`. */ -export interface StepInterval extends ClosedInterval { - readonly skill: string; - /** Whether `endMs` is a moment the journal witnessed or the cap standing in for one it - * never did - `answersFor` reads it, and it is the whole reason the cap above is safe to - * apply. */ - readonly closedBy: IntervalClosure; -} - -/** Journal lines in, closed intervals out - no filesystem, no record. Run through the one - * shared walk (`buildClosedIntervals`) rather than a second copy of it: this module used to - * carry its own `timed`/`parseableBoundaries` pair and its own closer scan, which is how it - * came to disagree with the two walks reading the same journal beside it. - * - * Any `step_start` opens an interval - unlike `buildFlowIntervals`, which opens one only - * for a skill declared to orchestrate. A `step_end` naming that same skill closes it, by - * `namesTheSameSkill` and never `===`: the host that opened the step may have written the - * skill's bare directory name while the end the skill echoes carries its plugin. A - * `step_end` naming a *different* skill is never a closer, which is the fault naming the - * skill exists to prevent. Every other line - a `turn_end`, a `file_written`, a - * `task_declared` - neither opens nor closes one, and only ever contributes its own moment - * toward the journal's last witnessed one. - * - * Two runs of the very same skill in one session yield two distinct intervals, never one - * merged by name, exactly as the boundaries dictate; nothing here decides which record - * falls into which, that is `attributeMoment`'s job. */ -/** Every step a session opened that does not orchestrate - each closed by its own - * `step_end`, by the next `step_start` whatever that one is, or by the journal's own last - * witnessed moment. Two ordinary skills in a row are a sequence, so the second ends the - * first; that reading is unchanged. */ -function buildInvokedStepIntervals( - journal: RunJournal, - periodEndMs: number | undefined -): readonly StepInterval[] { - return buildClosedIntervals< - RunJournalBoundary | RunJournalTaskDeclared | RunJournalFileWritten, - RunJournalStepStart, - StepInterval - >( - [...journal.boundaries, ...journal.taskDeclarations, ...journal.filesWritten], - periodEndMs, - (boundary): boundary is RunJournalStepStart => - boundary.type === "step_start" && !ORCHESTRATING_SKILLS.has(boundary.skill), - // Any `step_start` closes one of these, an orchestrating one included: a session that - // starts orchestrating is no longer running the plain skill it was running before. - // `isOpener` already covers the non-orchestrating half; naming the whole rule here is - // what keeps the orchestrating half from being an omission nobody wrote down. - (boundary, opener) => - boundary.type === "step_start" || - (boundary.type === "step_end" && namesTheSameSkill(boundary.skill, opener.skill)), - (opener, startMs, endMs, closedBy) => ({ skill: opener.skill, startMs, endMs, closedBy }) - ); -} - -/** - * Journal lines in, closed intervals out - no filesystem, no record. - * - * **An invoked step no longer closes the orchestration that invoked it**, changed - * 2026-09-05. Reading every `step_start` as the end of whatever was open assumes a session - * only ever runs one skill after another, and an orchestrating skill's whole job is to - * invoke others. Measured on the one orchestrated session captured, 2026-09-04: - * `aidd-orchestrator:01-sdlc` opened at 05:56:27 and `aidd-pm:04-spec` at 05:59:53, so the - * orchestration read as 206 seconds against a session that ran until 09:27:21 - which is - * why this axis named 1 record for that skill while `by_flow`, reading the same journal - * under the rule this now adopts, named 1,052. - * - * Which skills orchestrate is `ORCHESTRATING_SKILLS`'s declaration, never inferred from the - * lines: nesting and sequence produce the identical journal, so no rule read off the - * boundaries alone can separate them. That is also the limit - a skill that invokes another - * without being declared an orchestrator is still read as a sequence, and is still cut short - * by its own child. - * - * Built as two walks over the same lines rather than one with a branch inside it. The - * orchestrating half **is** `buildFlowIntervals` - a flow is an orchestrating step, and - * saying so by calling it is what keeps the two axes from drifting apart again. - */ -export function buildStepIntervals( - journal: RunJournal, - periodEndMs?: number -): readonly StepInterval[] { - return [ - ...buildFlowIntervals(journal, periodEndMs), - ...buildInvokedStepIntervals(journal, periodEndMs), - ]; -} - -/** Where a record's own moment falls inside one interval, that interval's skill is the - * attribution, marked as derived. A record with no moment, or one earlier than every - * interval, is unattributed — never folded into the first step, which would assume work - * began the instant a marker happened to be written rather than sometime before it. */ -/** The most specific interval a moment falls in: the latest to have opened, and among - * equals the first to close. An invoked step and the orchestration around it both contain - * the moment, and both claims are true - the inner one is the one that says more, and the - * outer one goes on answering for every moment the inner one does not cover. Order in the - * array decides nothing: the two walks that build these run separately, so a rule that - * read the first match would answer differently for the same journal depending on which - * walk happened to run first. */ -function innermostOf(intervals: readonly StepInterval[]): StepInterval | undefined { - let best: StepInterval | undefined; - for (const interval of intervals) { - if ( - best === undefined || - interval.startMs > best.startMs || - (interval.startMs === best.startMs && interval.endMs < best.endMs) - ) { - best = interval; - } - } - return best; -} - -/** Whether an interval nothing closed sits inside another that nothing closed either. - * - * Every unclosed interval ends at the same moment - the journal's own last witnessed one, - * capped identically for all of them - so containment between two of them reduces to which - * opened first, and comparing the ends would be a clause no input can make false. The - * enclosing one is the answer because the inner one's extent rests on no evidence at all, - * while the enclosing one is at least still known to have been open at that moment. */ -function enclosedByAnotherUnclosed( - covering: readonly StepInterval[], - interval: StepInterval -): boolean { - if (interval.closedBy !== "journal-end") return false; - return covering.some( - (other) => other.closedBy === "journal-end" && other.startMs < interval.startMs - ); -} - -/** The interval that answers for a moment. - * - * The innermost one covering it, *except* that an interval nothing ever closed yields to - * one that encloses it and was never closed either. An unclosed interval ends at the - * journal's own last witnessed moment, so its extent is a bound and not a measurement; a - * step opened shortly before a long session goes on working would otherwise be credited - * with all of it, purely for having opened later than the orchestration around it. - * Measured on the one orchestrated session captured, 2026-09-04: 972 records attributed to - * `aidd-dev:01-plan`, opened at 06:00:50 and never closed, inside an orchestration opened - * at 05:56:27 and never closed either. - * - * Yielding is between two unclosed intervals and no wider. Where the enclosing interval - * states its own end, the inner one runs past it and nothing encloses it, so the innermost - * claim stands - the same answer it gets when both ends are witnessed. And an unclosed - * interval that nothing encloses still answers: what is refused is preferring a bound over - * a wider claim that covers the same moment, never the bound itself. - * - * No tie between two unclosed *sibling* steps can arise to be broken here, and it is not - * this function that prevents it: any `step_start` closes whichever plain step was open, so - * at most one invoked step is ever left unclosed at a time. */ -function answersFor( - intervals: readonly StepInterval[], - momentMs: number -): StepInterval | undefined { - const covering = intervals.filter( - (interval) => momentMs >= interval.startMs && momentMs < interval.endMs - ); - return innermostOf(covering.filter((interval) => !enclosedByAnotherUnclosed(covering, interval))); -} - -export function attributeMoment( - intervals: readonly StepInterval[], - momentIso: string | undefined -): StepAttribution { - if (momentIso === undefined) return UNATTRIBUTED; - const momentMs = Date.parse(momentIso); - if (Number.isNaN(momentMs)) return UNATTRIBUTED; - const hit = answersFor(intervals, momentMs); - return hit ? { source: "journal-interval", step: hit.skill } : UNATTRIBUTED; -} diff --git a/cli/src/domain/models/task-attribution.ts b/cli/src/domain/models/task-attribution.ts deleted file mode 100644 index fa77c0486..000000000 --- a/cli/src/domain/models/task-attribution.ts +++ /dev/null @@ -1,200 +0,0 @@ -import type { - RunJournal, - RunJournalBoundary, - RunJournalFileWritten, - RunJournalTaskDeclared, -} from "../ports/run-journal-reader.js"; -import { buildClosedIntervals, type ClosedInterval } from "./journal-intervals.js"; -import { taskIdentityFromWrittenPath } from "./task-identity.js"; - -// Re-exported so this module's own callers and tests need not know the shared walk lives -// in `journal-intervals.ts` at all - `momentFallsWithin` is generic over `ClosedInterval`, -// which `TaskInterval` already satisfies structurally. -export { momentFallsWithin } from "./journal-intervals.js"; - -/** How a record's task came to be known. A declaration is a flow telling the journal which - * ticket it is on; an inference is this layer noticing a written file on its own - the same - * ordering `StepAttributionSource` already gives a step, for the same reason. No - * "unattributed" here: every record this type describes already matched a `--task` filter - * through one of the two routes `taskMembershipFor` names - one that matched neither is - * simply not in the report at all. */ -export type TaskAttributionSource = "declared" | "inferred"; - -export const TASK_ATTRIBUTION_SOURCES: readonly TaskAttributionSource[] = ["declared", "inferred"]; - -/** One declared interval, closed by a later declaration - or, unclosed, by the journal's - * own last recorded moment. - * - * **A `turn_end` stopped closing one on 2026-09-04.** It is a pause, not a change of - * subject: a session declared a task at 05:59, paused at 06:02, and worked on that same - * task for three more hours. Closed at the pause, 78% of that session read "before the next - * task this session declares" while only 1.8% of its tokens truly preceded any declaration. - * Measured again after: 20% attributed became 96%, and the residue matched a hand count of - * the records before the first declaration, to the token. - * - * `turn_end` remains a *witness*, so an interval with nothing after it still ends at the - * same moment it used to - the same number, for the honest reason. Never left open-ended the - * way `StepInterval` is: no tool exposes when a flow leaves a ticket, so a boundless interval - * would attribute everything a long-running session goes on to do to the first ticket it - * ever named, for as long as it keeps running - the failure this type exists to refuse. */ -export interface TaskInterval extends ClosedInterval { - readonly path: string; -} - -/** - * Journal lines in, bounded intervals out. `boundaries`, `taskDeclarations` and - * `filesWritten` are merged and sorted by their own moment into one list, then walked once: - * each `task_declared` closes at the next declaration. Unclosed, it is capped at that - * merged list's own last moment - a written file - * included, never only the kinds an interval actually closes on - so a session that is - * still running when a report is asked for, with a file written after its declaration and - * no `turn_end` yet, is bounded by that write rather than collapsing to `[t, t)` and losing - * everything after it. `RunJournalBoundary` itself carries no `file_written`: pairing one in - * there would let it close a running *step* early (see `run-journal-reader.ts`), a risk this - * merge never runs into because nothing below is treated as a closer at all. A session that crashes and produces no - * further line at all still leaves nothing after the declaration itself to misattribute. - * - * Still never open-ended: widening the last-witnessed moment moves an unclosed interval's - * end later, it never removes the cap. An interval closes at what the journal actually - * witnessed, not at "still running" read as "forever" - no tool exposes when a flow leaves a - * ticket, so a boundless interval would go on attributing a long-running session's every - * later record to the first ticket it ever named. - * - * `timed()` only refuses a moment it cannot parse at all - it does not refuse one that - * parses but is absurd, so a `file_written` line dated by clock skew or a damaged clock (say - * `9999-12-31`) still counts as a witnessed moment and can widen `lastMs` to it. `periodEndMs` - * exists to close that hole without a second, weaker notion of "too far in the future": no - * record this reader could ever be asked to place falls past the report's own period end (the - * sink itself never returns one), so capping the unclosed end there costs nothing a real - * record could have used and removes everything a clock-skewed one could have won. - * - * A `task_declared` line whose `path` `taskIdentityFromWrittenPath` cannot turn into an - * identity - a literal `..` path segment, say - still takes its place among `closers`: it - * still closes whatever interval came before it, at its own moment, exactly like any other - * declaration. It simply produces no `TaskInterval` of its own. The alternative - dropping - * such a line from `closers` entirely - would let the *previous* interval run past the - * moment this one was actually declared, silently widening it. This is not a defensive - * branch with no evidence it fires: `task-declared.cjs`'s own gate is a scan over free-form - * tool-call text, looser than `taskIdentityFromWrittenPath`, so a session really can declare - * a path this reader refuses. - */ -export function buildTaskIntervals( - journal: RunJournal, - periodEndMs?: number -): readonly TaskInterval[] { - return buildClosedIntervals< - RunJournalBoundary | RunJournalTaskDeclared | RunJournalFileWritten, - RunJournalTaskDeclared, - TaskInterval - >( - [...journal.boundaries, ...journal.taskDeclarations, ...journal.filesWritten], - periodEndMs, - (boundary): boundary is RunJournalTaskDeclared => boundary.type === "task_declared", - // Only a later declaration closes one. A `turn_end` is a pause, not a change of - // subject — it stays a *witness*, so an interval with nothing after it still ends at - // the same moment, for the honest reason. See this module's own doc comment. - () => false, - (opener, startMs, endMs) => - taskIdentityFromWrittenPath(opener.path) === null - ? null - : { path: opener.path, startMs, endMs } - ); -} - -/** Why a record belongs to no task - the distinct facts a person acts on differently, and - * no more than those. This function itself answers only the four that are facts about the - * record; `"no-journal"`, the fact about the read, is decided by `declaredTaskKeyOf` before - * this is ever called. Never called for a moment `momentFallsWithin` - * already reads as covered; that caller already knows which interval and needs no reason for - * what it found. - * - * - `"no-journal"`: no *usable* journal reached this record's session. Either none was read - * for it at all, or one was read and could not be used - `report-cost-use-case.ts` drops - * a journal whose `session_start` header is torn, since nothing then says which session - * its lines belong to, and that shape is real (the adapter's own "keeps a session's - * boundaries when its header line is torn" test produces it). "Usable" is the word this - * reason turns on: saying "no journal existed" would be false for the second case, which - * is the fault this reason exists to stop repeating one level down. Never produced by this - * function, which is only ever asked about a session whose journal was usable - - * `declaredTaskKeyOf` answers it before calling here, from the absence of an entry in its - * own per-session map. It belongs in - * this type all the same: it is one of the reasons a `by_task` row carries, and keeping it - * in the same closed set is what makes `Record` force - * every consumer to name it. The four below are facts about the record itself; this one is - * a fact about the read, and merging it into `"no-declaration"` asserted that a session declared - * nothing when the truth was that its journal was never found - measured on 2026-09-04, - * where running the report from a subdirectory reported 100% "no usable task declaration" - * for a period whose journals were all present one directory up. - * - `"no-declaration"`: this session's journal yields no usable declared interval - either it - * never wrote a `task_declared` line at all, or it wrote one this reader cannot place in - * time (an `at` `timed()` cannot parse, dropped before it ever reaches `buildTaskIntervals`). - * The two are indistinguishable from here, which is why this reason is worded "no usable - * declaration" rather than "none was ever declared" - the latter would be false for a - * session whose journal really does hold a line, just not a readable one. - * - `"precedes-journal"`: this record's moment is older than the earliest moment its - * session's journal witnessed. Nothing was declared late here; no journal existed yet. - * The population is large and it is not an anomaly: reading a resumed transcript stores - * the turns it inherited under the session that read them, dated when each was *billed* - - * days before that session ever started. Measured on 2026-09-04, 96.2% of a real period - * fell here and read `"precedes-declaration"`, which told a person their flow declares its - * task late in 96% of cases when fewer than one in five hundred of those records actually - * did. Separated for the same reason `"no-journal"` was separated from `"no-declaration"`: - * a fact about what the journal could cover is not a fact about how the work behaved. - * Decided before `"no-declaration"`, so a journal that declared nothing *and* did not cover - * the record is named by the coverage fact - the one that explains why no declaration - * could have covered it. Keyed on the journal's own earliest witnessed moment, never on - * its `session_start` line, which would be a second notion of when a session began. - * - `"precedes-declaration"`: a task was declared, but some declared interval starts *after* - * this moment. Since 2026-09-04 that means one thing only — a record before the session's - * very first declaration. Intervals now run contiguously from each declaration to the - * next, so the gap a `turn_end` used to leave between two of them no longer exists, and - * the test that described it was deleted rather than reworded. Since the reason above - * joined it, this is only ever a record the journal did witness: a genuinely late - * declaration, which is what the words say. - * - `"journal-silent"`: a task was declared, every declared interval starts at or before this - * moment, and still none of them reaches it - the journal's own declared coverage ran out - * before this record's moment did. A record with no moment at all, or an unparseable one, - * reads the same way: nothing here can place it inside coverage the journal did offer, so - * it is as unplaceable as one that arrived after that coverage lapsed. In practice a - * report never hands this function such a record - `report-cost-use-case.ts` splits an - * undated record off before any of this runs - but the function stays correct standalone. */ -export type TaskUnattributedReason = - | "no-journal" - | "precedes-journal" - | "no-declaration" - | "precedes-declaration" - | "journal-silent"; - -/** Fixed and always in this order, the same reason `TASK_ATTRIBUTION_SOURCES` is: a reader - * comparing two periods must find the reasons present listed the same way every time, never - * ordered by how much of a period each accounted for. */ -export const TASK_UNATTRIBUTED_REASONS: readonly TaskUnattributedReason[] = [ - "no-journal", - "precedes-journal", - "no-declaration", - "precedes-declaration", - "journal-silent", -]; - -/** `journalFirstWitnessedMs` is the earliest moment this session's journal witnessed, absent - * for a journal that carries no readable moment at all. Absent means no coverage claim, never - * a `0` that would place every record after it: a journal that witnessed nothing cannot - * testify that a record predates it. */ -export function taskUnattributedReason( - intervals: readonly TaskInterval[], - momentIso: string | undefined, - journalFirstWitnessedMs?: number -): TaskUnattributedReason { - const momentMs = momentIso === undefined ? Number.NaN : Date.parse(momentIso); - if ( - !Number.isNaN(momentMs) && - journalFirstWitnessedMs !== undefined && - momentMs < journalFirstWitnessedMs - ) { - return "precedes-journal"; - } - if (intervals.length === 0) return "no-declaration"; - if (Number.isNaN(momentMs)) return "journal-silent"; - const somethingDeclaredAfter = intervals.some((interval) => interval.startMs > momentMs); - return somethingDeclaredAfter ? "precedes-declaration" : "journal-silent"; -} diff --git a/cli/src/domain/models/task-backlog-link.ts b/cli/src/domain/models/task-backlog-link.ts deleted file mode 100644 index 8d3f2b27a..000000000 --- a/cli/src/domain/models/task-backlog-link.ts +++ /dev/null @@ -1,60 +0,0 @@ -import type { TaskIdentity } from "./task-identity.js"; - -/** - * The one file a task folder writes to declare which backlog item it delivers — - * `backlog-link.json`, named for what it is rather than for the fuller, now-rescoped shape - * #649 first proposed (`metadata.json`, carrying `unit_id`, a `steps[]` journal and - * `produced` file lists). That shape is not this one: the journal already writes - * `step_start { at, skill }` and `file_written { at, path, source }`, both timestamped, so - * which step produced which file is derivable by the same interval mechanism task - * attribution already reads — a second, hand-written copy of that could disagree with the - * journal and would be worse than no copy at all. This file carries the one fact nothing - * else in the repository knows: the backlog item. - * - * `backlog` follows `plugins/aidd-pm/skills/10-task/references/persistence.md:13` — - * *"Use native fields when supported; otherwise use stable ids, URLs, or project-relative - * paths. Keep one authority across supports."* One field carries the item whichever - * support it lives on: a forge reference (`"owner/repo#123"`) where the backlog lives with - * a ticket provider, or a project-relative path (`"aidd_docs/backlog/tasks/x.md"`) where it - * lives in Markdown. A reader never needs to know which support produced the string — both - * resolve as the same kind of row. - * - * Carries nothing the backlog artefact itself already holds: no `type`, no `work_kind`, no - * originating ticket. Copying any of those here would let this file and that artefact - * disagree, which is the one authority the framework's own rule above forbids. - */ -export interface TaskBacklogLink { - /** The backlog item this task delivers, on whatever support it lives — see the module - * doc. Never resolved to a title or a state here; that is a destination's work. */ - readonly backlog: string; - /** When this declaration was written, ISO 8601. Provenance, not status: a wrong link is - * worse than none, and the only way to judge one is to know which act produced it. */ - readonly writtenAt: string; - /** What wrote this declaration — a skill's own name (`"aidd-pm:04-spec"`), or `"hand"` - * for a person who corrected it directly. Beside `writtenAt`, this is what lets a wrong - * link be traced back to the act that made it. */ - readonly writtenBy: string; -} - -/** - * What reading a task folder's declaration answers — three states, never collapsed into - * two. `"declared"` and `"none"` are both normal: a folder that names an item and one that - * names nothing are equally valid states of a task, and neither is an error. `"unreadable"` - * is different in kind, not degree — the file exists but could not be parsed — and must - * never print the same as `"none"`: one bad folder's damage must be visible on its own row, - * never silently folded into "declared nothing". - */ -export type TaskBacklogDeclaration = - | { readonly kind: "declared"; readonly link: TaskBacklogLink } - | { readonly kind: "none" } - | { readonly kind: "unreadable" }; - -/** The project-relative folder a task's identity resolves to — the inverse of - * `taskIdentityFromWrittenPath`'s folder branch, and the one shape this module reads a - * declaration from: a single-file task (`task-identity.ts`'s other shape) has no folder to - * hold one, so resolving its identity here still yields a path, and a reader asking that - * path for a declaration simply finds none — the same answer an ordinary folder with no - * declaration gives, never a special case. */ -export function taskFolderPathFromIdentity(identity: TaskIdentity): string { - return `aidd_docs/tasks/${identity}/`; -} diff --git a/cli/src/domain/models/task-identity.ts b/cli/src/domain/models/task-identity.ts deleted file mode 100644 index ef91532e6..000000000 --- a/cli/src/domain/models/task-identity.ts +++ /dev/null @@ -1,60 +0,0 @@ -/** A task's identity, derived from a path a session wrote into. - * - * The run journal deliberately stores no task identity: its `file_written` line carries a - * repository-relative path and nothing derived from it, on the ground that a conclusion - * frozen at write time cannot be revised while a derivation re-runs over every past - * session the day it changes. This module is the other half of that decision. - * - * Two shapes exist side by side and both are real tasks — a folder of files, and a single - * `.md` file — so matching only the folder would leave half of them unattributable. The - * anchoring mirrors `plugins/aidd-telemetry/hooks/lib/file-writes.cjs`'s own - * `TASK_PATH_ANCHOR_PATTERN`, which is the gate that decides whether a write is journalled - * at all: a path this module refuses would never have produced a line to read. - */ -const TASK_FOLDER_PATTERN = /^aidd_docs\/tasks\/(\d{4}_\d{2})\/([^/]+)\//u; -const TASK_FILE_PATTERN = /^aidd_docs\/tasks\/(\d{4}_\d{2})\/([^/]+)\.md$/u; - -/** The identity a task is named by: its month and its own name, `2026_08/2026_08_21_slug`. - * A folder task and a single-file task of the same name resolve to the same identity, so a - * task that grew from one file into a folder does not read as two tasks. */ -export type TaskIdentity = string; - -/** - * The task a written path belongs to, or `null` for a path that belongs to none. - * - * Pure: a path in, an identity or nothing out. No filesystem, no configuration, nothing - * about which tasks exist — a path naming a task nobody has heard of still resolves, since - * this answers what a path says, not what is on disk. - * - * The path must be repository-relative and `/`-separated, which is what the journal writes - * on every platform. One that climbs out with a literal `..` *segment* belongs to no task. - * That is a segment check, not a substring one: `file-writes.cjs`'s own gate for a written - * path, and `task-declared.cjs`'s pattern for a declared one, both allow `..` to appear - * inside a folder or file name (`2026_02_10_a..b` is a name, not a climb) — only a path - * segment that is exactly `..` climbs anything, and only that shape is rejected here. - */ -export function taskIdentityFromWrittenPath(writtenPath: string): TaskIdentity | null { - if (writtenPath.split("/").includes("..")) return null; - const match = TASK_FOLDER_PATTERN.exec(writtenPath) ?? TASK_FILE_PATTERN.exec(writtenPath); - if (!match) return null; - const [, month, name] = match; - return month !== undefined && name !== undefined ? `${month}/${name}` : null; -} - -/** Every task a set of written paths names, in first-seen order and without repeats. A - * session that wrote into two tasks belongs to both; one that wrote into none belongs to - * none, and is still fully reportable by period. */ -export function taskIdentitiesFromWrittenPaths( - writtenPaths: readonly string[] -): readonly TaskIdentity[] { - const seen = new Set(); - const identities: TaskIdentity[] = []; - for (const writtenPath of writtenPaths) { - const identity = taskIdentityFromWrittenPath(writtenPath); - if (identity !== null && !seen.has(identity)) { - seen.add(identity); - identities.push(identity); - } - } - return identities; -} diff --git a/cli/src/domain/models/telemetry-claim.ts b/cli/src/domain/models/telemetry-claim.ts deleted file mode 100644 index ddb113b28..000000000 --- a/cli/src/domain/models/telemetry-claim.ts +++ /dev/null @@ -1,509 +0,0 @@ -import type { StepAttributionSource } from "./step-attribution.js"; -import type { AiToolId } from "./tool-ids.js"; - -/** - * Four independently verifiable claims about the measurement chain, each answered from - * what was actually read, never inferred from the others. Ported from the plugin's own - * `diagnose.cjs`, which the CLI pivot deleted along with the rest of the skills' scripts; - * the route it covered is named here rather than left behind a pointer to a file no reader - * can open. That route is the local one: `hook fired` -> `session journalled` -> - * `tool files readable` -> `records join`. - * - * Two more claims — `export-configured` and `identifier-joinable` — graded a second, - * export route. That route (the OTLP receiver, the export config reader, and the mapper - * that turned an exported payload into a stored record) was deleted in "one route, and - * every sentence about it true" (aidd_docs/tasks/2026_08/2026_08_28_one-route-that-is-true/): - * on the machine that built this system, those two claims graded a route that had never - * once produced a record, and failed a healthy install by recommending the one action that - * sends a person's address off the machine. Every diagnostic claim is now about the one - * route that exists. - */ -export type TelemetryClaimId = - | "hook-fired" - | "session-journalled" - | "tool-files-readable" - | "records-join"; - -export type TelemetryClaimVerdict = "ok" | "fail" | "unknown"; - -/** - * The closed set of reasons a claim can land on a verdict. One claim's `fail` can have - * several distinct causes — "no run file" and "untrusted hook" both fail `hook-fired`, and - * collapsing them into one reading is exactly what a diagnostic exists to prevent — so the - * reason, not the verdict alone, is what a caller must switch on to tell them apart. - */ - -/** - * The reasons `noRunFileClaim` can land the first claim on — one absence, told apart by - * what is actually known about it, never guessed from the absence alone. Its own union so - * a fifth cause can only ever be added by extending this type: `TelemetryClaimReason` - * composes it in, so a new member here forces every exhaustive `Record` keyed on it - * (the diagnostic skill's own drift guard, `telemetry-claim.unit.test.ts`) to name an - * account for it or fail to compile — the same failure mode this cluster exists to - * prevent is not allowed to reopen quietly through a sixth branch nobody documented. - */ -export type NoRunFileReason = - | "recorder-declared-nowhere" - | "recorder-declared-not-yet-fired" - | "recorder-declaration-unreadable" - | "anchorless-run-file" - | "journal-in-another-schema"; - -export type TelemetryClaimReason = - | "session-anchored" - | "untrusted-codex-hook" - | NoRunFileReason - | "unrecognised-payload" - | "session-left-no-run-file" - | "no-session-anchor" - | "turn-closed" - | "only-session-start" - | "no-run-file-to-read" - | "session-found" - | "no-session-found-for-any-tool" - | "no-session-named" - | "records-joined" - | "all-unattributed" - | "no-record-to-join" - | "no-join-material"; - -export interface TelemetryClaim { - readonly claim: TelemetryClaimId; - readonly verdict: TelemetryClaimVerdict; - readonly reason: TelemetryClaimReason; - readonly detail: string; -} - -/** One journalled session, the shape `claimHookFired`/`claimSessionJournalled` need from - * it — a fuller run journal (file writes, task declarations) carries nothing these claims - * read, so it stays out of this evidence shape entirely. */ -export interface TelemetryClaimJournal { - readonly vendorId?: string; - readonly sessionStartAt?: string; - readonly turnClosed: boolean; -} - -/** One covered tool's attempt to read one journalled session's own files. `records` carries - * only the step attribution each one resolved to — `claimRecordsJoin`'s only use for a - * record at all — never the counters themselves, which this diagnostic has no business - * repeating from the report. */ -export interface TelemetryClaimToolRead { - readonly tool: AiToolId; - readonly sessionFound: boolean; - readonly hasIntervals: boolean; - readonly records: readonly { - readonly stepAttribution: StepAttributionSource; - }[]; - readonly error?: string; -} - -/** Whether Codex has trusted this plugin's hook — `undefined` when there is no trust gate - * to consult at all (every host but Codex, or a Codex session whose anchor was never - * resolved). `readable: false` covers everything short of the config file actually opening - * as text; neither direction licenses a guess at trust. */ -export interface TelemetryCodexHookTrust { - readonly readable: boolean; - readonly trusted?: boolean; - readonly configPath?: string; - readonly reason?: string; -} - -export interface TelemetryEvidence { - readonly journals: readonly TelemetryClaimJournal[]; - readonly toolReads: readonly TelemetryClaimToolRead[]; - readonly runsDirLabel: string; - readonly currentSessionId?: string; - readonly unrecognisedPayloadAt?: string; - readonly hookTrust?: TelemetryCodexHookTrust; - /** Whether the recorder is declared anywhere this build knows to check (the AIDD - * manifest, or a tool's own settings) — read the same way `TelemetrySetup`'s own - * `recorderDeclaration` fact is, so the claim below can never disagree with what the - * stated half already printed. Decides which of two absences an empty journal is: one - * still worth naming a failure, one that has simply not happened yet. Never itself proof - * the hook will fire — see `noRunFileClaim`'s own doc for the measured case where a - * declaration is silently dropped. */ - readonly recorderDeclared: boolean; - /** Whether every location `readRecorderDeclaration` checked was actually readable — - * `false` only when `recorderDeclared` is `false` *and* at least one checked location - * exists but could not be read or parsed (a trailing comma, a `//` comment, unreadable - * permissions). A damaged declaring file is not the same absence as a recorder declared - * nowhere, the same "could not be read" distinction the switch file and identity already - * carry — collapsing it into "not declared" would grade a healthy, merely-unreadable - * install `FAIL` for a cause it does not have. Meaningless, and always `true`, when - * `recorderDeclared` is `true`: a declaration found at one readable location is real - * regardless of what else could not be read. */ - readonly recorderDeclarationReadable: boolean; - /** The schema stated by every run file the journal reader refused, from - * `RunJournalReader.listForeignSchemas`. Carried separately from `journals` because a - * refused file is absent from that list while being present on disk: without this, the - * one fact actually known about it — that it states a schema this build does not read — - * is invisible, and the claim below falls through to a branch that is false about it. */ - readonly foreignSchemaVersions: readonly number[]; -} - -function sessionJournalsOf( - journals: readonly TelemetryClaimJournal[] -): readonly TelemetryClaimJournal[] { - return journals.filter((journal) => journal.vendorId !== undefined); -} - -function latestSessionStart(journals: readonly TelemetryClaimJournal[]): string { - const starts = journals - .map((journal) => journal.sessionStartAt) - .filter((at): at is string => at !== undefined) - .sort(); - return starts[starts.length - 1] ?? "an unreadable session_start"; -} - -function firedForSession(journals: readonly TelemetryClaimJournal[], sessionId: string): boolean { - return journals.some((journal) => journal.vendorId === sessionId); -} - -function trustExplainsAbsence(hookTrust: TelemetryCodexHookTrust | undefined): boolean { - return Boolean(hookTrust?.readable && !hookTrust.trusted); -} - -function untrustedHookClaim(hookTrust: TelemetryCodexHookTrust): TelemetryClaim { - return { - claim: "hook-fired", - verdict: "fail", - reason: "untrusted-codex-hook", - detail: - "Codex has not trusted this plugin's hook — no trusted_hash for " + - `hooks/hooks.json:session_start in ${hookTrust.configPath}. Approve it interactively ` + - "once, or pass --dangerously-bypass-hook-trust to codex exec for a headless run.", - }; -} - -function unreadableTrustSuffix(hookTrust: TelemetryCodexHookTrust | undefined): string { - if (!hookTrust || hookTrust.readable) return ""; - return ` — Codex's own hook trust state could not be read either (${hookTrust.reason}), so this may be the same cause`; -} - -// The one absence, two causes this claim exists to tell apart: a recorder never declared -// anywhere this build reads has nothing that could have written a run file, which is a -// failure worth naming; a recorder that IS declared may simply not have run yet — that is -// nothing to evaluate, never a failure, and its detail says so without promising the -// declaration will actually fire (`claude-cli-adapter.ts` records the one measured case -// where a headless run silently drops a declared entry as orphaned). -function recorderDeclaredNotYetFiredClaim(runsDirLabel: string): TelemetryClaim { - return { - claim: "hook-fired", - verdict: "unknown", - reason: "recorder-declared-not-yet-fired", - detail: - `no run file in ${runsDirLabel} yet — nothing to evaluate. The recorder is declared, ` + - "but a declaration is not proof it will fire: a headless run can silently drop it " + - "without ever registering the plugin (see claude-cli-adapter.ts).", - }; -} - -function recorderNotDeclaredClaim( - runsDirLabel: string, - hookTrust: TelemetryCodexHookTrust | undefined -): TelemetryClaim { - return { - claim: "hook-fired", - verdict: "fail", - reason: "recorder-declared-nowhere", - detail: - `no run file in ${runsDirLabel} — the hook has never been observed firing, and the ` + - `recorder is declared nowhere this build checks${unreadableTrustSuffix(hookTrust)}`, - }; -} - -// A location that cannot be read says so and costs only itself: this is not proof the -// recorder is missing, only that whether it is declared could not be determined. Grading -// `FAIL` off an unreadable file would reinstate, one layer down, the exact bug this -// diagnostic exists to prevent — a healthy install told it is broken because something -// about it could not be read. -function recorderDeclarationUnreadableClaim(runsDirLabel: string): TelemetryClaim { - return { - claim: "hook-fired", - verdict: "unknown", - reason: "recorder-declaration-unreadable", - detail: - `no run file in ${runsDirLabel} yet, and whether the recorder is declared could not ` + - "be read — see recorder declared, above, for which location. A damaged declaring " + - "file is not the same absence as one that never declared the recorder.", - }; -} - -// A run file existing at all is direct evidence the recorder did something, whatever the -// declaration says: reading its absence as "no run file … yet" (nothing to evaluate) or -// as "the recorder is declared nowhere" (which claims no run file exists) are both wrong -// about the one fact that is actually known here — a file is present, and it has no -// session anchor to judge against. Unconditional on the recorder's own declaration: a -// hooks block that registers PostToolUse/Stop but never SessionStart produces exactly -// this file while reading "declared nowhere" (`hooksDeclarePlugin` only asks after -// SessionStart), so gating this on `recorderDeclared` would still print "no run file" -// about a file that demonstrably exists. -function anchorlessRunFileClaim(runsDirLabel: string, fileCount: number): TelemetryClaim { - return { - claim: "hook-fired", - verdict: "fail", - reason: "anchorless-run-file", - detail: - `${fileCount} run file(s) in ${runsDirLabel}, but none carry a readable session_start ` + - "to anchor them — a torn write, or a hooks block that registers another event " + - "without SessionStart, never a hook that has not fired", - }; -} - -// The schema a journal states is the writer's own statement about its shape, so a build -// that does not read that schema knows exactly one thing about the file: not what its lines -// mean. Ahead of `anchorlessRunFileClaim` for that reason — "none carry a readable -// session_start" is a claim about the file's contents, which is the claim this build has -// just said it cannot make. -function foreignSchemaClaim(runsDirLabel: string, stated: readonly number[]): TelemetryClaim { - const versions = [...new Set(stated)].sort((left, right) => left - right).join(", "); - return { - claim: "hook-fired", - verdict: "fail", - reason: "journal-in-another-schema", - detail: - `${stated.length} run file(s) in ${runsDirLabel} written under a schema this build does ` + - `not read (${versions}) — a journal from another version of the plugin, never a hook ` + - "that did not fire", - }; -} - -function noRunFileClaim( - runsDirLabel: string, - hookTrust: TelemetryCodexHookTrust | undefined, - recorderDeclared: boolean, - recorderDeclarationReadable: boolean, - anchorlessFileCount: number, - foreignSchemaVersions: readonly number[] -): TelemetryClaim { - if (hookTrust && trustExplainsAbsence(hookTrust)) return untrustedHookClaim(hookTrust); - if (foreignSchemaVersions.length > 0) { - return foreignSchemaClaim(runsDirLabel, foreignSchemaVersions); - } - if (anchorlessFileCount > 0) return anchorlessRunFileClaim(runsDirLabel, anchorlessFileCount); - if (!recorderDeclarationReadable) return recorderDeclarationUnreadableClaim(runsDirLabel); - if (recorderDeclared) return recorderDeclaredNotYetFiredClaim(runsDirLabel); - return recorderNotDeclaredClaim(runsDirLabel, hookTrust); -} - -function unrecognisedPayloadClaim(at: string): TelemetryClaim { - return { - claim: "hook-fired", - verdict: "fail", - reason: "unrecognised-payload", - detail: `a payload arrived and matched no known host at ${at} — this tool is not recognised, not a hook that never ran`, - }; -} - -function noAnchorClaim(journals: readonly TelemetryClaimJournal[], latest: string): TelemetryClaim { - return { - claim: "hook-fired", - verdict: "unknown", - reason: "no-session-anchor", - detail: `${journals.length} run file(s), most recent session_start ${latest} — no session anchor available to tell whether this session's hook fired`, - }; -} - -function sessionAnchoredClaim( - journals: readonly TelemetryClaimJournal[], - latest: string, - currentSessionId: string, - hookTrust: TelemetryCodexHookTrust | undefined -): TelemetryClaim { - if (!firedForSession(journals, currentSessionId)) { - if (hookTrust && trustExplainsAbsence(hookTrust)) return untrustedHookClaim(hookTrust); - return { - claim: "hook-fired", - verdict: "fail", - reason: "session-left-no-run-file", - detail: `this session left no run file — the newest one is from ${latest}${unreadableTrustSuffix(hookTrust)}`, - }; - } - return { - claim: "hook-fired", - verdict: "ok", - reason: "session-anchored", - detail: `${journals.length} run file(s), most recent session_start ${latest}`, - }; -} - -// Split out of `claimHookFired` to keep both under the line-count limit: everything this -// branch needs when no journal carries a session anchor at all. -function noSessionJournalClaim(evidence: TelemetryEvidence): TelemetryClaim { - const { journals, runsDirLabel, unrecognisedPayloadAt, hookTrust } = evidence; - if (unrecognisedPayloadAt !== undefined) return unrecognisedPayloadClaim(unrecognisedPayloadAt); - return noRunFileClaim( - runsDirLabel, - hookTrust, - evidence.recorderDeclared, - evidence.recorderDeclarationReadable, - journals.length, - evidence.foreignSchemaVersions - ); -} - -function claimHookFired(evidence: TelemetryEvidence): TelemetryClaim { - const sessionJournals = sessionJournalsOf(evidence.journals); - if (sessionJournals.length === 0) return noSessionJournalClaim(evidence); - const latest = latestSessionStart(sessionJournals); - if (evidence.currentSessionId === undefined) return noAnchorClaim(sessionJournals, latest); - return sessionAnchoredClaim( - sessionJournals, - latest, - evidence.currentSessionId, - evidence.hookTrust - ); -} - -function claimSessionJournalled(journals: readonly TelemetryClaimJournal[]): TelemetryClaim { - const sessionJournals = sessionJournalsOf(journals); - if (sessionJournals.length === 0) { - return { - claim: "session-journalled", - verdict: "unknown", - reason: "no-run-file-to-read", - detail: "no run file to read", - }; - } - const closed = sessionJournals.filter((journal) => journal.turnClosed); - if (closed.length === 0) { - return { - claim: "session-journalled", - verdict: "fail", - reason: "only-session-start", - detail: `${sessionJournals.length} run file(s), all carrying only session_start — nothing closed the turn`, - }; - } - return { - claim: "session-journalled", - verdict: "ok", - reason: "turn-closed", - detail: `${closed.length} of ${sessionJournals.length} run file(s) carry more than session_start`, - }; -} - -interface ToolTally { - attempted: number; - found: number; - errors: string[]; -} - -function tallyByTool(toolReads: readonly TelemetryClaimToolRead[]): Map { - const byTool = new Map(); - for (const read of toolReads) { - const entry = byTool.get(read.tool) ?? { attempted: 0, found: 0, errors: [] }; - entry.attempted += 1; - entry.found += read.sessionFound ? 1 : 0; - if (read.error !== undefined) entry.errors.push(read.error); - byTool.set(read.tool, entry); - } - return byTool; -} - -function readableSummary(toolReads: readonly TelemetryClaimToolRead[]): string { - return [...tallyByTool(toolReads).entries()] - .map(([tool, tally]) => { - const failed = tally.errors.length > 0 ? `, ${tally.errors.length} could not be read` : ""; - return `${tool}: ${tally.found} of ${tally.attempted} session(s) read${failed}`; - }) - .join("; "); -} - -function errorNote(toolReads: readonly TelemetryClaimToolRead[]): string { - const errors = toolReads - .map((read) => read.error) - .filter((error): error is string => error !== undefined); - return errors.length === 0 - ? "" - : ` — ${errors.length} read attempt(s) failed: ${errors[errors.length - 1]}`; -} - -function claimToolsReadable( - journals: readonly TelemetryClaimJournal[], - toolReads: readonly TelemetryClaimToolRead[] -): TelemetryClaim { - const sessionIds = [...new Set(sessionJournalsOf(journals).map((journal) => journal.vendorId))]; - if (sessionIds.length === 0) { - return { - claim: "tool-files-readable", - verdict: "unknown", - reason: "no-session-named", - detail: "no session named by the journal", - }; - } - if (!toolReads.some((read) => read.sessionFound)) { - const tools = [...new Set(toolReads.map((read) => read.tool))].join(", "); - return { - claim: "tool-files-readable", - verdict: "fail", - reason: "no-session-found-for-any-tool", - detail: `no session found for any journalled session, across every covered tool (${tools}) — while the journal names ${sessionIds.join(", ")}${errorNote(toolReads)}`, - }; - } - return { - claim: "tool-files-readable", - verdict: "ok", - reason: "session-found", - detail: readableSummary(toolReads), - }; -} - -function hasJoinMaterial( - toolReads: readonly TelemetryClaimToolRead[], - records: readonly { readonly stepAttribution: string }[] -): boolean { - return ( - toolReads.some((read) => read.hasIntervals) || - records.some((record) => record.stepAttribution === "tool-stated") - ); -} - -function joinedVerdict(records: readonly { readonly stepAttribution: string }[]): TelemetryClaim { - const joined = records.filter((record) => record.stepAttribution !== "unattributed"); - if (joined.length === 0) { - return { - claim: "records-join", - verdict: "fail", - reason: "all-unattributed", - detail: `${records.length} record(s) found, joined: 0 — every record unattributed`, - }; - } - const rest = records.length - joined.length; - return { - claim: "records-join", - verdict: "ok", - reason: "records-joined", - detail: `${joined.length} of ${records.length} record(s) joined a step, ${rest} unattributed`, - }; -} - -function claimRecordsJoin(toolReads: readonly TelemetryClaimToolRead[]): TelemetryClaim { - const records = toolReads.flatMap((read) => read.records); - if (records.length === 0) { - return { - claim: "records-join", - verdict: "unknown", - reason: "no-record-to-join", - detail: "no record read to join", - }; - } - if (!hasJoinMaterial(toolReads, records)) { - return { - claim: "records-join", - verdict: "unknown", - reason: "no-join-material", - detail: "no step interval and no tool-stated step — see session journalled", - }; - } - return joinedVerdict(records); -} - -/** The four claims, always in this order, and never a fifth line that summarises them. */ -export function diagnoseTelemetryClaims(evidence: TelemetryEvidence): readonly TelemetryClaim[] { - return [ - claimHookFired(evidence), - claimSessionJournalled(evidence.journals), - claimToolsReadable(evidence.journals, evidence.toolReads), - claimRecordsJoin(evidence.toolReads), - ]; -} diff --git a/cli/src/domain/models/telemetry-export-leftover.ts b/cli/src/domain/models/telemetry-export-leftover.ts deleted file mode 100644 index fccdb7b65..000000000 --- a/cli/src/domain/models/telemetry-export-leftover.ts +++ /dev/null @@ -1,45 +0,0 @@ -import { asPlainObject } from "../formats/plain-object.js"; - -/** - * The exact `env` keys `aidd telemetry endpoint` used to write into a Claude Code settings - * file, back when that command — and its targeted undo, `endpoint clear` — still existed. - * Detection only: nothing here builds this shape or writes it anywhere, and nothing in this - * system can any more (see "one route, and every sentence about it true", which deleted the - * writer on purpose). A settings file that still carries any of these keeps exporting - * whatever it always did; naming them is the only remedy left. - */ -export const CLAUDE_TELEMETRY_EXPORT_ENV_KEYS = [ - "CLAUDE_CODE_ENABLE_TELEMETRY", - "OTEL_METRICS_EXPORTER", - "OTEL_LOGS_EXPORTER", - "OTEL_EXPORTER_OTLP_PROTOCOL", - "OTEL_EXPORTER_OTLP_ENDPOINT", - "OTEL_METRIC_EXPORT_INTERVAL", - "OTEL_RESOURCE_ATTRIBUTES", -] as const; - -/** One settings file that still carries at least one of the keys above, and which of them — - * so a person reading this is told what is set, in which file, and can remove exactly those - * keys rather than guessing at the whole `env` block. */ -export interface TelemetryExportLeftover { - readonly path: string; - readonly keys: readonly string[]; -} - -/** Which of the known export keys sit in a settings file's `env` block, given its raw - * content — `null` for a file that does not exist, the same "absent reads as nothing found" - * rule every other reader in this layer follows. Never throws: an unreadable or malformed - * file has no keys this can find in it, which is not the same claim as "definitely clean" - * but is the only one this function is in a position to make. */ -export function findLeftoverExportKeys(content: string | null): readonly string[] { - if (content === null) return []; - let parsed: unknown; - try { - parsed = JSON.parse(content); - } catch { - return []; - } - const env = asPlainObject(asPlainObject(parsed)?.env); - if (env === null) return []; - return CLAUDE_TELEMETRY_EXPORT_ENV_KEYS.filter((key) => key in env); -} diff --git a/cli/src/domain/models/telemetry-removal.ts b/cli/src/domain/models/telemetry-removal.ts deleted file mode 100644 index 4772b06bf..000000000 --- a/cli/src/domain/models/telemetry-removal.ts +++ /dev/null @@ -1,100 +0,0 @@ -/** - * Every location `aidd telemetry forget` would remove from, resolved once. - * - * This value is built exactly once, by `ForgetTelemetryUseCase.preview()`, and is the - * *only* thing a person is shown before confirming. The removal step does not build a - * second one: it is handed this same value and reads paths and names off it rather than - * asking the sink, the journal or the identity store again. That is deliberate, not an - * optimisation — two computations that happen to agree today can disagree tomorrow (a - * relocated `AIDD_USER_CONFIG_DIR`, a file that appears between the two calls), and the - * failure that produces is deleting something a person was never shown. Passing this value - * through, rather than re-resolving inside the removal, is what makes that failure - * inexpressible rather than merely untested. - * - * `journal` and `sink`/`identity` are never the same shape: a project's run journal is - * one project's own, and the sink and the identity file both live under this machine's own - * profile and span every project this machine has ever measured. `scope` names that - * difference on the type itself so a renderer cannot fold the two into one sentence by - * accident. - */ - -export interface TelemetryProjectJournalRemoval { - readonly scope: "project"; - /** The run journal's own directory, as `RunJournalReader.runsDir` resolved it. */ - readonly path: string; - /** Every run file this project's journal holds, by name — not a count derived from - * parsing them. A run file too damaged to parse still has a name `readdir` can see, - * so it is still here and still removed. */ - readonly runFileNames: readonly string[]; -} - -export interface TelemetryMachineSinkRemoval { - readonly scope: "machine"; - /** This machine's sink directory, as `TelemetrySink.rootDir` resolved it — spans every - * project ever measured on this machine, never one project alone. */ - readonly path: string; - /** Every day file the sink holds, by name. A day file's content is never opened to - * produce this list, so a damaged one is named exactly like any other. */ - readonly dayFileNames: readonly string[]; -} - -export interface TelemetryMachineIdentityRemoval { - readonly scope: "machine"; - /** This machine's identity file, as `PersonIdentityStore.filePath` resolved it. */ - readonly path: string; - /** Whether a file is actually on disk — true even when it exists but could not be - * parsed, which is exactly the file a person most needs removed. */ - readonly present: boolean; - /** The file exists but `readStrict()` could not read it back. Carried beside `present` - * rather than folded into it: a damaged file and an absent one both count as nothing to - * show, but only one of them is a file still sitting there. */ - readonly unreadable: boolean; -} - -/** - * What is known about the run journal's history, at its true strength. - * - * `listTrackedFiles` (`git ls-files`) only ever answers what the *index* holds right now — - * it says nothing about whether any of that was ever actually committed. A file `git - * add`ed and never committed is tracked while history holds nothing for it, in a - * repository with zero commits or a thousand unrelated ones; `hasHistoryFor` (`git log`) - * is the call that tells the two apart. - * - * - `"committed"`: history holds at least one commit touching this project's run journal. - * `files` is what the index reports as tracked right now — informational, naming what a - * person can check, not a claim that each individual file was itself proven committed. - * - `"staged"`: tracked right now (the index holds it) but no commit anywhere touches it - * yet. The blob still sits in the index after this removal deletes the working-tree - * file, so a later `git commit` with nothing further done would put it back — that is - * worth saying, not just "not yet in history". - * - `"possible"`: not tracked now. It cannot be told apart from a file never committed at - * all, so this is never reported as an all-clear — only as what it is, a possibility. - * - `"none"`: not inside a git repository at all. No history could hold anything, so this - * is the one case allowed to say so without hedging. - */ -export type TelemetryHistoryReading = - | { readonly certainty: "committed"; readonly files: readonly string[] } - | { readonly certainty: "staged"; readonly files: readonly string[] } - | { readonly certainty: "possible" } - | { readonly certainty: "none" }; - -/** Every location a removal would touch, and what no removal can touch, resolved together - * so a caller cannot render one without the other — see the module doc comment for why - * this value is resolved exactly once. */ -export interface TelemetryRemovalPreview { - readonly journal: TelemetryProjectJournalRemoval; - readonly sink: TelemetryMachineSinkRemoval; - readonly identity: TelemetryMachineIdentityRemoval; - readonly history: TelemetryHistoryReading; -} - -/** Nothing to remove anywhere this tool looked — the case a person on a machine that never - * measured anything sees. Used to decide whether to offer removal at all, rather than - * asking to confirm removing nothing. */ -export function telemetryRemovalIsEmpty(preview: TelemetryRemovalPreview): boolean { - return ( - preview.journal.runFileNames.length === 0 && - preview.sink.dayFileNames.length === 0 && - !preview.identity.present - ); -} diff --git a/cli/src/domain/models/telemetry-setup.ts b/cli/src/domain/models/telemetry-setup.ts deleted file mode 100644 index d9352c5da..000000000 --- a/cli/src/domain/models/telemetry-setup.ts +++ /dev/null @@ -1,400 +0,0 @@ -import type { HostPluginRegistryReading } from "../ports/host-plugin-registry-reader.js"; -import { personRefusesTelemetry, TELEMETRY_REFUSAL_VARIABLE } from "./telemetry-switch.js"; -import type { AiToolId } from "./tool-ids.js"; - -/** - * What is already in place before `aidd telemetry check` grades whether anything - * recorded — the answer to "where would I go look, and whose choice was this" that today - * only a person reading several different files by hand could assemble. Printed first, - * and printed whether or not measurement is even on: that is exactly when a person needs - * it, and today it is exactly when they get nothing. - * - * Every fact here names the location it came from, so a person can go and change it, and - * carries no count and no figure of any kind — the report owns those, and a diagnostic - * that starts repeating quantities becomes a second report that can disagree with the - * first. - */ -export interface TelemetrySetup { - readonly allowed: TelemetryAllowedSetup; - readonly identity: TelemetryIdentitySetup; - readonly recordsLocation: TelemetryRecordsLocationSetup; - readonly recorderDeclaration: TelemetryRecorderDeclarationSetup; - readonly hostRegistration: TelemetryHostRegistrationSetup; - readonly commitTrailer: TelemetryCommitTrailerSetup; - readonly versions: TelemetryVersionsSetup; -} - -/** Which build of which piece produced what a person is reading. - * - * Two producers, and only one of them can be asked directly. The CLI's own version is here - * in this process; the plugin's is a fact about a different program, on a different - * schedule, and the only honest source for it is what that program itself wrote — so it is - * read back out of the journal rather than re-derived. Re-deriving it would mean a third - * copy of `plugin-version.cjs`'s two-route lookup, in a process that never ran the hook, - * able to disagree with the lines actually on disk. */ -export interface TelemetryVersionsSetup { - readonly cli: string; - readonly plugin: TelemetryPluginVersionSetup; -} - -/** Three answers, and only the first is a version. - * - * `"unrecorded"` and `"nothing-journalled"` are deliberately apart: the first is a hook - * that ran and could not name its own build — the plugin's manifest was not beside its - * hooks and no `aidd` install recorded one, which is a plugin copied in by hand. The second - * is a project where nothing has been measured yet, and says nothing about the plugin at - * all. Collapsing them would let "not measured yet" read as "damaged install". */ -export type TelemetryPluginVersionSetup = - | { readonly kind: "recorded"; readonly version: string } - | { readonly kind: "unrecorded" } - | { readonly kind: "nothing-journalled" }; - -/** Whether AIDD is allowed to measure this project, and whose decision that is. Mirrors - * `resolveTelemetryEnabled`'s own precedence exactly, so this can never disagree with the - * gate that decides whether `check` even reaches its four claims: a person's own refusal - * wins unconditionally over whatever the project file says. */ -export interface TelemetryAllowedSetup { - readonly allowed: boolean; - /** `"person-refusal"`: `AIDD_TELEMETRY=0` decided it, whatever the project file holds. - * `"project-switch"`: the project's own tracked file decided it — on, off, absent, or - * unreadable are all still that file's decision, never this person's own. */ - readonly decidedBy: "person-refusal" | "project-switch"; - /** The env var name for `"person-refusal"`; the switch file's path for - * `"project-switch"` — where a person would go to change this. */ - readonly location: string; - /** Reading an env var never fails, so this is always `true` for `"person-refusal"`. - * `false` for `"project-switch"` only when the file exists but could not be read or - * parsed — a damaged file is not the same choice as an absent or an explicit one. */ - readonly readable: boolean; -} - -/** What `buildTelemetryAllowedSetup` needs from the project's switch file — the primitives - * alone, never `TelemetrySwitchSetupRead` itself: that type lives on the port this model - * must not depend on, and importing it back here for one function would draw a domain - * model into the port layer it is the port's job to abstract away from. */ -export interface TelemetrySwitchFileFacts { - readonly path: string; - readonly enabled: boolean; - readonly readable: boolean; -} - -/** Builds `TelemetryAllowedSetup` from the project's switch file and the environment, - * mirroring `resolveTelemetryEnabled`'s own precedence exactly: a person's own refusal - * wins unconditionally, whatever the project file says. Pure, so the precedence itself — - * not just its effect on the gate `resolveTelemetryEnabled` decides — has its own test, - * independent of the adapter that reads the switch file or the use case that calls this. */ -export function buildTelemetryAllowedSetup( - switchFile: TelemetrySwitchFileFacts, - env: NodeJS.ProcessEnv -): TelemetryAllowedSetup { - if (personRefusesTelemetry(env)) { - return { - allowed: false, - decidedBy: "person-refusal", - location: TELEMETRY_REFUSAL_VARIABLE, - readable: true, - }; - } - return { - allowed: switchFile.enabled, - decidedBy: "project-switch", - location: switchFile.path, - readable: switchFile.readable, - }; -} - -/** Whether this person attached their own identifier to what gets read locally, and where - * that file lives. There is no file to fail to read when nobody has ever opted in, so - * `attached: false, readable: true` is the ordinary "nobody chose" case — `readable` is - * only ever `false` for a file that exists but is damaged. */ -export interface TelemetryIdentitySetup { - readonly attached: boolean; - readonly path: string; - readonly readable: boolean; -} - -/** Where `aidd telemetry read`/`report` keep what they store — the sink's own root - * directory, resolved by nothing but the adapter that already owns it. No `readable`: - * naming a directory never fails, whether or not anything has been written into it yet. */ -export interface TelemetryRecordsLocationSetup { - readonly path: string; -} - -/** Whether the recorder — the `aidd-telemetry` plugin whose hook has to fire for anything - * else here to have material to judge — is declared anywhere this build knows to check: - * the AIDD manifest a `plugin add` writes, a tool's own settings file declaring its - * enabled plugins, or a hooks block that invokes the recorder's own entry point directly - * (Claude's nested `hooks` key, or Cursor's project-scope flat file — the marketplace - * route is not the only one this build can see). A declaration is not proof the hook will - * fire: a declared entry is silently dropped as orphaned when a host never registers the - * plugin in its own registry (`claude-cli-adapter.ts`'s measured case). This fact states - * only that a declaration was found; whether the host will act on it is - * `TelemetryHostRegistrationSetup`, directly below. */ -export interface TelemetryRecorderDeclarationSetup { - readonly declared: boolean; - /** Where it was found declared — non-empty exactly when `declared` is `true`. */ - readonly declaredAt: readonly string[]; - /** Every location this build knows to check, so a person can go add it there when - * `declared` is `false` — the same set regardless of the outcome. */ - readonly locationsChecked: readonly string[]; - /** Every checked location that exists but could not be read or parsed — a trailing - * comma, a `//` comment, unreadable permissions. Mirrors the switch file and identity's - * own `readable` fact, the same "a damaged file is not a choice" distinction, just - * carried as a list here because more than one location is ever checked at once. - * Non-empty only makes sense alongside `declared: false`: a declaration actually found - * at one readable location is real regardless of what else could not be read, so a - * consumer should only look at this when `declared` is `false`. */ - readonly unreadable: readonly string[]; -} - -/** - * Whether the host will actually load what AIDD installed — the other half of - * `TelemetryRecorderDeclarationSetup`, which answers only whether a declaration exists. - * - * Two facts, two different files, and #703 is the gap between them: a project's own - * settings can carry a perfectly good `enabledPlugins` entry while the host's registry - * knows nothing about it, at which point the host drops the entry as orphaned and every - * visible signal still says healthy. `claude --debug-file` says it in one line nobody - * passes the flag to see: `Skipping orphaned enabledPlugins entry …: marketplace not - * registered`. - * - * **Read from AIDD's own manifest, never from `enabledPlugins`.** That is not a - * preference: `mergeEnabledPlugins` iterates the manifest and skips silently twice — once - * for a plugin recording no marketplace, once for a marketplace that does not resolve - * (`marketplace-sync-settings-use-case.ts`). A plugin installed under either condition - * reaches no settings file at all, so comparing settings against a registry would find - * both sides absent and read it as agreement while the plugin never loads. The manifest is - * what that loop reads from, so it is what this reads from too. - * - * Costs no session, no network and no money: every fact here comes from files already on - * disk, which is what makes it answerable before a person has spent anything. - */ -export interface TelemetryHostRegistrationSetup { - /** One per plugin AIDD installed for a tool whose registration can be asked about at - * all. Empty when the manifest records no plugin, which is a normal state, not a fault. */ - readonly entries: readonly TelemetryHostRegistrationEntry[]; - /** Why AIDD's own manifest could not be read, when it could not. - * - * Its own field rather than an absent-entries silence, and this is not defensive - * programming: `Manifest`'s parser reads `files.map(...)` on each tool without guarding - * the field, so a hand-edited or truncated `.aidd/manifest.json` throws a `TypeError` - * rather than returning null. Before this fact existed, `aidd telemetry check` never - * loaded the manifest at all — measuring it is what put that crash on the diagnostic's - * path, so the diagnostic is what has to survive it. A damaged manifest is exactly when a - * person runs `check`, and the one thing it must not do then is die. */ - readonly manifestUnreadable?: string; -} - -/** - * Four answers, and none of them collapses into another. - * - * `registered-disabled` is its own answer rather than a shade of `registered` because - * folding it in would report a plugin that will not load as one that will — Codex records - * `enabled` per plugin table and nothing else, so `enabled = false` is a host that knows - * the plugin and still declines it. - * - * `unanswerable` is its own answer rather than a shade of `not-registered` for the rule - * this whole layer is built on: an unknown is never a zero. A registry that cannot be read - * — absent, unreadable, or JSONC where JSON was expected — has said nothing, and printing - * that as "not registered" would invent a fact. Copilot's own `~/.copilot/config.json` - * opens with two `//` comment lines, so this is not hypothetical: a naive parse throws on - * the first registry a reader meets. - * - * **A fifth answer was designed and is not built here.** The plan distinguished a plugin - * the host does not carry from one that never reached the project's own `enabledPlugins` - * at all, which happens because `mergeEnabledPlugins` skips silently twice. Telling those - * apart needs the set of declared refs per tool, and `TelemetryEvidenceReader` exposes no - * accessor for it — `readRecorderDeclaration` looks for the recorder specifically, not for - * every declared key. Adding one is a port method, an adapter method and their tests, for a - * distinction between two flavours of the same outcome: the plugin will not load. The - * comparison here starts from the manifest, which is what made that distinction visible in - * the first place and is the half that matters; the second hop is named in #703's own - * thread rather than half-built. */ -export type TelemetryHostRegistrationAnswer = - | "registered" - | "registered-disabled" - | "not-registered" - | "unanswerable"; - -export interface TelemetryHostRegistrationEntry { - readonly tool: AiToolId; - readonly plugin: string; - /** `@`, the one string all three measured hosts key their registry - * on and the same string `enabledPlugins` uses. Absent exactly when the manifest records - * no marketplace for the plugin, which is the case no registry can be asked about. */ - readonly ref?: string; - readonly answer: TelemetryHostRegistrationAnswer; - /** One sentence naming what was read and what it said, so the answer can be acted on - * rather than merely believed. */ - readonly detail: string; -} - -/** What one tool contributes to the comparison: the plugins AIDD's own manifest records for - * it, and what its registry answered — `undefined` when nothing here knows how to ask that - * host, which is a different fact from asking and getting nothing back. */ -export interface TelemetryHostRegistrationEvidence { - readonly tool: AiToolId; - readonly plugins: readonly { readonly name: string; readonly marketplace?: string }[]; - readonly reading?: HostPluginRegistryReading; - /** Whether the tool declares a native activation at all — it drives its own CLI to - * register a plugin, so a registry exists to be found. Carried because the two silences - * are different problems: a tool that declares none has no registry to read, while one - * that declares an activation and has no reader here has a registry nobody has measured. - * Telling a person the first when the second is true would send them looking for a file - * that does not exist. */ - readonly declaresNativeActivation?: boolean; -} - -/** - * The comparison itself: for every plugin AIDD installed, what the host's own registry says - * about it. - * - * Pure, and driven from the manifest rather than from any settings file, for the reason - * `TelemetryHostRegistrationSetup` states — a plugin the settings sync skipped would - * otherwise be absent from both sides and read as agreement. - */ -export function buildHostRegistration( - evidence: readonly TelemetryHostRegistrationEvidence[] -): TelemetryHostRegistrationSetup { - const entries: TelemetryHostRegistrationEntry[] = []; - for (const item of evidence) { - const { tool, plugins, reading } = item; - for (const plugin of plugins) { - entries.push(hostRegistrationEntry(tool, plugin, reading, item.declaresNativeActivation)); - } - } - return { entries }; -} - -function hostRegistrationEntry( - tool: AiToolId, - plugin: { readonly name: string; readonly marketplace?: string }, - reading: HostPluginRegistryReading | undefined, - declaresNativeActivation: boolean | undefined -): TelemetryHostRegistrationEntry { - // No marketplace recorded means no ref exists to look up — every measured host keys its - // registry on `@`, so this is unanswerable at the source rather than - // a lookup that failed. - if (plugin.marketplace === undefined || plugin.marketplace === "") { - return { - tool, - plugin: plugin.name, - answer: "unanswerable", - detail: "AIDD records no marketplace for it, so no host registry can be asked", - }; - } - const ref = `${plugin.name}@${plugin.marketplace}`; - return { - tool, - plugin: plugin.name, - ref, - ...askRegistry(tool, ref, reading, declaresNativeActivation), - }; -} - -/** What one registry says about one ref, given the reading it produced. Split from the - * entry it becomes so the two absences above — no ref to ask about, and no registry to ask - * — stay visibly separate from the four answers a registry can give. */ -function askRegistry( - tool: AiToolId, - ref: string, - reading: HostPluginRegistryReading | undefined, - declaresNativeActivation: boolean | undefined -): { answer: TelemetryHostRegistrationAnswer; detail: string } { - const answered = whatAnswered(tool, reading, declaresNativeActivation); - if ("detail" in answered) return answered; - const enabled = answered.refs.get(ref); - if (enabled === undefined) { - return { - answer: "not-registered", - detail: `${answered.location} does not carry ${ref} — ${tool} will drop the declaration as orphaned`, - }; - } - if (!enabled) { - return { - answer: "registered-disabled", - detail: `${answered.location} carries ${ref} and records it disabled`, - }; - } - return { answer: "registered", detail: answered.location }; -} - -/** Either the refs a registry actually produced, or the reason nothing did — returned as - * one value so the caller never has to re-narrow, and so no branch can reach a lookup - * against a registry that never opened. - * - * Three reasons, not one, because they send a person somewhere different: a tool that drives - * its own CLI keeps a registry somebody could go and measure, a tool that declares no native - * activation has none to look for at all, and a registry that was found and could not be - * read names the file and the failure. */ -function whatAnswered( - tool: AiToolId, - reading: HostPluginRegistryReading | undefined, - declaresNativeActivation: boolean | undefined -): - | { readonly refs: ReadonlyMap; readonly location: string } - | { readonly answer: TelemetryHostRegistrationAnswer; readonly detail: string } { - if (reading === undefined) { - return { - answer: "unanswerable", - detail: - declaresNativeActivation === true - ? `${tool} keeps a plugin registry, and nothing here has established its shape` - : `${tool} declares no plugin registry to read`, - }; - } - if (reading.refs === undefined) { - return { - answer: "unanswerable", - detail: `${reading.location} could not be read — ${reading.unreadable ?? "no reason given"}`, - }; - } - return { refs: reading.refs, location: reading.location }; -} - -/** - * Whether a commit made by a session will carry it, and whether any actually has. - * - * The trailer is the one link that closes "this commit cost X". It is installed as a single - * line in `prepare-commit-msg`, and in a repository where another tool generates that file - * the line is erased the next time it regenerates — a loss with no symptom, since commits - * keep succeeding and records keep being written. `check` had no claim about any of this; - * these are the five facts that make the loss visible, and the hook now repairs the call - * site on the next session so this is a report rather than a chore. - * - * Four of the five describe pieces. `recentlyCarrying` is the only one that describes the - * chain: it reads git's own history, so it answers whether commits are actually being - * stamped rather than whether the parts that should stamp them are in place. - */ -export interface TelemetryCommitTrailerSetup { - /** Where git says it runs hooks from — `git rev-parse --git-path hooks`, never - * `.git/hooks` assumed, because `core.hooksPath` pointing elsewhere is exactly the - * configuration under which everything else here would describe the wrong directory. */ - readonly hooksDir?: string; - /** Why there is no `hooksDir`, when there is none. Two causes, never one sentence: a - * project outside git has no hook to carry anything, which is a fact about the project; - * a repository whose git could not answer is a reading that failed, and saying "no - * repository" about it would be false — measured against a git that rejects `--git-path`, - * which reported exactly that inside a repository with commits. */ - readonly hooksDirMissing?: "no-repository" | "unresolved"; - /** Whether the delegate script is there and executable. A file present but not executable - * is its own state: git will not run it, and saying "installed" would be a lie a person - * could not act on. */ - readonly delegate: "executable" | "not-executable" | "absent"; - /** Whether `prepare-commit-msg` carries the line that calls the delegate. */ - readonly callSite: "present" | "missing" | "no-hook-file"; - /** Whether that hook is executable, when there is one. Git refuses to run a hook without - * the bit and prints a hint on every commit; a regeneration that drops it leaves an - * install that looks perfect and writes nothing. Absent when there is no hook to ask - * about — a third state, not a `false`. */ - readonly hookExecutable?: boolean; - /** Whether that hook holds anything besides our own line — true where lefthook, husky or a - * hand-written hook owns the file. Said, never named: which tool it is changes nothing a - * person does, and naming one would be a guess from its contents. */ - readonly hookHasOtherContent: boolean; - /** How many of the commits looked at actually carry the trailer, and how many were looked - * at. A count, never a pass: "some of your recent commits carry it" is not something a - * person can check, and zero out of twenty in a repository that has been measuring for a - * week is the whole finding. Absent when history could not be read at all. */ - readonly recentlyCarrying?: { readonly carrying: number; readonly examined: number }; -} diff --git a/cli/src/domain/models/telemetry-sink-record.ts b/cli/src/domain/models/telemetry-sink-record.ts deleted file mode 100644 index 304800620..000000000 --- a/cli/src/domain/models/telemetry-sink-record.ts +++ /dev/null @@ -1,214 +0,0 @@ -import { UnknownTelemetrySinkSchemaVersionError } from "../errors.js"; -import type { StepAttributionSource } from "./step-attribution.js"; -import type { AiToolId } from "./tool-ids.js"; - -// v2 adds `provenance`, required rather than defaulted, because a default meaning "the -// old route" is exactly the ambiguity the field exists to remove. No migration: the sink -// is delivered but unmerged, so no v1 day file exists outside this branch to migrate. -export const SINK_SCHEMA_VERSION = 2; - -/** A request-kind record joins to a turn when its route can name one — an OTLP `api_request` - * names it via `turn_field`, a local read names it via the tool's own per-record id. A - * session-level measure never does — metric datapoints carry no turn identifier on any - * tool measured so far. `turn_id`, when present, is also the key a re-read is deduplicated - * on: the tool's own identifier for that record, never a hash of the line, since a hash - * changes the moment the tool appends anything else to the same record. - * - * No field here says a `kind: "request"` local-read record was still provisional when it - * was stored — a record read while its turn might still be running looks exactly like one - * read after it finished. That is deliberate: a stored line is never later confirmed closed - * or reopened, because there is nothing to confirm it against that outlives the moment a - * read happened — a run journal's own `turn_end` line says only that no further growth is - * coming, never that a given stored reading already saw all of it, and gating a correction - * on it would risk freezing a partial reading the instant a `turn_end` line existed at all. - * What *is* stored is every reading a later read judged a genuine, strictly larger - * improvement on the last — never a smaller one, and never a redundant one once a re-read - * brings nothing more (see `read-local-cost-use-case.ts`'s `storeNewCandidates` and - * `cost-report.ts`'s `collapseSupersededTurns`, which then keeps only the largest of - * however many readings a turn accumulated). */ -export type TelemetrySinkRecordKind = "request" | "session"; - -/** Which route produced this line. Never optional: a default meaning "the old route" - * would make the field unreadable the day a third route appears. - * - * `"export"` can no longer be *produced* by this system — the OTLP receiver, the export - * config reader/writer, and the mapper that turned an OTLP payload into a record were all - * deleted in "one route, and every sentence about it true" - * (aidd_docs/tasks/2026_08/2026_08_28_one-route-that-is-true/). It stays in this union, and - * every reader keeps honouring it, because a stored line outlives the code that wrote it: a - * record an earlier version of this tool wrote to someone's real sink must stay readable, - * countable, and reportable, exactly as before. Removing a way of writing never removes a - * way of reading. */ -export type TelemetrySinkRecordProvenance = "export" | "local-read"; - -/** The tool-neutral stored line, and the complete allowlist of what a session may leave - * behind — no identity of any kind on a *stored* export-provenance record; a person is - * named only via `person_id`, opted into on the local-read route (see - * `read-local-cost-use-case.ts`). This is a statement about what is written to disk, not - * about every in-memory record with `provenance: "export"`: `cost-report.ts`'s - * `withPersonBackfill` is the one place that pairs an export-route record with its - * local-read sibling for the same billed call at read time, and copies the sibling's - * `person_id` and `person_display_name` onto it, as a pair, before the report is built — - * see that function's own doc comment. - * `vendor_field` and `turn_field` name the export-side attribute a value came - * from, since that attribute differs per tool — `tool` names the tool itself, so no - * consumer ever has to reverse that attribute back into an identity. Never optional: an - * unnamed record is exactly the ambiguity this field exists to remove. */ -export interface TelemetrySinkRecord { - readonly sink_schema_version: number; - readonly kind: TelemetrySinkRecordKind; - readonly provenance: TelemetrySinkRecordProvenance; - readonly tool: AiToolId; - readonly vendor_id: string; - readonly vendor_field: string; - readonly turn_id?: string; - readonly turn_field?: string; - /** The tool's own identifier for one billed call, not one turn — present only where a - * route can name it, and, unlike `turn_id`, guaranteed unique per billed request where it - * is present at all. Claude Code names the same call `requestId` on its local transcript - * and `request_id` on its export's `api_request` log attribute — the one identifier this - * sink has ever measured both routes computing for the same real call. It exists so a - * report can collapse two records describing one call, made when both routes are live for - * a tool, into one — see "One billed call, both routes" in metrics-contract.md. Never used - * for the local-read re-read match `turn_id` exists for. */ - readonly billed_request_id?: string; - /** The prompt this billed call belongs to, where its tool's own files can say. - * - * A billed call and the prompt that caused it never share a transcript line — measured on - * a real 810-record session, zero lines carry both `requestId` and `promptId`, and only - * `type: "user"` lines carry the second. Every line bearing counters reaches one by - * following `parentUuid`, three hops in the median, which is how the reader resolves it. - * - * The run journal writes the same identifier on `step_start` (Claude Code hands its hooks - * `prompt_id`, stored there under the name `turn_id`). Matching the two joins a step to a - * record **exactly**, instead of inferring it from which interval each moment happens to - * fall in — the one route that stays true when two tasks advance at once, since two - * prompts remain two prompts however their moments overlap. - * - * Absent wherever a tool's files cannot say, which is every host but Claude Code today. */ - readonly prompt_id?: string; - /** The skill a `Skill` call invoked inside this record's own prompt — the same fact the - * run journal writes as `step_start`'s `turn_id`, seen from the transcript instead. - * - * Stored because the report never re-reads a transcript: it reads this sink and the - * journals beside it, so an observation only a transcript holds has to be written down - * when it is read or it is gone. An observation, and never a judgement — which step a - * record belongs to is `report-cost-use-case.ts`'s question, derived fresh every run - * from this and from the journal together. - * - * Scoped to the transcript the record itself sits in, which is what the reader accumulates: - * Claude Code writes a session's subagents to their own files under - * `/subagents/`, and a prompt is often spread across several — measured on one - * machine, 1,038 of 5,564 prompts appear in more than one file. A subagent that invoked its - * own skill did that work under that skill, so its records name it, while the main - * transcript's records name whatever the main flow invoked. Merging the files first would - * have to pick one of the two for both, and neither choice is true of both. - * - * It does not duplicate `step`. That one reads `attributionSkill`, which Claude Code - * writes per message: exact where it appears and sparse where it does not. Measured on - * the one orchestrated session captured, 2026-09-04, inside the window - * `aidd-dev:01-plan` demonstrably ran, 142 lines carry counters and 20 carry that field. - * So its absence is not the tool saying no skill ran, and naming the skill a prompt - * invoked contradicts nothing the tool states. */ - readonly prompt_skill?: string; - /** How `step` came to be known. Never optional, for the same reason `provenance` is not: - * an absent field would be read as "no step ran", which is exactly the assertion nothing - * on a transcript or a journal can support. See `domain/models/step-attribution.ts`. */ - readonly step_attribution: StepAttributionSource; - /** The skill or step name — present only where `step_attribution` names a source that - * actually found one; absent, never a placeholder, when `step_attribution` is - * `"unattributed"`. */ - readonly step?: string; - /** The plugin a tool-stated `step` came bundled with, when the tool reports one - * alongside the skill name. Never set from a journal interval, which carries no plugin - * at all. */ - readonly step_plugin?: string; - readonly project_id?: string; - /** Which field on the run journal's `session_start` line `project_id` came from — - * `"project_remote"` or `"project_id"`, present only on a record joined from a journal - * (see `domain/models/session-project.ts`). Absent on an export-provenance record: its - * `project_id` is set directly from the `aidd.project_id` OTLP attribute, with no - * journal join to name a source for. */ - readonly project_field?: string; - /** The identifier a person chose to attach to records this machine reads locally - never - * derived from `user_id`, a tool's own attribute, and never *written* onto an - * export-provenance record (see `read-local-cost-use-case.ts`). Absent whenever nobody - * opted in, which is the default. `cost-report.ts`'s `withPersonBackfill` is the one - * read-time exception: it can carry an export-route record's `person_id` in memory, - * backfilled onto it from its local-read sibling for the same billed call, as a pair with - * `person_display_name`, never one field from each - see that function's doc comment and - * the interface comment above. */ - readonly person_id?: string; - /** A separate, later choice from `person_id` - present only once asked for, and never - * derived from it or from anything else. */ - readonly person_display_name?: string; - /** The CLI's own version, read through the same port `current-version-adapter.ts` already - * resolves it through, stamped only on what the CLI itself stored — a `provenance: - * "local-read"` record, never a `provenance: "export"` one, the same restriction - * `person_id`'s own comment states for the same reason: the export route's records were - * never produced by this CLI at all (a different process, a tool's own SDK, gone even - * earlier - see `TelemetrySinkRecordProvenance`), so there is no version of *this tool* - * to name on one. Never the framework's own version, which stored nothing here, and never - * the plugin's, which stamps only the journal line beside this record (see - * `RunJournalSessionStart.plugin_version`) — two different fields, two different - * producers, two different values. Absent on a record written before this field existed, - * which reads as an unknown version, never as a default or a guess. */ - readonly cli_version?: string; - readonly cost_usd?: number; - readonly input_tokens?: number; - readonly output_tokens?: number; - readonly cache_read_tokens?: number; - readonly cache_creation_tokens?: number; - readonly model?: string; - readonly effort?: string; - readonly speed?: string; - readonly query_source?: string; - readonly agent_name?: string; - readonly duration_ms?: number; - readonly active_time_s?: number; - readonly event_timestamp?: string; - readonly event_sequence?: number; -} - -const DAY_KEY_LENGTH = "YYYY-MM-DD".length; - -/** The UTC day a record's own moment falls on, or `undefined` when it carries none. - * - * Lives here rather than in the sink adapter because more than one thing has to agree on - * it — the adapter that reads day files and every double that stands in for it — and two - * implementations of "which day is this" diverge on exactly the inputs nobody writes a - * fixture for. ISO 8601 with a `Z` offset is what every producer writes, so the first ten - * characters are already the UTC day; anything else is parsed rather than sliced, so a - * moment written with a non-UTC offset lands on the day it actually happened - * (`2026-08-18T01:00:00+05:00` is the 17th) and an unparseable one answers `undefined` - * rather than a sliced fragment. */ -export function telemetrySinkRecordDayKey(record: TelemetrySinkRecord): string | undefined { - const at = record.event_timestamp; - // `typeof`, not `!== undefined`: `parseTelemetrySinkLine` checks the schema version and - // casts the rest, so this field holds whatever its line held. A number passes an - // `undefined` check and `new Date(12345)` is a valid moment — epoch milliseconds — so the - // record would have been placed on 1970-01-01, fallen outside every real period, and gone - // missing from the read without ever being counted as undated. - if (typeof at !== "string") return undefined; - // The parse is checked first, always - the fast slice below is only ever a faster way to - // read a moment already known to parse, never a substitute for checking it does. Slicing - // first and parsing only for the rest let a string merely shaped like a moment - // ("not-a-momentZ") answer a fragment nothing on the calendar matches, instead of the - // `undefined` this function's whole contract promises for anything that isn't one. - const parsed = new Date(at); - if (Number.isNaN(parsed.getTime())) return undefined; - if (at.length >= DAY_KEY_LENGTH && at.endsWith("Z")) return at.slice(0, DAY_KEY_LENGTH); - return parsed.toISOString().slice(0, DAY_KEY_LENGTH); -} - -export function serializeTelemetrySinkRecord(record: TelemetrySinkRecord): string { - return JSON.stringify(record); -} - -export function parseTelemetrySinkLine(line: string): TelemetrySinkRecord { - const parsed = JSON.parse(line) as { sink_schema_version?: unknown }; - if (parsed.sink_schema_version !== SINK_SCHEMA_VERSION) { - throw new UnknownTelemetrySinkSchemaVersionError(parsed.sink_schema_version); - } - return parsed as TelemetrySinkRecord; -} diff --git a/cli/src/domain/models/telemetry-sink-retention.ts b/cli/src/domain/models/telemetry-sink-retention.ts deleted file mode 100644 index 7d8b8b61f..000000000 --- a/cli/src/domain/models/telemetry-sink-retention.ts +++ /dev/null @@ -1,23 +0,0 @@ -/** Measured: a mapped `request` line is 576 bytes, so ~281 KB on a 500-request day and - * ~25 MB over the window. */ -export const DEFAULT_TELEMETRY_SINK_RETENTION_DAYS = 90; - -export interface TelemetrySinkRetentionDecision { - readonly keep: readonly string[]; - readonly prune: readonly string[]; -} - -/** `windowDays` is clamped to at least 1, so the newest day file is never a prune - * candidate whatever value is passed. */ -export function decideTelemetrySinkRetention( - dayFileNames: readonly string[], - windowDays: number -): TelemetrySinkRetentionDecision { - const window = Math.max(1, Math.floor(windowDays)); - const sorted = [...dayFileNames].sort(); - if (sorted.length <= window) return { keep: sorted, prune: [] }; - return { - keep: sorted.slice(sorted.length - window), - prune: sorted.slice(0, sorted.length - window), - }; -} diff --git a/cli/src/domain/models/telemetry-switch.ts b/cli/src/domain/models/telemetry-switch.ts deleted file mode 100644 index 3b4d264a4..000000000 --- a/cli/src/domain/models/telemetry-switch.ts +++ /dev/null @@ -1,90 +0,0 @@ -import { join } from "node:path"; -import { AIDD_CONFIG_FILENAME, AIDD_DIR } from "./paths.js"; - -/** - * `.aidd/config.json`'s `telemetry` key — the one answer to "is AIDD allowed to measure - * this project", read fresh at every call by the journal hook, the sink, the diagnostic, - * and the report. Absent or unparseable means off, mirrored here from the hook's own - * `readTelemetryConfig` + `telemetryEnabled` failure direction. - */ -export interface TelemetrySwitch { - readonly enabled: boolean; - /** A destination `aidd telemetry endpoint` used to write here, before that command and - * its targeted undo (`endpoint clear`) were both deleted in "one route, and every - * sentence about it true". Nothing in this system sets, clears, or reads this value as a - * destination any more — `on` and `off` both preserve it verbatim, purely so neither - * silently drops a key it never wrote. A settings file a tool itself still reads for a - * real, live export is a different fact this field cannot see — see - * `telemetry-export-leftover.ts` for what detects that. */ - readonly endpoint?: string; -} - -export function telemetryConfigPath(projectRoot: string): string { - return join(projectRoot, AIDD_DIR, AIDD_CONFIG_FILENAME); -} - -/** The only refusal available at a person's own scope. Not a second config file: state for - * "is this measured" already lives in `.aidd/config.json` (the project's tracked decision), - * and a file at the person's scope would be a third place the same fact could live, in a - * change whose point is that there are too many already. An environment variable is - * refusable per shell, per session and per machine, and needs nothing to be created. - * - * Mirrors `plugins/aidd-telemetry/hooks/lib/repo.cjs`'s `personRefusesTelemetry` exactly - - * same variable name, same predicate - so the hook and the CLI can never disagree about - * whether a person has refused. Only the literal string `"0"` counts as a refusal: unset or - * empty is not a choice this variable can express, and never turns measurement on by - * itself. */ -export const TELEMETRY_REFUSAL_VARIABLE = "AIDD_TELEMETRY"; - -export function personRefusesTelemetry(env: NodeJS.ProcessEnv): boolean { - return env[TELEMETRY_REFUSAL_VARIABLE] === "0"; -} - -/** Whether measurement is on, from the CLI's side: the person's own refusal read first and - * winning unconditionally, the project's tracked switch read only when it does not apply - - * the same order and the same verdict `telemetryEnabled` in `repo.cjs` computes. */ -export function resolveTelemetryEnabled( - fileSwitch: TelemetrySwitch | null, - env: NodeJS.ProcessEnv -): boolean { - if (personRefusesTelemetry(env)) return false; - return fileSwitch?.enabled === true; -} - -function asRecord(value: unknown): Record | null { - return value !== null && typeof value === "object" && !Array.isArray(value) - ? (value as Record) - : null; -} - -function safeParse(content: string): unknown { - try { - return JSON.parse(content); - } catch { - return null; - } -} - -/** Unreadable, unparseable, or a `telemetry` key with the wrong shape all read as `null` - * (off) — never throws, same failure direction as everywhere else in this layer. */ -export function parseTelemetrySwitchFile(content: string): TelemetrySwitch | null { - const telemetry = asRecord(asRecord(safeParse(content))?.telemetry); - if (telemetry === null) return null; - const endpoint = typeof telemetry.endpoint === "string" ? telemetry.endpoint : undefined; - return { enabled: telemetry.enabled === true, endpoint }; -} - -/** Upserts `telemetry` into whatever JSON already lives at the switch path, leaving every - * other top-level key untouched. The switch shares `.aidd/config.json` with nothing else - * today, but a key this function did not add must survive both `on` and `off` regardless. */ -export function buildTelemetrySwitchFile( - existingRaw: string | null, - next: TelemetrySwitch -): string { - const root = (existingRaw !== null ? asRecord(safeParse(existingRaw)) : null) ?? {}; - root.telemetry = - next.endpoint !== undefined - ? { enabled: next.enabled, endpoint: next.endpoint } - : { enabled: next.enabled }; - return `${JSON.stringify(root, null, 2)}\n`; -} diff --git a/cli/src/domain/models/tool-ids.ts b/cli/src/domain/models/tool-ids.ts deleted file mode 100644 index 050afa33c..000000000 --- a/cli/src/domain/models/tool-ids.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { UnknownAiToolIdError } from "../errors.js"; - -export type AiToolId = "claude" | "cursor" | "copilot" | "opencode" | "codex"; -export type IdeToolId = "vscode"; -export type ToolId = AiToolId | IdeToolId; -export type ToolCategory = "ai" | "ide"; - -export const AI_TOOL_IDS: readonly AiToolId[] = [ - "claude", - "cursor", - "copilot", - "opencode", - "codex", -]; -export const IDE_TOOL_IDS: readonly IdeToolId[] = ["vscode"]; -export const VALID_TOOL_IDS: readonly ToolId[] = [...AI_TOOL_IDS, ...IDE_TOOL_IDS]; - -export function isAiToolId(id: string): id is AiToolId { - return AI_TOOL_IDS.includes(id as AiToolId); -} - -export function parseToolOption(tool: string | undefined): AiToolId[] | "all" { - if (tool === undefined || tool === "all") return "all"; - return [tool as AiToolId]; -} - -export function assertValidAiToolId(tool: string | undefined): void { - if (tool === undefined || tool === "all") return; - if (!isAiToolId(tool)) { - throw new UnknownAiToolIdError(tool, AI_TOOL_IDS); - } -} diff --git a/cli/src/domain/ports/.gitkeep b/cli/src/domain/ports/.gitkeep deleted file mode 100644 index e69de29bb..000000000 diff --git a/cli/src/domain/ports/asset-provider.ts b/cli/src/domain/ports/asset-provider.ts deleted file mode 100644 index e4c062dfc..000000000 --- a/cli/src/domain/ports/asset-provider.ts +++ /dev/null @@ -1,22 +0,0 @@ -import type { ToolId } from "../models/tool-ids.js"; - -export type ConfigAsset = Record | readonly unknown[] | string; - -export interface DefaultMarketplace { - readonly name: string; - readonly source: string; - readonly type: "git"; -} - -export type SchemaName = - | "plugin-manifest" - | "marketplace" - | "claude-marketplace" - | "codex-marketplace" - | "codex-plugin-manifest"; - -export interface AssetProvider { - loadConfigAsset(toolId: ToolId, fileName: string): ConfigAsset; - loadDefaultMarketplace(): DefaultMarketplace; - loadSchema(name: SchemaName): object; -} diff --git a/cli/src/domain/ports/file-merger.ts b/cli/src/domain/ports/file-merger.ts deleted file mode 100644 index d36dfa9d6..000000000 --- a/cli/src/domain/ports/file-merger.ts +++ /dev/null @@ -1,8 +0,0 @@ -import type { FileHash } from "../models/file.js"; -import type { MergeStrategy } from "../models/merge.js"; - -export interface FileMerger { - mergeJsonFile(path: string, content: string, strategy: MergeStrategy): Promise; - backup(absolutePath: string): Promise; - hasLocalChanges(path: string, knownHash: FileHash): Promise; -} diff --git a/cli/src/domain/ports/file-reader.ts b/cli/src/domain/ports/file-reader.ts deleted file mode 100644 index 9659981ad..000000000 --- a/cli/src/domain/ports/file-reader.ts +++ /dev/null @@ -1,21 +0,0 @@ -import type { FileHash } from "../models/file.js"; - -export interface FileReader { - readFile(path: string): Promise; - listDirectory(path: string): Promise; - fileExists(path: string): Promise; - readFileHash(path: string): Promise; - listFilesRecursive(dirPath: string): Promise; - - /** Whether whoever is running this can execute the file — `access(X_OK)`, never a - * permission bit. - * - * Git runs a hook as the person who invoked it, so that is the question `check` is - * actually asking. Reading `mode & 0o111` answered it wrongly on Windows, which records no - * execute bit at all: every readable file reports `0o666` and runs through `sh` regardless, - * so a hook git would happily run was reported as one it would refuse. - * - * Behind the port rather than read from `node:fs` at a call site, so a substituted reader - * cannot answer that a file exists while a real check on the same path throws. */ - isExecutable(path: string): Promise; -} diff --git a/cli/src/domain/ports/hasher.ts b/cli/src/domain/ports/hasher.ts deleted file mode 100644 index 890a2b6a2..000000000 --- a/cli/src/domain/ports/hasher.ts +++ /dev/null @@ -1,5 +0,0 @@ -import type { FileHash } from "../models/file.js"; - -export interface Hasher { - hash(content: string): FileHash; -} diff --git a/cli/src/domain/ports/hook-trust-reader.ts b/cli/src/domain/ports/hook-trust-reader.ts deleted file mode 100644 index c1b651a75..000000000 --- a/cli/src/domain/ports/hook-trust-reader.ts +++ /dev/null @@ -1,19 +0,0 @@ -import type { TelemetryCodexHookTrust } from "../models/telemetry-claim.js"; - -/** - * Whether Codex has trusted this plugin's hook, read the way Codex itself decides it — a - * `[hooks.state."@:hooks/hooks.json::0:0"]` table carrying a - * `trusted_hash`, in `~/.codex/config.toml`. Trust is keyed per entry, exactly on that - * event name: a hook approved under a renamed event inherits no approval, because the key - * it would need to match no longer exists in the file at all — the same absence an - * install that has simply never been approved leaves. - * - * `readable: false` covers everything short of the config file actually opening as text: - * missing, unreadable, or any other fs failure. Neither direction licenses a guess at - * trust — an unread state is not an absent one, and `aidd telemetry check`'s own - * `hook-fired` claim falls back to its generic "never fired" reading rather than - * pretending either way. - */ -export interface HookTrustReader { - read(): Promise; -} diff --git a/cli/src/domain/ports/host-plugin-registry-reader.ts b/cli/src/domain/ports/host-plugin-registry-reader.ts deleted file mode 100644 index 34648e859..000000000 --- a/cli/src/domain/ports/host-plugin-registry-reader.ts +++ /dev/null @@ -1,39 +0,0 @@ -/** - * What a host's own plugin registry says, read from the file that host maintains itself. - * - * A host loads a plugin only once it appears in its own user-global registry, whatever the - * project's settings declare. That is the asymmetry #703 is about: `aidd` writes a - * declaration, the host keeps a registry, and only the second one decides. Measured - * 2026-09-02 across the three hosts that declare a native activation — Claude Code, Codex - * and Copilot — all three key that registry on the same `@` string - * `enabledPlugins` uses, so a reading is a set of refs whatever file it came out of. - * - * One implementation per host, because only the file and its parse differ; the shape below - * is the same for all of them, and a host with no implementation is simply not in the map - * the diagnostic consults — never assumed to agree. - */ -export interface HostPluginRegistryReading { - /** The file consulted, named whatever it answered, so a person can open the same one. */ - readonly location: string; - /** - * Every ref the registry carries, mapped to whether the host records it as enabled. - * - * **Absent, never empty, when the registry could not be read.** An empty map is a real - * answer — the file opened and carries no plugin — and it must not be reachable from a - * file that never opened at all. Keeping the two apart in the type is what stops a - * caller inventing "not registered" out of a permissions error. - */ - readonly refs?: ReadonlyMap; - /** Why the registry could not be read, when it could not: absent, unreadable, or holding - * something this reader will not pretend to understand. Present exactly when `refs` is - * absent. */ - readonly unreadable?: string; -} - -export interface HostPluginRegistryReader { - /** `projectRoot` because a registry may bind a ref to one project rather than to the - * machine — Claude's does, on 100 of the 115 entries measured. A reader whose host records - * no such binding ignores it and says so in its own doc, rather than silently answering a - * narrower question than it was asked. */ - read(projectRoot: string): Promise; -} diff --git a/cli/src/domain/ports/json-schema-validator.ts b/cli/src/domain/ports/json-schema-validator.ts deleted file mode 100644 index c4369b891..000000000 --- a/cli/src/domain/ports/json-schema-validator.ts +++ /dev/null @@ -1,7 +0,0 @@ -/** - * Validates data against a JSON schema. - * Throws JsonSchemaValidationError on validation failure. - */ -export interface JsonSchemaValidator { - validate(schema: object, data: unknown): void; -} diff --git a/cli/src/domain/ports/latest-release-resolver.ts b/cli/src/domain/ports/latest-release-resolver.ts deleted file mode 100644 index 1513f1ed4..000000000 --- a/cli/src/domain/ports/latest-release-resolver.ts +++ /dev/null @@ -1,18 +0,0 @@ -export interface LatestReleaseResolver { - /** Most recent release tag of any kind (used for the CLI self-update repo). */ - resolveLatest(repo: string): Promise; - /** - * Root releases only — semver-style tags (`v4.0.0`, `v3.9.1`), newest first. - * Excludes release-please per-component tags (`aidd-context-v1.0.0`, ...). - * The marketplace manifest lives at repo root, so the root release is the - * correct install unit. - */ - listRootReleases(repo: string): Promise; - /** - * True when the repo is reachable without authentication (public). A private or - * non-existent repo returns 404 to an unauthenticated request; any other failure - * (network, rate-limit) resolves true so a public user is never wrongly gated. - * Used to skip the remote-auth requirement for a public framework source. - */ - isRepoPublic(repo: string): Promise; -} diff --git a/cli/src/domain/ports/manifest-repository.ts b/cli/src/domain/ports/manifest-repository.ts deleted file mode 100644 index fdd36a9a6..000000000 --- a/cli/src/domain/ports/manifest-repository.ts +++ /dev/null @@ -1,11 +0,0 @@ -import type { Manifest } from "../models/manifest.js"; - -export interface ManifestRepository { - /** Where the manifest lives, so a diagnostic can name the file it failed to read rather - * than report a failure a person cannot locate. Mirrors `PersonIdentityStore.filePath` - * and `TelemetrySink.rootDir`, which exist for the same reason. */ - readonly path: string; - load(): Promise; - save(manifest: Manifest): Promise; - delete(): Promise; -} diff --git a/cli/src/domain/ports/marketplace-cache.ts b/cli/src/domain/ports/marketplace-cache.ts deleted file mode 100644 index caabfb3fd..000000000 --- a/cli/src/domain/ports/marketplace-cache.ts +++ /dev/null @@ -1,6 +0,0 @@ -import type { MarketplaceCacheEntry } from "../models/marketplace-cache-entry.js"; - -export interface MarketplaceCachePort { - list(): Promise; - clear(name?: string): Promise; -} diff --git a/cli/src/domain/ports/marketplace-trust-store.ts b/cli/src/domain/ports/marketplace-trust-store.ts deleted file mode 100644 index 50318819e..000000000 --- a/cli/src/domain/ports/marketplace-trust-store.ts +++ /dev/null @@ -1,6 +0,0 @@ -import type { PluginSource } from "../models/plugin-source.js"; - -export interface MarketplaceTrustStore { - isTrusted(projectRoot: string, source: PluginSource): Promise; - trust(projectRoot: string, source: PluginSource): Promise; -} diff --git a/cli/src/domain/ports/native-plugin-activator.ts b/cli/src/domain/ports/native-plugin-activator.ts deleted file mode 100644 index 05196e6f4..000000000 --- a/cli/src/domain/ports/native-plugin-activator.ts +++ /dev/null @@ -1,26 +0,0 @@ -/** - * Drives a tool's native plugin CLI to register marketplaces and enable plugins. - * - * Some tools (Codex, Copilot) only load plugins from user-global state populated by - * their ` plugin` subcommands — writing a project-local config does not enable - * a plugin. Implementations shell out to the tool's CLI binary. Each implementation - * targets one binary; the binary it serves is declared via `NativeActivation.binary`. - */ -export interface NativePluginActivator { - /** Returns true when the tool's CLI binary is callable on PATH. Never throws. */ - isAvailable(): boolean; - /** Registers a marketplace source (local path, `owner/repo[@ref]`, or git URL). Idempotent. */ - addMarketplace(source: string): void; - /** Unregisters a marketplace by name. May throw when absent — callers wrap it best-effort. */ - removeMarketplace(name: string): void; - /** Refreshes marketplace snapshots so plugin installs pick up new versions. */ - upgradeMarketplaces(): void; - /** Installs and enables a plugin referenced as `@`. Idempotent. */ - enablePlugin(pluginRef: string): void; - /** - * Uninstalls a plugin referenced as `@` — the removal - * counterpart of {@link enablePlugin}. May throw when the plugin is already - * absent from the tool's own registry; callers wrap it best-effort. - */ - uninstallPlugin(pluginRef: string): void; -} diff --git a/cli/src/domain/ports/person-identity-reader.ts b/cli/src/domain/ports/person-identity-reader.ts deleted file mode 100644 index 62627fdba..000000000 --- a/cli/src/domain/ports/person-identity-reader.ts +++ /dev/null @@ -1,43 +0,0 @@ -/** What a person chose to attach to records this machine reads locally — never a name - * derived from a git author, an email, or a hostname, and never present unless the person - * turned it on for themselves. `displayName` is a later, separate choice: present only - * once asked for, absent whenever it was not, independent of `personId`. - * - * This is the whole declaration of who this machine's user is: one file, one person, - * nothing beside it. It can only ever describe this one machine's own user — nothing - * here can express a second person, so a claim that two people share one identifier - * cannot be written down at all. - * - * `origin` records how this identity came to be, at the only moment that fact is - * knowable: `"minted"` when this machine generated it, `"adopted"` when it was taken from - * elsewhere so the same person reads as one across machines. No third value is reserved - * for a verification nothing can perform — taking an identity is a declaration the tool - * cannot check, never a proven fact. - * - * `alsoMe` holds identifiers this person did not choose here — one kept from before a - * withdrawal, or a tool's own pseudonymous identifier for them — added onto an identity - * that already exists. The ordinary way to be one person on two machines is to take the - * same identity on both (`origin: "adopted"`), not to add one here; `alsoMe` is for the - * identifiers a person cannot simply carry that way. Required, not optional: an identity - * with nothing added reads back with an empty array, never an invented one and never an - * absent field standing in for "none" — only `displayName` uses absence for "not set". */ -export interface PersonIdentity { - readonly personId: string; - readonly origin: "minted" | "adopted"; - readonly alsoMe: readonly string[]; - readonly displayName?: string; -} - -/** - * What a person identity reader promises: the identifier this machine's own user chose to - * attach, or `null` when nobody did — a missing file, a damaged one, and a default - * installation all answer the same way, since none of them is a choice. Never throws: an - * unreadable identity file costs the identity, not the read a local-read sweep is doing. - * - * Deliberately reads only the OS user's own profile — see the adapter — never - * `AIDD_USER_CONFIG_DIR` and never a project's `.aidd/config.json`. Both are settings a - * repository or a CI job can set, and this choice is not theirs to make. - */ -export interface PersonIdentityReader { - read(): Promise; -} diff --git a/cli/src/domain/ports/person-identity-store.ts b/cli/src/domain/ports/person-identity-store.ts deleted file mode 100644 index d44735157..000000000 --- a/cli/src/domain/ports/person-identity-store.ts +++ /dev/null @@ -1,61 +0,0 @@ -import type { PersonIdentity, PersonIdentityReader } from "./person-identity-reader.js"; - -/** - * What the four `aidd telemetry identity` verbs need beyond `PersonIdentityReader.read()` — - * extends it rather than sitting beside it, so the one adapter that resolves the identity - * file implements exactly one port. - * - * `read()` promises to never throw, because one local-read sweep must not lose every tool's - * figures over a damaged identity file. The identity verbs are the opposite question — a - * person asking what their own state is — so `readStrict()` answers it honestly: a file - * that exists and could not be read back, or does not parse, throws rather than folding - * into "nobody chose". Never `AIDD_USER_CONFIG_DIR`-aware, like the reader: the OS user's - * own profile is the only place this is ever resolved from. - */ -export interface PersonIdentityStore extends PersonIdentityReader { - /** Where the identity file lives, for messages that name it. */ - readonly filePath: string; - - /** Like `read()`, but surfaces a damaged or unreadable file as a throw instead of `null`. */ - readStrict(): Promise; - - /** Generates a fresh identifier and writes it, unconditionally — the caller decides - * whether one is needed at all; a second mint while one already stands is never this - * store's call to make. Records `origin: "minted"`. */ - mint(): Promise; - - /** Writes `personId` as this machine's own identifier, taken from elsewhere rather than - * generated here — records `origin: "adopted"`, and keeps whatever `alsoMe` and - * `displayName` were already declared, since taking a different canonical identifier is - * not a reason to forget them. The caller decides whether adopting is the right move at - * all — reporting "already in effect" for the identifier already in place, or replacing - * one that differs — this store only ever writes what it is told. */ - adopt(personId: string): Promise; - - /** Adds `identity` to the current identity's `alsoMe`, unconditionally — the caller - * decides whether a person exists to add onto at all; this store assumes one does. - * A no-op, not a duplicate, when `identity` is already listed. */ - addAlsoMe(identity: string): Promise; - - /** Withdraws `identity` from the current identity's `alsoMe`, wherever it is. An - * identifier not listed is nothing to remove, never a failure. */ - removeAlsoMe(identity: string): Promise; - - /** Writes `identity` back with `displayName` attached, replacing any previous one. */ - setDisplayName(identity: PersonIdentity, displayName: string): Promise; - - /** Removes the identity file at `path`, answering whether one was actually there. A - * no-op, not a failure, when there was none. - * - * `path` is never resolved inside this method: a caller supplies exactly the value it - * already named — `forget-telemetry-use-case.ts` passes `TelemetryRemovalPreview. - * identity.path`, the same path a person was already shown, so a removal can never reach - * a file the preview never named. `PersonIdentityUseCase.off()` passes `this.store. - * filePath` for the same reason, even though it never previewed separately. - * - * Answers from the filesystem rather than from a parse, because the two disagree: a file - * holding an empty `person_id` parses to "nobody chose" while still existing on disk, so - * a caller inferring removal from `readStrict()` would leave it there forever with no - * verb able to remove it. Only this store can see the file itself. */ - forget(path: string): Promise; -} diff --git a/cli/src/domain/ports/plugin-catalog-repository.ts b/cli/src/domain/ports/plugin-catalog-repository.ts deleted file mode 100644 index c48003b6a..000000000 --- a/cli/src/domain/ports/plugin-catalog-repository.ts +++ /dev/null @@ -1,7 +0,0 @@ -import type { NormalizedPlugin } from "../models/normalized-plugin.js"; -import type { PluginCatalog } from "../models/plugin-catalog.js"; - -export interface PluginCatalogRepository { - load(frameworkPath: string): Promise; - loadForeign(frameworkPath: string): Promise; -} diff --git a/cli/src/domain/ports/plugin-distribution-reader.ts b/cli/src/domain/ports/plugin-distribution-reader.ts deleted file mode 100644 index 21a0e79a8..000000000 --- a/cli/src/domain/ports/plugin-distribution-reader.ts +++ /dev/null @@ -1,5 +0,0 @@ -import type { PluginDistribution } from "../models/plugin-distribution.js"; - -export interface PluginDistributionReader { - read(pluginRoot: string): Promise; -} diff --git a/cli/src/domain/ports/raw-catalog-fetcher.ts b/cli/src/domain/ports/raw-catalog-fetcher.ts deleted file mode 100644 index ce0ca67f1..000000000 --- a/cli/src/domain/ports/raw-catalog-fetcher.ts +++ /dev/null @@ -1,5 +0,0 @@ -import type { PluginSourceGitHub } from "../models/plugin-source.js"; - -export interface RawCatalogFetcher { - fetchCatalog(source: PluginSourceGitHub, catalogPath: string, cacheDir: string): Promise; -} diff --git a/cli/src/domain/ports/run-journal-reader.ts b/cli/src/domain/ports/run-journal-reader.ts deleted file mode 100644 index ee77f7b60..000000000 --- a/cli/src/domain/ports/run-journal-reader.ts +++ /dev/null @@ -1,184 +0,0 @@ -/** One `step_start` line from a session's run journal: a step's own start, and the - * skill name recorded for it. Mirrors what `plugins/aidd-telemetry/hooks/lib/record.cjs`'s - * `buildStepStartLine` writes. No end is ever carried — the journal was deliberately - * written without one, since no tool measured so far exposes when a skill's work finishes; - * an interval's end is the reader's own derivation, not a fact on this line. */ -export interface RunJournalStepStart { - readonly type: "step_start"; - readonly at: string; - readonly skill: string; - /** The host's own identifier for the prompt this step opened under, where it hands one to - * a hook — Claude Code's `prompt_id`. Named `turn_id` on the line because that is what - * `buildStepStartLine` has always written; it is a prompt, not a turn, and three steps - * opened under one prompt share it. - * - * Matched against a record's `prompt_id`, it attributes a step **exactly** rather than by - * asking which interval a moment fell in — the only reading that survives two tasks - * advancing at once. Absent for every host that hands its hooks no such identifier. */ - readonly turn_id?: string; -} - -/** One `turn_end` line: closes whatever step was open, even where no further step opens - * before the turn itself ends. */ -export interface RunJournalTurnEnd { - readonly type: "turn_end"; - readonly at: string; -} - -/** One `step_end` line: the moment a skill said its own work was over, and the skill it - * says it for. Mirrors `plugins/aidd-telemetry/hooks/lib/record.cjs`'s `buildStepEndLine`. - * - * The one thing about a step no host emits, which is why the skill declares it and the hook - * writes it (`plugins/aidd-telemetry/hooks/lib/step-ends.cjs`). Carries its skill, and closes only that skill's own - * open interval: closing "whatever is open" would close the wrong one the moment a skill - * invokes another, and an end naming a skill this session never started closes nothing at - * all rather than truncating whatever was running. */ -export interface RunJournalStepEnd { - readonly type: "step_end"; - readonly at: string; - readonly skill: string; -} - -export type RunJournalBoundary = RunJournalStepStart | RunJournalTurnEnd | RunJournalStepEnd; - -/** The `session_start` line: the one line naming what a session was. `tool` holds the - * journal hook's own host identifier ("claude-code", "codex", "copilot", "cursor"), which - * is not an `AiToolId` — `journalHostToAiToolId` in `domain/tools/registry.ts` is the only - * place the two are related, and it reads a declaration rather than a table. */ -export interface RunJournalSessionStart { - readonly type: "session_start"; - readonly at: string; - /** The schema the hook stamped this journal with, absent for a journal written before - * this reader looked at the field. Read and carried, never derived: which schema a file - * was written under is the writer's statement about it, and a reader that infers one - * from the shapes it happens to recognise is exactly the silent misreading the field - * exists to prevent. */ - readonly schema_version?: number; - readonly run_id: string; - readonly tool: string; - readonly vendor_id: string; - readonly project_id?: string; - /** The git remote this session's repository resolved to, absent for a repository with - * none. Carried beside `project_id` rather than replacing it, the same shape - * `record.cjs`'s own `session_start` line writes. */ - readonly project_remote?: string; - /** Git's own name for the linked worktree this session ran in, so two worktrees - * of one repository are distinguishable in a journal. Absent — never `""` — for a plain - * checkout, which is the common case and is not an unknown worktree. */ - readonly worktree_id?: string; - /** The repository those worktrees share, named from `--git-common-dir`. Recorded beside - * `worktree_id` rather than left to `project_id`, which falls back to the worktree's own - * directory name when a clone has no remote. Absent whenever `worktree_id` is. */ - readonly worktree_repo_id?: string; - /** The plugin's own version, read from its manifest by `record.cjs`'s - * `buildSessionStartLine` at the moment this line was written - never the framework's - * version, and never the CLI's, which stamps only the record it stores, not the journal - * line beside it (see `TelemetrySinkRecord.cli_version`). Absent for a line the hook - * could not read its own manifest to stamp, and for any line written before this field - * existed - either way reads as an unknown version, never as a default or a guess. */ - readonly plugin_version?: string; -} - -/** A `file_written` line: a repository-relative, "/"-separated path a session wrote inside - * a task folder, and when. Deliberately carries no task identity — the hook that writes it - * refuses to store a derivation as a fact, so deriving the task is the reader's job. */ -export interface RunJournalFileWritten { - readonly type: "file_written"; - readonly at: string; - readonly path: string; -} - -/** A `task_declared` line: a tool call named a file under a task folder, so this session is - * on that task from here on — told rather than inferred, the way `step_start` names a - * skill. Carries no task identity for the same reason `file_written` does not: `path` is - * the same repository-relative shape, and deriving the task from it is `task-identity.ts`'s - * job. Deliberately kept out of `RunJournalBoundary`: what opens or closes a step is a - * `step_start` and a `step_end` naming its skill, and a declaration is neither. It reaches - * every interval walk all the same - `buildTaskIntervals`, `buildFlowIntervals` and - * `buildStepIntervals` each merge this array and `filesWritten` into `boundaries` before - * walking - but as a moment the journal witnessed, never as a boundary that ends something. - * The type keeps the two apart so a later reader cannot confuse them by accident. */ -export interface RunJournalTaskDeclared { - readonly type: "task_declared"; - readonly at: string; - readonly path: string; -} - -/** What the journal side promises a reader, for one session's run file, in file order — - * lines read, nothing derived. Deriving intervals from `boundaries` is `domain/models/ - * step-attribution.ts`'s job; deriving a task from `filesWritten` is the cost report's. - * - * `boundaries` was once all of this: step attribution needed nothing else, and this port - * said so. It is no longer the whole readership. A report has to know which tool and which - * project a session belonged to, and which task it wrote into, and both facts are already - * lines in the same file — so the exclusion was scoped to step attribution, never to the - * journal as a source. `session` is optional because a file whose first line is torn is - * still worth its boundaries. */ -export interface RunJournal { - readonly boundaries: readonly RunJournalBoundary[]; - readonly session?: RunJournalSessionStart; - readonly filesWritten: readonly RunJournalFileWritten[]; - readonly taskDeclarations: readonly RunJournalTaskDeclared[]; -} - -/** - * What a run-journal reader promises: the boundaries recorded for one session, or - * `null` when nothing can be said about it — no run file for this session, an unreadable - * runs directory, telemetry that was never enabled. Never throws: a missing, unreadable or - * truncated journal costs attribution, not the read itself, so a session with no journal at - * all yields the same figures it would without this port existing. - * - * Read-only on purpose: `diagnose-telemetry-use-case.ts`, `report-cost-use-case.ts` and - * `read-local-cost-use-case.ts` each need to read a journal and none of them should be - * handed something that can delete one. `RunJournalStore` below extends this the same way - * `PersonIdentityStore` extends `PersonIdentityReader` — one adapter implements both, but a - * caller that only reads is typed so it cannot reach for the verb that removes. - */ -export interface RunJournalReader { - read(sessionId: string): Promise; - /** Every session the journal holds, for a caller that has no identifier to ask about — - * a report covers a stretch of time, and the sessions inside it are what it is looking - * for. Filtering to a period is the caller's, from each journal's own `session.at`: the - * run file's name carries no date. Never throws, for the same reason `read` does not; a - * missing or unreadable runs directory answers an empty list. */ - list(): Promise; - /** Every run file's own name, directly from the directory — never opened, never - * parsed. Distinct from `list()`, which reads and can silently drop a file it cannot - * parse: a caller counting what removing this journal would touch needs a name a - * damaged file still has, not a count that only survives files still readable. Never - * throws; a missing or unreadable runs directory answers an empty list, the same - * failure direction as `list()`. */ - listRunFiles(): Promise; - /** The schema stated by every journal this reader refused to read, one entry per file. - * `list()` drops such a journal outright — reading it would mean guessing that whatever - * lines this parser still recognises mean what they used to — and a caller shown only - * that emptiness would report a missing or torn file about one whose header it parsed - * perfectly well. Empty is the ordinary answer: every journal on disk states the schema - * this build reads, or states none at all. Never throws, like everything else here. */ - listForeignSchemas(): Promise; -} - -/** - * What `ForgetTelemetryUseCase` needs beyond a plain read — extends `RunJournalReader` - * rather than sitting beside it, so the one adapter that resolves the runs directory - * implements exactly one port, the same shape `PersonIdentityStore` already uses over - * `PersonIdentityReader`. - */ -export interface RunJournalStore extends RunJournalReader { - /** Where this project's run journal lives — the same directory `read`/`list` and - * `listRunFiles` resolve, exposed so a caller that only needs to name the location - * (never open a file in it) has one place to ask, rather than re-deriving the same - * `AIDD_RUNS_DIR`-aware resolution itself. This is also the value `ForgetTelemetryUseCase` - * carries into `TelemetryRemovalPreview.journal.path` — `deleteRunFile` below never - * re-derives it, it is handed back exactly what this named. */ - readonly runsDir: string; - /** Removes one run file, by the name `listRunFiles()` named it with, from `dir` — - * mirrors `TelemetrySink.deleteDayFile`. `dir` is never resolved inside this method: the - * caller (`forget-telemetry-use-case.ts`) passes `TelemetryRemovalPreview.journal.path`, - * the exact directory a person was already shown, so a removal can never reach a - * directory the preview never named — see that value's own doc for why. `fileName` must - * name exactly one entry directly inside `dir` (`isBareFileName`); anything else, - * including a relative walk out of it, is refused rather than deleted. A no-op, not a - * failure, when the name is already gone. */ - deleteRunFile(dir: string, fileName: string): Promise; -} diff --git a/cli/src/domain/ports/self-updater.ts b/cli/src/domain/ports/self-updater.ts deleted file mode 100644 index e3ddb531c..000000000 --- a/cli/src/domain/ports/self-updater.ts +++ /dev/null @@ -1,10 +0,0 @@ -export interface CliRelease { - version: string; - /** Release notes, or null when no changelog is available (e.g. private repo, no token). */ - changelog: string | null; -} - -export interface SelfUpdater { - fetchLatestRelease(): Promise; - install(): string; -} diff --git a/cli/src/domain/ports/session-cost-reader.ts b/cli/src/domain/ports/session-cost-reader.ts deleted file mode 100644 index c095f4117..000000000 --- a/cli/src/domain/ports/session-cost-reader.ts +++ /dev/null @@ -1,58 +0,0 @@ -import type { TelemetrySinkRecord } from "../models/telemetry-sink-record.js"; - -/** What a per-tool local reader returns: every field of the stored shape except the four - * the caller stamps uniformly across every tool — `sink_schema_version`, `provenance`, - * `tool`, and `step_attribution`. A reader that could set `provenance` itself could also - * claim to be an export it is not; `tool` joins the same omission list for the same reason - * — a reader that could name itself could name another. `step_attribution` joins it too: a - * reader that could stamp `"journal-interval"` could claim a derivation it never performed, - * and only the caller, which alone reads the run journal, may say that source was used. A - * reader may still set `step` (and `step_plugin` beside it) on a returned candidate — doing - * so *is* the tool-stated fact, read straight off the tool's own file, and the caller reads - * that presence to resolve `step_attribution` to `"tool-stated"` rather than falling back to - * a journal interval. */ -export type LocalCostCandidateRecord = Omit< - TelemetrySinkRecord, - "sink_schema_version" | "provenance" | "tool" | "step_attribution" ->; - -/** What a reader answers with. `sessionFound` separates the two silences a bare empty list - * conflates: a tool that held this session and recorded nothing billable, and a tool that - * held no trace of it at all. A report that printed both as zero would let a session read - * as free when in truth it was never found — the failure this whole layer exists to make - * impossible. */ -export interface LocalCostReadResult { - readonly records: readonly LocalCostCandidateRecord[]; - readonly sessionFound: boolean; -} - -/** - * What a per-tool local reader promises: given the session identity a run-journal entry - * already carries, return the records that tool's own file holds for it — nothing more, - * nothing else joined in. `read` never throws when the tool wrote no file for that - * session; it answers `sessionFound: false` with no records, which is a session this tool - * has no trace of rather than an error. - * - * Every returned record's `vendor_id` equals the `sessionId` passed in, so a caller never - * resolves identity twice. `turn_id`, when the tool's file carries a stable per-record - * identifier, is how a re-read is matched against what is already stored — a reader whose - * tool has none leaves `turn_id` unset rather than inventing one; a synthesised key that is - * not stable across reads is worse than an absent one, and records left unmatched by one - * are simply appended again rather than deduplicated. - */ -export interface SessionCostReader { - read(sessionId: string): Promise; -} - -/** - * What a per-line transcript format hands the streaming adapter: `push` for every line in - * file order, `build` once the file is exhausted. Stateful because not every tool's format - * maps one line to one record — Codex's spans a `turn_context` line and the `token_count` - * lines that follow it — while a format with no such pairing simply ignores everything but - * the current line. Declared here, in the port, so a domain format module can implement it - * without importing the infrastructure adapter that drives it. - */ -export interface TranscriptLineAccumulator { - push(line: string): void; - build(): readonly LocalCostCandidateRecord[]; -} diff --git a/cli/src/domain/ports/task-backlog-reader.ts b/cli/src/domain/ports/task-backlog-reader.ts deleted file mode 100644 index 31015ced6..000000000 --- a/cli/src/domain/ports/task-backlog-reader.ts +++ /dev/null @@ -1,20 +0,0 @@ -import type { TaskBacklogDeclaration } from "../models/task-backlog-link.js"; - -/** - * What a task backlog reader promises: one task folder's declaration, never a throw. A - * missing file answers `{ kind: "none" }` — a normal state, not an error — and a file that - * exists but cannot be parsed answers `{ kind: "unreadable" }`, so a report can distinguish - * "this task delivers nothing on the backlog" from "this task's declaration is damaged" - * without either costing the whole period its figures. - * - * **Never writes.** Reading what work cost must not modify the work — the property that - * lets a report run against a checkout someone else owns, and that keeps a read from ever - * being the thing that introduced the very drift it was asked to measure. An implementation - * must hold this as an invariant, not merely as today's behaviour. - */ -export interface TaskBacklogReader { - /** `taskFolderPath` is project-relative, in the shape `taskFolderPathFromIdentity` - * produces (`aidd_docs/tasks///`). Resolving it against a project root, and - * every other filesystem concern, is the adapter's job. */ - read(taskFolderPath: string): Promise; -} diff --git a/cli/src/domain/ports/telemetry-evidence-reader.ts b/cli/src/domain/ports/telemetry-evidence-reader.ts deleted file mode 100644 index 575a44605..000000000 --- a/cli/src/domain/ports/telemetry-evidence-reader.ts +++ /dev/null @@ -1,65 +0,0 @@ -import type { TelemetryExportLeftover } from "../models/telemetry-export-leftover.js"; -import type { TelemetryRecorderDeclarationSetup } from "../models/telemetry-setup.js"; - -/** The unrecognised-payload marker `hooks/lib/record.cjs` writes when a payload arrived - * and matched no host this build declares — read by name, never through the run journal - * reader, whose parser would leave it indistinguishable from a torn run file. */ -export interface TelemetryUnrecognisedPayload { - readonly at: string; -} - -/** The project's own switch file, read for what `TelemetryAllowedSetup` needs beyond - * `isTelemetryEnabled`'s plain boolean: the file's own `enabled` value, and whether the - * file could be read at all. Never folds in the person's own refusal — that is a separate, - * non-file fact the caller reads from `env` directly, the same way - * `resolveTelemetryEnabled` keeps the two apart. */ -export interface TelemetrySwitchSetupRead { - readonly path: string; - /** The file's own `enabled` value. Meaningless when `readable` is `false` — always - * `false` there, the same "damaged reads as off" direction `resolveTelemetryEnabled` - * already takes for a switch that decides the gate. */ - readonly enabled: boolean; - /** `true` for a file that is absent (nothing here is a person's choice yet) or that - * parses as a valid switch. `false` only for a file that exists but could not be read or - * parsed — a damaged file, not a choice. */ - readonly readable: boolean; -} - -/** - * The evidence `aidd telemetry check` and `aidd telemetry off` need beyond the run journal - * and each tool's own local reader — both already served by `RunJournalReader` and the - * `SessionCostReader` map `ReadLocalCostUseCase` uses — and beyond Codex's hook trust, which - * has its own dedicated port. This one covers what is left: whether the project switch is - * on, the unrecognised-payload marker, and whether a settings file still carries a stale - * export configuration nothing here can clear any more. A read that fails answers with the - * evidence that says so (`false`/`null`/`[]`) — never throws, the same rule - * `PersonIdentityReader.read()` follows, so one damaged file cannot cost every other claim - * its verdict. - */ -export interface TelemetryEvidenceReader { - /** `.aidd/config.json`'s `telemetry.enabled`, read the way the hook itself reads it — - * strict `=== true`, so a half-written config counts as off, never as on — and overridden - * to `false` by the person's own refusal (`AIDD_TELEMETRY=0`), which wins unconditionally - * over whatever the project's file says. */ - isTelemetryEnabled(projectRoot: string, env: NodeJS.ProcessEnv): Promise; - - readUnrecognisedPayload(projectRoot: string): Promise; - - /** Every settings file this build knows how to check that still carries a key - * `aidd telemetry endpoint` used to write, before that command was deleted — detection - * only, see `telemetry-export-leftover.ts`. Empty for a machine with nothing left over, - * not proof one was never configured: only the locations this build knows to look at are - * checked. */ - findLeftoverExportConfig(projectRoot: string): Promise; - - /** The project's switch file itself — see `TelemetrySwitchSetupRead` for why this is - * separate from `isTelemetryEnabled`. */ - readSwitchSetup(projectRoot: string): Promise; - - /** Whether the recorder is declared anywhere this build knows to check — the AIDD - * manifest and a tool's own settings file. See `TelemetryRecorderDeclarationSetup` for - * what "declared" does and does not promise. Never throws: a manifest or a settings file - * that cannot be parsed reads as "not declared there", the same failure direction every - * other read on this port already takes. */ - readRecorderDeclaration(projectRoot: string): Promise; -} diff --git a/cli/src/domain/ports/telemetry-sink.ts b/cli/src/domain/ports/telemetry-sink.ts deleted file mode 100644 index 8db31ed0c..000000000 --- a/cli/src/domain/ports/telemetry-sink.ts +++ /dev/null @@ -1,76 +0,0 @@ -import type { TelemetrySinkRecord } from "../models/telemetry-sink-record.js"; - -export interface TelemetrySinkAppendResult { - readonly filePath: string; - readonly dayFileIsNew: boolean; -} - -/** What a period read returns. - * - * `records` are the ones whose `event_timestamp` falls inside the period — when the work - * ran, which is what "a period" plainly means. It is deliberately not the day file's own - * name: a session read locally days after it happened lands in the day file for the day it - * was *stored*, so selecting by file name would put a July session in August's total and - * look right doing it. - * - * `undated` are the records carrying no moment at all. They are handed back rather than - * placed anywhere, because the only other moment available is the day the line was - * appended, and that is a fact about receiving rather than about working. A caller names - * them; it never folds them into a period. - * - * `skippedLines` is not diagnostics: a report built from a partial read is - * indistinguishable from a complete one unless the count travels with the records, and a - * total that quietly omits lines is the failure this layer exists to prevent. */ -/** Every value a filterable field has carried, anywhere this sweep looked - not only in - * the period returned. Telling a filter naming something that never existed apart from - * one that simply had no work in this period only stays cheap because these are gathered - * from the same bytes `records` already comes from, never a second read. */ -export interface TelemetrySinkKnownValues { - readonly projects: ReadonlySet; - readonly steps: ReadonlySet; - readonly models: ReadonlySet; -} - -export interface TelemetrySinkPeriodRead { - readonly records: readonly TelemetrySinkRecord[]; - readonly undated: readonly TelemetrySinkRecord[]; - readonly skippedLines: number; - readonly knownValues: TelemetrySinkKnownValues; -} - -/** Separate from `FileWriter`/`FileReader`: a day file is append-only for its whole life, - * never rewritten in place. `readRecordsForVendor` is the one read: a local re-read needs - * to know what is already stored for a session before it appends, or every read would - * double what came before. */ -export interface TelemetrySink { - readonly rootDir: string; - /** How `rootDir` was decided. `"user-config-dir"` is the one a caller has to react to: it - * means this person set `AIDD_USER_CONFIG_DIR`, which also relocates `auth.json`, so - * sharing this directory shares a GitHub token. Named on the port rather than left inside - * the adapter because the warning belongs where a person is looking, and only a command - * knows that. */ - readonly locatedBy: "telemetry-dir" | "user-config-dir" | "default"; - ensureWritable(): Promise; - appendRecord(record: TelemetrySinkRecord, at: Date): Promise; - listDayFiles(): Promise; - /** Removes one day file, by the name `listDayFiles()` named it with, from `dir`. `dir` - * is never resolved inside this method: `forget-telemetry-use-case.ts` passes - * `TelemetryRemovalPreview.sink.path`, and `read-local-cost-use-case.ts`'s own retention - * prune passes `this.rootDir` — either way, the caller supplies the exact directory it - * already named, this method never re-derives one of its own. `fileName` must name - * exactly one entry directly inside `dir`; anything else, including a relative walk out - * of it, is refused rather than deleted. A no-op, not a failure, when the name is already - * gone. */ - deleteDayFile(dir: string, fileName: string): Promise; - /** Every stored record whose `vendor_id` matches, across every day file. A line that - * cannot be parsed is skipped rather than failing the whole scan — a torn final line - * from a concurrent write must not block reading an unrelated session. */ - readRecordsForVendor(vendorId: string): Promise; - /** Every stored record whose own moment falls in an inclusive range of UTC days, - * whatever session it belongs to. Separate from `readRecordsForVendor` because a report - * asks about a stretch of time, not about a session it already knows the name of. Every - * day file is read: a record's moment and the file it landed in are different days - * whenever a session is read after the fact. Skips a line it cannot read for the same - * reason the per-vendor read does, and counts what it skipped. */ - readRecordsInPeriod(fromDay: Date, toDay: Date): Promise; -} diff --git a/cli/src/domain/ports/version-control.ts b/cli/src/domain/ports/version-control.ts deleted file mode 100644 index 00cc17862..000000000 --- a/cli/src/domain/ports/version-control.ts +++ /dev/null @@ -1,63 +0,0 @@ -import type { TelemetryCommitTrailerSetup } from "../models/telemetry-setup.js"; - -export interface VersionControl { - getRemoteUrl(repoRoot: string): Promise; - - /** Installs `delegateFile` beside the repository's hooks and adds one line to - * `prepare-commit-msg` calling it, answering whether that line was newly added. An - * existing hook is appended to, never replaced: a repository already running lefthook or - * husky keeps what it has. - * - * `false` both when the line is already there and when there is no repository to install - * into — neither is a failure, and neither leaves anything to report. Where the hooks - * directory actually is comes from git itself, never from `.git/hooks` assumed: a - * `core.hooksPath` pointing elsewhere is exactly the configuration under which a hook - * written to the assumed path is never run, and never says so. */ - installCommitMessageDelegate( - projectRoot: string, - delegateFile: string, - script: string - ): Promise; - - /** Undoes it: drops the line from `prepare-commit-msg` and deletes the delegate, - * answering whether anything was there to remove. Leaves a hook file holding other lines - * exactly as it found it, minus the one line — the counterpart of never having replaced - * it on the way in. */ - removeCommitMessageDelegate(projectRoot: string, delegateFile: string): Promise; - /** Every tracked path matching `pathspec`, relative to `repoRoot` — empty, never a - * throw, when there is no repository at all or nothing matches — the rule the plugin's - * own `warnIfTracked` read by before the CLI took this over: a project outside git still - * has to turn telemetry on quietly, so this can never be the reason that fails. */ - listTrackedFiles(repoRoot: string, pathspec: string): Promise; - - /** Whether `cwd` sits inside a git repository at all — read the way the hook itself - * reads it (`git rev-parse --show-toplevel`), never a throw. `aidd telemetry check`'s - * own gate: the journal writes nowhere without a repository, which is what tells that - * apart from a hook that fired and simply left no trace. */ - isRepository(cwd: string): Promise; - - /** Whether git's *history* — not the index `listTrackedFiles` reads — holds at least one - * commit touching `pathspec`. The two can disagree: a file `git add`ed and never - * committed is tracked (in the index) while history holds nothing for it yet, in a - * repository with zero commits or with a thousand unrelated ones. Never a throw — no - * commits yet, no repository at all, or git itself missing all read as "no history", - * the same failure direction as `listTrackedFiles`. `aidd telemetry forget`'s own gate on - * over-asserting what history holds: this is the call that separates "tracked now" from - * "actually committed". */ - hasHistoryFor(repoRoot: string, pathspec: string): Promise; - - /** Everything `aidd telemetry check` says about the commit trailer, gathered in one place - * because every part of it is a git question: where git runs hooks from, what is in that - * directory, and what the last commits actually carry. - * - * `limit` is how many commits to look back over — a count rather than a date, so the - * answer costs the same on a repository of ten commits and one of a million. Never a - * throw: no repository, no commits, or no git at all each leave the fields that need one - * absent rather than failing the diagnostic that exists to describe them. */ - readCommitTrailerSetup( - projectRoot: string, - delegateFile: string, - trailerToken: string, - limit: number - ): Promise; -} diff --git a/cli/src/domain/tools/ai/claude.ts b/cli/src/domain/tools/ai/claude.ts deleted file mode 100644 index 2575ebc6a..000000000 --- a/cli/src/domain/tools/ai/claude.ts +++ /dev/null @@ -1,161 +0,0 @@ -import { AgentsCapability } from "../../capabilities/agents-capability.js"; -import { CommandsCapability } from "../../capabilities/commands-capability.js"; -import { buildDefaultMarketplaceEntry } from "../../capabilities/marketplace-entry.js"; -import { McpCapability } from "../../capabilities/mcp-capability.js"; -import { PluginsCapability } from "../../capabilities/plugins-capability.js"; -import { RulesCapability } from "../../capabilities/rules-capability.js"; -import { SkillsCapability } from "../../capabilities/skills-capability.js"; -import { CLAUDE_CODE_TRANSCRIPT_LOCATION } from "../../formats/claude-code-transcript.js"; -import { - convertCommandFrontmatter, - detectSectionKeyFromPrefixes, - reverseConvertCommandFrontmatter, - stripToolSuffix, -} from "../../formats/command.js"; -import { baseReverseRewriteContent, baseRewriteContent } from "../../formats/placeholders.js"; -import { CLAUDE_PLUGIN_ROOT_TOKEN } from "../../formats/plugin-root-token-rewrite.js"; -import { CONFIG_MCP } from "../../models/framework.js"; -import type { - AiTool, - HasAgents, - HasCommands, - HasMcp, - HasPlugins, - HasRules, - HasSkills, - UserFileSectionKey, -} from "../contracts.js"; -import { registerTool } from "../registry.js"; - -const DIRECTORY = ".claude/"; -const TOOL_SUFFIX = ".claude.md"; - -function commandsDir(phase: string): string { - return `${DIRECTORY}commands/aidd/${phase}/`; -} - -export const claude: AiTool = - { - kind: "ai", - toolId: "claude", - displayName: "Claude Code", - directory: DIRECTORY, - toolSuffix: TOOL_SUFFIX, - signalDir: ".claude/commands", - configOutputPaths: { "settings.json": ".claude/settings.json" }, - - capabilities: { - agents: new AgentsCapability({ - directory: DIRECTORY, - toolSuffix: TOOL_SUFFIX, - format: "markdown", - }), - skills: new SkillsCapability({ - directory: DIRECTORY, - toolSuffix: TOOL_SUFFIX, - buildInstallPath: (fileName) => - `${DIRECTORY}skills/${stripToolSuffix(TOOL_SUFFIX, fileName)}`, - convertFrontmatter: (fm) => fm, - reverseConvertFrontmatter: (fm) => fm, - }), - commands: new CommandsCapability({ - directory: DIRECTORY, - toolSuffix: TOOL_SUFFIX, - buildInstallPath: (fileName) => { - const slashIdx = fileName.indexOf("/"); - if (slashIdx !== -1) { - const phaseDir = fileName.slice(0, slashIdx); - const rest = fileName.slice(slashIdx + 1); - const phase = phaseDir.match(/^(\d+)/)?.[1]; - if (phase) return `${commandsDir(phase)}${rest}`; - } - return `${DIRECTORY}commands/${stripToolSuffix(TOOL_SUFFIX, fileName)}`; - }, - convertFrontmatter: (fm, relativeFileName) => - convertCommandFrontmatter(fm, relativeFileName), - reverseConvertFrontmatter: (fm) => reverseConvertCommandFrontmatter(fm), - }), - rules: new RulesCapability({ - directory: DIRECTORY, - toolSuffix: TOOL_SUFFIX, - buildInstallPath: (fileName) => - `${DIRECTORY}rules/${stripToolSuffix(TOOL_SUFFIX, fileName)}`, - convertFrontmatter: (fm) => { - if ("paths" in fm) { - const paths = fm.paths; - if (Array.isArray(paths) && paths.length === 0) return {}; - return { paths }; - } - if ("globs" in fm) return { paths: fm.globs }; - if ("alwaysApply" in fm) { - if (fm.alwaysApply === false && fm.description !== undefined) { - return { description: fm.description }; - } - return {}; - } - return {}; - }, - reverseConvertFrontmatter: (fm) => - Array.isArray(fm.paths) && fm.paths.length > 0 ? { paths: fm.paths } : {}, - }), - mcp: new McpCapability({ - outputPath: ".mcp.json", - format: "json", - entrySection: "mcpServers", - consumes: [CONFIG_MCP], - }), - plugins: new PluginsCapability({ - mode: "native", - pluginsDir: ".claude/plugins/", - pluginManifestRelativePath: "plugin.json", - acceptsHooks: true, - pluginRootToken: CLAUDE_PLUGIN_ROOT_TOKEN, - acceptsMcp: true, - translationMode: "marketplace", - marketplaceSettings: { - settingsPath: ".claude/settings.json", - settingsKey: "extraKnownMarketplaces", - enabledPluginsKey: "enabledPlugins", - toEntry: buildDefaultMarketplaceEntry, - }, - // Measured: `claude -p` reads its own user-global plugin registry, not - // the project-local settings.json declaration above — see claude-cli-adapter.ts. - nativeActivation: { binary: "claude" }, - }), - }, - - // Measured 2026-08-20: an assistant message in ~/.claude/projects/*/*.jsonl carries - // `message.usage`'s four counters and `message.model`, keyed on `requestId`. See - // claude-code-transcript.ts for the full measurement and its two captured fixtures. - telemetryLocalRead: { - kind: "declared", - transcript: CLAUDE_CODE_TRANSCRIPT_LOCATION, - // The mirror image of the export: the transcript names the running skill exactly, on - // the same line as the counters, and carries no amount at all. - supplies: { tokenCounters: true, amount: false, toolStatedStep: true, agentName: true }, - }, - telemetryTaskAttributable: true, - telemetryJournalHost: "claude-code", - - rewriteContent(content: string, docsDir: string): string { - return baseRewriteContent(content, DIRECTORY, docsDir).replace( - /(@?)\.claude\/commands\/(\d+)[_][^/]+\//g, - (_, at, phase) => `${at}${commandsDir(phase)}` - ); - }, - - reverseRewriteContent(content: string, docsDir: string): string { - return baseReverseRewriteContent(content, DIRECTORY, docsDir); - }, - - detectUserFileSectionKey(relativePath: string): UserFileSectionKey | null { - return detectSectionKeyFromPrefixes(relativePath, [ - [`${DIRECTORY}agents/`, "agents"], - [`${DIRECTORY}commands/aidd/`, "commands"], - [`${DIRECTORY}rules/`, "rules"], - [`${DIRECTORY}skills/`, "skills"], - ]); - }, - }; - -registerTool(claude); diff --git a/cli/src/domain/tools/ai/codex.ts b/cli/src/domain/tools/ai/codex.ts deleted file mode 100644 index 1c440e6b1..000000000 --- a/cli/src/domain/tools/ai/codex.ts +++ /dev/null @@ -1,300 +0,0 @@ -import { AgentsCapability } from "../../capabilities/agents-capability.js"; -import { CommandsCapability } from "../../capabilities/commands-capability.js"; -import { HooksCapability } from "../../capabilities/hooks-capability.js"; -import { McpCapability } from "../../capabilities/mcp-capability.js"; -import { PluginsCapability } from "../../capabilities/plugins-capability.js"; -import { RulesCapability } from "../../capabilities/rules-capability.js"; -import { SkillsCapability } from "../../capabilities/skills-capability.js"; -import { CODEX_ROLLOUT_LOCATION } from "../../formats/codex-rollout.js"; -import { - buildAiddCommandFilePath, - convertCommandFrontmatter, - detectSectionKeyFromPrefixes, - reverseConvertCommandFrontmatter, - stripToolSuffix, -} from "../../formats/command.js"; -import { baseReverseRewriteContent, baseRewriteContent } from "../../formats/placeholders.js"; -import { PLUGIN_ROOT_TOKEN } from "../../formats/plugin-root-token-rewrite.js"; -import { parseToml, stringifyToml } from "../../formats/toml.js"; -import { CONFIG_MCP } from "../../models/framework.js"; -import type { - AiTool, - HasAgents, - HasCommands, - HasHooks, - HasMcp, - HasPlugins, - HasRules, - HasSkills, - UserFileSectionKey, -} from "../contracts.js"; -import { registerTool } from "../registry.js"; - -const DIRECTORY = ".codex/"; -const TOOL_SUFFIX = ".codex.md"; -const AGENTS_SKILLS_PREFIX = ".agents/skills/"; - -const SKILLS_TO_AGENTS_RE = /\.codex\/skills\//g; -const AGENTS_SKILLS_PLAIN_RE = /\.agents\/skills\/aidd-/g; - -function remapSkillPaths(content: string): string { - return content.replace(SKILLS_TO_AGENTS_RE, ".agents/skills/aidd-"); -} - -function reverseSkillPaths(content: string): string { - return content.replace(AGENTS_SKILLS_PLAIN_RE, ".codex/skills/"); -} - -export function rewriteCodexContent( - content: string, - context: { directory: string; docsDir: string } -): string { - const step1 = baseRewriteContent(content, context.directory, context.docsDir); - const step2 = remapSkillPaths(step1); - return step2.replace( - /(@?)\.codex\/commands\/(\d+)[_-][^/]+\/([^\s]+)/g, - "$1.codex/commands/aidd/$2/$3" - ); -} - -export function reverseRewriteCodexContent(content: string, docsDir: string): string { - const step1 = reverseSkillPaths(content); - return baseReverseRewriteContent(step1, DIRECTORY, docsDir); -} - -const MIN_PROJECT_DOC_MAX_BYTES = 262144; -const CONFIG_CODEX_HOOKS = "codex-hooks"; - -type TomlRecord = Record; - -function parseSafe(content: string): TomlRecord { - if (!content.trim()) return {}; - try { - return parseToml(content); - } catch { - return {}; - } -} - -function mergeMcpServers(existing: TomlRecord, incoming: TomlRecord): void { - const incomingServers = incoming.mcp_servers as TomlRecord | undefined; - if (!incomingServers) return; - const existingServers = (existing.mcp_servers ?? {}) as TomlRecord; - for (const [name, value] of Object.entries(incomingServers)) { - if (!(name in existingServers)) { - existingServers[name] = value; - } - } - existing.mcp_servers = existingServers; -} - -function ensureProjectDocMaxBytes(existing: TomlRecord, incoming: TomlRecord): void { - const existingVal = - typeof existing.project_doc_max_bytes === "number" ? existing.project_doc_max_bytes : 0; - const incomingVal = - typeof incoming.project_doc_max_bytes === "number" - ? incoming.project_doc_max_bytes - : MIN_PROJECT_DOC_MAX_BYTES; - if (existingVal >= MIN_PROJECT_DOC_MAX_BYTES) return; - existing.project_doc_max_bytes = Math.max(existingVal, incomingVal, MIN_PROJECT_DOC_MAX_BYTES); -} - -function ensureCodexHooks(existing: TomlRecord): void { - const features = existing.features as TomlRecord | undefined; - if (features?.hooks !== undefined || features?.codex_hooks !== undefined) return; - existing.features = { ...(features ?? {}), hooks: true }; -} - -export function mergeCodexConfigToml(existing: string, aiddPayload: string): string { - const result = parseSafe(existing); - const payload = parseSafe(aiddPayload); - mergeMcpServers(result, payload); - ensureProjectDocMaxBytes(result, payload); - ensureCodexHooks(result); - return stringifyToml(result); -} - -// Measured: four consecutive `codex exec` sessions installed a plugin's -// hooks, ran clean, and journaled nothing — no warning, no line in the output — until -// `--dangerously-bypass-hook-trust` made the same install produce all three hooks and its -// journal. Codex writes one `trusted_hash` per hook under `[hooks.state]` in -// `~/.codex/config.toml` when a person approves it; a hook with no entry is skipped in -// silence, and nothing prompts for it outside a terminal. -const CODEX_HOOKS_TRUST_NOTICE = - "Codex will not run this plugin's hooks until each one is trusted — approve the prompt " + - "once in an interactive session, or pass --dangerously-bypass-hook-trust to codex exec " + - "for a headless run. Until then, a session leaves no run journal and nothing says why."; - -const AIDD_HOOK_COMMAND = "node .aidd/scripts/update_memory.cjs"; - -const AIDD_HOOK_ENTRY = { - type: "command", - command: AIDD_HOOK_COMMAND, - statusMessage: "Syncing AIDD memory...", - timeout: 30, -}; - -const AIDD_SESSION_START_ENTRY = { - matcher: "startup|resume", - hooks: [AIDD_HOOK_ENTRY], -}; - -type HookEntry = { type: string; command: string; [key: string]: unknown }; -type SessionStartEntry = { matcher?: string; hooks: HookEntry[]; [key: string]: unknown }; -type HooksRoot = { SessionStart?: SessionStartEntry[]; [key: string]: unknown }; - -function isAiddHookPresent(entries: SessionStartEntry[]): boolean { - return entries.some((entry) => entry.hooks.some((hook) => hook.command === AIDD_HOOK_COMMAND)); -} - -function appendAiddEntry(entries: SessionStartEntry[]): SessionStartEntry[] { - if (isAiddHookPresent(entries)) return entries; - return [...entries, AIDD_SESSION_START_ENTRY]; -} - -function mergeSessionStart(existing: HooksRoot): HooksRoot { - const current = existing.SessionStart; - if (!Array.isArray(current)) { - return { ...existing, SessionStart: [AIDD_SESSION_START_ENTRY] }; - } - return { ...existing, SessionStart: appendAiddEntry(current) }; -} - -export function mergeCodexHooksJson(existing: string): string { - let parsed: HooksRoot = {}; - if (existing.trim()) { - try { - parsed = JSON.parse(existing) as HooksRoot; - } catch { - parsed = {}; - } - } - const merged = mergeSessionStart(parsed); - return JSON.stringify(merged, null, 2); -} - -function skillNameFromPath(fileName: string): string { - const parts = fileName.split("/"); - if (parts.length > 1) return parts[0]; - const base = parts[0]; - if (base.endsWith(TOOL_SUFFIX)) return base.slice(0, -TOOL_SUFFIX.length); - if (base.endsWith(".md")) return base.slice(0, -3); - return base; -} - -function buildCodexSkillFilePath(fileName: string): string { - return `${AGENTS_SKILLS_PREFIX}aidd-${skillNameFromPath(fileName)}/SKILL.md`; -} - -export function stripCodexSkillFrontmatter(fm: Record): Record { - const result: Record = {}; - if (fm.name !== undefined) result.name = fm.name; - if (fm.description !== undefined) result.description = fm.description; - if (fm.allowed_tools !== undefined) result.allowed_tools = fm.allowed_tools; - return result; -} - -export const codex: AiTool< - HasAgents & HasSkills & HasCommands & HasRules & HasMcp & HasHooks & HasPlugins -> = { - kind: "ai", - toolId: "codex", - displayName: "Codex", - directory: DIRECTORY, - toolSuffix: TOOL_SUFFIX, - signalDir: `${DIRECTORY}commands`, - configOutputPaths: { "config.toml": ".codex/config.toml" }, - - capabilities: { - agents: new AgentsCapability({ directory: DIRECTORY, toolSuffix: TOOL_SUFFIX, format: "toml" }), - skills: new SkillsCapability({ - prefix: "aidd-", - buildInstallPath: buildCodexSkillFilePath, - convertFrontmatter: stripCodexSkillFrontmatter, - reverseConvertFrontmatter: (fm) => fm, - }), - commands: new CommandsCapability({ - directory: DIRECTORY, - toolSuffix: TOOL_SUFFIX, - buildInstallPath: (fileName) => buildAiddCommandFilePath(DIRECTORY, fileName), - convertFrontmatter: (fm, relativeFileName) => convertCommandFrontmatter(fm, relativeFileName), - reverseConvertFrontmatter: (fm) => reverseConvertCommandFrontmatter(fm), - }), - rules: new RulesCapability({ - directory: DIRECTORY, - toolSuffix: TOOL_SUFFIX, - buildInstallPath: (fileName) => `${DIRECTORY}rules/${stripToolSuffix(TOOL_SUFFIX, fileName)}`, - convertFrontmatter: (fm) => fm, - reverseConvertFrontmatter: (fm) => fm, - }), - mcp: new McpCapability({ - outputPath: ".codex/config.toml", - format: "toml", - entrySection: "mcp_servers", - mergeFn: mergeCodexConfigToml, - consumes: [CONFIG_MCP], - }), - hooks: new HooksCapability({ - outputPath: ".codex/hooks.json", - mergeStrategy: "user-prime", - entrySection: "SessionStart", - mergeFn: mergeCodexHooksJson, - consumes: [CONFIG_CODEX_HOOKS], - }), - plugins: new PluginsCapability({ - mode: "native", - pluginsDir: ".codex/plugins/", - pluginManifestRelativePath: "plugin.json", - acceptsHooks: true, - hooksTrustNotice: CODEX_HOOKS_TRUST_NOTICE, - acceptsMcp: true, - // Measured, not read off the docs: a headless session ran five SessionStart hooks - // to completion, one written ${PLUGIN_ROOT} and three written ${CLAUDE_PLUGIN_ROOT}. - // Codex expands both, and reports a non-zero hook as failed — so completion is the - // proof. Either spelling works; this is the one its own plugins are built with. - pluginRootToken: PLUGIN_ROOT_TOKEN, - translationMode: "marketplace", - // Codex only enables plugins from its user-global config (~/.codex/config.toml) - // plus its plugin cache (~/.codex/plugins/cache/). A project-local settings file - // is inert, so we drive the `codex` CLI directly during marketplace sync instead. - nativeActivation: { binary: "codex" }, - }), - }, - - // Measured 2026-08-20: a rollout's `token_count` events carry counters but no model and - // no request id — those come from the preceding `turn_context` event, keyed on `turn_id`. - // Resolved by `session_meta.id`, not `session_id`, which a resumed session's rollout can - // disagree with. See codex-rollout.ts for the full measurement and its two captured - // fixtures. - telemetryLocalRead: { - kind: "declared", - transcript: CODEX_ROLLOUT_LOCATION, - // Complete counters per turn, no currency anywhere in a rollout, and no field naming a - // running skill - so a step here can only ever come from a run journal interval. - supplies: { tokenCounters: true, amount: false, toolStatedStep: false, agentName: false }, - }, - // Codex's payload carries no write-path field for any tool (writes go through - // apply_patch), but a declaration never needed one - it reads the same Bash command text - // its step detection already reads a SKILL.md path out of. - telemetryTaskAttributable: true, - telemetryJournalHost: "codex", - - rewriteContent(content: string, docsDir: string): string { - return rewriteCodexContent(content, { directory: DIRECTORY, docsDir }); - }, - - reverseRewriteContent(content: string, docsDir: string): string { - return reverseRewriteCodexContent(content, docsDir); - }, - - detectUserFileSectionKey(relativePath: string): UserFileSectionKey | null { - return detectSectionKeyFromPrefixes(relativePath, [ - [`${AGENTS_SKILLS_PREFIX}aidd-`, "skills"], - [`${DIRECTORY}agents/`, "agents"], - [`${DIRECTORY}commands/aidd/`, "commands"], - [`${DIRECTORY}rules/`, "rules"], - ]); - }, -}; - -registerTool(codex); diff --git a/cli/src/domain/tools/ai/copilot-paths.ts b/cli/src/domain/tools/ai/copilot-paths.ts deleted file mode 100644 index a62527ce5..000000000 --- a/cli/src/domain/tools/ai/copilot-paths.ts +++ /dev/null @@ -1,13 +0,0 @@ -/** - * Canonical path constants for the GitHub Copilot workspace layout. - * - * Exported from a dedicated file so both `copilot.ts` (tool definition) and - * flat-mode build helpers can import from a single source of truth, without - * introducing a cross-layer dependency. - */ - -/** Root directory for all Copilot workspace files. */ -export const COPILOT_WORKSPACE_DIR = ".github/"; - -/** Workspace-level VS Code MCP configuration path. */ -export const COPILOT_VSCODE_MCP_PATH = ".vscode/mcp.json"; diff --git a/cli/src/domain/tools/ai/copilot.ts b/cli/src/domain/tools/ai/copilot.ts deleted file mode 100644 index 4aa454acb..000000000 --- a/cli/src/domain/tools/ai/copilot.ts +++ /dev/null @@ -1,396 +0,0 @@ -import { AgentsCapability } from "../../capabilities/agents-capability.js"; -import { CommandsCapability } from "../../capabilities/commands-capability.js"; -import { buildDefaultMarketplaceEntry } from "../../capabilities/marketplace-entry.js"; -import { McpCapability } from "../../capabilities/mcp-capability.js"; -import { PluginsCapability } from "../../capabilities/plugins-capability.js"; -import { RulesCapability } from "../../capabilities/rules-capability.js"; -import { SettingsCapability } from "../../capabilities/settings-capability.js"; -import { SkillsCapability } from "../../capabilities/skills-capability.js"; -import { - convertCommandFrontmatter, - reverseConvertCommandFrontmatter, -} from "../../formats/command.js"; -import { PLUGIN_ROOT_TOKEN } from "../../formats/plugin-root-token-rewrite.js"; -import { - AT_DOCS_PLACEHOLDER, - AT_TOOLS_PLACEHOLDER, - CONFIG_MCP, - DOCS_PLACEHOLDER, - GITKEEP_FILE, - TOOLS_PLACEHOLDER, -} from "../../models/framework.js"; -import type { - AiTool, - HasAgents, - HasCommands, - HasMcp, - HasPlugins, - HasRules, - HasSettings, - HasSkills, - UserFileSectionKey, -} from "../contracts.js"; -import { registerTool } from "../registry.js"; -import { COPILOT_WORKSPACE_DIR } from "./copilot-paths.js"; - -const DIRECTORY = COPILOT_WORKSPACE_DIR; -const TOOL_SUFFIX = ".copilot.md"; - -const EXT_AGENT = ".agent.md"; -const EXT_PROMPT = ".prompt.md"; -const EXT_INSTRUCTIONS = ".instructions.md"; - -function basename(path: string): string { - return path.split("/").at(-1) ?? path; -} - -function flattenFileName( - fileName: string, - targetExt: string, - options: { toolSuffix?: string; stripNumericPrefix?: boolean } = {} -): string { - const parts = fileName.split("/"); - let baseName = parts[parts.length - 1]; - - if (options.stripNumericPrefix) { - baseName = baseName.replace(/^\d+[_-]/, ""); - } - if (options.toolSuffix && baseName.endsWith(options.toolSuffix)) { - baseName = `${baseName.slice(0, -options.toolSuffix.length)}.md`; - } - baseName = baseName.replaceAll("_", "-"); - - const withExt = addTargetExtension(baseName, targetExt); - - if (parts.length === 1) { - return withExt; - } - - const prefix = buildPrefix(parts.slice(0, -1).join("/")); - return `${prefix}-${withExt}`; -} - -function buildPrefix(subPath: string): string { - return subPath - .split("/") - .map((p) => p.replace(/^(\d+)[_-].*$/, "$1")) - .join("-"); -} - -function addTargetExtension(baseName: string, targetExt: string): string { - if (baseName.endsWith(targetExt)) return baseName; - const withoutMd = baseName.endsWith(".md") ? baseName.slice(0, -3) : baseName; - return `${withoutMd}${targetExt}`; -} - -function escapedRegex(literal: string): string { - return literal.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); -} - -const agentsHandler = { - buildFilePath(fileName: string): string | null { - const base = basename(fileName); - if (base === GITKEEP_FILE) return null; - const name = base.endsWith(".md") ? `${base.slice(0, -3)}${EXT_AGENT}` : base; - return `${DIRECTORY}agents/${name}`; - }, - convertFrontmatter(fm: Record, fileName?: string): Record { - const base = fileName?.split("/").at(-1); - const name = fm.name ?? base?.replace(/\.md$/, ""); - return { name: typeof name === "string" ? name : undefined, description: fm.description }; - }, - reverseConvertFrontmatter(fm: Record): Record { - return { name: fm.name, description: fm.description }; - }, -}; - -const commandsHandler = { - buildFilePath(fileName: string): string | null { - const base = basename(fileName); - if (base === GITKEEP_FILE) return null; - const flat = flattenFileName(fileName, EXT_PROMPT); - return `${DIRECTORY}prompts/${flat}`; - }, - convertFrontmatter( - fm: Record, - relativeFileName: string - ): Record { - return convertCommandFrontmatter(fm, relativeFileName); - }, - reverseConvertFrontmatter(fm: Record): Record { - return reverseConvertCommandFrontmatter(fm); - }, -}; - -const rulesHandler = { - buildFilePath(fileName: string): string | null { - const base = basename(fileName); - if (base === GITKEEP_FILE) return null; - const flat = flattenFileName(fileName, EXT_INSTRUCTIONS, { - toolSuffix: TOOL_SUFFIX, - stripNumericPrefix: true, - }); - return `${DIRECTORY}instructions/${flat}`; - }, - convertFrontmatter(fm: Record): Record { - const { paths, globs } = fm; - const patterns = Array.isArray(paths) ? paths : Array.isArray(globs) ? globs : null; - if (patterns !== null && patterns.length > 0) return { applyTo: patterns.join(",") }; - if (fm.alwaysApply === false && fm.description !== undefined) { - return { description: fm.description }; - } - return {}; - }, - reverseConvertFrontmatter(fm: Record): Record { - const { applyTo } = fm; - if (typeof applyTo === "string" && applyTo !== "**") { - return { paths: applyTo.split(",").map((s) => s.trim()) }; - } - // applyTo: "**" or absent → no paths (always apply) - return {}; - }, -}; - -const skillsHandler = { - buildFilePath(fileName: string): string | null { - const base = basename(fileName); - if (base === GITKEEP_FILE) return null; - return `${DIRECTORY}skills/${fileName}`; - }, - convertFrontmatter(fm: Record): Record { - return fm; - }, - reverseConvertFrontmatter(fm: Record): Record { - return fm; - }, -}; - -function resolveInstalledPath(path: string): string { - if (path.startsWith("agents/")) { - const subPath = path.slice("agents/".length); - if (subPath === "" || subPath.endsWith("/")) return `${DIRECTORY}agents/${subPath}`; - return agentsHandler.buildFilePath(subPath) ?? `${DIRECTORY}${path}`; - } - if (path.startsWith("commands/")) { - const subPath = path.slice("commands/".length); - if (subPath === "" || subPath.endsWith("/")) return `${DIRECTORY}prompts/${subPath}`; - return commandsHandler.buildFilePath(subPath) ?? `${DIRECTORY}${path}`; - } - if (path.startsWith("rules/")) { - const subPath = path.slice("rules/".length); - if (subPath === "" || subPath.endsWith("/")) return `${DIRECTORY}instructions/${subPath}`; - return rulesHandler.buildFilePath(subPath) ?? `${DIRECTORY}${path}`; - } - if (path.startsWith("skills/")) { - const subPath = path.slice("skills/".length); - if (subPath === "" || subPath.endsWith("/")) return `${DIRECTORY}skills/${subPath}`; - return skillsHandler.buildFilePath(subPath) ?? `${DIRECTORY}${path}`; - } - // Unknown section: fall back to raw directory-prefixed path. - // If a new section is added to the framework, this produces a predictable - // default rather than silently dropping the reference. - return `${DIRECTORY}${path}`; -} - -function rewriteCopilotContent(content: string, docsDir: string): string { - return ( - content - .replace( - new RegExp(`${escapedRegex(AT_TOOLS_PLACEHOLDER)}([^\\s\`'">,]+)`, "g"), - (_match, path: string) => { - const fullPath = resolveInstalledPath(path); - return `[${fullPath}](../../${fullPath})`; - } - ) - .replace( - new RegExp(`${escapedRegex(AT_DOCS_PLACEHOLDER)}([^\\s\`'">,]+)`, "g"), - (_match, path: string) => { - return `[${docsDir}/${path}](../../${docsDir}/${path})`; - } - ) - // {{TOOLS}}/ (without @) replaces directory prefix only — used for path references in frontmatter or prose. - // @{{TOOLS}}/ (with @) resolves to a full installed path via resolveInstalledPath — used for @-include syntax. - .replaceAll("{{TOOLS}}/agents/", `${DIRECTORY}agents/`) - .replace(/\{\{TOOLS\}\}\/commands\/([^\s\n`'">,]+)/g, (_match, path: string) => { - const flat = flattenFileName(path, EXT_PROMPT); - return `${DIRECTORY}prompts/${flat}`; - }) - .replaceAll("{{TOOLS}}/rules/", `${DIRECTORY}instructions/`) - .replaceAll("{{TOOLS}}/skills/", `${DIRECTORY}skills/`) - .replaceAll(TOOLS_PLACEHOLDER, DIRECTORY) - .replaceAll(DOCS_PLACEHOLDER, `${docsDir}/`) - ); -} - -function reverseCopilotContent(content: string, docsDir: string): string { - return content - .replace( - /\[\.github\/agents\/([^\]]+)\]\([^)]+\)/g, - (_match, path: string) => `${AT_TOOLS_PLACEHOLDER}agents/${path}` - ) - .replace( - /\[\.github\/prompts\/([^\]]+)\]\([^)]+\)/g, - (_match, path: string) => `${AT_TOOLS_PLACEHOLDER}commands/${path}` - ) - .replace( - /\[\.github\/instructions\/([^\]]+)\]\([^)]+\)/g, - (_match, path: string) => `${AT_TOOLS_PLACEHOLDER}rules/${path}` - ) - .replace( - /\[\.github\/skills\/([^\]]+)\]\([^)]+\)/g, - (_match, path: string) => `${AT_TOOLS_PLACEHOLDER}skills/${path}` - ) - .replace( - new RegExp(`\\[${escapedRegex(docsDir)}\\/([^\\]]+)\\]\\([^)]+\\)`, "g"), - (_match: string, path: string) => `${AT_DOCS_PLACEHOLDER}${path}` - ) - .replaceAll(`${DIRECTORY}agents/`, `${TOOLS_PLACEHOLDER}agents/`) - .replaceAll(`${DIRECTORY}prompts/`, `${TOOLS_PLACEHOLDER}commands/`) - .replaceAll(`${DIRECTORY}instructions/`, `${TOOLS_PLACEHOLDER}rules/`) - .replaceAll(`${DIRECTORY}skills/`, `${TOOLS_PLACEHOLDER}skills/`) - .replaceAll(DIRECTORY, TOOLS_PLACEHOLDER) - .replaceAll(`${docsDir}/`, DOCS_PLACEHOLDER); -} - -export const copilot: AiTool< - HasAgents & HasSkills & HasCommands & HasRules & HasMcp & HasSettings & HasPlugins -> = { - kind: "ai", - toolId: "copilot", - displayName: "GitHub Copilot", - directory: DIRECTORY, - toolSuffix: TOOL_SUFFIX, - signalDir: ".github/prompts", - requiredIdeIds: ["vscode"] as const, - - capabilities: { - agents: new AgentsCapability({ - directory: DIRECTORY, - toolSuffix: EXT_AGENT, - format: "markdown", - userFileExt: EXT_AGENT, - buildInstallPath: (fileName) => agentsHandler.buildFilePath(fileName), - convertFrontmatter: (fm, fileName) => agentsHandler.convertFrontmatter(fm, fileName), - reverseConvertFrontmatter: (fm) => agentsHandler.reverseConvertFrontmatter(fm), - }), - skills: new SkillsCapability({ - directory: DIRECTORY, - toolSuffix: TOOL_SUFFIX, - buildInstallPath: (fileName) => skillsHandler.buildFilePath(fileName), - convertFrontmatter: (fm) => skillsHandler.convertFrontmatter(fm), - reverseConvertFrontmatter: (fm) => skillsHandler.reverseConvertFrontmatter(fm), - }), - commands: new CommandsCapability({ - directory: DIRECTORY, - toolSuffix: EXT_PROMPT, - buildInstallPath: (fileName) => commandsHandler.buildFilePath(fileName), - convertFrontmatter: (fm, relativeFileName) => convertCommandFrontmatter(fm, relativeFileName), - reverseConvertFrontmatter: (fm) => reverseConvertCommandFrontmatter(fm), - }), - rules: new RulesCapability({ - directory: DIRECTORY, - toolSuffix: EXT_INSTRUCTIONS, - inputSuffix: TOOL_SUFFIX, - buildInstallPath: (fileName) => rulesHandler.buildFilePath(fileName), - convertFrontmatter: (fm) => rulesHandler.convertFrontmatter(fm), - reverseConvertFrontmatter: (fm) => rulesHandler.reverseConvertFrontmatter(fm), - }), - mcp: new McpCapability({ - outputPath: ".vscode/mcp.json", - format: "json", - entrySection: "servers", - consumes: [CONFIG_MCP], - transformContent: (content) => { - const parsed = JSON.parse(content) as Record; - if ("mcpServers" in parsed && !("servers" in parsed)) { - const { mcpServers, ...rest } = parsed as { mcpServers: unknown } & Record< - string, - unknown - >; - return JSON.stringify({ ...rest, servers: mcpServers }, null, 2); - } - return content; - }, - }), - settings: new SettingsCapability({ - outputPath: ".vscode/settings.json", - mergeStrategy: "framework-prime", - staticContentAssetFile: "vscode-settings.json", - requiresTool: "vscode", - }), - plugins: new PluginsCapability({ - mode: "native", - pluginsDir: ".github/plugins/", - pluginManifestRelativePath: "plugin.json", - acceptsHooks: true, - // Never measured against a running Copilot hook, unlike Codex's. This is what the - // build route has been shipping, kept as-is rather than changed on a guess. - pluginRootToken: PLUGIN_ROOT_TOKEN, - acceptsMcp: true, - translationMode: "marketplace", - // Copilot treats enabledPlugins in settings.json as a recommendation, not an - // auto-install (github/copilot-cli#2249); the project marketplace is also not - // installable from project scope. Drive `copilot plugin install` to - // actually load plugins — the settings file below still surfaces recommendations. - nativeActivation: { binary: "copilot" }, - // VS Code Copilot: extraKnownMarketplaces in .github/copilot/settings.json. - // chat.plugins.marketplaces has application scope and cannot be set in workspace - // .vscode/settings.json — VSCode rejects it with "This setting has an application scope". - // Source: https://code.visualstudio.com/docs/copilot/customization/agent-plugins - marketplaceSettings: { - settingsPath: ".github/copilot/settings.json", - settingsKey: "extraKnownMarketplaces", - enabledPluginsKey: "enabledPlugins", - toEntry: buildDefaultMarketplaceEntry, - }, - }), - }, - - // Measured, against a real ~/.copilot/session-state//events.jsonl: - // `session.shutdown`'s own `tokenDetails` carries all four counters, but once, for the - // whole session — never per request, so no per-step record can be built from it. No - // `transcript` location: the session id names the file exactly - // (~/.copilot/session-state//events.jsonl), so `CopilotCostReaderAdapter` opens it - // directly rather than walking a directory to find it — see domain/formats/ - // copilot-events.ts for the reader and the arithmetic that settles it. - telemetryLocalRead: { - kind: "declared", - supplies: { tokenCounters: true, amount: false, toolStatedStep: false, agentName: false }, - // The exclusivity of `input` against `cache_read` is measured, on the capture the - // earlier comment here asked for by name: a session with a non-zero `cache_read` - // (1.0.82, 2026-09-06, tests/fixtures/local-cost/.copilot/session-state/55555555-…). - // 9 (`input`) + 42038 (`cache_read`) + 21404 (`cache_write`) = 63451, exactly - // `modelMetrics..usage.inputTokens`, so the four counters this reader stores are - // disjoint and the report is right to add them. It mattered because an `input` that - // included `cache_read` would have over-counted every Copilot session by its cached - // share — 42038 of 63451 in this one. - limitation: - "Its own file names outputTokens per turn, but session.shutdown carries all four " + - "counters for the whole session — a session total, never a sum of requests. Its four " + - "counters are measured disjoint, cached prompt included.", - }, - // Copilot's canonical payload carries no tool_input, but a declaration reads its toolArgs - // JSON string as plain text instead - the same tolerance that already lets a step be read - // off either of Copilot's two shapes. - telemetryTaskAttributable: true, - telemetryJournalHost: "copilot", - - rewriteContent: rewriteCopilotContent, - - reverseRewriteContent: reverseCopilotContent, - - detectUserFileSectionKey(relativePath: string): UserFileSectionKey | null { - if (relativePath.startsWith(`${DIRECTORY}agents/`)) { - const base = relativePath.slice(`${DIRECTORY}agents/`.length); - const key = base.endsWith(EXT_AGENT) ? `${base.slice(0, -EXT_AGENT.length)}.md` : base; - return { section: "agents", key }; - } - if (relativePath.startsWith(`${DIRECTORY}skills/`)) { - return { section: "skills", key: relativePath.slice(`${DIRECTORY}skills/`.length) }; - } - // commands (prompts) and rules (instructions) use flattenFileName which is not reversible - return null; - }, -}; - -registerTool(copilot); diff --git a/cli/src/domain/tools/ai/cursor.ts b/cli/src/domain/tools/ai/cursor.ts deleted file mode 100644 index 82e6c6e30..000000000 --- a/cli/src/domain/tools/ai/cursor.ts +++ /dev/null @@ -1,193 +0,0 @@ -import { join } from "node:path"; -import { AgentsCapability } from "../../capabilities/agents-capability.js"; -import { CommandsCapability } from "../../capabilities/commands-capability.js"; -import { McpCapability } from "../../capabilities/mcp-capability.js"; -import { PluginsCapability } from "../../capabilities/plugins-capability.js"; -import { RulesCapability } from "../../capabilities/rules-capability.js"; -import { SkillsCapability } from "../../capabilities/skills-capability.js"; -import { - buildAiddCommandFilePath, - convertCommandFrontmatter, - detectSectionKeyFromPrefixes, - reverseConvertCommandFrontmatter, - stripToolSuffix, -} from "../../formats/command.js"; -import { baseReverseRewriteContent, baseRewriteContent } from "../../formats/placeholders.js"; -import { CURSOR_PLUGIN_ROOT_TOKEN } from "../../formats/plugin-root-token-rewrite.js"; -import { CONFIG_MCP } from "../../models/framework.js"; -import type { - AiTool, - HasAgents, - HasCommands, - HasMcp, - HasPlugins, - HasRules, - HasSkills, - UserFileSectionKey, -} from "../contracts.js"; -import { registerTool } from "../registry.js"; - -const DIRECTORY = ".cursor/"; -const TOOL_SUFFIX = ".cursor.md"; -const MDC_EXT = ".mdc"; - -function toMdc(fileName: string): string { - return fileName.endsWith(".md") ? `${fileName.slice(0, -3)}${MDC_EXT}` : fileName; -} - -export const cursor: AiTool = - { - kind: "ai", - toolId: "cursor", - displayName: "Cursor", - directory: DIRECTORY, - toolSuffix: TOOL_SUFFIX, - signalDir: ".cursor/commands", - configOutputPaths: { "settings.json": ".cursor/settings.json" }, - - capabilities: { - agents: new AgentsCapability({ - directory: DIRECTORY, - toolSuffix: TOOL_SUFFIX, - format: "markdown", - }), - skills: new SkillsCapability({ - directory: DIRECTORY, - toolSuffix: TOOL_SUFFIX, - buildInstallPath: (fileName) => - `${DIRECTORY}skills/${stripToolSuffix(TOOL_SUFFIX, fileName)}`, - convertFrontmatter: (fm) => fm, - reverseConvertFrontmatter: (fm) => fm, - }), - commands: new CommandsCapability({ - directory: DIRECTORY, - toolSuffix: TOOL_SUFFIX, - buildInstallPath: (fileName) => buildAiddCommandFilePath(DIRECTORY, fileName), - convertFrontmatter: (fm, relativeFileName) => - convertCommandFrontmatter(fm, relativeFileName), - reverseConvertFrontmatter: (fm) => reverseConvertCommandFrontmatter(fm), - }), - rules: new RulesCapability({ - directory: DIRECTORY, - toolSuffix: TOOL_SUFFIX, - buildInstallPath: (fileName) => - `${DIRECTORY}rules/${toMdc(stripToolSuffix(TOOL_SUFFIX, fileName))}`, - convertFrontmatter: (fm) => { - const { paths, globs, description } = fm; - const patterns = Array.isArray(paths) ? paths : Array.isArray(globs) ? globs : null; - if (patterns === null || patterns.length === 0) { - if (fm.alwaysApply === false && description !== undefined) { - return { description, alwaysApply: false }; - } - return {}; - } - const result: Record = {}; - if (description !== undefined) result.description = description; - return { - ...result, - globs: JSON.stringify(patterns).replace(/,/g, ", "), - alwaysApply: false, - }; - }, - reverseConvertFrontmatter: (fm) => { - const { globs } = fm; - if (Array.isArray(globs) && globs.length > 0) return { paths: globs }; - if (typeof globs === "string") { - try { - const parsed = JSON.parse(globs); - if (Array.isArray(parsed) && parsed.length > 0) return { paths: parsed }; - } catch { - /* globs is not valid JSON */ - } - } - return {}; - }, - }), - mcp: new McpCapability({ - outputPath: `${DIRECTORY}mcp.json`, - format: "json", - entrySection: "mcpServers", - consumes: [CONFIG_MCP], - }), - plugins: new PluginsCapability({ - mode: "native", - // Empty pluginsDir so translateNativeWithPaths computes pluginRoot = "/" - // (base-relative keys like "aidd-context/commands/foo.md" per D2). - pluginsDir: "", - pluginManifestRelativePath: null, - // plugin-local: Cursor auto-discovers mcp.json at the plugin root, but never a - // plugin-scope hooks.json - three probes (headless/interactive, auto-discovered - // and explicit --plugin-dir, with and without a manifest) fired zero of seven - // events. Only a project-scope .cursor/hooks.json is ever observed firing (see - // measurements.md Phase 4/6), so hooksDestination routes hooks there instead of - // here; hooksRelativePath/hooksContentFormat stay declared for the shape they - // still describe but are no longer read for Cursor's own install. - acceptsHooks: true, - pluginRootToken: CURSOR_PLUGIN_ROOT_TOKEN, - hooksRelativePath: "hooks.json", - hooksContentFormat: "cursor", - hooksDestination: "project", - acceptsMcp: true, - mcpRelativePath: "mcp.json", - installScope: "user", - userPluginsDir: (h) => join(h, ".cursor", "plugins", "local"), - }), - }, - - // Measured: Cursor writes no token count in any file it produces — there is nothing - // on disk for a local reader to find. A gap this deliverable names rather than fills; - // see spec.md non-goals. - // Re-measured 2026-09-02 against a real Cursor install, rather than carried forward as - // a declaration nobody had checked lately. Its own local stores hold no consumption at - // all: 76 chat stores under `~/.cursor/chats/*/store.db` (a `blobs`/`meta` pair of - // SQLite tables), and not one mentions `inputTokens`, `outputTokens`, `totalTokens`, - // `promptTokens` or `completionTokens`, nor carries a `usage` object. - // `~/.cursor/ai-tracking/ai-code-tracking.db` does count things — `linesAdded`, - // `composerLinesAdded`, `humanLinesAdded`, an AI percentage per commit — but those - // measure how much code came from the assistant, never what it consumed. There is no - // token or cost column anywhere in it. - // - // So this is a fact about Cursor, not a reader nobody has written yet: the number does - // not exist locally to be read. `by_tool` prints this sentence where a figure would go, - // which is the whole reason the reason travels with the declaration. - telemetryLocalRead: { - kind: "unsupported", - reason: "It writes no token count in any file it produces.", - }, - // A declared task no longer needs a written path in the payload at all - it reads a - // tool call's own arguments the same way a step's skill name is read, and Cursor's - // postToolUse payload carries tool_input on every call, exactly like Claude Code's. - telemetryTaskAttributable: true, - telemetryJournalHost: "cursor", - - rewriteContent(content: string, docsDir: string): string { - return baseRewriteContent(content, DIRECTORY, docsDir) - .replace( - /(@?)\.cursor\/commands\/(\d+)[_-][^/]+\/([^\s]+)/g, - "$1.cursor/commands/aidd/$2/$3" - ) - .replace(/(@\.cursor\/rules\/[^\s]+)\.md\b/g, "$1.mdc"); - }, - - reverseRewriteContent(content: string, docsDir: string): string { - return baseReverseRewriteContent( - content.replace(/(@\.cursor\/rules\/[^\s]+)\.mdc\b/g, "$1.md"), - DIRECTORY, - docsDir - ); - }, - - detectUserFileSectionKey(relativePath: string): UserFileSectionKey | null { - if (relativePath.startsWith(`${DIRECTORY}rules/`)) { - const key = relativePath.slice(`${DIRECTORY}rules/`.length); - return { section: "rules", key: key.endsWith(".mdc") ? `${key.slice(0, -4)}.md` : key }; - } - return detectSectionKeyFromPrefixes(relativePath, [ - [`${DIRECTORY}agents/`, "agents"], - [`${DIRECTORY}commands/aidd/`, "commands"], - [`${DIRECTORY}skills/`, "skills"], - ]); - }, - }; - -registerTool(cursor); diff --git a/cli/src/domain/tools/ai/opencode.ts b/cli/src/domain/tools/ai/opencode.ts deleted file mode 100644 index b70f08843..000000000 --- a/cli/src/domain/tools/ai/opencode.ts +++ /dev/null @@ -1,230 +0,0 @@ -import { join } from "node:path"; -import { AgentsCapability } from "../../capabilities/agents-capability.js"; -import { CommandsCapability } from "../../capabilities/commands-capability.js"; -import { McpCapability } from "../../capabilities/mcp-capability.js"; -import { PluginsCapability } from "../../capabilities/plugins-capability.js"; -import { RulesCapability } from "../../capabilities/rules-capability.js"; -import { SkillsCapability } from "../../capabilities/skills-capability.js"; -import { - InvalidMcpServerConfigError, - McpConfigError, - OpencodeDualConfigError, -} from "../../errors.js"; -import { - buildAiddCommandFilePath, - convertCommandFrontmatterNoHint, - detectSectionKeyFromPrefixes, - reverseConvertCommandFrontmatterNoHint, - stripToolSuffix, -} from "../../formats/command.js"; -import { baseReverseRewriteContent, baseRewriteContent } from "../../formats/placeholders.js"; -import { CONFIG_MCP, CONFIG_OPENCODE } from "../../models/framework.js"; -import type { - AiTool, - HasAgents, - HasCommands, - HasMcp, - HasPlugins, - HasRules, - HasSkills, - UserFileSectionKey, -} from "../contracts.js"; -import { registerTool } from "../registry.js"; - -const DIRECTORY = ".opencode/"; -// OpenCode auto-discovers `{plugin,plugins}/*.{ts,js}` under the project root — a -// non-recursive glob, so a hook's own runtime module has to sit directly here, not -// namespaced under a per-plugin subdirectory the way commands/agents/rules/skills are. -const FLAT_HOOKS_DIR = `${DIRECTORY}plugin/`; -const TOOL_SUFFIX = ".opencode.md"; - -type RawServer = - | { command: string; args?: string[]; env?: Record; disabled?: boolean } - | { url: string; disabled?: boolean }; - -interface OpencodeMcpLocalServer { - type: "local"; - command: string[]; - enabled: boolean; - environment?: Record; -} - -interface OpencodeMcpRemoteServer { - type: "remote"; - url: string; - enabled: boolean; -} - -type OpencodeMcpServer = OpencodeMcpLocalServer | OpencodeMcpRemoteServer; - -function convertRawServer(name: string, server: RawServer): OpencodeMcpServer { - const enabled = server.disabled !== true; - if ("command" in server) { - const { command, args = [], env } = server; - const local: OpencodeMcpLocalServer = { type: "local", command: [command, ...args], enabled }; - if (env && Object.keys(env).length > 0) local.environment = env; - return local; - } - if ("url" in server) { - return { type: "remote", url: server.url, enabled }; - } - throw new InvalidMcpServerConfigError(name); -} - -export function transformMcpToOpencode(content: string): string { - let parsed: { mcpServers?: Record }; - try { - parsed = JSON.parse(content) as typeof parsed; - } catch (err) { - throw new McpConfigError( - `Cannot parse MCP config: ${err instanceof Error ? err.message : String(err)}` - ); - } - if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) { - throw new McpConfigError("MCP config must be a JSON object"); - } - const mcp: Record = {}; - for (const [name, server] of Object.entries(parsed.mcpServers ?? {})) { - mcp[name] = convertRawServer(name, server); - } - return JSON.stringify({ mcp }, null, 2); -} - -export const opencode: AiTool< - HasAgents & HasSkills & HasCommands & HasRules & HasMcp & HasPlugins -> = { - kind: "ai", - toolId: "opencode", - displayName: "OpenCode", - directory: DIRECTORY, - toolSuffix: TOOL_SUFFIX, - signalDir: ".opencode/commands", - configOutputPaths: { "opencode.json": "opencode.json" }, - - capabilities: { - agents: new AgentsCapability({ - directory: DIRECTORY, - toolSuffix: TOOL_SUFFIX, - format: "markdown", - convertFrontmatter: (fm) => ({ description: fm.description, mode: "subagent" }), - reverseConvertFrontmatter: (fm) => ({ description: fm.description }), - }), - skills: new SkillsCapability({ - directory: DIRECTORY, - toolSuffix: TOOL_SUFFIX, - buildInstallPath: (fileName) => - `${DIRECTORY}skills/${stripToolSuffix(TOOL_SUFFIX, fileName)}`, - convertFrontmatter: (fm) => fm, - reverseConvertFrontmatter: (fm) => fm, - }), - commands: new CommandsCapability({ - directory: DIRECTORY, - toolSuffix: TOOL_SUFFIX, - buildInstallPath: (fileName) => buildAiddCommandFilePath(DIRECTORY, fileName), - convertFrontmatter: (fm, relativeFileName) => - convertCommandFrontmatterNoHint(fm, relativeFileName), - reverseConvertFrontmatter: (fm) => reverseConvertCommandFrontmatterNoHint(fm), - }), - rules: new RulesCapability({ - directory: DIRECTORY, - toolSuffix: TOOL_SUFFIX, - buildInstallPath: (fileName) => `${DIRECTORY}rules/${stripToolSuffix(TOOL_SUFFIX, fileName)}`, - convertFrontmatter: (fm) => { - if (fm.alwaysApply === false && fm.description !== undefined) { - return { description: fm.description }; - } - return {}; - }, - reverseConvertFrontmatter: () => ({}), - }), - mcp: new McpCapability({ - outputPath: "opencode.json", - format: "json", - entrySection: "mcp", - mergeStrategy: "framework-prime", - transformContent: transformMcpToOpencode, - consumes: [CONFIG_MCP, CONFIG_OPENCODE], - resolveOutputPath: async (projectRoot, fs) => { - const jsonExists = await fs.fileExists(join(projectRoot, "opencode.json")); - const jsoncExists = await fs.fileExists(join(projectRoot, "opencode.jsonc")); - if (jsonExists && jsoncExists) throw new OpencodeDualConfigError(); - if (jsoncExists) return "opencode.jsonc"; - return "opencode.json"; - }, - }), - // marketplaceSettings is not available in flat mode (FlatPluginsParams has no such field). - // Additionally, opencode's plugin[] array accepts only npm package name strings — - // there is no source/version concept that a marketplace entry could express. - plugins: new PluginsCapability({ - mode: "flat", - flatNamespacePrefix: "aidd-", - // Measured (2026-08-22, see the telemetry plan's measurements.md, Phase 5 and 7): - // OpenCode never runs a CommonJS module placed here, only a genuine ESM export — - // hooks/opencode-plugin.js is written that way and delivered verbatim, along with - // journal.cjs and lib/ beside it (its own relative import expects them there). - acceptsHooks: true, - flatHooksDir: FLAT_HOOKS_DIR, - }), - }, - - // Read via `opencode export --sanitize` (OpencodeCostReaderAdapter), - // measured 2026-08-20 on opencode 1.14.20 — see domain/formats/opencode-export.ts. Joins - // to a run journal entry through hooks/opencode-plugin.js (phase 5, see the telemetry - // plan's measurements.md): an OpenCode plugin module, loaded in-process since OpenCode has - // no hooks.json, writes session_start from `session.created`'s own session id. - telemetryLocalRead: { - kind: "declared", - // Counters per message, and no amount: `info.cost` is `0` in every message captured - // and its denomination was never established, so it is deliberately never read. No - // field names a running skill either. - supplies: { tokenCounters: true, amount: false, toolStatedStep: false, agentName: false }, - // Measured 2026-08-20: `input` is exclusive of `cache.read` for providerID "anthropic", - // matching that API's own documented behaviour. A second provider was probed 2026-08-24 - // (providerID "opencode") and reconciled the same way, but never exercised its cache - // across two turns of one session — no capture puts a large `cache.read` beside `input` - // for a non-Anthropic provider, so that probe corroborates without confirming. A - // provider that reports prompt tokens inclusive of the cached ones, the way native - // OpenAI's usage does, has never been captured here. See plugins/aidd-telemetry/README.md. - limitation: - "Its four counters are measured disjoint for the anthropic provider and for one " + - "OpenAI-compatible provider whose cache was exercised — not confirmed for a " + - "provider that reports prompt tokens inclusive of the cached ones, which none " + - "captured here does.", - }, - // The journal hook detects this host by a self-declared `tool: "opencode"` field, not by - // a vendor payload shape — OpenCode has none. hooks/opencode-plugin.js builds that payload - // itself and spawns hooks/journal.cjs with it, over the same stdin contract every other - // host's own hook already uses. - telemetryJournalHost: "opencode", - // Measured 2026-08-31, opencode 1.14.20: a completed tool part's own arguments do reach - // the plugin's `event` hook, on `message.part.updated` - a bounded, three-further-session - // spike settled what an earlier reading (no tool part observed across three sessions) had - // not: that absence was a model-selection artifact, not a property of the plugin surface. - // hooks/opencode-plugin.js's `declaredTaskCallFor` joins one into a tool-used call the - // same shape every other host's own hook already sends. A written path still cannot be - // read this way - no captured tool part named one - so `writtenPath` on - // hooks/lib/tools/opencode.cjs stays null; only the declared route opened. - telemetryTaskAttributable: true, - - rewriteContent(content: string, docsDir: string): string { - return baseRewriteContent(content, DIRECTORY, docsDir).replace( - /(@?)\.opencode\/commands\/(\d+)[_-][^/]+\/([^\s]+)/g, - "$1.opencode/commands/aidd/$2/$3" - ); - }, - - reverseRewriteContent(content: string, docsDir: string): string { - return baseReverseRewriteContent(content, DIRECTORY, docsDir); - }, - - detectUserFileSectionKey(relativePath: string): UserFileSectionKey | null { - return detectSectionKeyFromPrefixes(relativePath, [ - [`${DIRECTORY}agents/`, "agents"], - [`${DIRECTORY}commands/aidd/`, "commands"], - [`${DIRECTORY}rules/`, "rules"], - [`${DIRECTORY}skills/`, "skills"], - ]); - }, -}; - -registerTool(opencode); diff --git a/cli/src/domain/tools/build-contract.ts b/cli/src/domain/tools/build-contract.ts deleted file mode 100644 index 970fc8269..000000000 --- a/cli/src/domain/tools/build-contract.ts +++ /dev/null @@ -1,198 +0,0 @@ -import type { AssetProvider, SchemaName } from "../ports/asset-provider.js"; -import type { FileReader } from "../ports/file-reader.js"; -import type { FileWriter } from "../ports/file-writer.js"; -import type { JsonSchemaValidator } from "../ports/json-schema-validator.js"; - -/** - * Describes how to source the artifact files for a plugin. - * - * - filteredTree: walk a sub-directory, keep only files matching ext; agents/ (.md → per-tool) - * - fullTree: walk a sub-directory, copy all files; skills/ - * - configFile: single plugin-relative file path; mcp = .mcp.json - * - hooksBundle: hooks/hooks.json + sibling scripts; flat hooks logic - */ -export type ArtifactSource = - | { readonly kind: "filteredTree"; readonly srcDir: string; readonly inputExt: string } - | { readonly kind: "fullTree"; readonly srcDir: string } - | { readonly kind: "configFile"; readonly srcPath: string } - | { readonly kind: "hooksBundle"; readonly jsonPath: string; readonly scriptDir: string }; - -/** - * Per-artifact contract: how to produce output for one artifact kind in one tool. - */ -export type ArtifactContract = - | { readonly supported: false } - | { - readonly supported: true; - readonly source: ArtifactSource; - /** Output path for one file: receives plugin name + relative file path from source dir */ - readonly path: (plugin: string, relPath: string) => string; - /** Output file extension override; if absent the source extension is preserved. */ - readonly ext?: string; - /** - * Per-kind content transform. Receives raw content + plugin name + basename. - * Defaults to identity (byte-copy). - */ - readonly transform?: (content: string, plugin: string, basename: string) => string; - /** - * When true, the flat build strategy rewrites the `name` frontmatter of SKILL.md - * files to match the parent folder name (required by VS Code Copilot discovery). - * Only meaningful for skill artifacts in flat mode. - */ - readonly rewriteSkillName?: boolean; - /** - * Additive merge into an existing config file (mcp target). - * Only provided for config-kind artifacts that merge rather than per-plugin write. - */ - readonly merge?: ( - existing: string | null, - incomingPrefixed: Record, - force: boolean - ) => { mergedContent: string; collisions: ReadonlyArray }; - /** - * servers-key for the mcp merge target JSON (e.g. "servers" for copilot, "mcpServers" for claude). - * Only meaningful when merge is provided. - */ - readonly mcpServersKey?: string; - /** Absolute path to the shared merge target (mcp output file); only for merge contracts. */ - readonly mergeDest?: (outDir: string) => string; - /** - * Merge function for hooks — used when hooks.json must be merged with an existing file - * rather than per-plugin written (e.g. codex flat → .codex/hooks.json, claude settings). - * Receives existing content (or null) and path-rewritten plugin hooks content. - * Returns merged content + optional warnings to surface to the user. - */ - readonly hooksMerge?: ( - existing: string | null, - incoming: string - ) => { content: string; warnings: readonly string[] }; - /** Absolute path to the shared hooks merge target; only for hooksMerge contracts. */ - readonly hooksMergeDest?: (outDir: string) => string; - /** - * Optional shape transform for per-plugin hooks files (non-merge path). - * Applied after ${CLAUDE_PLUGIN_ROOT} path rewriting, before writing the file. - * Used to reshape the Claude nested format to a tool-specific flat format. - */ - readonly hooksTransform?: (rewrittenJson: string) => string; - /** - * When true, `writeHooks` delivers everything under hooks/ except hooks.json — - * for a tool whose hook is a runtime module a loader scans for, not a manifest a - * merge reads (opencode's flat plugin directory). - */ - readonly skipHooksJson?: boolean; - }; - -/** - * Per-tool build contract: artifact-symmetric (six kinds), schema validation wiring, - * and optional post-build config artifact. - */ -export interface ToolBuildContract { - /** Subdirectory name for the marketplace plugin tree (e.g. ".claude-plugin"). null for opencode. */ - readonly manifestDir: string | null; - /** - * Native plugin-root token for this tool in marketplace mode. - * Used to rewrite the source ${CLAUDE_PLUGIN_ROOT} placeholder in hooks/mcp content. - * Absent for flat-only contracts (no substitution needed). - * Examples: "${CLAUDE_PLUGIN_ROOT}", "${CURSOR_PLUGIN_ROOT}", "${PLUGIN_ROOT}", "${COPILOT_PLUGIN_ROOT}". - */ - readonly pluginRootToken?: string | null; - /** Relative path under the output dir where the marketplace catalog is written. null if no marketplace. */ - readonly marketplaceRelative: string | null; - /** Plugin-manifest file relative to plugin tree root (e.g. ".claude-plugin/plugin.json"). null if no manifest. */ - readonly manifestFileRelative: string | null; - - /** Synthesize a tool-native plugin manifest from the source manifest + presence flags. null if tool has no manifest. */ - readonly synthesizeManifest: - | ((source: Record, presence: PluginPresence) => Record) - | null; - - /** JSON schema name for validating the synthesized manifest. null if no validation needed. */ - readonly manifestSchemaName: SchemaName | null; - - readonly artifacts: { - readonly skills: ArtifactContract; - readonly agents: ArtifactContract; - readonly mcp: ArtifactContract; - readonly hooks: ArtifactContract; - readonly rules: ArtifactContract; - readonly commands: ArtifactContract; - }; - - /** - * Optional post-build step emitting a config artifact (e.g. config.toml for codex, opencode.json). - * Returns count of files written. - */ - readonly emitConfigArtifact?: - | (( - builtPlugins: readonly string[], - outDir: string, - sourceDir: string, - fs: FileReader & FileWriter, - jsonSchemaValidator: JsonSchemaValidator, - assetProvider: AssetProvider - ) => Promise) - | undefined; - - /** - * Build the marketplace catalog object after all plugins are written. - * Returns { catalog, schemaName } to write + validate. null if tool has no marketplace. - */ - readonly buildMarketplaceCatalog: - | (( - sourceMarketplace: SourceMarketplaceRef, - pluginEntries: readonly Record[], - fs: FileReader & FileWriter - ) => Promise<{ - catalog: Record; - schemaName: SchemaName | null; - destRelPath: string; - }>) - | null; - - /** - * Build a single marketplace entry for a built plugin. - */ - readonly buildMarketplaceEntry: - | (( - name: string, - pluginSrc: string, - outDir: string, - srcEntry: SourcePluginEntryRef | undefined, - fs: FileReader & FileWriter - ) => Promise>) - | null; -} - -/** - * Minimal reference to the source marketplace catalog. - * Avoids importing from application layer (hexagonal rule). - */ -export interface SourceMarketplaceRef { - readonly name: string; - readonly version?: string; - readonly description?: string; - readonly owner?: unknown; - readonly plugins: readonly SourcePluginEntryRef[]; - readonly [key: string]: unknown; -} - -export interface SourcePluginEntryRef { - readonly name: string; - readonly version?: string; - readonly description?: string; - readonly strict?: boolean; - readonly recommended?: boolean; - readonly [key: string]: unknown; -} - -/** - * Plugin presence flags used by manifest synthesis. - */ -export interface PluginPresence { - readonly hasAgents: boolean; - /** Agent markdown files relative to the plugin's `agents/` dir (e.g. "planner.md"), sorted. */ - readonly agentsList: readonly string[]; - readonly skillsList: readonly string[]; - readonly hasHooksJson: boolean; - readonly hasMcpJson: boolean; -} diff --git a/cli/src/domain/tools/contracts.ts b/cli/src/domain/tools/contracts.ts deleted file mode 100644 index f0ffc2ef8..000000000 --- a/cli/src/domain/tools/contracts.ts +++ /dev/null @@ -1,118 +0,0 @@ -import type { AgentsCapability } from "../capabilities/agents-capability.js"; -import type { CommandsCapability } from "../capabilities/commands-capability.js"; -import type { HooksCapability } from "../capabilities/hooks-capability.js"; -import type { McpCapability } from "../capabilities/mcp-capability.js"; -import type { PluginsCapability } from "../capabilities/plugins-capability.js"; -import type { RulesCapability } from "../capabilities/rules-capability.js"; -import type { SettingsCapability } from "../capabilities/settings-capability.js"; -import type { SkillsCapability } from "../capabilities/skills-capability.js"; -import type { TelemetryLocalRead } from "../capabilities/telemetry-capability.js"; -import type { AiToolId, IdeToolId } from "../models/tool-ids.js"; - -export type UserFileSection = "agents" | "commands" | "rules" | "skills"; - -export interface UserFileSectionKey { - section: UserFileSection; - key: string; -} - -export interface HasAgents { - readonly agents: AgentsCapability; -} - -export interface HasSkills { - readonly skills: SkillsCapability; -} - -export interface HasCommands { - readonly commands: CommandsCapability; -} - -export interface HasRules { - readonly rules: RulesCapability; -} - -export interface HasMcp { - readonly mcp: McpCapability; -} - -export interface HasHooks { - readonly hooks: HooksCapability; -} - -export interface HasSettings { - readonly settings: SettingsCapability | SettingsCapability[]; -} - -export interface HasPlugins { - readonly plugins: PluginsCapability; -} - -export interface AiTool { - readonly kind: "ai"; - readonly toolId: AiToolId; - /** How the vendor writes it. `toolId` is a key, not a name: nothing user-facing - * should print `copilot` where a person reads "GitHub Copilot". */ - readonly displayName: string; - /** Whether this tool's own file(s) can be read locally for a session's counters — see - * {@link TelemetryLocalRead}. This is the one route this system reads: nothing here - * declares an export, because nothing configures one any more. */ - readonly telemetryLocalRead: TelemetryLocalRead; - /** How the run journal's hook names this tool in its own `session_start` line, when the - * hook writes for it at all. Not the same string as `toolId` — the hook detects a host - * from the shape of a payload and spells Claude Code `claude-code`, while `toolId` is - * `claude`. Declared here so a report joining a journal to its records reads one - * declaration rather than carrying a table of four; a fifth host is a fifth declaration. - * Absent for a tool the journal hook does not run under. */ - readonly telemetryJournalHost?: string; - /** Whether a session on this tool can be traced to the task it worked on. Once true only - * where the journal hook could read a written path out of that tool's own hook payload; - * now true for every host `journal.cjs`'s `tool-used` dispatch reaches at all, because a - * task can be *declared* - a tool call's own arguments named a file under a task folder, - * read the same way `step_start` reads which skill is running, asking nothing of the - * host's payload shape. `false` would remain where no tool-used event ever reaches the - * host in the first place, which a declaration cannot work around any more than a written - * path could - a case every declared host has cleared as of 2026-08-31, OpenCode included: - * its plugin's `event` hook does receive a completed tool call's own arguments - * (`hooks/opencode-plugin.js`'s `declaredTaskCallFor`), a bounded measurement settled - * rather than assumed either way. The truth lives in `hooks/lib/task-declared.cjs` and - * `hooks/journal.cjs`'s dispatch, inside a zero-dependency script the framework build - * copies verbatim and this side cannot import, so it is declared here and pinned to - * `journalAttributable` by a test — the same arrangement `telemetryJournalHost` already - * uses for `DECLARED_HOSTS`. - * - * A tool declaring `false` is still fully reportable by period, and by step wherever a - * journal covers it. It simply belongs to no task, which is not the same as having - * touched nothing. */ - readonly telemetryTaskAttributable: boolean; - readonly directory: string; - readonly toolSuffix: string; - readonly signalDir: string | null; - readonly requiredIdeIds?: readonly IdeToolId[]; - readonly capabilities: C; - readonly configOutputPaths?: Readonly>; - rewriteContent(content: string, docsDir: string): string; - reverseRewriteContent(content: string, docsDir: string): string; - detectUserFileSectionKey(relativePath: string): UserFileSectionKey | null; -} - -export interface IdeToolConfig { - readonly kind: "ide"; - readonly toolId: IdeToolId; - readonly directory: string; - readonly signalDir: string | null; -} - -/** Whether this tool declares a rules capability at all. - * - * Generic over the tool's own capability set so a caller keeps whatever it had already - * narrowed: `plugin-content-translator.ts` asks it of a tool it has narrowed to - * `HasPlugins` and keeps that, while a caller holding an unnarrowed tool gets `HasRules` - * alone. It lived privately in that translator until a second caller needed it; a copy - * beside it would have been free to answer differently about the same tool. - */ -export function hasRules( - tool: AiTool -): tool is AiTool { - return "rules" in (tool.capabilities as object); -} diff --git a/cli/src/domain/tools/ide/vscode.ts b/cli/src/domain/tools/ide/vscode.ts deleted file mode 100644 index 7ec7c2e96..000000000 --- a/cli/src/domain/tools/ide/vscode.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { SettingsCapability } from "../../capabilities/settings-capability.js"; -import { - CONFIG_VSCODE_EXTENSIONS, - CONFIG_VSCODE_KEYBINDINGS, - CONFIG_VSCODE_SETTINGS, -} from "../../models/framework.js"; -import type { HasSettings, IdeToolConfig } from "../contracts.js"; -import { registerTool } from "../registry.js"; - -const DIRECTORY = ".vscode/"; - -export const vscodeToolConfig: IdeToolConfig & HasSettings = { - kind: "ide", - toolId: "vscode", - directory: DIRECTORY, - signalDir: null, - - settings: [ - new SettingsCapability({ - outputPath: ".vscode/extensions.json", - mergeStrategy: "user-prime", - consumes: [CONFIG_VSCODE_EXTENSIONS], - }), - new SettingsCapability({ - outputPath: ".vscode/keybindings.json", - mergeStrategy: "none", - consumes: [CONFIG_VSCODE_KEYBINDINGS], - }), - new SettingsCapability({ - outputPath: ".vscode/settings.json", - mergeStrategy: "user-prime", - consumes: [CONFIG_VSCODE_SETTINGS], - }), - ], -}; - -registerTool(vscodeToolConfig); diff --git a/cli/src/domain/tools/registry.ts b/cli/src/domain/tools/registry.ts deleted file mode 100644 index b2a4460c9..000000000 --- a/cli/src/domain/tools/registry.ts +++ /dev/null @@ -1,119 +0,0 @@ -import { join } from "node:path"; -import type { PluginsCapability } from "../capabilities/plugins-capability.js"; -import { - CategoryMismatchError, - UnknownToolCategoryError, - UnregisteredToolError, -} from "../errors.js"; -import { - AI_TOOL_IDS, - type AiToolId, - IDE_TOOL_IDS, - type IdeToolId, - isAiToolId, - type ToolCategory, - type ToolId, - VALID_TOOL_IDS, -} from "../models/tool-ids.js"; -import type { FileReader } from "../ports/file-reader.js"; -import type { AiTool, IdeToolConfig } from "./contracts.js"; - -export type { AiToolId, IdeToolId, ToolCategory, ToolId }; -export { AI_TOOL_IDS, IDE_TOOL_IDS, isAiToolId, VALID_TOOL_IDS }; - -export type ToolConfig = AiTool | IdeToolConfig; - -export function isAiTool(config: ToolConfig): config is AiTool { - return config.kind === "ai"; -} - -export function toolIdsForCategory(category: ToolCategory): readonly ToolId[] { - switch (category) { - case "ai": - return AI_TOOL_IDS; - case "ide": - return IDE_TOOL_IDS; - default: { - const _exhaustive: never = category; - throw new UnknownToolCategoryError(String(_exhaustive)); - } - } -} - -export function isIdeToolId(id: string): id is IdeToolId { - return (IDE_TOOL_IDS as readonly string[]).includes(id); -} - -export function assertToolIdsMatchCategory(toolIds: ToolId[], category: ToolCategory): void { - const allowed = toolIdsForCategory(category); - const wrong = toolIds.filter((id) => !(allowed as readonly string[]).includes(id)); - if (wrong.length === 0) return; - throw new CategoryMismatchError(wrong, category, allowed); -} - -const TOOL_REGISTRY = new Map(); - -export function registerTool(config: ToolConfig): void { - TOOL_REGISTRY.set(config.toolId, config); -} - -export function getToolConfig(toolId: ToolId): ToolConfig { - const config = TOOL_REGISTRY.get(toolId); - if (!config) throw new UnregisteredToolError(toolId); - return config; -} - -export function getAiToolConfig(toolId: AiToolId): AiTool { - const config = getToolConfig(toolId); - if (!isAiTool(config)) throw new UnregisteredToolError(toolId); - return config; -} - -/** The `AiToolId` whose declaration claims a journal host, or `null` for a host no - * registered tool claims. The only place the journal hook's host names and this codebase's - * tool ids are related, and it relates them by reading declarations rather than by holding - * a table that a fifth host would have to be remembered into. */ -export function journalHostToAiToolId(journalHost: string): AiToolId | null { - for (const toolId of AI_TOOL_IDS) { - if (getAiToolConfig(toolId).telemetryJournalHost === journalHost) return toolId; - } - return null; -} - -export function getAllRegisteredTools(): Map { - return new Map(TOOL_REGISTRY); -} - -export async function hasToolSignals( - fs: FileReader, - config: ToolConfig, - projectRoot: string -): Promise { - if (!config.signalDir) return []; - const dir = join(projectRoot, config.signalDir); - if (!(await fs.fileExists(dir))) return []; - const files = await fs.listDirectory(dir); - const matches: string[] = []; - for (const filePath of files) { - if (!filePath.endsWith(".md")) continue; - const content = await fs.readFile(join(dir, filePath)); - if (/^name:\s*['"]?aidd[_:]/m.test(content)) matches.push(join(config.signalDir, filePath)); - } - return matches; -} - -/** - * A tool's plugin capability, or `null` when it declares none. - * - * Here rather than beside one of its callers: it reads nothing but this registry, and its - * callers now span three of them — the plugin translators, plugin removal, and the telemetry - * diagnostic. A use case reaching into a hooks materializer to ask what a tool declares is a - * placement the layering gate happens to permit and the project's own rule does not. - */ -export function resolvePluginsCapability(toolId: AiToolId): PluginsCapability | null { - const toolConfig = getToolConfig(toolId); - if (!isAiTool(toolConfig)) return null; - const caps = toolConfig.capabilities as Record; - if (!("plugins" in caps)) return null; - return caps.plugins as PluginsCapability; -} diff --git a/cli/src/infrastructure/adapters/.gitkeep b/cli/src/infrastructure/adapters/.gitkeep deleted file mode 100644 index e69de29bb..000000000 diff --git a/cli/src/infrastructure/adapters/abstract-native-plugin-cli-adapter.ts b/cli/src/infrastructure/adapters/abstract-native-plugin-cli-adapter.ts deleted file mode 100644 index a6ae1de44..000000000 --- a/cli/src/infrastructure/adapters/abstract-native-plugin-cli-adapter.ts +++ /dev/null @@ -1,62 +0,0 @@ -import { spawnSync } from "node:child_process"; -import { accessSync, constants } from "node:fs"; -import { delimiter, join } from "node:path"; -import { NativePluginCliError } from "../../domain/errors.js"; -import type { NativePluginActivator } from "../../domain/ports/native-plugin-activator.js"; - -// `plugin add/install` may fetch and cache a marketplace snapshot from a git remote. -const COMMAND_TIMEOUT_MS = 120000; - -/** - * Shared shell-out machinery for a tool's plugin CLI. Subclasses declare the - * binary and the tool-specific verbs (enable / upgrade) that differ between CLIs. - */ -export abstract class AbstractNativePluginCliAdapter implements NativePluginActivator { - protected abstract readonly binary: string; - - /** - * Resolves the binary on PATH (filesystem check, no process spawn). Spawning a - * `--version` probe just to test presence is flake-prone under load (transient - * spawn failures); a PATH lookup is what "callable on PATH" actually means. - */ - isAvailable(): boolean { - const dirs = (process.env.PATH ?? "").split(delimiter).filter((dir) => dir !== ""); - return dirs.some((dir) => { - try { - accessSync(join(dir, this.binary), constants.X_OK); - return true; - } catch { - return false; - } - }); - } - - addMarketplace(source: string): void { - this.run(["plugin", "marketplace", "add", source], `marketplace add ${source}`); - } - - removeMarketplace(name: string): void { - this.run(["plugin", "marketplace", "remove", name], `marketplace remove ${name}`); - } - - abstract upgradeMarketplaces(): void; - abstract enablePlugin(pluginRef: string): void; - abstract uninstallPlugin(pluginRef: string): void; - - protected run(args: readonly string[], label: string): void { - const result = spawnSync(this.binary, [...args], { - timeout: COMMAND_TIMEOUT_MS, - stdio: ["ignore", "pipe", "pipe"], - encoding: "utf-8", - }); - if (result.error) { - throw new NativePluginCliError(`${this.binary} ${label} failed: ${result.error.message}`); - } - if (result.status !== 0) { - const detail = result.stderr?.trim() ?? ""; - throw new NativePluginCliError( - `${this.binary} ${label} failed: ${detail || `exited with code ${result.status ?? "unknown"}`}` - ); - } - } -} diff --git a/cli/src/infrastructure/adapters/ajv-schema-validator-adapter.ts b/cli/src/infrastructure/adapters/ajv-schema-validator-adapter.ts deleted file mode 100644 index dbb50536f..000000000 --- a/cli/src/infrastructure/adapters/ajv-schema-validator-adapter.ts +++ /dev/null @@ -1,44 +0,0 @@ -import { createRequire } from "node:module"; -import { JsonSchemaValidationError } from "../../domain/errors.js"; -import type { JsonSchemaValidator } from "../../domain/ports/json-schema-validator.js"; - -// CJS interop: ajv v8 + ajv-formats are CommonJS; NodeNext requires createRequire. -// require("ajv") returns a module where the constructor is at .default. -const require = createRequire(import.meta.url); -const ajvModule = require("ajv") as { default: new (opts?: unknown) => AjvInstance }; -const AjvClass = ajvModule.default; -const addFormats = require("ajv-formats") as (ajv: AjvInstance) => void; - -interface ValidateFunction { - (data: unknown): boolean; - errors?: AjvError[] | null; -} - -interface AjvInstance { - compile(schema: object): ValidateFunction; -} - -interface AjvError { - instancePath: string; - message?: string; -} - -export class AjvSchemaValidatorAdapter implements JsonSchemaValidator { - private readonly ajv: AjvInstance; - - constructor() { - this.ajv = new AjvClass({ allErrors: true }); - addFormats(this.ajv); - } - - validate(schema: object, data: unknown): void { - const validateFn = this.ajv.compile(schema); - const valid = validateFn(data); - if (!valid) { - const errors = (validateFn.errors ?? []).map( - (e) => `${e.instancePath || "(root)"} ${e.message ?? "unknown error"}` - ); - throw new JsonSchemaValidationError(errors); - } - } -} diff --git a/cli/src/infrastructure/adapters/claude-cli-adapter.ts b/cli/src/infrastructure/adapters/claude-cli-adapter.ts deleted file mode 100644 index 40be5f8e6..000000000 --- a/cli/src/infrastructure/adapters/claude-cli-adapter.ts +++ /dev/null @@ -1,47 +0,0 @@ -import { AbstractNativePluginCliAdapter } from "./abstract-native-plugin-cli-adapter.js"; - -const PROJECT_SCOPE_ARGS = ["--scope", "project"]; - -/** - * Activates plugins through the `claude` CLI. Measured: a project-local - * `.claude/settings.json` declares `extraKnownMarketplaces`/`enabledPlugins`, but the - * runtime only loads a plugin once it's in the user-global registry - * (`~/.claude/plugins/known_marketplaces.json` and `installed_plugins.json`), which only - * `claude plugin marketplace add` / `claude plugin install` populate. Interactive Claude - * Code performs that registration itself once a person accepts the workspace trust - * dialog; headless `claude -p` skips that dialog and never registers, so the declared - * entry is silently dropped as orphaned. - */ -export class ClaudeCliAdapter extends AbstractNativePluginCliAdapter { - protected readonly binary = "claude"; - - addMarketplace(source: string): void { - this.run( - ["plugin", "marketplace", "add", ...PROJECT_SCOPE_ARGS, source], - `marketplace add ${source}` - ); - } - - upgradeMarketplaces(): void { - this.run(["plugin", "marketplace", "update"], "marketplace update"); - } - - enablePlugin(pluginRef: string): void { - this.run( - ["plugin", "install", pluginRef, ...PROJECT_SCOPE_ARGS, "--yes"], - `plugin install ${pluginRef}` - ); - } - - // `install` above registered the plugin at project scope (`--scope project`); `uninstall` - // defaults to `user` scope, which would silently miss a project-scoped entry, so the same - // scope must be passed back here. `--yes` only gates the `--prune` confirmation prompt, which - // this call never requests, but a headless stdin has no TTY to answer any prompt at all — so - // pass it unconditionally rather than assume today's uninstall never grows one. - uninstallPlugin(pluginRef: string): void { - this.run( - ["plugin", "uninstall", pluginRef, ...PROJECT_SCOPE_ARGS, "--yes"], - `plugin uninstall ${pluginRef}` - ); - } -} diff --git a/cli/src/infrastructure/adapters/codex-cli-adapter.ts b/cli/src/infrastructure/adapters/codex-cli-adapter.ts deleted file mode 100644 index 96ea9bd8d..000000000 --- a/cli/src/infrastructure/adapters/codex-cli-adapter.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { AbstractNativePluginCliAdapter } from "./abstract-native-plugin-cli-adapter.js"; - -/** - * Activates plugins through the `codex` CLI. Codex only loads plugins from its - * user-global config (`~/.codex/config.toml`) plus its cache (`~/.codex/plugins/cache/`), - * both populated by `codex plugin` — a project-local file does not enable a plugin. - */ -export class CodexCliAdapter extends AbstractNativePluginCliAdapter { - protected readonly binary = "codex"; - - upgradeMarketplaces(): void { - this.run(["plugin", "marketplace", "upgrade"], "marketplace upgrade"); - } - - enablePlugin(pluginRef: string): void { - this.run(["plugin", "add", pluginRef], `plugin add ${pluginRef}`); - } - - // The removal counterpart of `plugin add` above: drops the plugin from - // `~/.codex/config.toml` and its cache (`~/.codex/plugins/cache/`). - uninstallPlugin(pluginRef: string): void { - this.run(["plugin", "remove", pluginRef], `plugin remove ${pluginRef}`); - } -} diff --git a/cli/src/infrastructure/adapters/copilot-cli-adapter.ts b/cli/src/infrastructure/adapters/copilot-cli-adapter.ts deleted file mode 100644 index 4c2d06cd9..000000000 --- a/cli/src/infrastructure/adapters/copilot-cli-adapter.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { AbstractNativePluginCliAdapter } from "./abstract-native-plugin-cli-adapter.js"; - -/** - * Activates plugins through the `copilot` CLI. A project-local - * `.github/copilot/settings.json` only surfaces plugins as recommendations - * (enabledPlugins does not auto-install — github/copilot-cli#2249); the actual - * load comes from `copilot plugin install`, which populates `~/.copilot/`. - */ -export class CopilotCliAdapter extends AbstractNativePluginCliAdapter { - protected readonly binary = "copilot"; - - upgradeMarketplaces(): void { - this.run(["plugin", "marketplace", "update"], "marketplace update"); - } - - enablePlugin(pluginRef: string): void { - this.run(["plugin", "install", pluginRef], `plugin install ${pluginRef}`); - } - - // The removal counterpart of `plugin install` above: drops the entry from - // `~/.copilot/config.json` (`enabled`/`cache_path`) and `~/.copilot/settings.json`. - uninstallPlugin(pluginRef: string): void { - this.run(["plugin", "uninstall", pluginRef], `plugin uninstall ${pluginRef}`); - } -} diff --git a/cli/src/infrastructure/adapters/copilot-cost-reader-adapter.ts b/cli/src/infrastructure/adapters/copilot-cost-reader-adapter.ts deleted file mode 100644 index 2f642e143..000000000 --- a/cli/src/infrastructure/adapters/copilot-cost-reader-adapter.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { readFile } from "node:fs/promises"; -import { join } from "node:path"; -import { mapCopilotEventsToSinkRecords } from "../../domain/formats/copilot-events.js"; -import type { - LocalCostReadResult, - SessionCostReader, -} from "../../domain/ports/session-cost-reader.js"; - -/** - * Reads one Copilot session's own `~/.copilot/session-state//events.jsonl` directly, - * rather than through `TranscriptCostReaderAdapter`'s directory walk: the session id names - * the exact file, so there is nothing to search for and no other file that could be - * mistaken for it. That directness is also what keeps `vendor_id` correct — the id this - * reader stamps is the one it was asked for, never one re-derived from the file's own - * content (see copilot-events.ts's header comment for why that distinction matters here). - * - * A missing file is no trace of the session, not a session that cost nothing — matching - * every other reader's `sessionFound: false` for that case, whatever the underlying error - * (missing directory, permissions, a session id that never wrote anything). - */ -export class CopilotCostReaderAdapter implements SessionCostReader { - constructor(private readonly homeDir: string) {} - - async read(sessionId: string): Promise { - const path = join(this.homeDir, ".copilot", "session-state", sessionId, "events.jsonl"); - let content: string; - try { - content = await readFile(path, "utf8"); - } catch { - return { records: [], sessionFound: false }; - } - return { records: mapCopilotEventsToSinkRecords(content, sessionId), sessionFound: true }; - } -} diff --git a/cli/src/infrastructure/adapters/current-version-adapter.ts b/cli/src/infrastructure/adapters/current-version-adapter.ts deleted file mode 100644 index fdcf65a8f..000000000 --- a/cli/src/infrastructure/adapters/current-version-adapter.ts +++ /dev/null @@ -1,8 +0,0 @@ -import pkg from "../../../package.json" with { type: "json" }; -import type { VersionReader } from "../../domain/ports/version-reader.js"; - -export class CurrentVersionAdapter implements VersionReader { - get(): string { - return pkg.version; - } -} diff --git a/cli/src/infrastructure/adapters/git-adapter.ts b/cli/src/infrastructure/adapters/git-adapter.ts deleted file mode 100644 index 946da78f1..000000000 --- a/cli/src/infrastructure/adapters/git-adapter.ts +++ /dev/null @@ -1,314 +0,0 @@ -import { spawnSync } from "node:child_process"; -import { join, resolve } from "node:path"; -import { - SESSION_TRAILER_HOOK_HEADER, - sessionTrailerHookLine, -} from "../../domain/formats/commit-session-trailer.js"; -import type { TelemetryCommitTrailerSetup } from "../../domain/models/telemetry-setup.js"; -import type { FileReader } from "../../domain/ports/file-reader.js"; -import type { FileWriter } from "../../domain/ports/file-writer.js"; -import type { VersionControl } from "../../domain/ports/version-control.js"; -import { environmentWithoutGitVariables } from "../git-environment.js"; - -const PREPARE_COMMIT_MSG_HOOK = "prepare-commit-msg"; - -export class GitAdapter implements VersionControl { - constructor(private readonly fs: FileReader & FileWriter) {} - - async installCommitMessageDelegate( - projectRoot: string, - delegateFile: string, - script: string - ): Promise { - const hooksDir = await this.resolveHooksDir(projectRoot); - if (hooksDir === null) return false; - - const delegatePath = join(hooksDir, delegateFile); - await this.writeDelegate(hooksDir, delegatePath, script); - return this.callDelegateFromHook(hooksDir, sessionTrailerHookLine(delegatePath)); - } - - async removeCommitMessageDelegate(projectRoot: string, delegateFile: string): Promise { - const hooksDir = await this.resolveHooksDir(projectRoot); - if (hooksDir === null) return false; - - const delegatePath = join(hooksDir, delegateFile); - const lineDropped = await this.stopCallingDelegate( - hooksDir, - sessionTrailerHookLine(delegatePath) - ); - const fileDeleted = await this.deleteDelegate(delegatePath); - // Either half on its own still counts as something removed: a hook edited by hand, or a - // delegate deleted by one, leaves the other behind, and reporting "nothing to remove" - // there would be a lie a person could not act on. - return lineDropped || fileDeleted; - } - - /** Rewritten on every install, never only when absent: this is how a delegate left by an - * older version of the CLI is brought up to date. This file is ours outright — unlike the - * hook that calls it, which may be somebody else's. */ - private async writeDelegate(hooksDir: string, delegatePath: string, script: string) { - await this.fs.createDirectory(hooksDir); - await this.fs.writeFile(delegatePath, script); - await this.fs.chmodExecutable(delegatePath); - } - - /** Appends one line to `prepare-commit-msg`, answering whether it was newly added. An - * existing hook is kept whole and gains a line at the end; only a repository with no hook - * at all gets one written from scratch. */ - private async callDelegateFromHook(hooksDir: string, line: string): Promise { - const hookPath = join(hooksDir, PREPARE_COMMIT_MSG_HOOK); - const existing = (await this.fs.fileExists(hookPath)) - ? await this.fs.readFile(hookPath) - : `${SESSION_TRAILER_HOOK_HEADER}\n`; - if (existing.includes(line)) return false; - - const separator = existing.endsWith("\n") ? "" : "\n"; - await this.fs.writeFile(hookPath, `${existing}${separator}${line}\n`); - await this.fs.chmodExecutable(hookPath); - return true; - } - - /** Drops that one line and leaves every other byte of the hook alone, answering whether - * there was one to drop. */ - private async stopCallingDelegate(hooksDir: string, line: string): Promise { - const hookPath = join(hooksDir, PREPARE_COMMIT_MSG_HOOK); - if (!(await this.fs.fileExists(hookPath))) return false; - - const content = await this.fs.readFile(hookPath); - if (!content.includes(line)) return false; - - const kept = content.split("\n").filter((entry) => entry.trim() !== line); - await this.fs.writeFile(hookPath, kept.join("\n")); - return true; - } - - private async deleteDelegate(delegatePath: string): Promise { - if (!(await this.fs.fileExists(delegatePath))) return false; - await this.fs.deleteFile(delegatePath); - return true; - } - - // Mirrors the journal hook's own `getRemoteUrl` (plugins/aidd-telemetry/hooks/lib/repo.cjs) - // exactly, so `aidd telemetry on` derives the same `aidd.project_id` the journal does. - async getRemoteUrl(repoRoot: string): Promise { - try { - const result = spawnSync("git", ["remote", "get-url", "origin"], { - cwd: repoRoot, - encoding: "utf8", - env: environmentWithoutGitVariables(), - }); - if (result.status !== 0) return null; - return result.stdout.trim() || null; - } catch { - return null; - } - } - - // Mirrors the plugin's own `repo.cjs` (`isGitRepo`): a non-zero exit or a thrown spawn - // error both read as "not a repository", never a throw here — `aidd telemetry check` - // gates on this before judging anything else, and a gate that could itself throw would - // be the exact silent failure this command exists to avoid. - async isRepository(cwd: string): Promise { - try { - const result = spawnSync("git", ["rev-parse", "--show-toplevel"], { - cwd, - encoding: "utf8", - env: environmentWithoutGitVariables(), - }); - return result.status === 0 && result.stdout.trim() !== ""; - } catch { - return false; - } - } - - // The rule the plugin's own `warnIfTracked` read by, before the CLI took this over: a - // non-zero exit — - // no repository at all, or git itself missing — reads the same as "nothing tracked", - // never a throw. Turning telemetry on must not depend on being inside a git repository. - async listTrackedFiles(repoRoot: string, pathspec: string): Promise { - try { - const result = spawnSync("git", ["ls-files", "--", pathspec], { - cwd: repoRoot, - encoding: "utf8", - env: environmentWithoutGitVariables(), - }); - if (result.status !== 0) return []; - return result.stdout.split("\n").filter((line) => line.trim() !== ""); - } catch { - return []; - } - } - - // `git log` on a pathspec, not `git ls-files`: the index and history are different - // questions, and this is the one call that actually asks the second. A zero-commit - // repository (`git log` itself fails: "does not have any commits yet") and a normal repo - // where `pathspec` was only ever staged both read the same way here — no history — which - // is the honest answer for both. - async hasHistoryFor(repoRoot: string, pathspec: string): Promise { - try { - const result = spawnSync("git", ["log", "--oneline", "-1", "--", pathspec], { - cwd: repoRoot, - encoding: "utf8", - env: environmentWithoutGitVariables(), - }); - return result.status === 0 && result.stdout.trim() !== ""; - } catch { - return false; - } - } - - /** Every trailer fact, gathered from one place because every one of them is a git - * question. Each field is answered independently: a hooks directory that cannot be - * resolved leaves the file facts absent rather than guessed, and history that cannot be - * read leaves the count absent rather than reported as zero — which would be the one - * reading a person must never be handed, since zero commits carrying it is also what a - * genuinely broken install looks like. */ - async readCommitTrailerSetup( - projectRoot: string, - delegateFile: string, - trailerToken: string, - limit: number - ): Promise { - const hooksDir = await this.resolveHooksDir(projectRoot); - const recentlyCarrying = this.countCommitsCarrying(projectRoot, trailerToken, limit); - const history = recentlyCarrying === null ? {} : { recentlyCarrying }; - if (hooksDir === null) return { ...(await this.withoutHooksDir(projectRoot)), ...history }; - return { ...(await this.hookFacts(hooksDir, delegateFile)), hooksDir, ...history }; - } - - /** Which of the two causes left no hooks directory, asked rather than assumed: only a - * project outside git means "no hook to carry anything", and saying that about a git that - * merely could not answer prints a falsehood beside the row that says so correctly. */ - private async withoutHooksDir( - projectRoot: string - ): Promise> { - const inRepository = await this.isRepository(projectRoot); - return { - delegate: "absent", - callSite: "no-hook-file", - hookHasOtherContent: false, - hooksDirMissing: inRepository ? "unresolved" : "no-repository", - }; - } - - private async hookFacts( - hooksDir: string, - delegateFile: string - ): Promise> { - const hookPath = join(hooksDir, PREPARE_COMMIT_MSG_HOOK); - const line = sessionTrailerHookLine(join(hooksDir, delegateFile)); - const hook = await this.readIfPresent(hookPath); - return { - delegate: await this.delegateState(join(hooksDir, delegateFile)), - // The hook's own bit, not the delegate's. Git refuses to run a `prepare-commit-msg` it - // cannot execute and says so on every commit; the repair preserves whatever mode it - // finds, so a bit lost to a regeneration stays lost. Reported here rather than fixed - // there — quietly widening a file this project did not write is what the repair spends - // its whole guard budget avoiding. - ...(hook === null ? {} : { hookExecutable: await this.fs.isExecutable(hookPath) }), - callSite: callSiteState(hook, line), - hookHasOtherContent: holdsSomebodyElsesLines(hook, line), - }; - } - - /** Present, and executable. Git will not run a hook it cannot execute, so a delegate that - * is there but unrunnable is a distinct answer from one that is missing. Asked as "can - * whoever runs git execute this", not as a permission bit — see `FileReader.isExecutable` - * for the platform that made the difference matter. */ - private async delegateState(path: string): Promise { - if (!(await this.fs.fileExists(path))) return "absent"; - return (await this.fs.isExecutable(path)) ? "executable" : "not-executable"; - } - - private async readIfPresent(path: string): Promise { - return (await this.fs.fileExists(path)) ? await this.fs.readFile(path) : null; - } - - /** How many of the last `limit` non-merge commits carry the trailer. `%(trailers:key=…)` is git's - * own reader, so this agrees with what `git log` shows a person by construction rather - * than by a regex of ours. `null` — never `0` — when there is no history to read: a - * repository with no commits and one whose every commit is unstamped are different facts, - * and only the second is a finding. */ - private countCommitsCarrying( - projectRoot: string, - trailerToken: string, - limit: number - ): { carrying: number; examined: number } | null { - try { - const result = spawnSync( - "git", - [ - "log", - `-${limit}`, - // Merges are excluded because the delegate refuses them by design — a merge commit - // carrying one session's id would attribute every commit it brings in to that - // session. Counting them would put commits in the denominator that can never be in - // the numerator, which is arithmetic that reads as breakage. - "--no-merges", - `--format=%(trailers:key=${trailerToken},valueonly)%x00`, - ], - { cwd: projectRoot, encoding: "utf8", env: environmentWithoutGitVariables() } - ); - if (result.status !== 0) return null; - // No guard for an empty list: `git log` exits non-zero in a repository with no - // commits, so the branch above already answers `null` there, and a repository whose - // every commit is a merge cannot exist. A line for a case nothing can reach is a guard - // nothing can fail for. - const commits = result.stdout.split("\u0000").slice(0, -1); - return { - carrying: commits.filter((one) => one.trim() !== "").length, - examined: commits.length, - }; - } catch { - return null; - } - } - - /** - * Where this repository's hooks actually live, asked of git rather than assembled from - * `.git`. - * - * `git rev-parse --git-path hooks` answers all three cases one expression at a time could - * not: it returns `core.hooksPath` when one is set — the configuration under which a hook - * written to `.git/hooks` is silently never run — and in a linked worktree it returns the - * *common* git dir's hooks, which is where git looks. The path comes back relative in an - * ordinary repository and absolute otherwise, and git prints a relative one against the - * directory it ran in — which here is `projectRoot`, the same value it is resolved - * against. The hook side must resolve against its own cwd for that reason, and a version - * that resolved against the repository root instead sent a session started in a - * subdirectory outside the checkout entirely. - * - * `null` when there is no repository here, or when git itself cannot be run: installing a - * hook is never allowed to be the reason a command fails. - */ - private async resolveHooksDir(projectRoot: string): Promise { - try { - const result = spawnSync("git", ["rev-parse", "--git-path", "hooks"], { - cwd: projectRoot, - encoding: "utf8", - env: environmentWithoutGitVariables(), - }); - if (result.status !== 0) return null; - const answer = result.stdout.trim(); - return answer === "" ? null : resolve(projectRoot, answer); - } catch { - return null; - } - } -} - -function callSiteState(hook: string | null, line: string): TelemetryCommitTrailerSetup["callSite"] { - if (hook === null) return "no-hook-file"; - return hook.includes(line) ? "present" : "missing"; -} - -/** Any line that is neither ours nor blank. `#!/bin/sh` alone is the file the CLI writes - * when a repository had none, so a hook holding only that is still ours. */ -function holdsSomebodyElsesLines(hook: string | null, line: string): boolean { - if (hook === null) return false; - return hook - .split("\n") - .map((entry) => entry.trim()) - .some((entry) => entry !== "" && entry !== line && entry !== SESSION_TRAILER_HOOK_HEADER); -} diff --git a/cli/src/infrastructure/adapters/github-release-resolver-adapter.ts b/cli/src/infrastructure/adapters/github-release-resolver-adapter.ts deleted file mode 100644 index f9d35bb25..000000000 --- a/cli/src/infrastructure/adapters/github-release-resolver-adapter.ts +++ /dev/null @@ -1,76 +0,0 @@ -import { - AuthenticationError, - CatalogFetchAuthError, - CatalogFetchError, -} from "../../domain/errors.js"; -import type { LatestReleaseResolver } from "../../domain/ports/latest-release-resolver.js"; -import type { TokenProvider } from "../../domain/ports/token-provider.js"; -import { HttpNotFoundError } from "../errors.js"; -import type { HttpGet } from "../http/http-client.js"; - -const GITHUB_API_BASE = "https://api.github.com"; - -/** Root release tag: `v` followed by a digit (`v4.0.0`, `v3.7.3-pm.1`). */ -const ROOT_RELEASE_TAG_REGEX = /^v\d/; - -export class GitHubReleaseResolverAdapter implements LatestReleaseResolver { - constructor( - private readonly http: HttpGet, - private readonly tokenProvider?: TokenProvider - ) {} - - async resolveLatest(repo: string): Promise { - // Use /releases?per_page=1 (not /releases/latest) — the latter excludes - // prereleases. We want the most recent published release of any kind so - // beta tags resolve too. - const url = `${GITHUB_API_BASE}/repos/${repo}/releases?per_page=1`; - const token = (await this.tokenProvider?.resolve()) ?? undefined; - try { - const response = await this.http.get(url, { token }); - const body = response.body as unknown[]; - if (!Array.isArray(body) || body.length === 0) return null; - const first = body[0] as Record; - return typeof first.tag_name === "string" ? first.tag_name : null; - } catch (err) { - return this.handleError(err, url); - } - } - - async listRootReleases(repo: string): Promise { - // per_page=100 (GitHub max) so root tags are not buried under - // release-please per-component tags on busy repos. - const url = `${GITHUB_API_BASE}/repos/${repo}/releases?per_page=100`; - const token = (await this.tokenProvider?.resolve()) ?? undefined; - try { - const response = await this.http.get(url, { token }); - const body = response.body as unknown[]; - if (!Array.isArray(body)) return []; - return body - .map((r) => (r as Record).tag_name) - .filter((t): t is string => typeof t === "string" && ROOT_RELEASE_TAG_REGEX.test(t)); - } catch (err) { - this.handleError(err, url); - return []; - } - } - - async isRepoPublic(repo: string): Promise { - // No token — probe unauthenticated reachability. A private/absent repo returns - // 404; only that unambiguously means "auth required". Any other error resolves - // true so a rate-limited or offline public user is not wrongly sent to login. - const url = `${GITHUB_API_BASE}/repos/${repo}`; - try { - await this.http.get(url); - return true; - } catch (err) { - return !(err instanceof HttpNotFoundError); - } - } - - private handleError(err: unknown, url: string): never | null { - if (err instanceof HttpNotFoundError) return null; - if (err instanceof AuthenticationError) throw new CatalogFetchAuthError(url); - const detail = err instanceof Error ? err.message : String(err); - throw new CatalogFetchError(url, detail); - } -} diff --git a/cli/src/infrastructure/adapters/hasher-adapter.ts b/cli/src/infrastructure/adapters/hasher-adapter.ts deleted file mode 100644 index 8e3ee7be8..000000000 --- a/cli/src/infrastructure/adapters/hasher-adapter.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { createHash } from "node:crypto"; -import { FileHash } from "../../domain/models/file.js"; -import type { Hasher } from "../../domain/ports/hasher.js"; - -export class HasherAdapter implements Hasher { - hash(content: string): FileHash { - const hex = createHash("md5").update(content, "utf-8").digest("hex"); - return new FileHash(hex); - } -} diff --git a/cli/src/infrastructure/adapters/hook-trust-reader-adapter.ts b/cli/src/infrastructure/adapters/hook-trust-reader-adapter.ts deleted file mode 100644 index 833e39198..000000000 --- a/cli/src/infrastructure/adapters/hook-trust-reader-adapter.ts +++ /dev/null @@ -1,62 +0,0 @@ -import { readFile } from "node:fs/promises"; -import { join } from "node:path"; -import { describeError } from "../../domain/describe-error.js"; -import type { TelemetryCodexHookTrust } from "../../domain/models/telemetry-claim.js"; -import type { HookTrustReader } from "../../domain/ports/hook-trust-reader.js"; -import { resolveHomeDir } from "../home-dir.js"; - -// The exact table header Codex writes to `~/.codex/config.toml` once a hook is approved. -// These came from the plugin's own PLUGIN_NAME/HOOKS_FILE/SESSION_START_EVENT constants -// (`hook-trust.cjs`), deleted when the CLI took the read path; they live here alone now, -// so there is no second copy to keep them in step with. Only the SessionStart hook decides whether a journal opens at all — -// the claim this exists for — so that is the one event whose trust state actually explains -// an empty journal. -// -// Exported so `telemetry-evidence-adapter.ts` can check the same literal for the recorder -// declaration fact, rather than a second copy that could drift from this one. -export const PLUGIN_NAME = "aidd-telemetry"; -const HOOKS_FILE = "hooks/hooks.json"; -const SESSION_START_EVENT = "session_start"; - -// The recorder's own hook entry point (`plugins/aidd-telemetry/hooks/hooks.json`'s -// `command` for every event it registers). Exported alongside `PLUGIN_NAME` so a hooks -// block found declared in a project's own settings — rather than via `enabledPlugins` — -// is recognised by the script it actually invokes, not a loose substring: a rename here -// is the one place `telemetry-evidence-adapter.ts`'s detection needs to follow. -export const HOOK_ENTRY_SCRIPT = "journal.cjs"; - -function codexConfigPath(homeDir: string): string { - return join(homeDir, ".codex", "config.toml"); -} - -// Line-scanned, not TOML-parsed — `config.toml` carries arbitrary nested tables and -// multi-line values this adapter has no business understanding. The one shape it needs is -// a header Codex itself always emits verbatim, directly followed by its `trusted_hash` -// line: a plain string match, mirroring the plugin's own `parseHookTrust`. Matched on the -// full key including the event name, so a hook approved under a renamed event — a -// different key entirely — is not found here and reads as untrusted, never as approved -// under its old name. -function parseHookTrust(content: string): { trusted: boolean } { - const lines = content.split("\n"); - const prefix = `[hooks.state."${PLUGIN_NAME}@`; - const suffix = `:${HOOKS_FILE}:${SESSION_START_EVENT}:0:0"]`; - const at = lines.findIndex((line) => line.startsWith(prefix) && line.endsWith(suffix)); - if (at === -1) return { trusted: false }; - return { trusted: /^trusted_hash\s*=/.test((lines[at + 1] ?? "").trim()) }; -} - -export class HookTrustReaderAdapter implements HookTrustReader { - async read(): Promise { - const configPath = codexConfigPath(resolveHomeDir()); - let content: string; - try { - content = await readFile(configPath, "utf8"); - } catch (error) { - return { - readable: false, - reason: `${configPath} could not be read (${describeError(error)})`, - }; - } - return { readable: true, configPath, ...parseHookTrust(content) }; - } -} diff --git a/cli/src/infrastructure/adapters/host-plugin-registry-reader-adapter.ts b/cli/src/infrastructure/adapters/host-plugin-registry-reader-adapter.ts deleted file mode 100644 index df47dceb7..000000000 --- a/cli/src/infrastructure/adapters/host-plugin-registry-reader-adapter.ts +++ /dev/null @@ -1,284 +0,0 @@ -import { readFile, realpath } from "node:fs/promises"; -import { join } from "node:path"; -import { describeError } from "../../domain/describe-error.js"; -import type { AiToolId } from "../../domain/models/tool-ids.js"; -import type { - HostPluginRegistryReader, - HostPluginRegistryReading, -} from "../../domain/ports/host-plugin-registry-reader.js"; -import { resolveHomeDir } from "../home-dir.js"; - -/** - * One reader per host whose own plugin registry was measured, keyed the way - * `DiagnoseTelemetryUseCase` already keys its cost readers: a tool absent from the map is - * a tool nothing here claims to know, and the diagnostic reports it unanswerable rather - * than assuming it agrees. - * - * Measured 2026-09-02 on the machine that built this, reading shape only: - * - * Claude Code ~/.claude/plugins/installed_plugins.json { version, plugins: { ref: [ … ] } } - * Codex ~/.codex/config.toml [plugins."ref"] enabled = true - * Copilot ~/.copilot/config.json JSONC, { …, installedPlugins: [] } - * - * **Copilot's registry was measured on 2026-09-03, and it is not the file this once - * declined to read.** An earlier version refused Copilot because `~/.copilot/config.json` is - * JSONC and its `installedPlugins` read empty on a machine that had run installs. The - * instinct was right and the conclusion was wrong: that array is not the registry, and the - * registry is `~/.copilot/settings.json`. Driven live under a sandboxed home, - * `copilot plugin marketplace add ` writes `extraKnownMarketplaces` and - * `copilot plugin install @` writes - * `enabledPlugins: { "@": true }` — plain JSON, no comments, and the - * same key every other host uses. `copilot plugin uninstall` sets that value to `false` - * rather than deleting the key, so a registered-but-off plugin is an ordinary state here, - * not a shape only Codex can produce. - */ -export function hostPluginRegistryReaders( - home: string = resolveHomeDir() -): ReadonlyMap { - return new Map([ - [ - "claude", - new ClaudeInstalledPluginsReader(join(home, ".claude", "plugins", "installed_plugins.json")), - ], - ["codex", new CodexConfigPluginsReader(join(home, ".codex", "config.toml"))], - ["copilot", new CopilotSettingsPluginsReader(join(home, ".copilot", "settings.json"))], - ]); -} - -/** - * Claude Code's own registry: a JSON document whose `plugins` object is keyed by the same - * `@` ref `enabledPlugins` uses, each key holding one entry per scope - * the ref was installed at. - * - * **Presence is not the whole answer, and an earlier version of this file said it was.** - * That claim came from reading the first entry of the first key and generalising; read - * across all 115 entries on the machine measured, they carry a seventh field the first one - * did not — `projectPath` — on 100 of them, exactly the 99 at `scope: "project"` plus the - * one at `"local"`. So the registry does say which project wants a ref, and `aidd` writes - * every one of them at project scope: `claude-cli-adapter.ts`'s own `PROJECT_SCOPE_ARGS`. - * - * Ignoring that would report a ref installed for another project as `registered` here, which - * is the same unmeasured confidence this file refuses to extend to Copilot a few lines up. A - * ref counts for this project when some entry is user-scoped — machine-wide by construction, - * and those carry no `projectPath` — or names this project. Claude records no enabled flag - * anywhere, so a ref that counts maps to `true`. - */ -class ClaudeInstalledPluginsReader implements HostPluginRegistryReader { - constructor(private readonly path: string) {} - - async read(projectRoot: string): Promise { - let content: string; - try { - content = await readFile(this.path, "utf8"); - } catch (error) { - return { location: this.path, unreadable: describeError(error) }; - } - try { - const parsed = JSON.parse(content) as { plugins?: Record }; - const plugins = parsed.plugins; - if (plugins === undefined || typeof plugins !== "object") { - return { location: this.path, unreadable: "no `plugins` object" }; - } - const refs = new Map(); - const here = await resolvedPath(projectRoot); - for (const [ref, entries] of Object.entries(plugins)) { - if (await countsForProject(entries, here)) refs.set(ref, true); - } - return { location: this.path, refs }; - } catch (error) { - return { location: this.path, unreadable: describeError(error) }; - } - } -} - -/** - * Codex's own registry, line-scanned rather than parsed — the choice - * `hook-trust-reader-adapter.ts` already made against this same file, for the reason it - * states: it *"carries arbitrary nested tables and multi-line values this adapter has no - * business understanding."* Concretely, the file measured is 26 KB and most of it is - * `[projects.""]` tables; parsing the whole document to read `[plugins.…]` - * would pull every project path on the machine into a process that then writes diagnostic - * output to a terminal. - * - * The one shape it needs is the header Codex writes verbatim, `[plugins.""]`, and the - * `enabled` key in the table under it. Line-scanning reads that shape wherever it appears, - * a string value included, which is why the first occurrence of a ref is the one kept. `enabled = false` is carried through as `false` rather - * than dropped: a host that knows a plugin and declines it is not a host that never heard - * of it, and the two must not print alike. - */ -class CodexConfigPluginsReader implements HostPluginRegistryReader { - constructor(private readonly path: string) {} - - // `projectRoot` is deliberately unused: Codex's plugin tables carry `enabled` and nothing - // else — no path, no scope — so its registry answers for the machine and cannot answer for - // one project. Said here rather than left to look like an oversight. - async read(_projectRoot: string): Promise { - let content: string; - try { - content = await readFile(this.path, "utf8"); - } catch (error) { - return { location: this.path, unreadable: describeError(error) }; - } - return { location: this.path, refs: scanCodexPluginTables(content) }; - } -} - -/** - * Copilot's own registry: `enabledPlugins` in `~/.copilot/settings.json`, keyed on the same - * `@` ref as every other host and carrying a boolean. - * - * The boolean is the whole of the difference from Claude's file. `copilot plugin uninstall` - * writes `false` and keeps the key, so a plugin the host knows and declines is a state a - * person reaches with one ordinary command — measured, not inferred. - * - * No project binding: the file records nothing but marketplaces and refs, so like Codex it - * answers for the machine and cannot answer for one project. - */ -class CopilotSettingsPluginsReader implements HostPluginRegistryReader { - constructor(private readonly path: string) {} - - async read(_projectRoot: string): Promise { - let content: string; - try { - content = await readFile(this.path, "utf8"); - } catch (error) { - return { location: this.path, unreadable: describeError(error) }; - } - try { - const parsed = JSON.parse(content) as { enabledPlugins?: Record }; - const enabled = parsed.enabledPlugins; - // Absent is a real answer here, unlike a file that would not open: Copilot writes the - // key on its first install, so a settings file without one belongs to somebody who has - // installed no plugin — which is "carries none", not "could not be read". - if (enabled === undefined) return { location: this.path, refs: new Map() }; - return { - location: this.path, - refs: new Map(Object.entries(enabled).map(([ref, on]) => [ref, on !== false])), - }; - } catch (error) { - return { location: this.path, unreadable: describeError(error) }; - } - } -} - -const CODEX_PLUGIN_HEADER = /^\[plugins\."(.+?)"\]\s*(?:#.*)?$/u; -const CODEX_TABLE_HEADER = /^\[/u; -const CODEX_ENABLED_LINE = /^enabled\s*=\s*(true|false)\s*(?:#.*)?$/u; -const CODEX_MULTILINE_DELIMITER = /"""|'''/gu; - -/** - * Reads each plugin table's body, and only outside multi-line strings. - * - * Two defects were found here by running it rather than reading it, and both produced the - * inversion this whole feature exists to remove: a host that will not load a plugin, - * reported as one that will. - * - * The first took `lines[index + 1]` and called that "absent `enabled` reads as enabled". It - * meant "not on the immediately following line", so a blank line, a comment, a reordered key - * or a trailing comment each turned `enabled = false` into an enabled plugin. - * - * The second was the fix for the first. Keeping the first occurrence of a ref was justified - * by "TOML forbids defining a table twice, so a second line that looks like this header is - * necessarily not one" — which proves one of the two is fake and never which one. A header - * spelled inside a multi-line string BEFORE the real table therefore won, and a disabled - * plugin read as registered again, in the mirror image of what last-wins got wrong. - * - * Skipping multi-line strings is what makes the ordering rule true rather than asserted: a - * fake header is not seen at all, so the only header that can be taken is a real one, and - * TOML's own prohibition then guarantees there is exactly one of those. - * - * Absent `enabled` reads as enabled: Codex writes the key on every table it creates, so a - * table without one is a shape it does not produce, and between "the host listed this - * plugin" and "the host listed it and said nothing", the listing is the fact. - */ -function scanCodexPluginTables(content: string): ReadonlyMap { - const refs = new Map(); - const lines = outsideMultilineStrings(content.split("\n")); - for (const [index, line] of lines.entries()) { - if (line === null) continue; - const ref = CODEX_PLUGIN_HEADER.exec(line.trim())?.[1]; - if (ref === undefined || refs.has(ref)) continue; - refs.set(ref, enabledInTableBody(lines, index + 1)); - } - return refs; -} - -/** - * The same lines, with every one inside a multi-line string replaced by `null`. - * - * Positions are preserved rather than filtered out, so a table's body still begins at the - * line after its header. A delimiter can open and close on one line, so the number of - * delimiters on a line decides the state after it and an odd count is what flips it; the - * line carrying the opening delimiter is itself outside, which is right — that line is the - * assignment, not the content. - */ -function outsideMultilineStrings(lines: readonly string[]): readonly (string | null)[] { - let inside = false; - return lines.map((line) => { - const wasInside = inside; - const delimiters = line.match(CODEX_MULTILINE_DELIMITER)?.length ?? 0; - if (delimiters % 2 === 1) inside = !inside; - return wasInside ? null : line; - }); -} - -/** The first `enabled` assignment between a table header and the next table, defaulting to - * enabled when the table declares none. A line inside a multi-line string is `null` here and - * neither ends the table nor answers for it. */ -function enabledInTableBody(lines: readonly (string | null)[], from: number): boolean { - for (let at = from; at < lines.length; at += 1) { - const line = lines[at]; - if (line === null || line === undefined) continue; - const trimmed = line.trim(); - if (CODEX_TABLE_HEADER.test(trimmed)) return true; - if (trimmed === "" || trimmed.startsWith("#")) continue; - const enabled = CODEX_ENABLED_LINE.exec(trimmed); - if (enabled !== null) return enabled[1] === "true"; - } - return true; -} - -/** One installed-plugin entry, narrowed to the two fields that decide whether a ref counts - * for the project being diagnosed. Everything else Claude records there — install path, - * version, timestamps, commit sha — describes what was installed, never where it applies. */ -interface ClaudeEntry { - readonly scope?: string; - readonly projectPath?: string; -} - -/** A user-scoped entry applies everywhere and carries no `projectPath`; any other scope - * applies to the project it names. An entry with neither is not evidence of anything and is - * ignored rather than counted, which is the same refusal to guess the rest of this file - * makes. */ -async function countsForProject( - entries: readonly ClaudeEntry[], - projectRoot: string -): Promise { - if (!Array.isArray(entries)) return false; - for (const entry of entries) { - if (entry.scope === "user") return true; - if (entry.projectPath === undefined) continue; - if ((await resolvedPath(entry.projectPath)) === projectRoot) return true; - } - return false; -} - -/** - * Both sides of the project comparison go through here, and this is not defensive tidying: - * an end-to-end run caught the string comparison failing on an ordinary macOS temp - * directory, where `/var` is a symlink to `/private/var`. Claude writes the path it resolved - * and the CLI holds the path it was invoked from; on any machine where one of them crosses a - * link, comparing the raw strings reports a registered plugin as missing. - * - * A path that cannot be resolved — most often because it no longer exists, which a stale - * registry entry naturally produces — falls back to itself rather than throwing: an entry - * for a deleted project should simply not match this one, not cost every other entry its - * answer. - */ -async function resolvedPath(path: string): Promise { - try { - return await realpath(path); - } catch { - return path; - } -} diff --git a/cli/src/infrastructure/adapters/manifest-repository-adapter.ts b/cli/src/infrastructure/adapters/manifest-repository-adapter.ts deleted file mode 100644 index 9ace02213..000000000 --- a/cli/src/infrastructure/adapters/manifest-repository-adapter.ts +++ /dev/null @@ -1,51 +0,0 @@ -import { mkdir, readdir, readFile, rm, rmdir, writeFile } from "node:fs/promises"; -import { join } from "node:path"; -import { Manifest } from "../../domain/models/manifest.js"; -import { AIDD_DIR, MANIFEST_FILENAME } from "../../domain/models/paths.js"; -import type { ManifestRepository } from "../../domain/ports/manifest-repository.js"; - -export class ManifestRepositoryAdapter implements ManifestRepository { - constructor(private readonly projectRoot: string) {} - - get path(): string { - return join(this.projectRoot, AIDD_DIR, MANIFEST_FILENAME); - } - - private get aiddDir(): string { - return join(this.projectRoot, AIDD_DIR); - } - - async load(): Promise { - let raw: string; - try { - raw = await readFile(this.path, "utf-8"); - } catch { - return null; - } - - return Manifest.fromJSON(JSON.parse(raw)); - } - - async save(manifest: Manifest): Promise { - await mkdir(this.aiddDir, { recursive: true }); - const json = JSON.stringify(manifest.toJSON(), null, 2); - await writeFile(this.path, json, "utf-8"); - } - - async delete(): Promise { - try { - await rm(this.path, { force: true }); - } catch { - // No error if missing - } - - try { - const entries = await readdir(this.aiddDir); - if (entries.length === 0) { - await rmdir(this.aiddDir); - } - } catch { - // No error if dir missing - } - } -} diff --git a/cli/src/infrastructure/adapters/marketplace-cache-adapter.ts b/cli/src/infrastructure/adapters/marketplace-cache-adapter.ts deleted file mode 100644 index da664b19b..000000000 --- a/cli/src/infrastructure/adapters/marketplace-cache-adapter.ts +++ /dev/null @@ -1,84 +0,0 @@ -import { readdir, readFile, rm, stat } from "node:fs/promises"; -import { join } from "node:path"; -import { MarketplaceCacheEntry } from "../../domain/models/marketplace-cache-entry.js"; -import { MARKETPLACE_CACHE_SUBDIR } from "../../domain/models/paths.js"; -import type { MarketplaceCachePort } from "../../domain/ports/marketplace-cache.js"; - -const FETCH_META_FILE = ".fetch-meta.json"; - -export class MarketplaceCacheAdapter implements MarketplaceCachePort { - constructor(private readonly projectRoot: string) {} - - async list(): Promise { - const cacheRoot = join(this.projectRoot, MARKETPLACE_CACHE_SUBDIR); - let entries: string[]; - try { - entries = await readdir(cacheRoot); - } catch { - return []; - } - const results: MarketplaceCacheEntry[] = []; - for (const name of entries) { - const entry = await this.buildEntry(name, join(cacheRoot, name)); - if (entry !== null) results.push(entry); - } - return results; - } - - async clear(name?: string): Promise { - const cacheRoot = join(this.projectRoot, MARKETPLACE_CACHE_SUBDIR); - if (name !== undefined) { - await rm(join(cacheRoot, name), { recursive: true, force: true }); - } else { - let entries: string[]; - try { - entries = await readdir(cacheRoot); - } catch { - return; - } - for (const entry of entries) { - await rm(join(cacheRoot, entry), { recursive: true, force: true }); - } - } - } - - private async buildEntry(name: string, dirPath: string): Promise { - try { - const sizeBytes = await this.computeSize(dirPath); - const lastFetchedAt = await this.readLastFetchedAt(dirPath); - return new MarketplaceCacheEntry({ name, path: dirPath, sizeBytes, lastFetchedAt }); - } catch { - return null; - } - } - - private async computeSize(dirPath: string): Promise { - let total = 0; - let entries: string[]; - try { - entries = await readdir(dirPath, { recursive: true, encoding: "utf-8" }); - } catch { - return 0; - } - for (const entry of entries) { - try { - const info = await stat(join(dirPath, entry)); - if (info.isFile()) total += info.size; - } catch { - // skip unreadable entries - } - } - return total; - } - - private async readLastFetchedAt(dirPath: string): Promise { - try { - const raw = await readFile(join(dirPath, FETCH_META_FILE), "utf-8"); - const parsed = JSON.parse(raw) as { lastFetchedAt?: string }; - if (parsed.lastFetchedAt) return new Date(parsed.lastFetchedAt); - } catch { - // file absent or malformed — backfill safe - } - return null; - } -} diff --git a/cli/src/infrastructure/adapters/person-identity-adapter.ts b/cli/src/infrastructure/adapters/person-identity-adapter.ts deleted file mode 100644 index bd4c1261c..000000000 --- a/cli/src/infrastructure/adapters/person-identity-adapter.ts +++ /dev/null @@ -1,167 +0,0 @@ -import { randomUUID } from "node:crypto"; -import { mkdir, readFile, rm, writeFile } from "node:fs/promises"; -import { dirname, join } from "node:path"; -import { errorMessage } from "../../domain/describe-error.js"; -import { UnreadableIdentityFileError } from "../../domain/errors.js"; -import { - withAlsoMeAdded, - withAlsoMeRemoved, - withPersonIdAdopted, -} from "../../domain/models/person-resolution.js"; -import type { PersonIdentity } from "../../domain/ports/person-identity-reader.js"; -import type { PersonIdentityStore } from "../../domain/ports/person-identity-store.js"; -import { IdentityWriteError } from "../errors.js"; -import { resolveAiddConfigDir } from "../home-dir.js"; -import { asPlainObject, isErrnoException } from "../json-file.js"; - -const PRIVATE_FILE_MODE = 0o600; -const PRIVATE_DIR_MODE = 0o700; - -// A file with no `origin` at all is read as `"minted"`, never guessed as anything else: -// every file written before this change - by this adapter's own earlier shape, or by the -// plugin's now-deleted `identity.cjs` - is exactly what `"minted"` describes, and `origin` -// is only ever knowable at the moment an identity is created or adopted, never afterwards. -function parseIdentity(raw: string): PersonIdentity | null { - const parsed = asPlainObject(JSON.parse(raw)); - if (typeof parsed.person_id !== "string" || parsed.person_id === "") return null; - const identity: { personId: string; origin: "minted" | "adopted"; alsoMe: string[] } = { - personId: parsed.person_id, - origin: parsed.origin === "adopted" ? "adopted" : "minted", - alsoMe: Array.isArray(parsed.also_me) - ? parsed.also_me.filter((v) => typeof v === "string") - : [], - }; - if (typeof parsed.display_name === "string" && parsed.display_name !== "") { - return { ...identity, displayName: parsed.display_name }; - } - return identity; -} - -// `also_me` is omitted from the written file when empty, the same way `display_name` is -// omitted when unset - an empty array is what most identities have, and writing it out on -// every file would make the common case noisier than the shape it describes. -function serializeIdentity(identity: PersonIdentity): string { - const record: { - person_id: string; - origin: "minted" | "adopted"; - display_name?: string; - also_me?: readonly string[]; - } = { person_id: identity.personId, origin: identity.origin }; - if (identity.displayName !== undefined) record.display_name = identity.displayName; - if (identity.alsoMe.length > 0) record.also_me = identity.alsoMe; - return `${JSON.stringify(record, null, 2)}\n`; -} - -/** Reads and writes only this machine's own user profile - `resolveHomeDir()` honors `HOME` - * on every platform, and this adapter never reads `AIDD_USER_CONFIG_DIR`. That variable is documented as a location - * a team or a CI can point every figure at; a choice reachable that way would not be this - * person's own. - * - * `filePath` is resolved exactly once, in the constructor, and frozen from then on — a - * relocation of `HOME` after construction can never change what this instance answers, - * matching `TelemetrySinkAdapter.rootDir`. */ -export class PersonIdentityAdapter implements PersonIdentityStore { - readonly filePath: string; - - constructor() { - this.filePath = join(resolveAiddConfigDir(), "identity.json"); - } - - async read(): Promise { - try { - return parseIdentity(await readFile(this.filePath, "utf8")); - } catch { - return null; - } - } - - async readStrict(): Promise { - const raw = await this.readFileOrNull(); - if (raw === null) return null; - try { - return parseIdentity(raw); - } catch (error) { - throw new UnreadableIdentityFileError(this.filePath, errorMessage(error)); - } - } - - async mint(): Promise { - const identity: PersonIdentity = { personId: randomUUID(), origin: "minted", alsoMe: [] }; - await this.write(identity); - return identity; - } - - async adopt(personId: string): Promise { - const identity = withPersonIdAdopted(await this.readStrict(), personId); - await this.write(identity); - return identity; - } - - async addAlsoMe(identity: string): Promise { - const next = withAlsoMeAdded(await this.requireCurrent("add"), identity); - await this.write(next); - return next; - } - - async removeAlsoMe(identity: string): Promise { - const next = withAlsoMeRemoved(await this.requireCurrent("remove"), identity); - await this.write(next); - return next; - } - - async setDisplayName(identity: PersonIdentity, displayName: string): Promise { - const next: PersonIdentity = { ...identity, displayName }; - await this.write(next); - return next; - } - - // `recursive: true` is what lets this discard a damaged identity file that turns out to - // be a directory (the Test Scope's own "the identity file is unreadable" edge case) — - // `off` is a privacy control, and withdrawing must not depend on the damage taking one - // particular shape. `force: true` is deliberately NOT set: forcing folds "already gone" - // into success, and that is the one case this has to report back - see the port. `path` - // is never `this.filePath` re-derived here — see the port's own doc for why the caller - // supplies it. - async forget(path: string): Promise { - try { - await rm(path, { recursive: true }); - return true; - } catch (error) { - if (isErrnoException(error) && error.code === "ENOENT") return false; - throw new IdentityWriteError(path, error, "remove"); - } - } - - // `addAlsoMe`/`removeAlsoMe` assume a person exists to add onto - the use case that - // calls them already refused "nobody opted in" against its own read of the identity - // before ever reaching here. This is the defensive fallback for that contract, not a - // path a normal call takes. - private async requireCurrent(action: "add" | "remove"): Promise { - const current = await this.readStrict(); - if (current !== null) return current; - throw new IdentityWriteError( - this.filePath, - new Error(`no identity exists to ${action} an identifier onto`), - "write" - ); - } - - private async readFileOrNull(): Promise { - try { - return await readFile(this.filePath, "utf8"); - } catch (error) { - if (isErrnoException(error) && error.code === "ENOENT") return null; - throw new UnreadableIdentityFileError(this.filePath, errorMessage(error)); - } - } - - private async write(identity: PersonIdentity): Promise { - const filePath = this.filePath; - try { - await mkdir(dirname(filePath), { recursive: true, mode: PRIVATE_DIR_MODE }); - await writeFile(filePath, serializeIdentity(identity), { mode: PRIVATE_FILE_MODE }); - } catch (error) { - throw new IdentityWriteError(filePath, error); - } - } -} diff --git a/cli/src/infrastructure/adapters/platform-adapter.ts b/cli/src/infrastructure/adapters/platform-adapter.ts deleted file mode 100644 index b809668f9..000000000 --- a/cli/src/infrastructure/adapters/platform-adapter.ts +++ /dev/null @@ -1,7 +0,0 @@ -import type { Platform } from "../../domain/ports/platform.js"; - -export class PlatformAdapter implements Platform { - current(): string { - return process.platform; - } -} diff --git a/cli/src/infrastructure/adapters/plugin-catalog-repository-adapter.ts b/cli/src/infrastructure/adapters/plugin-catalog-repository-adapter.ts deleted file mode 100644 index bf3690839..000000000 --- a/cli/src/infrastructure/adapters/plugin-catalog-repository-adapter.ts +++ /dev/null @@ -1,122 +0,0 @@ -import { isAbsolute, join, resolve } from "node:path"; -import { MalformedMarketplaceCatalogError } from "../../domain/errors.js"; -import { parseCodexMarketplace } from "../../domain/formats/codex-marketplace.js"; -import { parseCopilotMarketplace } from "../../domain/formats/copilot-marketplace.js"; -import { parseCopilotMarketplaceCatalog } from "../../domain/formats/copilot-marketplace-catalog.js"; -import { parseCursorMarketplace } from "../../domain/formats/cursor-marketplace.js"; -import { parseOpencodeMarketplace } from "../../domain/formats/opencode-marketplace.js"; -import type { NormalizedPlugin } from "../../domain/models/normalized-plugin.js"; -import { MARKETPLACE_CACHE_SUBDIR } from "../../domain/models/paths.js"; -import { type PluginCatalog, parsePluginCatalog } from "../../domain/models/plugin-catalog.js"; -import { MARKETPLACE_PROBES } from "../../domain/models/plugin-format.js"; -import type { PluginSource } from "../../domain/models/plugin-source.js"; -import type { FileReader } from "../../domain/ports/file-reader.js"; -import type { PluginCatalogRepository } from "../../domain/ports/plugin-catalog-repository.js"; - -const COPILOT_MARKETPLACE_PATH = ".plugin/marketplace.json"; -const CLAUDE_MARKETPLACE_PATH = ".claude-plugin/marketplace.json"; - -export class PluginCatalogRepositoryAdapter implements PluginCatalogRepository { - constructor(private readonly fs: FileReader) {} - - async load(frameworkPath: string): Promise { - const copilotPath = join(frameworkPath, COPILOT_MARKETPLACE_PATH); - if (await this.fs.fileExists(copilotPath)) { - const catalog = await this.readCopilotNativeCatalog(copilotPath); - return this.resolveLocalPaths(catalog, frameworkPath); - } - const claudePath = join(frameworkPath, CLAUDE_MARKETPLACE_PATH); - if (!(await this.fs.fileExists(claudePath))) { - return null; - } - const catalog = await this.readClaudeCatalog(claudePath); - return this.resolveLocalPaths(catalog, frameworkPath); - } - - async loadForeign(frameworkPath: string): Promise { - for (const probe of MARKETPLACE_PROBES) { - if (probe.format === "claude") continue; - const fullPath = join(frameworkPath, probe.relativePath); - if (!(await this.fs.fileExists(fullPath))) continue; - if (probe.format === "cursor") return this.readCursorCatalog(fullPath); - if (probe.format === "codex") return this.readCodexCatalog(fullPath); - if (probe.format === "copilot") return this.readCopilotCatalog(fullPath); - if (probe.format === "opencode") return this.readOpencodeCatalog(fullPath); - } - return []; - } - - private isCachePath(fullPath: string): boolean { - return fullPath.includes(MARKETPLACE_CACHE_SUBDIR); - } - - private parseDetail(err: unknown): string { - const message = err instanceof Error ? err.message : String(err); - return message.replace(/^Invalid plugin manifest:\s*/, ""); - } - - private async readCopilotNativeCatalog(fullPath: string): Promise { - const raw = await this.fs.readFile(fullPath); - try { - return parseCopilotMarketplaceCatalog(raw); - } catch (err) { - throw new MalformedMarketplaceCatalogError( - fullPath, - this.parseDetail(err), - this.isCachePath(fullPath) - ); - } - } - - private async readClaudeCatalog(fullPath: string): Promise { - const cached = this.isCachePath(fullPath); - let raw: unknown; - try { - raw = JSON.parse(await this.fs.readFile(fullPath)); - } catch { - throw new MalformedMarketplaceCatalogError(fullPath, "not valid JSON", cached); - } - try { - return parsePluginCatalog(raw); - } catch (err) { - throw new MalformedMarketplaceCatalogError(fullPath, this.parseDetail(err), cached); - } - } - - private async readCursorCatalog(fullPath: string): Promise { - const raw = await this.fs.readFile(fullPath); - return [...parseCursorMarketplace(raw).plugins]; - } - - private async readCodexCatalog(fullPath: string): Promise { - const raw = await this.fs.readFile(fullPath); - return [...parseCodexMarketplace(raw).plugins]; - } - - private async readCopilotCatalog(fullPath: string): Promise { - const raw = await this.fs.readFile(fullPath); - return [...parseCopilotMarketplace(raw).plugins]; - } - - private async readOpencodeCatalog(fullPath: string): Promise { - const raw = await this.fs.readFile(fullPath); - return [...parseOpencodeMarketplace(raw).plugins]; - } - - private resolveLocalPaths(catalog: PluginCatalog, frameworkPath: string): PluginCatalog { - const plugins = catalog.plugins.map((entry) => ({ - ...entry, - source: this.resolveSource(entry.source, frameworkPath), - })); - const resolved: PluginCatalog = { plugins }; - if (catalog.name !== undefined) resolved.name = catalog.name; - if (catalog.version !== undefined) resolved.version = catalog.version; - return resolved; - } - - private resolveSource(source: PluginSource, frameworkPath: string): PluginSource { - if (source.kind !== "local") return source; - if (isAbsolute(source.path)) return source; - return { kind: "local", path: resolve(frameworkPath, source.path) }; - } -} diff --git a/cli/src/infrastructure/adapters/run-journal-reader-adapter.ts b/cli/src/infrastructure/adapters/run-journal-reader-adapter.ts deleted file mode 100644 index 20b4b893b..000000000 --- a/cli/src/infrastructure/adapters/run-journal-reader-adapter.ts +++ /dev/null @@ -1,333 +0,0 @@ -import { readdir, readFile, rm } from "node:fs/promises"; -import { join } from "node:path"; -import { DOCS_DIR, RUNS_SUBDIR } from "../../domain/models/paths.js"; -import type { - RunJournal, - RunJournalBoundary, - RunJournalFileWritten, - RunJournalSessionStart, - RunJournalStore, - RunJournalTaskDeclared, -} from "../../domain/ports/run-journal-reader.js"; -import { isBareFileName } from "../confined-file-name.js"; -import { repositoryRootAbove } from "../repository-root.js"; - -/** - * The one schema this reader knows how to read, mirroring `record.cjs`'s own - * `SCHEMA_VERSION` — the same kind of mirror `sanitizePathSegment` above is, and pinned the - * same way: `run-journal-reader-adapter.integration.test.ts` compares this against the - * hook's own exported constant rather than against a second copy of the number. - * - * Version 1 was a mutable record, not this append-only line log, so its lines are another - * shape entirely; a later version can change any line's shape the same way. A journal - * stating either is refused rather than read, since reading it would mean guessing that - * whatever lines this parser still recognises mean what they used to. - */ -export const READABLE_JOURNAL_SCHEMA_VERSION = 2; - -const ULID_LENGTH = 26; // encodeTime(10) + encodeRandom(16), matching record.cjs's own ULID_LENGTH. -const RUN_FILE_EXTENSION = ".jsonl"; - -// Mirrors plugins/aidd-telemetry/hooks/lib/repo.cjs's own `sanitizePathSegment`, character -// for character, so a vendor id sanitized there on write matches what is sanitized here on -// read. Not a shared runtime import: the hook is a zero-dependency CommonJS script the -// framework build copies verbatim (see telemetry-project-id.ts's doc comment for the same -// reasoning, applied to project id sanitizing rather than a run file name). Exported so -// run-journal-reader-adapter.integration.test.ts can assert agreement against the hook's -// own function directly, the same way telemetry-project-id.unit.test.ts pins its copy. -export function sanitizePathSegment(segment: string): string { - const cleaned = segment.replace(/[^\w.-]/gu, "-"); - return cleaned === "" || cleaned === "." || cleaned === ".." ? "-" : cleaned; -} - -// Mirrors record.cjs's parseRunFileName: split on the fixed ULID length, never on "__", -// since a sanitized vendor id can itself contain that substring. -function matchesVendorId(entry: string, wantedSegment: string): boolean { - if (!entry.endsWith(RUN_FILE_EXTENSION)) return false; - const minLength = ULID_LENGTH + "__".length + RUN_FILE_EXTENSION.length; - if (entry.length <= minLength) return false; - if (entry.slice(ULID_LENGTH, ULID_LENGTH + 2) !== "__") return false; - return entry.slice(ULID_LENGTH + 2, -RUN_FILE_EXTENSION.length) === wantedSegment; -} - -function asString(value: unknown): string | undefined { - return typeof value === "string" ? value : undefined; -} - -function asNumber(value: unknown): number | undefined { - return typeof value === "number" && Number.isFinite(value) ? value : undefined; -} - -/** Whether a journal says outright that it was written under some other schema. Absence is - * never that statement: every journal on disk before this reader looked at the field - * carries none, and refusing those would drop attribution this reader has always given — - * "an unknown is never a zero", applied to the reader rather than to a figure. A value that - * is not a finite number is read as absent for the same reason, since a torn or hand-edited - * field states nothing either. */ -function statesAnotherSchema(session: RunJournalSessionStart | undefined): boolean { - const stated = session?.schema_version; - return stated !== undefined && stated !== READABLE_JOURNAL_SCHEMA_VERSION; -} - -interface RawJournalLine { - readonly type?: unknown; - readonly at?: unknown; - readonly skill?: unknown; - readonly turn_id?: unknown; - readonly run_id?: unknown; - readonly tool?: unknown; - readonly vendor_id?: unknown; - readonly project_id?: unknown; - readonly project_remote?: unknown; - readonly worktree_id?: unknown; - readonly worktree_repo_id?: unknown; - readonly path?: unknown; - readonly plugin_version?: unknown; - readonly schema_version?: unknown; -} - -function parseLine(line: string): RawJournalLine | null { - const trimmed = line.trim(); - if (!trimmed) return null; - try { - return JSON.parse(trimmed) as RawJournalLine; - } catch { - return null; - } -} - -/** One `step_start`, `turn_end` or `step_end` line, or `null` for every other line type and every line - * this file cannot parse — a torn final line from a session still in progress reads as - * nothing, not as a boundary at the wrong moment. */ -function parseBoundary(parsed: RawJournalLine): RunJournalBoundary | null { - const at = asString(parsed.at); - if (at === undefined) return null; - if (parsed.type === "turn_end") return { type: "turn_end", at }; - const skill = asString(parsed.skill); - if (skill === undefined) return null; - // An end with no skill is dropped rather than read as a bare boundary: it would close a - // step it cannot name, which is the one thing `RunJournalStepEnd` exists to prevent. - if (parsed.type === "step_end") return { type: "step_end", at, skill }; - if (parsed.type !== "step_start") return null; - const turnId = asString(parsed.turn_id); - return { type: "step_start", at, skill, ...(turnId === undefined ? {} : { turn_id: turnId }) }; -} - -/** The worktree a session ran in, where the line names one. A plain checkout writes - * neither key, and neither is read here — `asString` already rejects `""`, so a torn or - * empty value reads as "not stated" rather than as a worktree named nothing. */ -function parseWorktree( - parsed: RawJournalLine -): Pick { - const worktreeId = asString(parsed.worktree_id); - const worktreeRepoId = asString(parsed.worktree_repo_id); - return { - ...(worktreeId === undefined ? {} : { worktree_id: worktreeId }), - ...(worktreeRepoId === undefined ? {} : { worktree_repo_id: worktreeRepoId }), - }; -} - -/** The header line, or `null` when the line is not one or is missing a field a join needs. - * `run_id`, `tool` and `vendor_id` are all required: a header naming two of the three - * cannot say which session it belongs to, and a half-read header is worse than none. */ -function parseSessionStart(parsed: RawJournalLine): RunJournalSessionStart | null { - if (parsed.type !== "session_start") return null; - const at = asString(parsed.at); - const runId = asString(parsed.run_id); - const tool = asString(parsed.tool); - const vendorId = asString(parsed.vendor_id); - if (at === undefined || runId === undefined || tool === undefined || vendorId === undefined) { - return null; - } - return { - type: "session_start", - at, - run_id: runId, - tool, - vendor_id: vendorId, - ...headerExtras(parsed), - }; -} - -/** Every header field a journal may state and may omit — each absent rather than defaulted, - * the same rule `parseWorktree` above already follows: a field the writer left out is one - * this reader has nothing to say about, and a default would be an answer nobody wrote. Split - * out of `parseSessionStart` so that function stays under the line-count limit. */ -function headerExtras(parsed: RawJournalLine): Partial { - const projectId = asString(parsed.project_id); - const projectRemote = asString(parsed.project_remote); - const pluginVersion = asString(parsed.plugin_version); - const schemaVersion = asNumber(parsed.schema_version); - return { - ...(schemaVersion === undefined ? {} : { schema_version: schemaVersion }), - ...(projectId === undefined ? {} : { project_id: projectId }), - ...(projectRemote === undefined ? {} : { project_remote: projectRemote }), - ...parseWorktree(parsed), - ...(pluginVersion === undefined ? {} : { plugin_version: pluginVersion }), - }; -} - -function parseFileWritten(parsed: RawJournalLine): RunJournalFileWritten | null { - if (parsed.type !== "file_written") return null; - const at = asString(parsed.at); - const writtenPath = asString(parsed.path); - return at === undefined || writtenPath === undefined - ? null - : { type: "file_written", at, path: writtenPath }; -} - -function parseTaskDeclared(parsed: RawJournalLine): RunJournalTaskDeclared | null { - if (parsed.type !== "task_declared") return null; - const at = asString(parsed.at); - const declaredPath = asString(parsed.path); - return at === undefined || declaredPath === undefined - ? null - : { type: "task_declared", at, path: declaredPath }; -} - -/** One journal file's lines, sorted into their buckets as each is read - mutable so - * `classifyLine` can fill it one line at a time without every caller threading four - * separate arrays through. */ -interface JournalCollector { - readonly boundaries: RunJournalBoundary[]; - readonly filesWritten: RunJournalFileWritten[]; - readonly taskDeclarations: RunJournalTaskDeclared[]; - session: RunJournalSessionStart | undefined; -} - -function newJournalCollector(): JournalCollector { - return { boundaries: [], filesWritten: [], taskDeclarations: [], session: undefined }; -} - -/** One parsed line, sorted into whichever bucket recognises it - the four line types this - * port promises, tried in the order they are written most often. A line matching none of - * them is the header, kept only the first time it is seen (see the comment below). */ -function classifyLine(collector: JournalCollector, parsed: RawJournalLine): void { - const boundary = parseBoundary(parsed); - if (boundary) { - collector.boundaries.push(boundary); - return; - } - const written = parseFileWritten(parsed); - if (written) { - collector.filesWritten.push(written); - return; - } - const declared = parseTaskDeclared(parsed); - if (declared) { - collector.taskDeclarations.push(declared); - return; - } - // The header is written once, first. Keeping the first one read means a second, however - // it got there, never silently replaces the identity the file opened with. - collector.session ??= parseSessionStart(parsed) ?? undefined; -} - -/** - * Reads a session's run journal — the one class in this path allowed to open a file - * under `aidd_docs/runs`. - * Never throws: no run file for this session, an unreadable runs directory, or a truncated - * final line all answer `null` or an empty boundary list, since a missing or damaged - * journal costs attribution, not the read itself. `AIDD_RUNS_DIR` overrides the directory - * outright, matching the hook that writes it — resolved exactly once, in the constructor, - * and frozen from then on: a relocation of that variable after construction can never - * change what this instance answers. - */ -export class RunJournalReaderAdapter implements RunJournalStore { - readonly runsDir: string; - - constructor(projectRoot: string) { - this.runsDir = - process.env.AIDD_RUNS_DIR || join(repositoryRootAbove(projectRoot), DOCS_DIR, RUNS_SUBDIR); - } - - async read(sessionId: string): Promise { - const filePath = await this.findRunFile(this.runsDir, sessionId); - return filePath ? this.readJournal(filePath) : null; - } - - async list(): Promise { - const dir = this.runsDir; - let entries: string[]; - try { - entries = await readdir(dir); - } catch { - return []; - } - const journals: RunJournal[] = []; - for (const entry of entries.sort()) { - if (!entry.endsWith(RUN_FILE_EXTENSION)) continue; - const journal = await this.readJournal(join(dir, entry)); - if (journal) journals.push(journal); - } - return journals; - } - - async listForeignSchemas(): Promise { - const stated: number[] = []; - for (const fileName of await this.listRunFiles()) { - const collector = await this.collect(join(this.runsDir, fileName)); - const version = collector?.session?.schema_version; - if (version !== undefined && version !== READABLE_JOURNAL_SCHEMA_VERSION) - stated.push(version); - } - return stated; - } - - async listRunFiles(): Promise { - try { - const entries = await readdir(this.runsDir); - return entries.filter((entry) => entry.endsWith(RUN_FILE_EXTENSION)).sort(); - } catch { - return []; - } - } - - // `force: true`, exactly like `TelemetrySinkAdapter.deleteDayFile`: a name already gone - // is nothing to remove, never a failure. `dir` is never this instance's own `runsDir` — - // it is whatever the caller passes, which `forget-telemetry-use-case.ts` always takes - // from `TelemetryRemovalPreview.journal.path`, the value a person was already shown. - // `isBareFileName` is the actual confinement: `join` alone normalises `..` away visually - // but still deletes wherever the normalised path lands, so a `fileName` that is not a - // bare component of `dir` is refused before it ever reaches `rm`. - async deleteRunFile(dir: string, fileName: string): Promise { - if (!isBareFileName(fileName)) { - throw new Error(`refusing to delete "${fileName}" — not a run file name inside ${dir}`); - } - await rm(join(dir, fileName), { force: true }); - } - - private async findRunFile(dir: string, sessionId: string): Promise { - let entries: string[]; - try { - entries = await readdir(dir); - } catch { - return null; - } - const wanted = sanitizePathSegment(sessionId); - const match = entries.find((entry) => matchesVendorId(entry, wanted)); - return match ? join(dir, match) : null; - } - - private async collect(filePath: string): Promise { - let content: string; - try { - content = await readFile(filePath, "utf8"); - } catch { - return null; - } - const collector = newJournalCollector(); - for (const line of content.split("\n")) { - const parsed = parseLine(line); - if (parsed) classifyLine(collector, parsed); - } - return collector; - } - - private async readJournal(filePath: string): Promise { - const collector = await this.collect(filePath); - if (!collector || statesAnotherSchema(collector.session)) return null; - const { boundaries, filesWritten, taskDeclarations, session } = collector; - return { boundaries, filesWritten, taskDeclarations, ...(session ? { session } : {}) }; - } -} diff --git a/cli/src/infrastructure/adapters/task-backlog-adapter.ts b/cli/src/infrastructure/adapters/task-backlog-adapter.ts deleted file mode 100644 index 16a9c4ef0..000000000 --- a/cli/src/infrastructure/adapters/task-backlog-adapter.ts +++ /dev/null @@ -1,69 +0,0 @@ -import { readFile } from "node:fs/promises"; -import { join } from "node:path"; -import type { - TaskBacklogDeclaration, - TaskBacklogLink, -} from "../../domain/models/task-backlog-link.js"; -import type { TaskBacklogReader } from "../../domain/ports/task-backlog-reader.js"; -import { asPlainObject, isErrnoException } from "../json-file.js"; -import { repositoryRootAbove } from "../repository-root.js"; - -/** The one file a task folder writes to declare its backlog item — see - * `domain/models/task-backlog-link.ts` for why this is not `metadata.json`. */ -export const TASK_BACKLOG_LINK_FILENAME = "backlog-link.json"; - -function nonEmptyString(value: unknown): string | undefined { - return typeof value === "string" && value !== "" ? value : undefined; -} - -/** `null` for anything this file cannot be read as — JSON that will not parse, a shape - * missing `backlog`, `written_at` or `written_by`, or either provenance field present but - * not a non-empty string. Deliberately stricter than `PersonIdentityAdapter.read`'s own - * precedent, which reads a wrong-shaped-but-parseable file as "no identity" rather than - * "unreadable": this file's own contract requires distinguishing "declared nothing" from - * "could not be read" (see the port), and a file that exists with a broken shape is - * evidence of a declaration someone attempted, not one that was never made — so it must - * surface as damage, never silently read the same as an absent file. */ -function parseLink(raw: string): TaskBacklogLink | null { - const parsed = asPlainObject(JSON.parse(raw)); - const backlog = nonEmptyString(parsed.backlog); - const writtenAt = nonEmptyString(parsed.written_at); - const writtenBy = nonEmptyString(parsed.written_by); - if (backlog === undefined || writtenAt === undefined || writtenBy === undefined) return null; - return { backlog, writtenAt, writtenBy }; -} - -/** - * Reads one task folder's `backlog-link.json` — see `TaskBacklogReader` for the contract - * this promises: never throws, and never writes. `read()` performs no write of any kind, - * on any path, in any circumstance — the property that lets a report run against a - * checkout someone else owns without ever risking the work it is describing. - */ -export class TaskBacklogAdapter implements TaskBacklogReader { - private readonly repositoryRoot: string; - - // Resolved once, at construction, for the same reason `RunJournalReaderAdapter` freezes - // its own directory there: a relocation after construction can never change what this - // instance answers. A task folder path arrives repository-relative, because the journal - // line it came from was written that way. - constructor(projectRoot: string) { - this.repositoryRoot = repositoryRootAbove(projectRoot); - } - - async read(taskFolderPath: string): Promise { - const filePath = join(this.repositoryRoot, taskFolderPath, TASK_BACKLOG_LINK_FILENAME); - let raw: string; - try { - raw = await readFile(filePath, "utf8"); - } catch (error) { - if (isErrnoException(error) && error.code === "ENOENT") return { kind: "none" }; - return { kind: "unreadable" }; - } - try { - const link = parseLink(raw); - return link === null ? { kind: "unreadable" } : { kind: "declared", link }; - } catch { - return { kind: "unreadable" }; - } - } -} diff --git a/cli/src/infrastructure/adapters/telemetry-evidence-adapter.ts b/cli/src/infrastructure/adapters/telemetry-evidence-adapter.ts deleted file mode 100644 index 3dd8632e0..000000000 --- a/cli/src/infrastructure/adapters/telemetry-evidence-adapter.ts +++ /dev/null @@ -1,318 +0,0 @@ -import { readFile } from "node:fs/promises"; -import { join } from "node:path"; -import type { MarketplaceSettings } from "../../domain/capabilities/plugins-capability.js"; -import { cursorProjectHooksScriptDir } from "../../domain/formats/cursor-hooks-project-merge.js"; -import { hookCommandsForEvent } from "../../domain/formats/flat-hooks-merge.js"; -import { genericFlatHooksScriptPath } from "../../domain/formats/flat-paths.js"; -import { asPlainObject } from "../../domain/formats/plain-object.js"; -import { CLAUDE_PLUGIN_ROOT_TOKEN } from "../../domain/formats/plugin-root-token-rewrite.js"; -import { AIDD_DIR, MANIFEST_FILENAME } from "../../domain/models/paths.js"; -import { - findLeftoverExportKeys, - type TelemetryExportLeftover, -} from "../../domain/models/telemetry-export-leftover.js"; -import type { TelemetryRecorderDeclarationSetup } from "../../domain/models/telemetry-setup.js"; -import { - parseTelemetrySwitchFile, - resolveTelemetryEnabled, - telemetryConfigPath, -} from "../../domain/models/telemetry-switch.js"; -import type { - TelemetryEvidenceReader, - TelemetrySwitchSetupRead, - TelemetryUnrecognisedPayload, -} from "../../domain/ports/telemetry-evidence-reader.js"; -import { AI_TOOL_IDS, getAiToolConfig } from "../../domain/tools/registry.js"; -import { resolveHomeDir } from "../home-dir.js"; -import { isErrnoException } from "../json-file.js"; -import { - HOOK_ENTRY_SCRIPT, - PLUGIN_NAME as RECORDER_PLUGIN_NAME, -} from "./hook-trust-reader-adapter.js"; - -const UNRECOGNISED_FILE_NAME = "_unrecognised.jsonl"; - -function runsDir(projectRoot: string): string { - return process.env.AIDD_RUNS_DIR || join(projectRoot, "aidd_docs", "runs"); -} - -function manifestPath(projectRoot: string): string { - return join(projectRoot, AIDD_DIR, MANIFEST_FILENAME); -} - -// Only Claude Code ever wrote a real settings-file export: it is the one tool whose -// (now-deleted) `TelemetryActivation` was `kind: "settings-file"` — every other tool's was -// `environment-variable`, `planned`, or `external`, none of which land in a file this could -// ever find stale keys in. `local` is `DEFAULT_TELEMETRY_SCOPE`, the common case; `project` -// and `user` are the other two scopes `endpoint --scope` ever accepted. -// -// Reused below for the hooks-block declaration route too — a *different* justification -// that happens to name the same three files: `aidd-context`'s own `tool-paths.md` lists -// all three as real Claude Code hook scopes a person can hand-author into (project, -// project-local, and user/global). Never reused for the `enabledPlugins` declaration -// route below that: `settings.local.json` and the home settings file are not where -// `marketplace-sync-settings-use-case.ts` — or `claude-cli-adapter.ts`'s own measured -// comment on where the real runtime actually reads `enabledPlugins` from — ever write it. -function claudeSettingsCandidates(projectRoot: string): readonly string[] { - return [ - join(projectRoot, ".claude", "settings.local.json"), - join(projectRoot, ".claude", "settings.json"), - join(resolveHomeDir(), ".claude", "settings.json"), - ]; -} - -// Where `enabledPlugins` can actually arrive, one location per AI tool that declares a -// `marketplaceSettings.enabledPluginsKey` in its own registry entry — resolved the exact -// way `marketplace-sync-settings-use-case.ts:304` resolves it when writing, so this read -// can never disagree with the write it is reading back. Only Claude and Copilot declare -// one today: Claude's own project `.claude/settings.json`, and Copilot's -// `.github/copilot/settings.json` (`copilot.ts`'s own `marketplaceSettings`) — a real -// consumer route the old reused `claudeSettingsCandidates` list never reached. -function enabledPluginsCandidates(projectRoot: string): readonly string[] { - const paths: string[] = []; - for (const toolId of AI_TOOL_IDS) { - const caps = getAiToolConfig(toolId).capabilities as { - plugins?: { marketplaceSettings?: MarketplaceSettings | null }; - }; - const settings = caps.plugins?.marketplaceSettings; - if (!settings || settings.enabledPluginsKey === undefined) continue; - const path = settings.enabledPluginsSettingsPath ?? join(projectRoot, settings.settingsPath); - if (!paths.includes(path)) paths.push(path); - } - return paths; -} - -// The project-scope hooks file `ProjectHooksMaterializer`/`cursor-hooks-project-merge.ts` -// write and merge into for Cursor: Cursor's own plugin-scope hooks never fire (measured, -// see that module's doc comment), so a Cursor install's only working declaration route is -// here, in Cursor's flat `version: 1` shape — a hooks block, never `enabledPlugins`, which -// Cursor has no concept of. -function cursorHooksJsonPath(projectRoot: string): string { - return join(projectRoot, ".cursor", "hooks.json"); -} - -function dedupe(values: readonly string[]): readonly string[] { - return [...new Set(values)]; -} - -type DeclarationCheck = "declared" | "not-declared" | "unreadable"; - -type JsonRead = - | { readonly status: "absent" } - | { readonly status: "unreadable" } - | { readonly status: "ok"; readonly raw: string; readonly value: unknown }; - -// The read-and-parse preamble every declaration check below shares, and the seam where a -// present-but-damaged file (a trailing comma, a `//` comment, unreadable permissions) -// is told apart from one that simply never existed — the same ENOENT-vs-other split -// `readSwitchFile` already makes for the switch file, generalised to every location this -// module checks for a declaration. -async function readJsonIfExists(path: string): Promise { - let raw: string; - try { - raw = await readFile(path, "utf8"); - } catch (error) { - if (isErrnoException(error) && error.code === "ENOENT") return { status: "absent" }; - return { status: "unreadable" }; - } - try { - return { status: "ok", raw, value: JSON.parse(raw) }; - } catch { - return { status: "unreadable" }; - } -} - -async function readIfExists(path: string): Promise { - try { - return await readFile(path, "utf8"); - } catch { - return null; - } -} - -/** The switch file's own read, factored out so `isTelemetryEnabled` and - * `readSwitchSetup` share one parse rather than each restating it — the exact failure this - * layer exists to avoid, a diagnostic disagreeing with the thing it describes. An absent - * file (`ENOENT`) is `readable: true` with nothing decided yet; any other read failure, or - * content that fails to parse as JSON at all, is `readable: false` — a damaged file, not a - * choice. A file that parses but carries no (or a malformed) `telemetry` key still reads - * `readable: true, fileSwitch: null` — nothing was damaged, nothing was ever set. */ -async function readSwitchFile(projectRoot: string): Promise<{ - readonly readable: boolean; - readonly fileSwitch: ReturnType; -}> { - let content: string; - try { - content = await readFile(telemetryConfigPath(projectRoot), "utf8"); - } catch (error) { - if (isErrnoException(error) && error.code === "ENOENT") { - return { readable: true, fileSwitch: null }; - } - return { readable: false, fileSwitch: null }; - } - try { - JSON.parse(content); - } catch { - return { readable: false, fileSwitch: null }; - } - return { readable: true, fileSwitch: parseTelemetrySwitchFile(content) }; -} - -/** Whether the AIDD manifest a `plugin add` writes declares `pluginName`, for any tool — - * a lenient, defensive walk of the raw JSON rather than `Manifest.fromJSON`'s own strict - * schema, which throws on a shape this read must never crash over. */ -async function manifestDeclaresPlugin(path: string, pluginName: string): Promise { - const result = await readJsonIfExists(path); - if (result.status !== "ok") return result.status === "absent" ? "not-declared" : "unreadable"; - const tools = asPlainObject(asPlainObject(result.value)?.tools); - if (tools === null) return "not-declared"; - const found = Object.values(tools).some((entry) => { - const plugins = asPlainObject(entry)?.plugins; - return Array.isArray(plugins) && plugins.some((p) => asPlainObject(p)?.name === pluginName); - }); - return found ? "declared" : "not-declared"; -} - -/** Whether a tool's own settings file declares `pluginName` enabled — the - * `enabledPlugins` map `marketplace-sync-settings-use-case.ts` writes keys like - * `"@"` into. A prefix match, never a full key match: the - * marketplace half of the key is this project's own choice, not the recorder's identity. */ -async function settingsDeclaresPlugin(path: string, pluginName: string): Promise { - const result = await readJsonIfExists(path); - if (result.status !== "ok") return result.status === "absent" ? "not-declared" : "unreadable"; - const enabledPlugins = asPlainObject(asPlainObject(result.value)?.enabledPlugins); - if (enabledPlugins === null) return "not-declared"; - const prefix = `${pluginName}@`; - return Object.keys(enabledPlugins).some((key) => key.startsWith(prefix)) - ? "declared" - : "not-declared"; -} - -// Every plugin-unique path this build ever actually writes the recorder's own hook entry -// point to, never the bare leaf `journal.cjs` another plugin's own hooks block could just -// as easily name: a bare-leaf match reads any plugin's journal.cjs as this one (masking a -// genuinely undeclared install), and misses this build's own routes just as easily if the -// leaf happened to collide the other way. Three real routes, not two: the unexpanded -// `${CLAUDE_PLUGIN_ROOT}` token (a hand-authored or copied-verbatim Claude hooks block — -// Claude Code itself resolves the token, never this build), the path -// `aidd framework build --target claude --flat` actually rewrites it to -// (`flat-build-strategy.ts`'s own `resolveClaudeRootRelative`, mirrored here via the same -// `genericFlatHooksScriptPath` primitive so a change to that path shape cannot drift from -// this one), and Cursor's project-scope directory `stripPluginEntries` already matches on. -// A plain substring check is enough for all three: each is already a multi-segment, -// plugin-unique path, so a quoted command (`"…/journal.cjs"`) still matches with no -// separate boundary logic, and every marker is authored with forward slashes regardless -// of platform. -const CLAUDE_HOOKS_TOKEN_MARKER = `${CLAUDE_PLUGIN_ROOT_TOKEN}/hooks/${HOOK_ENTRY_SCRIPT}`; -const CLAUDE_HOOKS_FLAT_MARKER = genericFlatHooksScriptPath( - ".claude/hooks/", - RECORDER_PLUGIN_NAME, - HOOK_ENTRY_SCRIPT -); -const CURSOR_HOOKS_MARKER = `${cursorProjectHooksScriptDir(RECORDER_PLUGIN_NAME)}${HOOK_ENTRY_SCRIPT}`; - -function invokesRecorderEntryPoint(command: string): boolean { - return ( - command.includes(CLAUDE_HOOKS_TOKEN_MARKER) || - command.includes(CLAUDE_HOOKS_FLAT_MARKER) || - command.includes(CURSOR_HOOKS_MARKER) - ); -} - -/** Whether a hooks block written in any of the four shapes `flat-hooks-merge.ts` knows — - * Claude's nested settings.json `hooks` key, or Cursor's flat `version: 1` file — invokes - * the recorder's own `SessionStart` hook. A hooks block is a declaration exactly like - * `enabledPlugins`, never proof: this only reads that the entry point was asked for. */ -async function hooksDeclarePlugin(path: string): Promise { - const result = await readJsonIfExists(path); - if (result.status !== "ok") return result.status === "absent" ? "not-declared" : "unreadable"; - const commands = hookCommandsForEvent(result.raw, "SessionStart"); - return commands.some((command) => invokesRecorderEntryPoint(command)) - ? "declared" - : "not-declared"; -} - -function parseUnrecognisedPayload(raw: string): TelemetryUnrecognisedPayload | null { - const line = raw.split("\n").find((candidate) => candidate.trim() !== ""); - if (line === undefined) return null; - let parsed: unknown; - try { - parsed = JSON.parse(line); - } catch { - return null; - } - const record = asPlainObject(parsed); - const at = record?.at; - if (record?.type !== "unrecognised_payload" || typeof at !== "string") return null; - return { at }; -} - -/** Evidence `aidd telemetry check` needs beyond the run journal, each tool's own local - * reader, and Codex's hook trust — see the port's own doc comment for why those are not - * repeated here. */ -export class TelemetryEvidenceAdapter implements TelemetryEvidenceReader { - async isTelemetryEnabled(projectRoot: string, env: NodeJS.ProcessEnv): Promise { - const { fileSwitch } = await readSwitchFile(projectRoot); - return resolveTelemetryEnabled(fileSwitch, env); - } - - async readSwitchSetup(projectRoot: string): Promise { - const { readable, fileSwitch } = await readSwitchFile(projectRoot); - return { - path: telemetryConfigPath(projectRoot), - enabled: readable && fileSwitch?.enabled === true, - readable, - }; - } - - async readRecorderDeclaration(projectRoot: string): Promise { - const manifestFile = manifestPath(projectRoot); - const enabledPluginsFiles = enabledPluginsCandidates(projectRoot); - // A hooks block is a second, independent declaration route from `enabledPlugins` — - // every real Claude Code hook scope (see `claudeSettingsCandidates`'s own comment) - // plus Cursor's project-scope file, the only one this build ever writes outside them, - // since Cursor's plugin-scope hooks never fire (see `cursorHooksJsonPath`). - const hooksFiles = [...claudeSettingsCandidates(projectRoot), cursorHooksJsonPath(projectRoot)]; - const locationsChecked = dedupe([manifestFile, ...enabledPluginsFiles, ...hooksFiles]); - const declaredAt: string[] = []; - const unreadable: string[] = []; - - const record = (path: string, outcome: DeclarationCheck): void => { - if (outcome === "declared") declaredAt.push(path); - else if (outcome === "unreadable") unreadable.push(path); - }; - - record(manifestFile, await manifestDeclaresPlugin(manifestFile, RECORDER_PLUGIN_NAME)); - for (const path of enabledPluginsFiles) { - record(path, await settingsDeclaresPlugin(path, RECORDER_PLUGIN_NAME)); - } - for (const path of hooksFiles) { - if (declaredAt.includes(path)) continue; - record(path, await hooksDeclarePlugin(path)); - } - return { - declared: declaredAt.length > 0, - declaredAt: dedupe(declaredAt), - locationsChecked, - unreadable: dedupe(unreadable), - }; - } - - async readUnrecognisedPayload(projectRoot: string): Promise { - try { - const content = await readFile(join(runsDir(projectRoot), UNRECOGNISED_FILE_NAME), "utf8"); - return parseUnrecognisedPayload(content); - } catch { - return null; - } - } - - async findLeftoverExportConfig(projectRoot: string): Promise { - const leftovers: TelemetryExportLeftover[] = []; - for (const path of claudeSettingsCandidates(projectRoot)) { - const keys = findLeftoverExportKeys(await readIfExists(path)); - if (keys.length > 0) leftovers.push({ path, keys }); - } - return leftovers; - } -} diff --git a/cli/src/infrastructure/adapters/telemetry-sink-adapter.ts b/cli/src/infrastructure/adapters/telemetry-sink-adapter.ts deleted file mode 100644 index 37ded1d93..000000000 --- a/cli/src/infrastructure/adapters/telemetry-sink-adapter.ts +++ /dev/null @@ -1,278 +0,0 @@ -import { spawnSync } from "node:child_process"; -import { chmodSync, readdirSync } from "node:fs"; -import { access, appendFile, mkdir, readdir, readFile, rm, writeFile } from "node:fs/promises"; -import { userInfo } from "node:os"; -import { join } from "node:path"; -import { - parseTelemetrySinkLine, - serializeTelemetrySinkRecord, - type TelemetrySinkRecord, - telemetrySinkRecordDayKey, -} from "../../domain/models/telemetry-sink-record.js"; -import type { - TelemetrySink, - TelemetrySinkAppendResult, - TelemetrySinkPeriodRead, -} from "../../domain/ports/telemetry-sink.js"; -import { isBareFileName } from "../confined-file-name.js"; -import { TelemetrySinkUnwritableError } from "../errors.js"; -import { resolveHomeDir } from "../home-dir.js"; - -const DAY_FILE_EXTENSION = ".jsonl"; -const PRIVATE_FILE_MODE = 0o600; -const PRIVATE_DIR_MODE = 0o700; - -const DAY_KEY_LENGTH = "YYYY-MM-DD".length; - -function dayKey(at: Date): string { - return at.toISOString().slice(0, DAY_KEY_LENGTH); -} - -function dayFileName(at: Date): string { - return `${dayKey(at)}${DAY_FILE_EXTENSION}`; -} - -async function pathExists(path: string): Promise { - try { - await access(path); - return true; - } catch { - return false; - } -} - -function legacyConfigDir(): string { - return join(resolveHomeDir(), ".config", "aidd"); -} - -function hasLegacyTelemetryData(): boolean { - try { - const entries = readdirSync(join(legacyConfigDir(), "telemetry")); - return entries.some((entry) => entry.endsWith(DAY_FILE_EXTENSION)); - } catch { - return false; - } -} - -// `%APPDATA%` is where a Windows application puts this, not `.config` (measured on a real -// windows-latest runner). A machine that already journalled under the old `.config` default -// keeps landing there rather than losing access to what it already wrote; only a machine -// starting fresh gets `%APPDATA%`. -// -// Exported for the test that pins it on any platform rather than only on a Windows runner: -// where the figures land is a pure resolution, and a rule only a rarely-run job can check is -// a rule that regresses in silence. It was the plugin's own sink that held this pin until the -// read path moved here; it holds nothing now, so this is the only place left to hold it. -export function defaultConfigDir(): string { - if (process.platform !== "win32") return legacyConfigDir(); - if (hasLegacyTelemetryData()) return legacyConfigDir(); - return process.env.APPDATA ? join(process.env.APPDATA, "aidd") : legacyConfigDir(); -} - -// The identical no-op in the journal (hooks/lib/repo.cjs): `mkdir`/`appendFile`'s -// `mode` option is accepted on Windows without error and does nothing with it. `icacls` is -// the mechanism that actually restricts a path there. `%APPDATA%` is already the current OS -// user's own profile, unlike a git checkout that can sit anywhere, so restricting it to that -// same account narrows nothing that Windows' own convention did not already imply. -function restrictToCurrentUser(target: string, options: { recursive?: boolean } = {}): void { - try { - const owner = process.env.USERDOMAIN - ? `${process.env.USERDOMAIN}\\${process.env.USERNAME}` - : (process.env.USERNAME ?? userInfo().username); - if (!owner) return; - const grant = options.recursive ? `${owner}:(OI)(CI)F` : `${owner}:F`; - const args = [target, "/inheritance:r", "/grant:r", grant]; - if (options.recursive) args.push("/T"); - args.push("/C", "/Q"); - spawnSync("icacls", args, { encoding: "utf8" }); - } catch { - // icacls missing, no resolvable owner, or a domain-policy refusal: leave it as it is. - } -} - -/** Every write is `appendFile`. `readRecordsForVendor` is the only method that reads a day - * file's content, and only to let a local re-read know what is already stored. */ -export class TelemetrySinkAdapter implements TelemetrySink { - readonly rootDir: string; - // A user who names their own location keeps responsibility for its permissions - a shared - // directory is what this exists for, and locking it down to one account on Windows would - // break exactly that sharing. - private readonly userNamed: boolean; - - /** - * `AIDD_TELEMETRY_DIR` names this directory outright; `AIDD_USER_CONFIG_DIR` names the - * directory *above* it and is kept only so a setup that predates the split keeps working. - * - * They are two variables because they answer to two different needs that used to share - * one name: `AIDD_USER_CONFIG_DIR` also relocates `auth.json` (`auth-storage.ts:19`), a - * GitHub token, so it could never be the variable a team shares. The full argument, and - * what to tell someone still on the older one, live in `plugins/aidd-telemetry/README.md` - * under "Share `AIDD_TELEMETRY_DIR`, never `AIDD_USER_CONFIG_DIR`" - one home, since a - * copy here would be a second one to keep true. - */ - /** Which of the three answers above this directory came from. Carried because one of - * them has a consequence a person has to be told about: `AIDD_USER_CONFIG_DIR` also names - * where `auth.json` is written, so anyone who set it on the old advice has a credential in - * the directory they were told to share, and nothing else would ever mention it. */ - readonly locatedBy: "telemetry-dir" | "user-config-dir" | "default"; - - constructor(userConfigDir?: string) { - const named = process.env.AIDD_TELEMETRY_DIR; - const legacy = userConfigDir ?? process.env.AIDD_USER_CONFIG_DIR; - this.userNamed = named !== undefined || legacy !== undefined; - this.rootDir = named ?? join(legacy ?? defaultConfigDir(), "telemetry"); - this.locatedBy = - named !== undefined ? "telemetry-dir" : legacy !== undefined ? "user-config-dir" : "default"; - } - - private tightenDir(): void { - if (this.userNamed) return; - if (process.platform === "win32") { - restrictToCurrentUser(this.rootDir, { recursive: true }); - return; - } - // POSIX had no branch at all, so the figures landed in a world-listable directory while - // the run journal beside them was 0700. `mkdir`'s own `mode` is masked by the process - // umask and applies only when it creates the directory, so it cannot be relied on for - // one that already exists. A day file's content was already private at 0600; what - // leaked was the listing - which days this person worked, and how many. - try { - chmodSync(this.rootDir, PRIVATE_DIR_MODE); - } catch { - // Someone else's directory, or a filesystem with no modes: the content stays 0600. - } - } - - // `/T` on the directory does not reliably carry the grant onto a leaf file it walks into - // (measured on a real windows-latest runner), so a day file gets its own pass too - - // only the write that creates it, the one `PRIVATE_FILE_MODE` itself only applies to. - private tightenFile(filePath: string): void { - if (this.userNamed || process.platform !== "win32") return; - restrictToCurrentUser(filePath, { recursive: false }); - } - - async ensureWritable(): Promise { - try { - await mkdir(this.rootDir, { recursive: true }); - this.tightenDir(); - const probePath = join(this.rootDir, `.write-check-${process.pid}`); - await writeFile(probePath, "", { mode: PRIVATE_FILE_MODE }); - await rm(probePath, { force: true }); - } catch (error) { - throw new TelemetrySinkUnwritableError(this.rootDir, error); - } - } - - async appendRecord(record: TelemetrySinkRecord, at: Date): Promise { - const filePath = join(this.rootDir, dayFileName(at)); - const dayFileIsNew = !(await pathExists(filePath)); - await mkdir(this.rootDir, { recursive: true }); - this.tightenDir(); - await appendFile(filePath, `${serializeTelemetrySinkRecord(record)}\n`, { - mode: PRIVATE_FILE_MODE, - }); - if (dayFileIsNew) this.tightenFile(filePath); - return { filePath, dayFileIsNew }; - } - - async listDayFiles(): Promise { - try { - const entries = await readdir(this.rootDir); - return entries.filter((entry) => entry.endsWith(DAY_FILE_EXTENSION)).sort(); - } catch { - return []; - } - } - - // `dir` is always a caller-supplied value (never `this.rootDir` re-derived here) — see - // the port's own doc. `isBareFileName` is the actual confinement: `join` alone normalises - // `..` away visually but still deletes wherever the normalised path lands, so a - // `fileName` that is not a bare component of `dir` is refused before it ever reaches `rm`. - async deleteDayFile(dir: string, fileName: string): Promise { - if (!isBareFileName(fileName)) { - throw new Error(`refusing to delete "${fileName}" — not a day file name inside ${dir}`); - } - await rm(join(dir, fileName), { force: true }); - } - - async readRecordsForVendor(vendorId: string): Promise { - const records: TelemetrySinkRecord[] = []; - for (const fileName of await this.listDayFiles()) { - records.push(...(await this.readVendorRecordsFromFile(fileName, vendorId))); - } - return records; - } - - // Every day file is opened, not only the ones the period names: a session read locally - // days after it ran is appended to today's file while its records carry their own, older - // moments. Selecting by file name would be selecting by when we heard about the work. - async readRecordsInPeriod(fromDay: Date, toDay: Date): Promise { - const [fromKey, toKey] = [dayKey(fromDay), dayKey(toDay)].sort(); - const records: TelemetrySinkRecord[] = []; - const undated: TelemetrySinkRecord[] = []; - let skippedLines = 0; - const projects = new Set(); - const steps = new Set(); - const models = new Set(); - for (const fileName of await this.listDayFiles()) { - const read = await this.readAllRecordsFromFile(fileName); - skippedLines += read.skippedLines; - for (const record of read.records) { - if (record.project_id !== undefined) projects.add(record.project_id); - if (record.step !== undefined) steps.add(record.step); - if (record.model !== undefined) models.add(record.model); - const key = telemetrySinkRecordDayKey(record); - if (key === undefined) undated.push(record); - else if (key >= fromKey && key <= toKey) records.push(record); - } - } - return { records, undated, skippedLines, knownValues: { projects, steps, models } }; - } - - private async readAllRecordsFromFile( - fileName: string - ): Promise<{ records: TelemetrySinkRecord[]; skippedLines: number }> { - let content: string; - try { - content = await readFile(join(this.rootDir, fileName), "utf8"); - } catch { - // A file listed a moment ago and unreadable now — rotated, deleted, or never ours. - // Nothing about it is known, so nothing about it is counted as skipped either. - return { records: [], skippedLines: 0 }; - } - const records: TelemetrySinkRecord[] = []; - let skippedLines = 0; - for (const line of content.split("\n")) { - if (line.trim() === "") continue; - const record = this.parseLineOrSkip(line); - if (record) records.push(record); - else skippedLines += 1; - } - return { records, skippedLines }; - } - - private async readVendorRecordsFromFile( - fileName: string, - vendorId: string - ): Promise { - const content = await readFile(join(this.rootDir, fileName), "utf8"); - const records: TelemetrySinkRecord[] = []; - for (const line of content.split("\n")) { - if (line.trim() === "") continue; - const record = this.parseLineOrSkip(line); - if (record?.vendor_id === vendorId) records.push(record); - } - return records; - } - - // A torn final line (a concurrent write still in flight) or a stray older-schema line - // must not fail an unrelated session's read — skipped, not translated, since there is - // no typed exception a caller could usefully act on for one line among many. - private parseLineOrSkip(line: string): TelemetrySinkRecord | undefined { - try { - return parseTelemetrySinkLine(line); - } catch { - return undefined; - } - } -} diff --git a/cli/src/infrastructure/adapters/transcript-cost-reader-adapter.ts b/cli/src/infrastructure/adapters/transcript-cost-reader-adapter.ts deleted file mode 100644 index 1395fccbb..000000000 --- a/cli/src/infrastructure/adapters/transcript-cost-reader-adapter.ts +++ /dev/null @@ -1,73 +0,0 @@ -import type { Dirent } from "node:fs"; -import { createReadStream } from "node:fs"; -import { readdir } from "node:fs/promises"; -import { join, relative } from "node:path"; -import { createInterface } from "node:readline"; -import type { TranscriptLocation } from "../../domain/capabilities/telemetry-capability.js"; -import type { - LocalCostCandidateRecord, - LocalCostReadResult, - SessionCostReader, - TranscriptLineAccumulator, -} from "../../domain/ports/session-cost-reader.js"; - -async function* walk(dir: string): AsyncGenerator { - let entries: Dirent[]; - try { - entries = await readdir(dir, { withFileTypes: true }); - } catch { - return; - } - for (const entry of entries) { - const absolutePath = join(dir, entry.name); - if (entry.isDirectory()) yield* walk(absolutePath); - else if (entry.isFile()) yield absolutePath; - } -} - -/** - * Streams a tool's own transcript file(s) for one session and maps them through that tool's - * pure format module — the only part of the local-read path allowed to open a file. Which - * directory to search, and which file names belong to a session, are the tool's own - * declaration (`TranscriptLocation`, from `telemetryLocalRead.transcript`); this class walks - * and reads, and never encodes a path of its own. A missing directory, or no matching file, - * answers `sessionFound: false` rather than an empty success — this tool has no trace of - * that session, which is a different fact from a transcript that exists and holds nothing - * billable, and not a failure to read either. A file is read through `readline` rather than `readFile`, so a large transcript - * is never held whole in memory, and a half-written final line (a live session being - * appended to as this reads) reaches the format module like any other line — its own job to - * accept or skip. - */ -export class TranscriptCostReaderAdapter implements SessionCostReader { - constructor( - private readonly homeDir: string, - private readonly location: TranscriptLocation, - private readonly createAccumulator: () => TranscriptLineAccumulator - ) {} - - async read(sessionId: string): Promise { - const root = this.location.root(this.homeDir); - const files = await this.findMatchingFiles(root, sessionId); - const records: LocalCostCandidateRecord[] = []; - for (const file of files) { - records.push(...(await this.readFile(file))); - } - return { records, sessionFound: files.length > 0 }; - } - - private async findMatchingFiles(root: string, sessionId: string): Promise { - const matches: string[] = []; - for await (const absolutePath of walk(root)) { - const relativePath = relative(root, absolutePath); - if (this.location.matches(relativePath, sessionId)) matches.push(absolutePath); - } - return matches; - } - - private async readFile(path: string): Promise { - const accumulator = this.createAccumulator(); - const lines = createInterface({ input: createReadStream(path), crlfDelay: Infinity }); - for await (const line of lines) accumulator.push(line); - return accumulator.build(); - } -} diff --git a/cli/src/infrastructure/auth/auth-storage.ts b/cli/src/infrastructure/auth/auth-storage.ts deleted file mode 100644 index 6b9ea5d9c..000000000 --- a/cli/src/infrastructure/auth/auth-storage.ts +++ /dev/null @@ -1,104 +0,0 @@ -import { execSync } from "node:child_process"; -import { chmod, mkdir, readFile, rm, writeFile } from "node:fs/promises"; -import { homedir } from "node:os"; -import { dirname, join } from "node:path"; -import type { AuthConfig, AuthCredential, AuthLevel } from "../../domain/models/auth.js"; -import { AIDD_DIR } from "../../domain/models/paths.js"; -import { AuthStorageError } from "../errors.js"; - -interface SaveOptions { - credential: AuthCredential; - level: AuthLevel; - projectRoot: string; -} - -export class AuthStorage { - private static readonly AUTH_FILE = "auth.json"; - - userConfigPath(): string { - const override = process.env.AIDD_USER_CONFIG_DIR; - const dir = override ?? join(homedir(), ".config", "aidd"); - return join(dir, AuthStorage.AUTH_FILE); - } - - projectConfigPath(projectRoot: string): string { - return join(projectRoot, AIDD_DIR, AuthStorage.AUTH_FILE); - } - - async read(path: string): Promise { - try { - const content = await readFile(path, "utf-8"); - const parsed = JSON.parse(content) as unknown; - if (!isAuthConfig(parsed)) return null; - return parsed; - } catch { - return null; - } - } - - async write(path: string, config: AuthConfig): Promise { - await mkdir(dirname(path), { recursive: true }); - await writeFile(path, JSON.stringify(config, null, 2), "utf-8"); - if (process.platform === "win32") { - try { - execSync(`icacls "${path}" /inheritance:r /grant:r "%USERNAME%:(R,W)"`, { - stdio: ["ignore", "ignore", "pipe"], - }); - } catch (err) { - throw new AuthStorageError( - `Failed to set restrictive permissions on ${path}: ${err instanceof Error ? err.message : String(err)}` - ); - } - } else { - await chmod(path, 0o600); - } - } - - async delete(path: string): Promise { - await rm(path, { force: true }); - } - - async readActive(projectRoot: string): Promise { - const envToken = process.env.AIDD_TOKEN; - if (envToken) { - return { - version: 1, - method: "stored", - level: "user", - token: envToken, - createdAt: new Date().toISOString(), - }; - } - const projectConfig = await this.read(this.projectConfigPath(projectRoot)); - if (projectConfig !== null) return projectConfig; - return this.read(this.userConfigPath()); - } - - async save(options: SaveOptions): Promise { - const config: AuthConfig = { - version: 1, - method: options.credential.method, - level: options.level, - createdAt: new Date().toISOString(), - ...(options.credential.method === "stored" - ? { token: options.credential.token } - : { provider: options.credential.provider }), - }; - const path = - options.level === "project" - ? this.projectConfigPath(options.projectRoot) - : this.userConfigPath(); - await this.write(path, config); - } -} - -function isAuthConfig(value: unknown): value is AuthConfig { - if (typeof value !== "object" || value === null) return false; - const obj = value as Record; - return ( - obj.version === 1 && - (obj.method === "external" || obj.method === "stored") && - (obj.level === "user" || obj.level === "project") && - typeof obj.createdAt === "string" - ); -} diff --git a/cli/src/infrastructure/confined-file-name.ts b/cli/src/infrastructure/confined-file-name.ts deleted file mode 100644 index 9e18640f6..000000000 --- a/cli/src/infrastructure/confined-file-name.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { basename } from "node:path"; - -/** - * Whether `fileName` names exactly one entry directly inside whatever directory it will be - * joined to — never a relative walk out of it (`"../../VICTIM.txt"`), the directory itself - * (`"."`, `".."`), or an absolute path smuggled in as a "name". - * - * This is the actual confinement `RunJournalReaderAdapter.deleteRunFile` and - * `TelemetrySinkAdapter.deleteDayFile` rely on — not `join`. `join` normalises `..` - * segments away visually, but still deletes wherever the normalised path lands; a name - * that fails this check is refused before it ever reaches `rm`. Before this existed, - * nothing on the production path stopped such a name — the reason one never arrived here - * was that both callers pass names straight from `readdir`, which yields bare components by - * construction. That was an accident of the caller, never a guarantee this made itself; this - * function is what turns it into one. - */ -export function isBareFileName(fileName: string): boolean { - if (fileName === "" || fileName === "." || fileName === "..") return false; - return basename(fileName) === fileName; -} diff --git a/cli/src/infrastructure/deps.ts b/cli/src/infrastructure/deps.ts deleted file mode 100644 index 93f86690f..000000000 --- a/cli/src/infrastructure/deps.ts +++ /dev/null @@ -1,860 +0,0 @@ -import { stat } from "node:fs/promises"; -import { homedir } from "node:os"; -import "../domain/tools/ai/claude.js"; -import "../domain/tools/ai/codex.js"; -import "../domain/tools/ai/copilot.js"; -import "../domain/tools/ai/cursor.js"; -import "../domain/tools/ai/opencode.js"; -import "../domain/tools/ide/vscode.js"; -import { CLIOutput } from "../application/output.js"; -import { RequireAuthUseCase } from "../application/use-cases/auth/require-auth-use-case.js"; -import { CheckUpdateUseCase } from "../application/use-cases/check-update-use-case.js"; -import { CleanUseCase } from "../application/use-cases/clean-use-case.js"; -import { DoctorLayoutUseCase } from "../application/use-cases/doctor/doctor-layout-use-case.js"; -import { DoctorMergeFilesUseCase } from "../application/use-cases/doctor/doctor-merge-files-use-case.js"; -import { DoctorPluginUseCase } from "../application/use-cases/doctor/doctor-plugin-use-case.js"; -import { DoctorReferencesUseCase } from "../application/use-cases/doctor/doctor-references-use-case.js"; -import { DoctorTrackedFilesUseCase } from "../application/use-cases/doctor/doctor-tracked-files-use-case.js"; -import { DoctorUseCase } from "../application/use-cases/doctor/doctor-use-case.js"; -import { FrameworkBuildUseCase } from "../application/use-cases/framework/framework-build-use-case.js"; -import { FlatBuildStrategy } from "../application/use-cases/framework/strategies/flat-build-strategy.js"; -import { MarketplaceBuildStrategy } from "../application/use-cases/framework/strategies/marketplace-build-strategy.js"; -import { - buildClaudeContract, - buildClaudeFlatContract, - buildCodexContract, - buildCodexFlatContract, - buildCopilotFlatContract, - buildCopilotMarketplaceContract, - buildCursorContract, - buildCursorFlatContract, - buildOpencodeFlatContract, -} from "../application/use-cases/framework/strategies/tool-contracts.js"; -import { DoctorAllUseCase } from "../application/use-cases/global/doctor-all-use-case.js"; -import { RestoreAllUseCase } from "../application/use-cases/global/restore-all-use-case.js"; -import { StatusAllUseCase } from "../application/use-cases/global/status-all-use-case.js"; -import { UpdateAiToolsUseCase } from "../application/use-cases/global/update-ai-tools-use-case.js"; -import { UpdateAllUseCase } from "../application/use-cases/global/update-all-use-case.js"; -import { UpdateIdeToolsUseCase } from "../application/use-cases/global/update-ide-tools-use-case.js"; -import { InstallAiToolUseCase } from "../application/use-cases/install/install-ai-tool-use-case.js"; -import { InstallIdeConfigUseCase } from "../application/use-cases/install/install-ide-config-use-case.js"; -import { InstallIdeToolUseCase } from "../application/use-cases/install/install-ide-tool-use-case.js"; -import { InstallRuntimeConfigUseCase } from "../application/use-cases/install/install-runtime-config-use-case.js"; -import { ListInstalledRulesUseCase } from "../application/use-cases/list-installed-rules-use-case.js"; -import { MarketplaceAddUseCase } from "../application/use-cases/marketplace/marketplace-add-use-case.js"; -import { MarketplaceCheckUseCase } from "../application/use-cases/marketplace/marketplace-check-use-case.js"; -import { MarketplaceListUseCase } from "../application/use-cases/marketplace/marketplace-list-use-case.js"; -import { MarketplaceRefreshUseCase } from "../application/use-cases/marketplace/marketplace-refresh-use-case.js"; -import { MarketplaceRegisterFrameworkUseCase } from "../application/use-cases/marketplace/marketplace-register-framework-use-case.js"; -import { MarketplaceRemoveUseCase } from "../application/use-cases/marketplace/marketplace-remove-use-case.js"; -import { MarketplaceSyncSettingsUseCase } from "../application/use-cases/marketplace/marketplace-sync-settings-use-case.js"; -import { PluginAddUseCase } from "../application/use-cases/plugin/plugin-add-use-case.js"; -import { PluginCreateUseCase } from "../application/use-cases/plugin/plugin-create-use-case.js"; -import { PluginInstallFromMarketplaceUseCase } from "../application/use-cases/plugin/plugin-install-from-marketplace-use-case.js"; -import { PluginInstallUseCase } from "../application/use-cases/plugin/plugin-install-use-case.js"; -import { PluginListUseCase } from "../application/use-cases/plugin/plugin-list-use-case.js"; -import { PluginPickUseCase } from "../application/use-cases/plugin/plugin-pick-use-case.js"; -import { PluginRemoveUseCase } from "../application/use-cases/plugin/plugin-remove-use-case.js"; -import { PluginSearchUseCase } from "../application/use-cases/plugin/plugin-search-use-case.js"; -import { PluginUpdateUseCase } from "../application/use-cases/plugin/plugin-update-use-case.js"; -import { RestoreUseCase } from "../application/use-cases/restore/restore-use-case.js"; -import { SelfUpdateUseCase } from "../application/use-cases/self-update-use-case.js"; -import { ProjectContextDetectorUseCase } from "../application/use-cases/setup/project-context-detector-use-case.js"; -import { SetupMarketplaceSourceUseCase } from "../application/use-cases/setup/setup-marketplace-source-use-case.js"; -import { SetupPluginsPromptUseCase } from "../application/use-cases/setup/setup-plugins-prompt-use-case.js"; -import { SetupToolsPromptUseCase } from "../application/use-cases/setup/setup-tools-prompt-use-case.js"; -import { SetupToolsUseCase } from "../application/use-cases/setup/setup-tools-use-case.js"; -import { DetectPluginDriftUseCase } from "../application/use-cases/shared/detect-plugin-drift-use-case.js"; -import { - EnsureBuiltMarketplaceUseCase, - type FrameworkBuildFor, -} from "../application/use-cases/shared/ensure-built-marketplace-use-case.js"; -import { FetchMarketplaceSourceUseCase } from "../application/use-cases/shared/fetch-marketplace-source-use-case.js"; -import { GitignoreUseCase } from "../application/use-cases/shared/gitignore-use-case.js"; -import { PostInstallPipelineUseCase } from "../application/use-cases/shared/post-install-pipeline-use-case.js"; -import { ResolveMarketplaceUseCase } from "../application/use-cases/shared/resolve-marketplace-use-case.js"; -import { ResolveUpdateDecisionUseCase } from "../application/use-cases/shared/resolve-update-decision-use-case.js"; -import { UpdateOneToolUseCase } from "../application/use-cases/shared/update-one-tool-use-case.js"; -import { StatusUseCase } from "../application/use-cases/status-use-case.js"; -import { SyncConflictResolverUseCase } from "../application/use-cases/sync/sync-conflict-resolver-use-case.js"; -import { DiagnoseTelemetryUseCase } from "../application/use-cases/telemetry/diagnose-telemetry-use-case.js"; -import { ForgetTelemetryUseCase } from "../application/use-cases/telemetry/forget-telemetry-use-case.js"; -import { PersonIdentityUseCase } from "../application/use-cases/telemetry/person-identity-use-case.js"; -import { ReadLocalCostUseCase } from "../application/use-cases/telemetry/read-local-cost-use-case.js"; -import { ReportCostUseCase } from "../application/use-cases/telemetry/report-cost-use-case.js"; -import { TelemetryOffUseCase } from "../application/use-cases/telemetry/telemetry-off-use-case.js"; -import { TelemetryOnUseCase } from "../application/use-cases/telemetry/telemetry-on-use-case.js"; -import { UninstallIdeUseCase } from "../application/use-cases/uninstall/uninstall-ide-use-case.js"; -import { UninstallToolsUseCase } from "../application/use-cases/uninstall/uninstall-tools-use-case.js"; -import { UninstallUseCase } from "../application/use-cases/uninstall/uninstall-use-case.js"; -import { - CLAUDE_CODE_TRANSCRIPT_LOCATION, - createClaudeCodeTranscriptAccumulator, -} from "../domain/formats/claude-code-transcript.js"; -import { - CODEX_ROLLOUT_LOCATION, - createCodexRolloutAccumulator, -} from "../domain/formats/codex-rollout.js"; -import type { AiToolId } from "../domain/models/tool-ids.js"; -import type { AssetProvider } from "../domain/ports/asset-provider.js"; -import type { CredentialStore } from "../domain/ports/credential-store.js"; -import type { FileMerger } from "../domain/ports/file-merger.js"; -import type { FileReader } from "../domain/ports/file-reader.js"; -import type { FileWriter } from "../domain/ports/file-writer.js"; -import type { Hasher } from "../domain/ports/hasher.js"; -import type { LatestReleaseResolver } from "../domain/ports/latest-release-resolver.js"; -import type { Logger } from "../domain/ports/logger.js"; -import type { ManifestRepository } from "../domain/ports/manifest-repository.js"; -import type { MarketplaceRegistry } from "../domain/ports/marketplace-registry.js"; -import type { MarketplaceTrustStore } from "../domain/ports/marketplace-trust-store.js"; -import type { NativePluginActivator } from "../domain/ports/native-plugin-activator.js"; -import type { Platform } from "../domain/ports/platform.js"; -import type { PluginCatalogRepository } from "../domain/ports/plugin-catalog-repository.js"; -import type { PluginDistributionReader } from "../domain/ports/plugin-distribution-reader.js"; -import type { PluginFetcher } from "../domain/ports/plugin-fetcher.js"; -import type { Prompter } from "../domain/ports/prompter.js"; -import type { SelfUpdater } from "../domain/ports/self-updater.js"; -import type { SessionCostReader } from "../domain/ports/session-cost-reader.js"; -import type { VersionControl } from "../domain/ports/version-control.js"; -import type { VersionReader } from "../domain/ports/version-reader.js"; -import { AjvSchemaValidatorAdapter } from "./adapters/ajv-schema-validator-adapter.js"; -import { AuthProviderAdapter } from "./adapters/auth-provider-adapter.js"; -import { AuthReaderAdapter } from "./adapters/auth-reader-adapter.js"; -import { ClaudeCliAdapter } from "./adapters/claude-cli-adapter.js"; -import { CodexCliAdapter } from "./adapters/codex-cli-adapter.js"; -import { CopilotCliAdapter } from "./adapters/copilot-cli-adapter.js"; -import { CopilotCostReaderAdapter } from "./adapters/copilot-cost-reader-adapter.js"; -import { CurrentVersionAdapter } from "./adapters/current-version-adapter.js"; -import { FileAdapter } from "./adapters/file-adapter.js"; -import { GhCliAdapter } from "./adapters/gh-cli-adapter.js"; -import { GhTokenAdapter } from "./adapters/gh-token-adapter.js"; -import { GitAdapter } from "./adapters/git-adapter.js"; -import { GitHubRawFetcherAdapter } from "./adapters/github-raw-fetcher-adapter.js"; -import { GitHubReleaseResolverAdapter } from "./adapters/github-release-resolver-adapter.js"; -import { HasherAdapter } from "./adapters/hasher-adapter.js"; -import { HookTrustReaderAdapter } from "./adapters/hook-trust-reader-adapter.js"; -import { hostPluginRegistryReaders } from "./adapters/host-plugin-registry-reader-adapter.js"; -import { ManifestRepositoryAdapter } from "./adapters/manifest-repository-adapter.js"; -import { MarketplaceCacheAdapter } from "./adapters/marketplace-cache-adapter.js"; -import { MarketplaceRegistryAdapter } from "./adapters/marketplace-registry-adapter.js"; -import { MarketplaceTrustStoreAdapter } from "./adapters/marketplace-trust-store-adapter.js"; -import { OpencodeCostReaderAdapter } from "./adapters/opencode-cost-reader-adapter.js"; -import { PersonIdentityAdapter } from "./adapters/person-identity-adapter.js"; -import { PlatformAdapter } from "./adapters/platform-adapter.js"; -import { PluginCatalogRepositoryAdapter } from "./adapters/plugin-catalog-repository-adapter.js"; -import { PluginDistributionReaderAdapter } from "./adapters/plugin-distribution-reader-adapter.js"; -import { PluginFetcherAdapter } from "./adapters/plugin-fetcher-adapter.js"; -import { InquirerPrompterAdapter, SilentPrompterAdapter } from "./adapters/prompter-adapter.js"; -import { RunJournalReaderAdapter } from "./adapters/run-journal-reader-adapter.js"; -import { SelfUpdaterAdapter } from "./adapters/self-updater-adapter.js"; -import { TaskBacklogAdapter } from "./adapters/task-backlog-adapter.js"; -import { TelemetryEvidenceAdapter } from "./adapters/telemetry-evidence-adapter.js"; -import { TelemetrySinkAdapter } from "./adapters/telemetry-sink-adapter.js"; -import { TranscriptCostReaderAdapter } from "./adapters/transcript-cost-reader-adapter.js"; -import { BundledAssetProviderAdapter } from "./assets/asset-loader.js"; -import { AuthStorage } from "./auth/auth-storage.js"; -import { resolveHomeDir } from "./home-dir.js"; -import { HttpClient } from "./http/http-client.js"; - -interface GlobalOptions { - verbose: boolean; -} - -interface Deps { - fs: FileReader & FileWriter & FileMerger; - manifestRepo: ManifestRepository; - hasher: Hasher; - logger: Logger; - cliUpdater: SelfUpdater; - currentVersionProvider: VersionReader; - git: VersionControl; - platform: Platform; - prompter: Prompter; - authReader: AuthReaderAdapter; - authStorage: AuthStorage; - credentialStore: CredentialStore; - http: HttpClient; - pluginCatalogRepository: PluginCatalogRepository; - pluginFetcher: PluginFetcher; - pluginDistributionReader: PluginDistributionReader; - marketplaceRegistry: MarketplaceRegistry; - marketplaceTrustStore: MarketplaceTrustStore; - pluginAddUseCase: PluginAddUseCase; - frameworkBuildUseCase: FrameworkBuildUseCase; - pluginCreateUseCase: PluginCreateUseCase; - pluginRemoveUseCase: PluginRemoveUseCase; - pluginListUseCase: PluginListUseCase; - pluginUpdateUseCase: PluginUpdateUseCase; - marketplaceAddUseCase: MarketplaceAddUseCase; - marketplaceListUseCase: MarketplaceListUseCase; - marketplaceRemoveUseCase: MarketplaceRemoveUseCase; - marketplaceRefreshUseCase: MarketplaceRefreshUseCase; - marketplaceCheckUseCase: MarketplaceCheckUseCase; - pluginInstallFromMarketplaceUseCase: PluginInstallFromMarketplaceUseCase; - resolveMarketplaceUseCase: ResolveMarketplaceUseCase; - ensureBuiltMarketplaceUseCase: EnsureBuiltMarketplaceUseCase; - installRuntimeConfigUseCase: InstallRuntimeConfigUseCase; - installAiToolUseCase: InstallAiToolUseCase; - installIdeConfigUseCase: InstallIdeConfigUseCase; - installIdeToolUseCase: InstallIdeToolUseCase; - uninstallIdeUseCase: UninstallIdeUseCase; - assetProvider: AssetProvider; - pluginSearchUseCase: PluginSearchUseCase; - marketplaceRegisterFrameworkUseCase: MarketplaceRegisterFrameworkUseCase; - pluginPickUseCase: PluginPickUseCase; - pluginInstallUseCase: PluginInstallUseCase; - marketplaceSyncSettingsUseCase: MarketplaceSyncSettingsUseCase; - syncConflictResolverUseCase: SyncConflictResolverUseCase; - doctorUseCase: DoctorUseCase; - releaseResolver: LatestReleaseResolver; - setupMarketplaceSourceUseCase: SetupMarketplaceSourceUseCase; - setupToolsUseCase: SetupToolsUseCase; - setupPluginsPromptUseCase: SetupPluginsPromptUseCase; - setupToolsPromptUseCase: SetupToolsPromptUseCase; - projectContextDetector: ProjectContextDetectorUseCase; - requireAuthUseCase: RequireAuthUseCase; - selfUpdateUseCase: SelfUpdateUseCase; - statusUseCase: StatusUseCase; - restoreUseCase: RestoreUseCase; - uninstallUseCase: UninstallUseCase; - statusAllUseCase: StatusAllUseCase; - restoreAllUseCase: RestoreAllUseCase; - updateAllUseCase: UpdateAllUseCase; - updateAiToolsUseCase: UpdateAiToolsUseCase; - updateIdeToolsUseCase: UpdateIdeToolsUseCase; - cleanUseCase: CleanUseCase; - doctorAllUseCase: DoctorAllUseCase; - checkUpdateUseCase: CheckUpdateUseCase; - telemetryOnUseCase: TelemetryOnUseCase; - telemetryOffUseCase: TelemetryOffUseCase; - readLocalCostUseCase: ReadLocalCostUseCase; - personIdentityUseCase: PersonIdentityUseCase; - diagnoseTelemetryUseCase: DiagnoseTelemetryUseCase; - reportCostUseCase: ReportCostUseCase; - /** Exposed so a command can say how this machine located its figures — see - * `warnIfFiguresMoveTheTokenToo`. */ - telemetrySink: TelemetrySinkAdapter; - forgetTelemetryUseCase: ForgetTelemetryUseCase; - listInstalledRulesUseCase: ListInstalledRulesUseCase; -} - -const _cache = new Map(); - -async function isDirectory(path: string): Promise { - try { - return (await stat(path)).isDirectory(); - } catch { - return false; - } -} - -export interface FrameworkBuildContext { - readonly target: string; - readonly mode: string; - readonly outDir: string; - readonly force: boolean; -} - -/** The subset of Deps the framework build pipeline reads — lets EnsureBuilt build any target. */ -export type FrameworkBuildDeps = Pick; - -type FrameworkBuildFactory = ( - deps: FrameworkBuildDeps, - ctx: FrameworkBuildContext -) => FrameworkBuildUseCase; - -function buildFrameworkUseCase( - deps: FrameworkBuildDeps, - makeStrategy: ( - deps: FrameworkBuildDeps, - av: AjvSchemaValidatorAdapter - ) => MarketplaceBuildStrategy | FlatBuildStrategy -): FrameworkBuildUseCase { - const av = new AjvSchemaValidatorAdapter(); - return new FrameworkBuildUseCase( - deps.fs, - av, - deps.assetProvider, - deps.logger, - makeStrategy(deps, av) - ); -} - -const FRAMEWORK_BUILD_REGISTRY: Record = { - "claude:marketplace": (deps) => - buildFrameworkUseCase( - deps, - (d, av) => new MarketplaceBuildStrategy(d.fs, av, d.assetProvider, buildClaudeContract()) - ), - "cursor:marketplace": (deps) => - buildFrameworkUseCase( - deps, - (d, av) => new MarketplaceBuildStrategy(d.fs, av, d.assetProvider, buildCursorContract()) - ), - "copilot:marketplace": (deps) => - buildFrameworkUseCase( - deps, - (d, av) => - new MarketplaceBuildStrategy(d.fs, av, d.assetProvider, buildCopilotMarketplaceContract()) - ), - "codex:marketplace": (deps) => - buildFrameworkUseCase( - deps, - (d, av) => new MarketplaceBuildStrategy(d.fs, av, d.assetProvider, buildCodexContract()) - ), - "copilot:flat": (deps, ctx) => - buildFrameworkUseCase( - deps, - (d, av) => - new FlatBuildStrategy( - d.fs, - av, - d.assetProvider, - buildCopilotFlatContract(), - ctx.force, - ctx.outDir, - isDirectory, - d.logger - ) - ), - "claude:flat": (deps, ctx) => - buildFrameworkUseCase( - deps, - (d, av) => - new FlatBuildStrategy( - d.fs, - av, - d.assetProvider, - buildClaudeFlatContract(), - ctx.force, - ctx.outDir, - isDirectory, - d.logger - ) - ), - "cursor:flat": (deps, ctx) => - buildFrameworkUseCase( - deps, - (d, av) => - new FlatBuildStrategy( - d.fs, - av, - d.assetProvider, - buildCursorFlatContract(), - ctx.force, - ctx.outDir, - isDirectory, - d.logger - ) - ), - "codex:flat": (deps, ctx) => - buildFrameworkUseCase( - deps, - (d, av) => - new FlatBuildStrategy( - d.fs, - av, - d.assetProvider, - buildCodexFlatContract(), - ctx.force, - ctx.outDir, - isDirectory, - d.logger - ) - ), - "opencode:flat": (deps, ctx) => - buildFrameworkUseCase( - deps, - (d, av) => - new FlatBuildStrategy( - d.fs, - av, - d.assetProvider, - buildOpencodeFlatContract(), - ctx.force, - ctx.outDir, - isDirectory, - d.logger - ) - ), -}; - -export function createFrameworkBuildUseCase( - deps: FrameworkBuildDeps, - ctx: FrameworkBuildContext -): FrameworkBuildUseCase | undefined { - const key = `${ctx.target}:${ctx.mode}`; - const factory = FRAMEWORK_BUILD_REGISTRY[key]; - return factory?.(deps, ctx); -} - -export function createMenuDeps(projectRoot: string): { - manifestRepo: ManifestRepository; - prompter: Prompter; -} { - return { - manifestRepo: new ManifestRepositoryAdapter(projectRoot), - prompter: process.stdout.isTTY ? new InquirerPrompterAdapter() : new SilentPrompterAdapter(), - }; -} - -export async function createDeps( - projectRoot: string, - options: GlobalOptions, - output?: CLIOutput -): Promise { - const cached = _cache.get(projectRoot); - if (cached !== undefined) return cached; - const hasher = new HasherAdapter(); - const logger = output ?? new CLIOutput(options.verbose); - const fs = new FileAdapter(hasher, logger); - const pluginCatalogRepository = new PluginCatalogRepositoryAdapter(fs); - const pluginDistributionReader = new PluginDistributionReaderAdapter(fs); - const marketplaceCache = new MarketplaceCacheAdapter(projectRoot); - const marketplaceRegistry = new MarketplaceRegistryAdapter(); - const marketplaceTrustStore = new MarketplaceTrustStoreAdapter(hasher); - const manifestRepo = new ManifestRepositoryAdapter(projectRoot); - const http = new HttpClient(); - const authStorage = new AuthStorage(); - const ghCliAdapter = new GhCliAdapter(); - const authReader = new AuthReaderAdapter(authStorage, projectRoot, logger, ghCliAdapter); - const credentialStore = new AuthProviderAdapter( - authStorage, - new Map([["gh", ghCliAdapter]]), - new GhTokenAdapter(http), - projectRoot - ); - const pluginFetcher = new PluginFetcherAdapter(fs, authReader); - const rawCatalogFetcher = new GitHubRawFetcherAdapter(http, authReader); - const cliUpdater = new SelfUpdaterAdapter(http, { - tokenProvider: authReader, - githubApiBase: process.env.AIDD_SELF_UPDATE_API_BASE, - npmRegistryBase: process.env.AIDD_SELF_UPDATE_NPM_BASE, - logger, - }); - const currentVersionProvider = new CurrentVersionAdapter(); - const requireAuthUseCase = new RequireAuthUseCase(authReader); - const selfUpdateUseCase = new SelfUpdateUseCase(cliUpdater, currentVersionProvider); - const git = new GitAdapter(fs); - const platform = new PlatformAdapter(); - const prompter = process.stdout.isTTY - ? new InquirerPrompterAdapter() - : new SilentPrompterAdapter(); - const nativePluginActivators = new Map([ - ["claude", new ClaudeCliAdapter()], - ["codex", new CodexCliAdapter()], - ["copilot", new CopilotCliAdapter()], - ]); - const pluginRemoveUseCase = new PluginRemoveUseCase( - fs, - manifestRepo, - logger, - nativePluginActivators - ); - const pluginListUseCase = new PluginListUseCase(manifestRepo); - const fetchMarketplaceSource = new FetchMarketplaceSourceUseCase( - pluginFetcher, - rawCatalogFetcher, - fs, - logger - ); - const resolveMarketplaceUseCase = new ResolveMarketplaceUseCase( - fetchMarketplaceSource, - pluginCatalogRepository - ); - const marketplaceListUseCase = new MarketplaceListUseCase( - marketplaceRegistry, - resolveMarketplaceUseCase, - logger - ); - const marketplaceRemoveUseCase = new MarketplaceRemoveUseCase( - fs, - manifestRepo, - marketplaceRegistry, - prompter - ); - const marketplaceAddUseCase = new MarketplaceAddUseCase( - marketplaceRegistry, - marketplaceTrustStore, - resolveMarketplaceUseCase, - prompter, - marketplaceRemoveUseCase - ); - const marketplaceRefreshUseCase = new MarketplaceRefreshUseCase( - marketplaceRegistry, - resolveMarketplaceUseCase, - marketplaceCache, - logger, - fs - ); - const marketplaceCheckUseCase = new MarketplaceCheckUseCase( - manifestRepo, - marketplaceRegistry, - resolveMarketplaceUseCase - ); - const assetProvider = new BundledAssetProviderAdapter(); - const jsonSchemaValidator = new AjvSchemaValidatorAdapter(); - // force:true is safe here: outDir is always builtMarketplaceDir(), an aidd-owned - // disposable cache under .aidd/cache/built/, never a user-owned directory. A - // collision only means "the cache from a previous build already exists" — the - // whole point of a rebuild. The real user --force (framework.ts) is unrelated - // and already threaded correctly for the direct `framework build --flat` path. - const frameworkBuildFor: FrameworkBuildFor = (target, mode, outDir) => - createFrameworkBuildUseCase( - { fs, assetProvider, logger }, - { target, mode, outDir, force: true } - ); - const ensureBuiltMarketplaceUseCase = new EnsureBuiltMarketplaceUseCase( - fs, - resolveMarketplaceUseCase, - frameworkBuildFor, - currentVersionProvider - ); - const marketplaceSyncSettingsUseCase = new MarketplaceSyncSettingsUseCase( - fs, - manifestRepo, - marketplaceRegistry, - pluginCatalogRepository, - hasher, - logger, - nativePluginActivators, - ensureBuiltMarketplaceUseCase - ); - const pluginAddUseCase = new PluginAddUseCase( - fs, - manifestRepo, - pluginFetcher, - pluginDistributionReader, - hasher, - logger, - marketplaceRegistry, - ensureBuiltMarketplaceUseCase - ); - const frameworkBuildUseCase = new FrameworkBuildUseCase( - fs, - jsonSchemaValidator, - assetProvider, - logger, - new MarketplaceBuildStrategy( - fs, - jsonSchemaValidator, - assetProvider, - buildCopilotMarketplaceContract() - ) - ); - const pluginCreateUseCase = new PluginCreateUseCase( - fs, - prompter, - jsonSchemaValidator, - assetProvider, - logger - ); - const gitignoreUseCase = new GitignoreUseCase(fs); - const postInstallPipelineUseCase = new PostInstallPipelineUseCase(manifestRepo, gitignoreUseCase); - const installRuntimeConfigUseCase = new InstallRuntimeConfigUseCase( - fs, - hasher, - logger, - assetProvider, - postInstallPipelineUseCase - ); - const installIdeConfigUseCase = new InstallIdeConfigUseCase( - fs, - hasher, - logger, - assetProvider, - postInstallPipelineUseCase - ); - const installIdeToolUseCase = new InstallIdeToolUseCase( - installIdeConfigUseCase, - manifestRepo, - fs, - hasher, - postInstallPipelineUseCase, - assetProvider - ); - const uninstallIdeUseCase = new UninstallIdeUseCase( - manifestRepo, - new UninstallToolsUseCase(fs, logger) - ); - const pluginInstallFromMarketplaceUseCase = new PluginInstallFromMarketplaceUseCase( - resolveMarketplaceUseCase, - marketplaceRegistry, - pluginAddUseCase, - prompter, - logger - ); - const pluginSearchUseCase = new PluginSearchUseCase( - marketplaceRegistry, - resolveMarketplaceUseCase - ); - const marketplaceRegisterFrameworkUseCase = new MarketplaceRegisterFrameworkUseCase( - marketplaceRegistry - ); - const pluginPickUseCase = new PluginPickUseCase( - marketplaceRegistry, - resolveMarketplaceUseCase, - pluginAddUseCase, - prompter - ); - const pluginInstallUseCase = new PluginInstallUseCase( - pluginPickUseCase, - pluginAddUseCase, - pluginInstallFromMarketplaceUseCase, - manifestRepo, - marketplaceTrustStore, - prompter - ); - const installAiToolUseCase = new InstallAiToolUseCase( - installRuntimeConfigUseCase, - manifestRepo, - pluginInstallFromMarketplaceUseCase, - marketplaceSyncSettingsUseCase, - logger - ); - const syncConflictResolverUseCase = new SyncConflictResolverUseCase(fs); - const doctorTrackedFilesUseCase = new DoctorTrackedFilesUseCase(fs); - const doctorMergeFilesUseCase = new DoctorMergeFilesUseCase(fs, hasher); - const detectPluginDriftUseCase = new DetectPluginDriftUseCase(fs); - const doctorPluginUseCase = new DoctorPluginUseCase(detectPluginDriftUseCase); - const doctorReferencesUseCase = new DoctorReferencesUseCase(fs); - const doctorLayoutUseCase = new DoctorLayoutUseCase(fs, authReader); - const doctorUseCase = new DoctorUseCase( - manifestRepo, - doctorTrackedFilesUseCase, - doctorMergeFilesUseCase, - doctorPluginUseCase, - doctorReferencesUseCase, - doctorLayoutUseCase - ); - const releaseResolver = new GitHubReleaseResolverAdapter(http, authReader); - const setupMarketplaceSourceUseCase = new SetupMarketplaceSourceUseCase( - prompter, - releaseResolver - ); - const setupToolsUseCase = new SetupToolsUseCase( - manifestRepo, - installRuntimeConfigUseCase, - installIdeConfigUseCase - ); - const setupPluginsPromptUseCase = new SetupPluginsPromptUseCase( - pluginPickUseCase, - pluginInstallFromMarketplaceUseCase, - marketplaceRegistry, - resolveMarketplaceUseCase - ); - const setupToolsPromptUseCase = new SetupToolsPromptUseCase(prompter); - const projectContextDetector = new ProjectContextDetectorUseCase(fs); - const statusUseCase = new StatusUseCase(fs, manifestRepo, hasher, detectPluginDriftUseCase); - // Lets restore re-materialize cursor/opencode plugins via the build pipeline, - // matching what install wrote (otherwise restore rewrites raw content → drift). - const builtMaterializationDeps = { - ensureBuilt: ensureBuiltMarketplaceUseCase, - marketplaceRegistry, - homedir, - }; - const pluginUpdateUseCase = new PluginUpdateUseCase( - fs, - manifestRepo, - pluginFetcher, - pluginDistributionReader, - hasher, - builtMaterializationDeps - ); - const restoreUseCase = new RestoreUseCase( - fs, - manifestRepo, - hasher, - logger, - platform, - prompter, - pluginFetcher, - pluginDistributionReader, - assetProvider, - builtMaterializationDeps - ); - const uninstallUseCase = new UninstallUseCase(fs, manifestRepo, logger); - const statusAllUseCase = new StatusAllUseCase(statusUseCase); - const restoreAllUseCase = new RestoreAllUseCase( - manifestRepo, - prompter, - statusUseCase, - restoreUseCase - ); - const resolveUpdateDecisionUseCase = new ResolveUpdateDecisionUseCase(prompter); - const updateOneToolUseCase = new UpdateOneToolUseCase( - installRuntimeConfigUseCase, - installIdeConfigUseCase, - syncConflictResolverUseCase, - resolveUpdateDecisionUseCase, - fs - ); - const updateAllUseCase = new UpdateAllUseCase( - manifestRepo, - currentVersionProvider, - pluginUpdateUseCase, - marketplaceRefreshUseCase, - updateOneToolUseCase - ); - const updateAiToolsUseCase = new UpdateAiToolsUseCase( - manifestRepo, - currentVersionProvider, - updateOneToolUseCase - ); - const updateIdeToolsUseCase = new UpdateIdeToolsUseCase( - manifestRepo, - currentVersionProvider, - updateOneToolUseCase - ); - const cleanUseCase = new CleanUseCase(fs, manifestRepo, logger, gitignoreUseCase, prompter); - const doctorAllUseCase = new DoctorAllUseCase(doctorUseCase); - const checkUpdateUseCase = new CheckUpdateUseCase(cliUpdater, currentVersionProvider, logger, fs); - const telemetryEvidenceAdapter = new TelemetryEvidenceAdapter(); - const telemetryOnUseCase = new TelemetryOnUseCase(fs, logger, gitignoreUseCase, git); - const telemetryOffUseCase = new TelemetryOffUseCase(fs, logger, telemetryEvidenceAdapter, git); - const telemetrySink = new TelemetrySinkAdapter(); - // This is the one place allowed to map a tool that declares `telemetryLocalRead: { - // kind: "declared" }` to the adapter that reads it. - // `resolveHomeDir()`, not a bare `homedir()`: on Windows the bare call ignores a `HOME` - // a person set or a test sandboxed this process under - see `home-dir.ts`. - const localCostReaders: ReadonlyMap = new Map< - AiToolId, - SessionCostReader - >([ - ["opencode", new OpencodeCostReaderAdapter()], - [ - "claude", - new TranscriptCostReaderAdapter( - resolveHomeDir(), - CLAUDE_CODE_TRANSCRIPT_LOCATION, - createClaudeCodeTranscriptAccumulator - ), - ], - [ - "codex", - new TranscriptCostReaderAdapter( - resolveHomeDir(), - CODEX_ROLLOUT_LOCATION, - createCodexRolloutAccumulator - ), - ], - ["copilot", new CopilotCostReaderAdapter(resolveHomeDir())], - ]); - const runJournalReader = new RunJournalReaderAdapter(projectRoot); - const personIdentityAdapter = new PersonIdentityAdapter(); - const readLocalCostUseCase = new ReadLocalCostUseCase( - telemetrySink, - localCostReaders, - runJournalReader, - personIdentityAdapter, - telemetryEvidenceAdapter, - currentVersionProvider, - logger - ); - const personIdentityUseCase = new PersonIdentityUseCase(personIdentityAdapter); - const hookTrustReaderAdapter = new HookTrustReaderAdapter(); - const diagnoseTelemetryUseCase = new DiagnoseTelemetryUseCase( - telemetryEvidenceAdapter, - git, - runJournalReader, - localCostReaders, - hookTrustReaderAdapter, - personIdentityAdapter, - telemetrySink, - currentVersionProvider, - manifestRepo, - hostPluginRegistryReaders() - ); - const reportCostUseCase = new ReportCostUseCase( - telemetrySink, - runJournalReader, - personIdentityAdapter, - telemetryEvidenceAdapter, - new TaskBacklogAdapter(projectRoot), - readLocalCostUseCase, - logger - ); - const forgetTelemetryUseCase = new ForgetTelemetryUseCase( - telemetrySink, - runJournalReader, - personIdentityAdapter, - git - ); - const deps: Deps = { - fs, - manifestRepo, - hasher, - logger, - cliUpdater, - currentVersionProvider, - git, - platform, - prompter, - authReader, - authStorage, - credentialStore, - http, - pluginCatalogRepository, - pluginFetcher, - pluginDistributionReader, - marketplaceRegistry, - marketplaceTrustStore, - pluginAddUseCase, - frameworkBuildUseCase, - pluginCreateUseCase, - pluginRemoveUseCase, - pluginListUseCase, - pluginUpdateUseCase, - marketplaceAddUseCase, - marketplaceListUseCase, - marketplaceRemoveUseCase, - marketplaceRefreshUseCase, - marketplaceCheckUseCase, - pluginInstallFromMarketplaceUseCase, - resolveMarketplaceUseCase, - ensureBuiltMarketplaceUseCase, - installRuntimeConfigUseCase, - installAiToolUseCase, - installIdeConfigUseCase, - installIdeToolUseCase, - uninstallIdeUseCase, - assetProvider, - pluginSearchUseCase, - marketplaceRegisterFrameworkUseCase, - pluginPickUseCase, - pluginInstallUseCase, - marketplaceSyncSettingsUseCase, - syncConflictResolverUseCase, - doctorUseCase, - releaseResolver, - setupMarketplaceSourceUseCase, - setupToolsUseCase, - setupPluginsPromptUseCase, - setupToolsPromptUseCase, - projectContextDetector, - requireAuthUseCase, - selfUpdateUseCase, - statusUseCase, - restoreUseCase, - uninstallUseCase, - statusAllUseCase, - restoreAllUseCase, - updateAllUseCase, - updateAiToolsUseCase, - updateIdeToolsUseCase, - cleanUseCase, - doctorAllUseCase, - checkUpdateUseCase, - telemetryOnUseCase, - telemetryOffUseCase, - readLocalCostUseCase, - personIdentityUseCase, - diagnoseTelemetryUseCase, - reportCostUseCase, - telemetrySink, - forgetTelemetryUseCase, - listInstalledRulesUseCase: new ListInstalledRulesUseCase(fs), - }; - _cache.set(projectRoot, deps); - return deps; -} diff --git a/cli/src/infrastructure/errors.ts b/cli/src/infrastructure/errors.ts deleted file mode 100644 index 9581e1bc9..000000000 --- a/cli/src/infrastructure/errors.ts +++ /dev/null @@ -1,77 +0,0 @@ -export class HttpError extends Error { - constructor( - readonly statusCode: number, - readonly url: string - ) { - super(`Unexpected HTTP ${statusCode} from ${url}`); - this.name = "HttpError"; - } -} - -export class HttpNotFoundError extends Error { - constructor(readonly url: string) { - super(`Resource not found (HTTP 404): ${url}`); - this.name = "HttpNotFoundError"; - } -} - -export class HttpRedirectError extends Error { - constructor(readonly url: string) { - super(`HTTP redirect without location header from ${url}`); - this.name = "HttpRedirectError"; - } -} - -export class JsonParseError extends Error { - constructor(path: string, cause: string) { - super(`Cannot parse existing JSON at ${path}: ${cause}`); - this.name = "JsonParseError"; - } -} - -export class AuthStorageError extends Error { - constructor(message: string) { - super(message); - this.name = "AuthStorageError"; - } -} - -export class TelemetrySinkUnwritableError extends Error { - constructor(path: string, cause: unknown) { - super( - `Telemetry sink directory is not writable: ${path} ` + - `(${cause instanceof Error ? cause.message : String(cause)})` - ); - this.name = "TelemetrySinkUnwritableError"; - } -} - -/** A write or a delete against the identity file failed for a reason other than the file - * simply not being there — permission denied, a full disk, and the like. Distinct from - * `UnreadableIdentityFileError` (domain/errors.ts): that one names a read that could not - * come back, this one a write or a forget that could not go out. */ -export class IdentityWriteError extends Error { - /** `action` names what the person was doing, because the sentence reaches them: someone - * withdrawing should not be told a write failed. */ - constructor(filePath: string, cause: unknown, action: "write" | "remove" = "write") { - super( - `Could not ${action} the identity file at ${filePath} ` + - `(${cause instanceof Error ? cause.message : String(cause)}).` - ); - this.name = "IdentityWriteError"; - } -} - -export class GhCliError extends Error { - constructor(message: string) { - super(message); - this.name = "GhCliError"; - } -} - -export class AssetNotFoundError extends Error { - constructor(assetName: string) { - super(`Bundled asset not found: '${assetName}'`); - this.name = "AssetNotFoundError"; - } -} diff --git a/cli/src/infrastructure/git-environment.ts b/cli/src/infrastructure/git-environment.ts deleted file mode 100644 index dcbba5fdd..000000000 --- a/cli/src/infrastructure/git-environment.ts +++ /dev/null @@ -1,11 +0,0 @@ -/** - * git exports GIT_DIR, GIT_WORK_TREE, GIT_INDEX_FILE and friends into every process it - * spawns. Left in place, a `git` call made from inside a git hook or a CI step reads the - * repository the environment names instead of the one at `cwd` — silently, and with a - * plausible wrong answer rather than an error. - */ -export function environmentWithoutGitVariables( - env: NodeJS.ProcessEnv = process.env -): NodeJS.ProcessEnv { - return Object.fromEntries(Object.entries(env).filter(([key]) => !key.startsWith("GIT_"))); -} diff --git a/cli/src/infrastructure/git/inject-token.ts b/cli/src/infrastructure/git/inject-token.ts deleted file mode 100644 index dfbe1e682..000000000 --- a/cli/src/infrastructure/git/inject-token.ts +++ /dev/null @@ -1,20 +0,0 @@ -interface HostMatcher { - match: (url: string) => boolean; - authPrefix: string; -} - -const HOST_MATCHERS: readonly HostMatcher[] = [ - { match: (u) => u.includes("github.com"), authPrefix: "x-access-token:" }, - { match: (u) => u.includes("gitlab.com"), authPrefix: "oauth2:" }, - { match: (u) => u.includes("bitbucket.org"), authPrefix: "x-token-auth:" }, - { match: (u) => u.includes("dev.azure.com"), authPrefix: ":" }, -]; - -export function injectTokenIntoUrl(url: string, token: string | undefined): string { - if (!token || !url.startsWith("https://")) return url; - const matcher = HOST_MATCHERS.find((m) => m.match(url)); - if (matcher === undefined) { - return url.replace("https://", `https://${token}@`); - } - return url.replace("https://", `https://${matcher.authPrefix}${token}@`); -} diff --git a/cli/src/infrastructure/home-dir.ts b/cli/src/infrastructure/home-dir.ts deleted file mode 100644 index 2c756e782..000000000 --- a/cli/src/infrastructure/home-dir.ts +++ /dev/null @@ -1,45 +0,0 @@ -import { homedir as osHomedir } from "node:os"; -import { join } from "node:path"; - -/** - * The OS user's home directory, `HOME` first. - * - * `os.homedir()` already reads `$HOME` on POSIX, so calling it directly is invisible there. - * On Windows it never does — it reads `USERPROFILE` instead, and falls back to the current - * user's profile directory (https://nodejs.org/api/os.html#oshomedir) — so a `HOME` a person - * sets under Git Bash/MSYS2, or a test sandboxes a process under, is silently ignored by a - * bare `homedir()` call there. - * - * Every site that has to name the directory holding a tool's session files, the telemetry - * sink, or this machine's identity file resolves it through this function rather than - * `node:os`'s `homedir()` directly, so one answer serves them all. - * - * This rule was once a parity obligation: the plugin's own scripts resolved `HOME` the same - * way, and an e2e held the two sides to each other. Those scripts are gone, and the hooks - * that remain resolve no home directory at all - they write beside the repository. The rule - * stands on the paragraph above alone, which is why it is stated there and not borrowed - * from a second implementation that no longer exists. - */ -export function resolveHomeDir( - env: NodeJS.ProcessEnv = process.env, - osHomedirFn: () => string = osHomedir -): string { - return env.HOME || osHomedirFn(); -} - -/** - * `/.config/aidd` on POSIX, `%APPDATA%/aidd` on Windows — the directory a - * *person's own choice* lives under, never a project's. Isolated as its own function, - * beside `resolveHomeDir`, because its contract refuses `AIDD_USER_CONFIG_DIR` on purpose: - * that variable is a location a repository or a CI job can set, and reaching the identity - * file through it would not be this person's own choice to make. The telemetry sink is - * deliberately not a caller of this function — `TelemetrySinkAdapter`'s own constructor - * honours that variable, and its `defaultConfigDir` additionally falls back to a legacy - * POSIX-shaped directory on Windows, a concern this function has no reason to carry. - */ -export function resolveAiddConfigDir(): string { - if (process.platform === "win32" && process.env.APPDATA) { - return join(process.env.APPDATA, "aidd"); - } - return join(resolveHomeDir(), ".config", "aidd"); -} diff --git a/cli/src/infrastructure/json-file.ts b/cli/src/infrastructure/json-file.ts deleted file mode 100644 index 3b674bfd3..000000000 --- a/cli/src/infrastructure/json-file.ts +++ /dev/null @@ -1,20 +0,0 @@ -/** - * Small, shared reading helpers for `person-identity-adapter.ts`, which keeps a person's - * own choice in a hand-editable JSON file under their profile. Kept as its own module - * rather than folded back inline because it keeps raw-JSON narrowing and - * raw-filesystem-error inspection out of the adapter itself, which stays about the one - * shape it reads and writes. - */ - -/** A parsed JSON value, narrowed to a plain object - `null`, an array, or a primitive all - * answer `{}` rather than throwing, so a caller reads a missing or wrong-shaped field as - * absent instead of having to guard the narrowing itself. */ -export function asPlainObject(value: unknown): Record { - return value !== null && typeof value === "object" && !Array.isArray(value) - ? (value as Record) - : {}; -} - -export function isErrnoException(error: unknown): error is NodeJS.ErrnoException { - return error instanceof Error && "code" in error; -} diff --git a/cli/src/infrastructure/repository-root.ts b/cli/src/infrastructure/repository-root.ts deleted file mode 100644 index b370002ce..000000000 --- a/cli/src/infrastructure/repository-root.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { existsSync } from "node:fs"; -import { dirname, join } from "node:path"; - -/** - * The checkout `start` sits in, or `start` itself when it sits in none. - * - * Everything the run journal records is written relative to a repository root: the hook - * that writes it anchors at `git rev-parse --show-toplevel` - * (`plugins/aidd-telemetry/hooks/lib/repo.cjs`), so a session started anywhere inside a - * checkout writes one journal at its root, and every path inside that journal - a written - * file, a declared task folder - is relative to that same root. Every reader of those paths - * must therefore resolve them against the root too, not against the directory the command - * happened to be run from. - * - * Anchoring a reader at the process working directory instead made the report answer - * differently depending on where it ran. Measured on 2026-09-04: from a subdirectory the - * journal directory was never found, and `by_task` reported `"no-declaration"` - a claim - * about the work - for a period whose journals sat one directory up. Anchoring only *one* - * reader is the same fault wearing a better disguise: with the journal reader moved and the - * backlog reader left behind, `by_task` named the task correctly while `by_backlog` said - * that task declared no backlog item, which reads as a fact about the task rather than a - * path that missed. - * - * Walked rather than shelled out to `git`, because this runs on every report and a - * subprocess buys nothing here: `.git` is accepted as a directory (a main checkout) or as a - * file (a linked worktree's `gitdir:` pointer), which is the same root `--show-toplevel` - * prints for both. It is not the answer git would give in every case - a bare repository, - * or `GIT_DIR` pointed elsewhere - and it does not need to be: every caller here has its - * own override, and both agree with git for every layout a session is actually run in. - * - * Terminates at the filesystem root, where `dirname` reaches a fixed point, and answers - * `start` unchanged from there: never climbs past it to read a stranger's journal. - */ -export function repositoryRootAbove(start: string): string { - let current = start; - for (;;) { - if (existsSync(join(current, ".git"))) return current; - const parent = dirname(current); - if (parent === current) return start; - current = parent; - } -} diff --git a/cli/src/kernel/describe-error.ts b/cli/src/kernel/describe-error.ts new file mode 100644 index 000000000..d52fa34a7 --- /dev/null +++ b/cli/src/kernel/describe-error.ts @@ -0,0 +1,19 @@ +/** A filesystem failure's `code` says what happened where `.message` only restates the path + * around it; a parse failure carries no `code`, and its message is the useful half. Lives in + * the domain because a use case describes an error too and may not import infrastructure. */ +export function describeError(error: unknown): string { + if (error instanceof Error && "code" in error && typeof error.code === "string") { + return error.code; + } + return error instanceof Error ? error.message : String(error); +} + +/** + * The message alone, for a failure whose `code` says nothing worth reading — a JSON parse + * error being the case that matters here, where the `SyntaxError`'s message is the whole + * answer and there is no `code` at all. Beside `describeError` because the two are one + * decision with two answers. + */ +export function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} diff --git a/cli/src/kernel/errors.ts b/cli/src/kernel/errors.ts new file mode 100644 index 000000000..d55531434 --- /dev/null +++ b/cli/src/kernel/errors.ts @@ -0,0 +1,756 @@ +import type { ToolCategory } from "./tool.js"; + +export class CapabilityConfigError extends Error { + constructor(message: string) { + super(message); + this.name = "CapabilityConfigError"; + } +} + +export class CursorProjectScopeUnsupportedError extends Error { + constructor() { + super( + "Cursor plugins only support user-scope install (~/.cursor/plugins/local/). Project-scope is not auto-loaded by Cursor." + ); + this.name = "CursorProjectScopeUnsupportedError"; + } +} + +export class InvalidPluginScopeError extends Error { + constructor(toolId: string, requested: "project" | "user", supported: "project" | "user") { + super( + `Tool '${toolId}' does not support scope '${requested}'. Supported scope: '${supported}'. ` + + `Re-run with --scope ${supported} or omit the flag.` + ); + this.name = "InvalidPluginScopeError"; + } +} + +export class AuthenticationError extends Error { + constructor(source: string) { + super(`Authentication failed (${source}). Run \`aidd auth login\` to authenticate.`); + this.name = "AuthenticationError"; + } +} + +export class UpdateError extends Error { + constructor() { + super( + "Update failed. If you saw a 403 error above, ensure your GitHub token includes both repo and read:packages scopes.\n" + + "Update your token at https://github.com/settings/tokens, then re-run `aidd auth login`." + ); + this.name = "UpdateError"; + } +} + +export class ElevatedPermissionUpdateError extends Error { + constructor(installCommand: string) { + super( + "Update failed: the global package directory is not writable (EPERM/EACCES).\n" + + "Pick one:\n" + + " 1. Run the terminal as Administrator (Windows) or with sudo (macOS/Linux), then re-run `aidd update`.\n" + + " 2. Move global installs to a user-writable prefix, then re-run the update:\n" + + " Windows: npm config set prefix %APPDATA%\\npm\n" + + " macOS/Linux: npm config set prefix ~/.npm-global\n" + + ` 3. Run the update directly: ${installCommand}` + ); + this.name = "ElevatedPermissionUpdateError"; + } +} + +export class ManifestValidationError extends Error { + constructor(message: string) { + super(message); + this.name = "ManifestValidationError"; + } +} + +export class McpConfigError extends Error { + constructor(message: string) { + super(message); + this.name = "McpConfigError"; + } +} + +export class FrameworkResolutionError extends Error { + constructor(message: string) { + super(message); + this.name = "FrameworkResolutionError"; + } +} + +export class CategoryMismatchError extends Error { + constructor(wrong: string[], category: ToolCategory, validToolIds: readonly string[]) { + const label = category === "ai" ? "AI" : "IDE"; + const verb = wrong.length === 1 ? `is not an ${label} tool` : `are not ${label} tools`; + super(`${wrong.join(", ")} ${verb}. Valid ${label} tools: ${validToolIds.join(", ")}`); + this.name = "CategoryMismatchError"; + } +} + +export class UnregisteredToolError extends Error { + constructor(toolId: string) { + super(`Tool '${toolId}' is not registered.`); + this.name = "UnregisteredToolError"; + } +} + +export class ToolNotInManifestError extends Error { + constructor(toolId: string) { + super(`Tool '${toolId}' is not installed in the manifest.`); + this.name = "ToolNotInManifestError"; + } +} + +export class InvalidManifestDataError extends Error { + constructor(detail?: string) { + super(detail ? `Invalid manifest data: ${detail}` : "Invalid manifest data."); + this.name = "InvalidManifestDataError"; + } +} + +export class InvalidManifestToolIdError extends Error { + constructor(key: string) { + super(`Invalid tool id in manifest: '${key}'.`); + this.name = "InvalidManifestToolIdError"; + } +} + +export class InvalidMcpServerConfigError extends Error { + constructor(name: string) { + super(`MCP server "${name}" must have either a "command" or "url" field`); + this.name = "InvalidMcpServerConfigError"; + } +} + +export class OpencodeDualConfigError extends Error { + constructor() { + super("Both opencode.json and opencode.jsonc exist. Remove one."); + this.name = "OpencodeDualConfigError"; + } +} + +export class PackageManagerDetectionError extends Error { + constructor(commands: readonly string[]) { + super(`Could not detect package manager. Run manually:\n ${commands.join("\n ")}`); + this.name = "PackageManagerDetectionError"; + } +} + +export class InvalidPluginSourceError extends Error { + constructor(detail?: string) { + super(detail ? `Invalid plugin source: ${detail}` : "Invalid plugin source."); + this.name = "InvalidPluginSourceError"; + } +} + +export class InvalidPluginNameError extends Error { + constructor(name: string) { + super( + `Invalid plugin name: "${name}". Use lowercase alphanumeric characters and hyphens only.` + ); + this.name = "InvalidPluginNameError"; + } +} + +export class InvalidPluginVersionError extends Error { + constructor(version: string) { + super(`Invalid plugin version: "${version}". Expected semver format (e.g. 1.0.0).`); + this.name = "InvalidPluginVersionError"; + } +} + +// Not `InvalidPluginScopeError`, the CLI-facing "this tool does not support the scope you +// asked for" (`install-scope.ts`): this one is a manifest integrity failure — the recorded +// `scope` field is missing or not one of the two values it may hold. +export class MalformedPluginScopeError extends Error { + constructor(pluginName: string, scope: unknown) { + super( + `Plugin "${pluginName}" carries an invalid scope: ${JSON.stringify(scope)}. Expected "project" or "user".` + ); + this.name = "MalformedPluginScopeError"; + } +} + +export class UnresolvableUserScopeError extends Error { + constructor(toolId: string) { + super( + `Manifest records a user-scope plugin for "${toolId}", but this tool's profile declares no user-scope plugins directory. Refusing to guess a base directory rather than silently resolving under the project root.` + ); + this.name = "UnresolvableUserScopeError"; + } +} + +export class InvalidPluginManifestError extends Error { + constructor(detail?: string) { + super(detail ? `Invalid plugin manifest: ${detail}` : "Invalid plugin manifest."); + this.name = "InvalidPluginManifestError"; + } +} + +// Extends InvalidPluginManifestError so existing `instanceof` checks still hold, adding the +// recovery: a cached catalog heals by re-fetch, a user-provided source only by hand. +export class MalformedMarketplaceCatalogError extends InvalidPluginManifestError { + constructor(path: string, detail: string, cached: boolean) { + const recovery = cached + ? "Run 'aidd marketplace refresh --force' to re-fetch a clean copy." + : "Fix or re-create the marketplace catalog file."; + super(`catalog at "${path}" is malformed (${detail}). ${recovery}`); + this.name = "MalformedMarketplaceCatalogError"; + } +} + +export class PluginNotFoundError extends Error { + constructor(name: string) { + super(`Plugin '${name}' is not installed.`); + this.name = "PluginNotFoundError"; + } +} + +export class DuplicatePluginError extends Error { + constructor(name: string) { + super(`Plugin '${name}' is already installed.`); + this.name = "DuplicatePluginError"; + } +} + +export class PluginFetchError extends Error { + constructor(detail: string) { + super(`Failed to fetch plugin: ${detail}`); + this.name = "PluginFetchError"; + } +} + +export class InvalidMarketplaceNameError extends Error { + constructor(detail: string) { + super( + `Invalid marketplace name: "${detail}". Use lowercase alphanumeric characters and hyphens only.` + ); + this.name = "InvalidMarketplaceNameError"; + } +} + +export class InvalidMarketplaceScopeError extends Error { + constructor(scope: string) { + super(`Invalid marketplace scope: "${scope}". Expected "project" or "user".`); + this.name = "InvalidMarketplaceScopeError"; + } +} + +export class MarketplaceAlreadyRegisteredError extends Error { + constructor(name: string) { + super(`Marketplace '${name}' is already registered.`); + this.name = "MarketplaceAlreadyRegisteredError"; + } +} + +export class MarketplaceNotFoundError extends Error { + constructor(name: string) { + super(`Marketplace '${name}' is not registered.`); + this.name = "MarketplaceNotFoundError"; + } +} + +export class TrustDeniedError extends Error { + constructor(name: string) { + super(`Trust denied for marketplace '${name}'. Aborting.`); + this.name = "TrustDeniedError"; + } +} + +export class PluginNotInMarketplaceError extends Error { + constructor(plugin: string) { + super(`Plugin '${plugin}' was not found in any registered marketplace.`); + this.name = "PluginNotInMarketplaceError"; + } +} + +export class VersionMismatchError extends Error { + constructor(plugin: string, requested: string, actual: string) { + super( + `Plugin '${plugin}': requested version '${requested}' does not match catalog version '${actual}'.` + ); + this.name = "VersionMismatchError"; + } +} + +export class AmbiguousPluginMatchError extends Error { + constructor(plugin: string, marketplaces: readonly string[]) { + super( + `Plugin '${plugin}' matches multiple marketplaces: ${marketplaces.join(", ")}. Use --from .` + ); + this.name = "AmbiguousPluginMatchError"; + } +} + +export class NoMarketplacesRegisteredError extends Error { + constructor() { + super("No marketplaces registered. Use `aidd marketplace add ` first."); + this.name = "NoMarketplacesRegisteredError"; + } +} + +/** An unreadable registry file is never read as an empty one: `save()` reads this same list, + * appends to it and writes the whole file back, so a silent empty read would delete the + * marketplaces a person registered on the very next write. */ +export class UnreadableMarketplaceRegistryError extends Error { + constructor(path: string, reason: string) { + super( + `Cannot read the marketplace registry at ${path}: ${reason}. Repair the file, or ` + + `delete it to start from an empty registry.` + ); + this.name = "UnreadableMarketplaceRegistryError"; + } +} + +/** As `UnreadableMarketplaceRegistryError`: `references.json` is written by appending to + * what it already holds, so a silent empty read on a corrupted file would delete every + * other project's own reference on the very next write. */ +export class UnreadableUserSourceReferencesError extends Error { + constructor(path: string, reason: string) { + super( + `Cannot read the shared-source reference registry at ${path}: ${reason}. Repair the ` + + `file, or delete it to start from an empty registry.` + ); + this.name = "UnreadableUserSourceReferencesError"; + } +} + +export class InteractiveOnlyError extends Error { + constructor(action: string) { + super(`'${action}' requires an interactive terminal.`); + this.name = "InteractiveOnlyError"; + } +} + +/** Each failure already reached the user through its own `output.warn` line, so this names + * only the scopes and lets `errorHandler` be the one place turning a partly failed sync + * into a non-zero exit. */ +export class SyncFailedError extends Error { + constructor(errors: readonly { scope: string; message: string }[]) { + super(`Sync failed for: ${errors.map((e) => e.scope).join(", ")}. See the warnings above.`); + this.name = "SyncFailedError"; + } +} + +export class CatalogFetchNotFoundError extends Error { + constructor(url: string) { + super(`Catalog not found (HTTP 404): ${url}`); + this.name = "CatalogFetchNotFoundError"; + } +} + +export class CatalogFetchAuthError extends Error { + constructor(url: string) { + super( + `Authentication required to fetch catalog from "${url}". Run \`aidd auth login\` first or use \`--source local --path \`.` + ); + this.name = "CatalogFetchAuthError"; + } +} + +export class CatalogFetchError extends Error { + constructor(url: string, detail: string) { + super(`Failed to fetch catalog from "${url}": ${detail}`); + this.name = "CatalogFetchError"; + } +} + +export class MissingPluginMetadataError extends Error { + constructor() { + super("Cannot register github marketplace plugin: catalog entry is missing plugin metadata."); + this.name = "MissingPluginMetadataError"; + } +} + +export class JsonSchemaValidationError extends Error { + constructor(errors: string[]) { + super(`Manifest validation failed: ${errors.join("; ")}`); + this.name = "JsonSchemaValidationError"; + } +} + +export class FrameworkPlaceholderInPluginError extends Error { + constructor(pluginName: string, relativePath: string) { + super( + `Framework placeholder '@{{TOOLS}}/' is not allowed inside plugin '${pluginName}' (file: ${relativePath}).` + ); + this.name = "FrameworkPlaceholderInPluginError"; + } +} + +export class InvalidBuildPathsError extends Error { + constructor(sourceDir: string, outDir: string) { + super( + `Refusing to build: --out '${outDir}' and --source '${sourceDir}' must not contain each other.` + ); + this.name = "InvalidBuildPathsError"; + } +} + +export class InvalidSourceMarketplaceError extends Error { + constructor(detail: string) { + super(`Invalid source marketplace: ${detail}.`); + this.name = "InvalidSourceMarketplaceError"; + } +} + +export class OutDirNotDirectoryError extends Error { + constructor(outDir: string) { + super(`Refusing to build: --out '${outDir}' does not exist or is not a directory.`); + this.name = "OutDirNotDirectoryError"; + } +} + +export class FlatTargetExistsError extends Error { + constructor(targetPath: string, pluginName: string) { + super( + `Flat build conflict: '${targetPath}' already exists (plugin '${pluginName}'). ` + + "Re-run with --force to overwrite." + ); + this.name = "FlatTargetExistsError"; + } +} + +export class MarketplaceOutDirNotEmptyError extends Error { + constructor(outDir: string) { + super( + `Refusing to build: '${outDir}' is not empty. ` + + "Re-run with --force to overwrite files this build produces, or choose an empty --out directory." + ); + this.name = "MarketplaceOutDirNotEmptyError"; + } +} + +export class UnknownToolCategoryError extends Error { + constructor(category: string) { + super(`Unknown category: ${category}`); + this.name = "UnknownToolCategoryError"; + } +} + +export class MarketplaceSourceKindError extends Error { + constructor(expected: "remote" | "local") { + super(expected === "remote" ? "Not a remote source" : "Not a local source"); + this.name = "MarketplaceSourceKindError"; + } +} + +export class EmptyLocalSourcePathError extends Error { + constructor() { + super("Local source path must not be empty."); + this.name = "EmptyLocalSourcePathError"; + } +} + +export class InvalidSetupToolIdError extends Error { + constructor(id: string, validIds: readonly string[]) { + super(`Invalid tool ID: "${id}". Valid IDs: ${validIds.join(", ")}`); + this.name = "InvalidSetupToolIdError"; + } +} + +export class UserScopeUnavailableError extends Error { + constructor() { + super( + "--scope user is not wired for this command yet — no user-scope manifest " + + "repository was provided at construction." + ); + this.name = "UserScopeUnavailableError"; + } +} + +export class UserScopeIdeToolsError extends Error { + constructor(ideTools: readonly string[]) { + super( + `--scope user installs no project files, so an IDE tool (${ideTools.join(", ")}) has ` + + "nothing to install at user scope. Drop --ide, or run `aidd setup --ide ` " + + "separately at project scope." + ); + this.name = "UserScopeIdeToolsError"; + } +} + +export class UserScopeUnsupportedAiToolsError extends Error { + constructor(aiTools: readonly string[]) { + super( + `--scope user drives native activation machine-wide, and ${aiTools.join(", ")} declares ` + + "no user-scope settings this CLI can point at. Drop it from --ai, or run " + + "`aidd setup --ai ` separately at project scope." + ); + this.name = "UserScopeUnsupportedAiToolsError"; + } +} + +export class UserScopeNoToolsError extends Error { + constructor() { + super( + "--scope user with no --ai registers the shared source for no tool at all. Pass " + + "`--ai ` naming which tool to activate machine-wide." + ); + this.name = "UserScopeNoToolsError"; + } +} + +export class UserScopePluginModeError extends Error { + constructor() { + super( + "--scope user has no manifest entry a plugin can be recorded against yet, so " + + "--plugins has nothing to enable. Drop --plugins, or run `aidd plugin install` " + + "separately at project scope." + ); + this.name = "UserScopePluginModeError"; + } +} + +export class UserScopeFilterUnsupportedError extends Error { + constructor(flag: string, command: string) { + super( + `--scope user tracks nothing ${flag} can narrow — it names every requested tool, ` + + `not one plugin or one file. Drop ${flag}, or run \`aidd ${command}\` at project scope.` + ); + this.name = "UserScopeFilterUnsupportedError"; + } +} + +export class InvalidPluginModeConfigError extends Error { + constructor(message: string) { + super(message); + this.name = "InvalidPluginModeConfigError"; + } +} + +export class InvalidInstallScopeError extends Error { + constructor(value: string) { + super(`Invalid scope '${value}'. Expected 'project' or 'user'.`); + this.name = "InvalidInstallScopeError"; + } +} + +export class UnknownAiToolIdError extends Error { + constructor(tool: string, validIds: readonly string[]) { + super(`Unknown AI tool: ${tool}. Valid AI tools: ${validIds.join(", ")}`); + this.name = "UnknownAiToolIdError"; + } +} + +export class NativePluginCliError extends Error { + constructor(message: string) { + super(message); + this.name = "NativePluginCliError"; + } +} + +/** A host's own marketplace registry already holds this catalog's declared name pointed at a + * genuinely *different* catalog, so registering it would steal or mislabel that name. A + * project's local alias diverging from its catalog's declared name is not this, and is never + * refused. */ +export class MarketplaceSourceConflictError extends Error { + constructor(message: string) { + super(message); + this.name = "MarketplaceSourceConflictError"; + } +} + +/** A host-facing registration must be keyed by the catalog's own declared name, and an + * unreadable catalog leaves this CLI no other fact to key it by. Registering nothing beats + * writing this project's local alias: a manifest that guessed would claim a `hostName` the + * host was never asked to hold. */ +export class UnreadableBuiltCatalogError extends Error { + constructor(path: string) { + super( + `Cannot read the marketplace catalog this project just built, at ${path} — nothing was ` + + "registered for it. Run `aidd sync` again once the source is fixed." + ); + this.name = "UnreadableBuiltCatalogError"; + } +} + +export class HttpError extends Error { + constructor( + readonly statusCode: number, + readonly url: string + ) { + super(`Unexpected HTTP ${statusCode} from ${url}`); + this.name = "HttpError"; + } +} + +export class HttpNotFoundError extends Error { + constructor(readonly url: string) { + super(`Resource not found (HTTP 404): ${url}`); + this.name = "HttpNotFoundError"; + } +} + +export class HttpRedirectError extends Error { + constructor(readonly url: string) { + super(`HTTP redirect without location header from ${url}`); + this.name = "HttpRedirectError"; + } +} + +export class JsonParseError extends Error { + constructor(path: string, cause: string) { + super(`Cannot parse existing JSON at ${path}: ${cause}`); + this.name = "JsonParseError"; + } +} + +export class AuthStorageError extends Error { + constructor(message: string) { + super(message); + this.name = "AuthStorageError"; + } +} + +export class GhCliError extends Error { + constructor(message: string) { + super(message); + this.name = "GhCliError"; + } +} + +export class AssetNotFoundError extends Error { + constructor(assetName: string) { + super(`Bundled asset not found: '${assetName}'`); + this.name = "AssetNotFoundError"; + } +} +export class NoManifestError extends Error { + constructor() { + super("No AIDD manifest found. Run `aidd setup` to initialize your project."); + this.name = "NoManifestError"; + } +} + +export class AiddFilesDetectedError extends Error { + constructor() { + super( + "AIDD files detected but no manifest found.\nRun `aidd setup` to register existing files." + ); + this.name = "AiddFilesDetectedError"; + } +} + +export class AlreadyInitializedError extends Error { + constructor(message = "Already initialized. Use `aidd update` to upgrade.") { + super(message); + this.name = "AlreadyInitializedError"; + } +} + +export class InputRequiredError extends Error { + constructor(message: string) { + super(message); + this.name = "InputRequiredError"; + } +} + +export class ToolNotInstalledError extends Error { + constructor(toolId: string, context?: string) { + super(context ? `${context} '${toolId}' is not installed.` : `${toolId} is not installed`); + this.name = "ToolNotInstalledError"; + } +} + +export class UnknownTelemetrySinkSchemaVersionError extends Error { + constructor(version: unknown) { + super( + `Unknown telemetry sink schema version '${String(version)}' — refusing to guess its shape.` + ); + this.name = "UnknownTelemetrySinkSchemaVersionError"; + } +} + +/** A genuine `opencode export` failure — a non-zero exit not explained by "no such + * session", or a timeout. An absent binary or an unknown session mean the machine holds no + * OpenCode data, and resolve to an empty array instead of throwing. */ +export class OpencodeExportError extends Error { + constructor(message: string) { + super(message); + this.name = "OpencodeExportError"; + } +} + +export class InvalidReportDayError extends Error { + constructor(flag: string, value: string) { + super(`Invalid ${flag} '${value}'. Expected a UTC day, as YYYY-MM-DD.`); + this.name = "InvalidReportDayError"; + } +} + +export class InvalidReportSpanError extends Error { + constructor(value: string, maxDays: number) { + super(`Invalid --days '${value}'. Expected an integer between 1 and ${maxDays}.`); + this.name = "InvalidReportSpanError"; + } +} + +/** The identity file exists but could not be read back — a read failure (e.g. it is a + * directory) or content that does not parse. Distinct from no file at all, which is a + * person never having opted in and answers `null` rather than throwing. */ +export class UnreadableIdentityFileError extends Error { + constructor(filePath: string, cause: string) { + super(`Could not read the identity file at ${filePath} (${cause}).`); + this.name = "UnreadableIdentityFileError"; + } +} + +/** One consequence, shared by `endpoint --scope project` and `telemetry on`: writing a + * git-tracked file that turns telemetry on for everyone who clones. `action` and + * `trackedPath` are all that differ between them. */ +export class TelemetryProjectScopeRequiresYesError extends Error { + constructor(action: string, trackedPath: string) { + super( + `${action} writes the git-tracked ${trackedPath}, turning telemetry on for ` + + "everyone who clones. Pass --yes to confirm." + ); + this.name = "TelemetryProjectScopeRequiresYesError"; + } +} + +export class EmptyDisplayNameError extends Error { + constructor() { + super("`aidd telemetry identity use --name` needs a non-empty value."); + this.name = "EmptyDisplayNameError"; + } +} + +export class IdentityRequiredToLinkError extends Error { + constructor() { + super("No identity to link onto yet. Run `aidd telemetry identity use` first."); + this.name = "IdentityRequiredToLinkError"; + } +} + +export class EmptyIdentifierError extends Error { + constructor(command: "use" | "link") { + super(`\`aidd telemetry identity ${command}\` needs a non-empty value.`); + this.name = "EmptyIdentifierError"; + } +} +export class TelemetrySinkUnwritableError extends Error { + constructor(path: string, cause: unknown) { + super( + `Telemetry sink directory is not writable: ${path} ` + + `(${cause instanceof Error ? cause.message : String(cause)})` + ); + this.name = "TelemetrySinkUnwritableError"; + } +} + +/** A write or a delete against the identity file failed for a reason other than the file + * not being there — permission denied, a full disk. `UnreadableIdentityFileError` above is + * the read that could not come back; this is the write that could not go out. */ +export class IdentityWriteError extends Error { + /** `action` names what the person was doing, because the sentence reaches them: someone + * withdrawing should not be told a write failed. */ + constructor(filePath: string, cause: unknown, action: "write" | "remove" = "write") { + super( + `Could not ${action} the identity file at ${filePath} ` + + `(${cause instanceof Error ? cause.message : String(cause)}).` + ); + this.name = "IdentityWriteError"; + } +} diff --git a/cli/src/kernel/file.ts b/cli/src/kernel/file.ts new file mode 100644 index 000000000..893c276c1 --- /dev/null +++ b/cli/src/kernel/file.ts @@ -0,0 +1,60 @@ +import { ManifestValidationError } from "./errors.js"; +import type { MergeStrategy } from "./merge.js"; + +// Kernel vocabulary because `removeRedundantGitkeeps` below reasons about it independently +// of any context's directory conventions. +export const GITKEEP_FILE = ".gitkeep"; + +const MD5_PATTERN = /^[0-9a-f]{32}$/; + +export class FileHash { + readonly value: string; + + constructor(value: string) { + if (!MD5_PATTERN.test(value)) { + throw new ManifestValidationError( + `Invalid MD5 hash: "${value}". Expected 32 lowercase hex characters.` + ); + } + this.value = value; + } + + equals(other: FileHash): boolean { + return this.value === other.value; + } +} + +export class InstallationFile { + readonly relativePath: string; + readonly content: string; + readonly hash: FileHash; + readonly mergeStrategy: MergeStrategy; + readonly frameworkPath?: string; + + constructor(params: { + relativePath: string; + content: string; + hash: FileHash; + mergeStrategy?: MergeStrategy; + frameworkPath?: string; + }) { + this.relativePath = params.relativePath; + this.content = params.content; + this.hash = params.hash; + this.mergeStrategy = params.mergeStrategy ?? "none"; + this.frameworkPath = params.frameworkPath; + } +} + +export function removeRedundantGitkeeps(files: InstallationFile[]): InstallationFile[] { + const nonEmptyDirs = new Set( + files + .filter((f) => !f.relativePath.endsWith(`/${GITKEEP_FILE}`)) + .map((f) => f.relativePath.split("/").slice(0, -1).join("/")) + ); + return files.filter((f) => { + if (!f.relativePath.endsWith(`/${GITKEEP_FILE}`)) return true; + const dir = f.relativePath.split("/").slice(0, -1).join("/"); + return !nonEmptyDirs.has(dir); + }); +} diff --git a/cli/src/domain/formats/markdown.ts b/cli/src/kernel/markdown.ts similarity index 87% rename from cli/src/domain/formats/markdown.ts rename to cli/src/kernel/markdown.ts index d6e55cf7b..2be1507cc 100644 --- a/cli/src/domain/formats/markdown.ts +++ b/cli/src/kernel/markdown.ts @@ -4,12 +4,10 @@ export function parseFrontmatter(content: string): { frontmatter: Record; body: string; } { - // A line ends either way: a Windows checkout (no .gitattributes here) hands this the - // same document with CRLF, and the key/value patterns below are `$`-anchored, so a - // trailing carriage return made every one of them miss — `allowed_tools:` matched as a - // key with no items and the document came back all but empty. Splitting on - // either ending is not the same as stripping every `\r`: one inside a value is content, - // and stays. + // A line ends either way: a Windows checkout hands this the same document with CRLF, and + // the key/value patterns below are `$`-anchored, so a trailing carriage return makes every + // one of them miss. Splitting on either ending is not the same as stripping every `\r`: + // one inside a value is content, and stays. const lines = content.split(/\r?\n/); if (lines[0]?.trim() !== FRONTMATTER_DELIMITER) { @@ -66,8 +64,8 @@ export function serializeFrontmatter(frontmatter: Record, body: } // `[^\n]` rather than `.` throughout: `.` excludes a carriage return, so a `$`-anchored -// value pattern silently missed any line carrying one. Splitting already removed the `\r` -// of a CRLF ending, so what is left here is content and must survive. +// value pattern silently misses any line carrying one — and splitting already removed the +// `\r` of a CRLF ending, so what is left is content. function parseYamlLike(lines: string[]): Record { const result: Record = {}; let i = 0; diff --git a/cli/src/kernel/materialization/claude-root-path-rewrite.ts b/cli/src/kernel/materialization/claude-root-path-rewrite.ts new file mode 100644 index 000000000..027aa004a --- /dev/null +++ b/cli/src/kernel/materialization/claude-root-path-rewrite.ts @@ -0,0 +1,39 @@ +// Written as a split literal to avoid biome's noTemplateCurlyInString warning. +const CLAUDE_ROOT_PREFIX = "$" + "{CLAUDE_PLUGIN_ROOT}/"; +const DEFAULT_RELATIVE_PREFIX = "./"; + +/** String values only, never keys: a key name carrying the same prefix is left untouched. + * `substitute` receives the suffix and returns its replacement, defaulting to `./`. */ +export function rewriteClaudeRootInJson( + parsed: unknown, + substitute?: (suffix: string) => string +): unknown { + if (typeof parsed === "string") return rewriteStringValue(parsed, substitute); + if (Array.isArray(parsed)) return parsed.map((item) => rewriteClaudeRootInJson(item, substitute)); + if (parsed !== null && typeof parsed === "object") + return rewriteObject(parsed as Record, substitute); + return parsed; +} + +function rewriteStringValue(value: string, substitute?: (suffix: string) => string): string { + if (!value.includes(CLAUDE_ROOT_PREFIX)) return value; + if (!substitute) return value.replaceAll(CLAUDE_ROOT_PREFIX, DEFAULT_RELATIVE_PREFIX); + return value.split(CLAUDE_ROOT_PREFIX).reduce((acc, segment, i) => { + if (i === 0) return segment; + const spaceIdx = segment.search(/[\s"'<>]/); + const suffix = spaceIdx === -1 ? segment : segment.slice(0, spaceIdx); + const rest = spaceIdx === -1 ? "" : segment.slice(spaceIdx); + return acc + substitute(suffix) + rest; + }, ""); +} + +function rewriteObject( + obj: Record, + substitute?: (suffix: string) => string +): Record { + const result: Record = {}; + for (const [key, value] of Object.entries(obj)) { + result[key] = rewriteClaudeRootInJson(value, substitute); + } + return result; +} diff --git a/cli/src/kernel/materialization/flat-paths.ts b/cli/src/kernel/materialization/flat-paths.ts new file mode 100644 index 000000000..fe9fbd785 --- /dev/null +++ b/cli/src/kernel/materialization/flat-paths.ts @@ -0,0 +1,88 @@ +/** + * Flat mode carries no `/` directory segment: the plugin name is hyphen-prefixed + * onto the leaf filename instead, so a tool discovers the file at the depth it expects and + * the plugin origin still survives in the name. + */ +export function genericFlatAgentPath( + agentsPrefix: string, + plugin: string, + agentBaseName: string, + outputExt: string +): string { + const withoutMd = agentBaseName.endsWith(".md") ? agentBaseName.slice(0, -3) : agentBaseName; + return `${agentsPrefix}${plugin}-${withoutMd}${outputExt}`; +} + +/** + * Assumes every immediate child of `skills/` is a self-contained skill folder: the hyphen + * lands on that child's own name, so a non-skill sibling (a shared helper directory, a + * manifest file) is renamed exactly like one and any relative path reaching it by its + * original name stops resolving. A plugin where that does not hold takes + * `genericFlatSkillTreePath` instead. + */ +export function genericFlatSkillPath( + skillsPrefix: string, + plugin: string, + skillRelPath: string +): string { + return `${skillsPrefix}${plugin}-${skillRelPath}`; +} + +/** + * Nests the plugin's entire `skills/` subtree under one `/` segment, so every name + * below `skillsPrefix` survives — a script's `require()` is never rewritten, so a path + * crossing a renamed name would stop resolving. + */ +export function genericFlatSkillTreePath( + skillsPrefix: string, + plugin: string, + skillRelPath: string +): string { + return `${skillsPrefix}${plugin}/${skillRelPath}`; +} + +export function genericFlatHooksFile(hooksPrefix: string, plugin: string): string { + return `${hooksPrefix}${plugin}.hooks.json`; +} + +export function genericFlatHooksScriptPath( + hooksPrefix: string, + plugin: string, + scriptRelPath: string +): string { + return `${hooksPrefix}${plugin}/${scriptRelPath}`; +} + +export function flatMcpKeyPrefix(plugin: string): string { + return `${plugin}-`; +} + +/** + * A flat-mode loader's own module: the one hook script a plugin ships that the loader + * imports as itself. Kept in its own directory, apart from the per-plugin hooks tree, so + * renaming it to the plugin's name can never collide with another plugin shipping one. + */ +export interface FlatHooksLoaderEntry { + readonly dir: string; + readonly baseName: string; +} + +/** + * A script named `loaderEntry.baseName` is the loader's own runtime module (see + * {@link FlatHooksLoaderEntry}): it lands flat in `loaderEntry.dir` under the plugin's own + * name, so two plugins shipping one cannot collide. Every other script is namespaced under + * `perPluginHooksDir`, giving a loader that also scans one flat directory a landing spot + * for the scripts it does not import directly. `null` means no such convention. + */ +export function flatHooksPathWithLoaderEntry( + perPluginHooksDir: string, + loaderEntry: FlatHooksLoaderEntry | null, + plugin: string, + hooksRelativePath: string +): string { + const rest = hooksRelativePath.replace(/^hooks\//, ""); + if (loaderEntry !== null && rest === loaderEntry.baseName) { + return `${loaderEntry.dir}${plugin}.js`; + } + return genericFlatHooksScriptPath(perPluginHooksDir, plugin, rest); +} diff --git a/cli/src/kernel/materialization/relative-link-rewrite.ts b/cli/src/kernel/materialization/relative-link-rewrite.ts new file mode 100644 index 000000000..b3f4c1ccc --- /dev/null +++ b/cli/src/kernel/materialization/relative-link-rewrite.ts @@ -0,0 +1,48 @@ +import { basename, dirname, posix } from "node:path"; + +// The same character class `rewriteCopilotContent` uses, so both recognise the same +// reference. +const REFERENCE_CHAR_CLASS = "[^\\s`'\">,]+"; + +const RELATIVE_CURRENT_RE = new RegExp(`@\\.\\/(${REFERENCE_CHAR_CLASS})`, "g"); +const RELATIVE_PARENT_RE = new RegExp(`@\\.\\.\\/(${REFERENCE_CHAR_CLASS})`, "g"); + +// Only with a leading `@`: a bare ${CLAUDE_PLUGIN_ROOT} is left alone. +const CLAUDE_ROOT_RE = new RegExp(`@\\$\\{CLAUDE_PLUGIN_ROOT\\}\\/(${REFERENCE_CHAR_CLASS})`, "g"); + +export interface RewriteRelativeLinksOptions { + readonly currentFilePluginRelative: string; + /** Where a plugin-relative target lands before the link is computed. Defaults to identity, + * which keeps the link relative to the current file. */ + readonly resolveTargetPath?: (pluginRelPath: string) => string; +} + +/** + * `@{{TOOLS}}/...` is left alone: a caller has to detect that pattern and halt on it. + * + * One-way: the markdown links this produces are indistinguishable from ones a person wrote, + * so the `@`-shorthand cannot be recovered from them. + */ +export function rewriteRelativeLinks( + content: string, + options: RewriteRelativeLinksOptions +): string { + const afterParent = content.replace(RELATIVE_PARENT_RE, "[$1](../$1)"); + const afterCurrent = afterParent.replace(RELATIVE_CURRENT_RE, "[$1](./$1)"); + return afterCurrent.replace(CLAUDE_ROOT_RE, (_match, rel: string) => + rewriteClaudeRootRef(rel, options.currentFilePluginRelative, options.resolveTargetPath) + ); +} + +function rewriteClaudeRootRef( + targetPluginRel: string, + currentFilePluginRelative: string, + resolveTargetPath?: (pluginRelPath: string) => string +): string { + const resolved = resolveTargetPath ? resolveTargetPath(targetPluginRel) : targetPluginRel; + const currentDirPluginRel = dirname(currentFilePluginRelative); + let linkPath = posix.relative(currentDirPluginRel, resolved); + if (!linkPath.startsWith(".")) linkPath = `./${linkPath}`; + const label = basename(targetPluginRel); + return `[${label}](${linkPath})`; +} diff --git a/cli/src/kernel/measurement.ts b/cli/src/kernel/measurement.ts new file mode 100644 index 000000000..0dc76b025 --- /dev/null +++ b/cli/src/kernel/measurement.ts @@ -0,0 +1,53 @@ +/** What a route was **measured to supply**, not what it might: a consumer has to tell apart + * five states that all look like a missing number — no counters at all, counters without an + * amount, an amount, figures carrying the step the tool itself named, figures carrying the + * agent a record belongs to. + * + * Per route rather than per tool, since different tools' local reads carry different things. + * Every field is required: a default would assert a capability nobody measured. */ +export interface TelemetryRouteSupply { + /** The four token counters. */ + readonly tokenCounters: boolean; + /** A figure denominated in currency. Never a credit, a premium request, or a zero whose + * denomination was never established. */ + readonly amount: boolean; + /** The tool names the running step itself, on the record. An interval derived from the + * run journal is not this — that is the framework's inference, not the tool's statement. */ + readonly toolStatedStep: boolean; + /** The tool names the agent a record belongs to, and so also says when a record is the + * main thread's own. Without it a record carrying no agent states nothing, and `by_agent` + * may not read it as the main thread. */ + readonly agentName: boolean; +} + +/** Where a tool's own transcript files live and how to recognise the one for a session — + * declared per tool, so the adapter that opens files encodes no layout itself. `matches` + * receives the candidate's path relative to `root`, never its basename: Claude Code's + * subagent transcripts (`/subagents/*.jsonl`) differ only by that nesting. */ +export interface TranscriptLocation { + root(homeDir: string): string; + matches(relativePath: string, sessionId: string): boolean; +} + +/** This tool's own file(s) can be read for a session's counters with nothing exported and no + * process running — read through a use case that asks each declaration and never branches on + * `toolId`. `transcript` is optional: a tool read by another means entirely (OpenCode shells + * out to its own CLI) declares none. */ +export interface TelemetryLocalReadDeclared { + readonly kind: "declared"; + readonly transcript?: TranscriptLocation; + readonly supplies: TelemetryRouteSupply; + /** A caveat that survives to the person reading the result, when this tool can be read for + * less than the others. Data rather than a source comment: a comment reaches nobody + * downstream, leaving a consumer to guess why the figures are thin. */ + readonly limitation?: string; +} + +/** This tool's own file cannot yield what a local read needs, established by probe rather + * than assumed from an empty result. */ +export interface TelemetryLocalReadUnsupported { + readonly kind: "unsupported"; + readonly reason: string; +} + +export type TelemetryLocalRead = TelemetryLocalReadDeclared | TelemetryLocalReadUnsupported; diff --git a/cli/src/kernel/merge.ts b/cli/src/kernel/merge.ts new file mode 100644 index 000000000..939979259 --- /dev/null +++ b/cli/src/kernel/merge.ts @@ -0,0 +1,88 @@ +import type { FileHash } from "./file.js"; +import type { Hasher } from "./ports/hasher.js"; +import { stripJsonComments } from "./reading/jsonc.js"; + +export type PerKeyMergeStrategy = { + default: "framework-prime" | "user-prime"; + /** Keys where framework always wins, overriding the default strategy. */ + frameworkOverrideKeys: readonly string[]; +}; + +export type MergeStrategy = "none" | "framework-prime" | "user-prime" | PerKeyMergeStrategy; + +export function isPerKeyMergeStrategy(s: MergeStrategy): s is PerKeyMergeStrategy { + return typeof s === "object" && s !== null; +} + +export interface MergeFileEntry { + readonly relativePath: string; + readonly sectionKey: string | null; + readonly entries: Readonly>; +} + +export function extractMergeEntries( + jsonContent: string, + sectionKey: string | null, + hasher: Hasher +): Record { + let parsed: Record; + try { + parsed = JSON.parse(stripJsonComments(jsonContent)) as Record; + } catch { + return {}; + } + const container = resolveContainer(parsed, sectionKey); + if (container === null || typeof container !== "object" || Array.isArray(container)) return {}; + return hashJsonEntries(container as Record, hasher); +} + +export function hashJsonEntries( + entries: Record, + hasher: Hasher +): Record { + const result: Record = {}; + for (const [key, value] of Object.entries(entries)) { + result[key] = hasher.hash(JSON.stringify(value)); + } + return result; +} + +function resolveContainer(parsed: Record, sectionKey: string | null): unknown { + if (sectionKey === null) return parsed; + return parsed[sectionKey] ?? null; +} + +export function removeEntriesFromJson( + content: string, + sectionKey: string | null, + keysToRemove: string[] +): string { + const parsed = JSON.parse(content) as Record; + if (sectionKey === null) { + for (const key of keysToRemove) delete parsed[key]; + return JSON.stringify(parsed, null, 2); + } + const container = (parsed[sectionKey] as Record | undefined) ?? {}; + for (const key of keysToRemove) delete container[key]; + // An emptied section must vanish, not linger as `{}`: a settings file sharing its top + // level with unrelated keys must come back byte-identical once every key we own is gone. + if (Object.keys(container).length === 0) { + delete parsed[sectionKey]; + } else { + parsed[sectionKey] = container; + } + return JSON.stringify(parsed, null, 2); +} + +export function isMergeContentEmpty(content: string, sectionKey: string | null): boolean { + try { + const parsed = JSON.parse(content) as Record; + if (sectionKey === null) return Object.keys(parsed).length === 0; + const otherKeys = Object.keys(parsed).filter((k) => k !== sectionKey); + if (otherKeys.length > 0) return false; + const section = parsed[sectionKey] as Record | undefined; + return !section || Object.keys(section).length === 0; + } catch { + return false; + } +} diff --git a/cli/src/kernel/paths.ts b/cli/src/kernel/paths.ts new file mode 100644 index 000000000..3311a59b4 --- /dev/null +++ b/cli/src/kernel/paths.ts @@ -0,0 +1,227 @@ +import { join, posix, win32 } from "node:path"; +import { repositoryRootAbove } from "./reading/repository-root.js"; + +export const AIDD_DIR = ".aidd"; +export const AIDD_CONFIG_FILENAME = "config.json"; +/** The project-scope marketplace registry, named once: `MarketplaceRegistryAdapter` writes + * it and `CleanUseCase` removes it, and a second spelling is how one of them forgets. */ +export const AIDD_MARKETPLACES_FILENAME = "marketplaces.json"; +/** The registry of projects referencing the shared machine-scope source, named once for the + * same reason as `AIDD_MARKETPLACES_FILENAME`: one adapter writes it, a machine-scope + * `clean` purges it, and a second spelling is how one of them forgets the other. */ +export const USER_SOURCE_REFERENCES_FILENAME = "references.json"; +export const DOCS_DIR = "aidd_docs" as const; +export const RUNS_SUBDIR = "runs" as const; +export const PLUGIN_CACHE_SUBDIR = join(AIDD_DIR, "plugin-cache"); +export const MARKETPLACE_CACHE_SUBDIR = join(AIDD_DIR, "cache", "marketplaces"); +export const BUILT_CACHE_SUBDIR = join(AIDD_DIR, "cache", "built"); + +// The one spelling of "the run journal's directory, as a gitignore/pathspec entry": +// `telemetry-on-use-case.ts` and `forget-telemetry-use-case.ts` both ask +// `VersionControl.listTrackedFiles` about exactly this path. +export const RUNS_ENTRY = `${DOCS_DIR}/${RUNS_SUBDIR}/`; + +/** + * Where the run journal lives — at the repository root above `projectRoot`, never + * `projectRoot` itself, because the hook that writes it anchors there (`repositoryRootAbove` + * carries why). The one resolver, so two readers cannot disagree from a subdirectory. + * `AIDD_RUNS_DIR` overrides it outright, matching the hook. + */ +export function resolvedRunsDir(projectRoot: string): string { + return process.env.AIDD_RUNS_DIR || join(repositoryRootAbove(projectRoot), DOCS_DIR, RUNS_SUBDIR); +} + +export function marketplaceCacheDir(projectRoot: string, marketplaceName: string): string { + return join(projectRoot, MARKETPLACE_CACHE_SUBDIR, marketplaceName); +} + +export function builtMarketplaceDir( + projectRoot: string, + marketplaceName: string, + target: string +): string { + return join(projectRoot, BUILT_CACHE_SUBDIR, marketplaceName, target); +} + +/** The directory every version's own built tree sits under — `clean --scope user`'s + * whole-source purge target, three segments above `userBuiltMarketplaceDir`. Named once so + * the version-scoped path and the root holding every version cannot drift apart. */ +export function userBuiltCacheRoot(userConfigDir: string): string { + return join(userConfigDir, "cache", "built"); +} + +/** The CLI version sits before the marketplace name, so purging one version is a single + * `rm -rf` and two projects on two CLI versions never resolve to the same directory — what + * keeps a second project from silently repointing a host away from the first. */ +export function userBuiltMarketplaceDir( + userConfigDir: string, + cliVersion: string, + marketplaceName: string, + target: string +): string { + return join(userBuiltCacheRoot(userConfigDir), cliVersion, marketplaceName, target); +} + +/** What `userBuiltMarketplaceDir` encoded into a path, read back. */ +export interface UserBuiltMarketplaceLocation { + readonly version: string; + readonly marketplaceName: string; + readonly target: string; +} + +/** + * The inverse of `userBuiltMarketplaceDir`: the version/name/target `path` carries when it + * has exactly that shape under `userConfigDir`, `undefined` otherwise. Decided from the + * path's own segments, so a host's registered source is told apart from another version of + * the shared build without opening any catalog. + */ +export function parseUserBuiltMarketplaceDir( + userConfigDir: string, + path: string, + platform: string = process.platform +): UserBuiltMarketplaceLocation | undefined { + const segments = segmentsUnder(userBuiltCacheRoot(userConfigDir), path, platform); + if (segments === undefined || segments.length !== 3) return undefined; + const [version, marketplaceName, target] = segments; + return { version, marketplaceName, target }; +} + +/** What `builtMarketplaceDir` encoded into a path, read back. */ +export interface BuiltMarketplaceLocation { + readonly marketplaceName: string; + readonly target: string; +} + +/** The inverse of `builtMarketplaceDir`, decided from the path's own segments the way + * `parseUserBuiltMarketplaceDir` decides the user-scope shape. */ +export function parseBuiltMarketplaceDir( + projectRoot: string, + path: string, + platform: string = process.platform +): BuiltMarketplaceLocation | undefined { + const segments = segmentsUnder(join(projectRoot, BUILT_CACHE_SUBDIR), path, platform); + if (segments === undefined || segments.length !== 2) return undefined; + const [marketplaceName, target] = segments; + return { marketplaceName, target }; +} + +/** `builtMarketplaceDir`'s own shape plus the project root that produced it — the root + * `parseBuiltMarketplaceDir` has to be handed and this one exists to discover. */ +export interface AnyProjectBuiltMarketplaceLocation extends BuiltMarketplaceLocation { + readonly projectRoot: string; +} + +/** Matched segment by segment, never by index arithmetic over a case-folded string: win32 + * folding need not preserve length. `projectRoot` is rejoined with `platform`'s own separator, + * since every other writer of `references.json` records a backslash `realpath` on win32 and + * `samePathSegment` compares spelling. */ +export function parseBuiltMarketplaceDirAtAnyRoot( + path: string, + platform: string = process.platform +): AnyProjectBuiltMarketplaceLocation | undefined { + const segments = stripTrailingSeparator(path.replace(/\\/g, "/")).split("/"); + const marker = BUILT_CACHE_SUBDIR.replace(/\\/g, "/").split("/"); + const markerStart = segments.length - marker.length - 2; + if (markerStart < 1) return undefined; + const candidateMarker = segments.slice(markerStart, markerStart + marker.length); + const markerMatches = candidateMarker.every( + (segment, index) => + marker[index] !== undefined && samePathSegment(segment, marker[index], platform) + ); + if (!markerMatches) return undefined; + const projectRoot = segments.slice(0, markerStart).join(platform === "win32" ? "\\" : "/"); + if (projectRoot.length === 0) return undefined; + const [marketplaceName, target] = segments.slice(markerStart + marker.length); + if (marketplaceName === undefined || target === undefined) return undefined; + return { projectRoot, marketplaceName, target }; +} + +/** The path segments of `path` past `base`, `undefined` when `path` does not sit under + * `base`. Compares spelling — folded by `platform` for the containment test only, so the + * segments returned keep their original casing — and resolves neither side. A trailing + * separator on `base` is tolerated. */ +function segmentsUnder(base: string, path: string, platform: string): string[] | undefined { + const normalizedBase = stripTrailingSeparator(base.replace(/\\/g, "/")); + const normalizedPath = stripTrailingSeparator(path.replace(/\\/g, "/")); + const compareBase = foldCase(normalizedBase, platform); + const comparePath = foldCase(normalizedPath, platform); + if (!comparePath.startsWith(`${compareBase}/`)) return undefined; + const remainder = normalizedPath.slice(normalizedBase.length + 1); + if (remainder.length === 0) return undefined; + return remainder.split("/"); +} + +function stripTrailingSeparator(p: string): string { + return p.length > 1 && p.endsWith("/") ? p.slice(0, -1) : p; +} + +function foldCase(p: string, platform: string): string { + return platform === "win32" ? p.toLowerCase() : p; +} + +/** Whether two path segments name the same thing, folded the way a case-insensitive + * filesystem would: identical spelling everywhere but win32, where the comparison ignores + * case. `platform` is a parameter, never `process.platform` read inline, so a test can + * exercise the win32 branch on any OS. */ +export function samePathSegment( + a: string, + b: string, + platform: string = process.platform +): boolean { + return platform === "win32" ? a.toLowerCase() === b.toLowerCase() : a === b; +} + +/** `roots` with every `samePathSegment` duplicate collapsed away, the first spelling kept. + * A plain `Set` compares by exact string equality, so on win32 two entries differing only + * in case — one directory the filesystem never tells apart — would survive as two. */ +export function dedupePathSegments( + roots: readonly string[], + platform: string = process.platform +): string[] { + const deduped: string[] = []; + for (const root of roots) { + if (!deduped.some((seen) => samePathSegment(seen, root, platform))) deduped.push(root); + } + return deduped; +} + +// One directory is the other, or contains it — separators normalised, so nesting is +// recognised on Windows too. Both sides are expected already resolved: this compares +// spelling, it does not resolve. +export function pathContainsOrEquals(outer: string, inner: string): boolean { + const normalizedOuter = outer.replace(/\\/g, "/"); + const normalizedInner = inner.replace(/\\/g, "/"); + return normalizedOuter === normalizedInner || normalizedInner.startsWith(`${normalizedOuter}/`); +} + +/** Either direction: neither may sit inside the other. */ +export function pathsOverlap(a: string, b: string): boolean { + return pathContainsOrEquals(a, b) || pathContainsOrEquals(b, a); +} + +/** Where the user-scope manifest lives: directly under `userConfigDir()`, never nested in an + * `.aidd/` segment — there is no project to hold one, and `marketplaces.json` and + * `references.json` sit at that same root already. */ +export function userManifestPath(userConfigDir: string): string { + return join(userConfigDir, MANIFEST_FILENAME); +} + +/** The manifest's filename, named once because `telemetry-evidence-adapter.ts` and + * `manifest-repository-adapter.ts` both open that file and `aidd telemetry check` prints a + * row from each: two literals would let a rename make those rows contradict each other. */ +export const MANIFEST_FILENAME = "manifest.json"; + +/** + * `target` relative to `base`, spelled with forward slashes whatever the platform — the form + * every path this CLI records for another machine to read takes (a manifest's + * `files[].relativePath`, a snapshot key, a plugin ref), so a tree written on Windows and + * read on Linux names the same file. + */ +export function posixRelative( + base: string, + target: string, + platform: NodeJS.Platform = process.platform +): string { + const impl = platform === "win32" ? win32 : posix; + return impl.relative(base, target).split(impl.sep).join("/"); +} diff --git a/cli/src/kernel/ports/asset-provider.ts b/cli/src/kernel/ports/asset-provider.ts new file mode 100644 index 000000000..cd2545ed4 --- /dev/null +++ b/cli/src/kernel/ports/asset-provider.ts @@ -0,0 +1,15 @@ +import type { ToolId } from "../tool.js"; + +export type ConfigAsset = Record | readonly unknown[] | string; + +export type SchemaName = + | "plugin-manifest" + | "marketplace" + | "claude-marketplace" + | "codex-marketplace" + | "codex-plugin-manifest"; + +export interface AssetProvider { + loadConfigAsset(toolId: ToolId, fileName: string): ConfigAsset; + loadSchema(name: SchemaName): object; +} diff --git a/cli/src/kernel/ports/file-reader.ts b/cli/src/kernel/ports/file-reader.ts new file mode 100644 index 000000000..5f673d041 --- /dev/null +++ b/cli/src/kernel/ports/file-reader.ts @@ -0,0 +1,27 @@ +import type { FileHash } from "../file.js"; + +export interface FileReader { + readFile(path: string): Promise; + /** Every file under `path`, recursively, relative to it and always separated by `/`: + * these paths are compared against profile and manifest entries, which use `/` on every + * platform, so Windows' native separator must never reach a caller. */ + listDirectory(path: string): Promise; + fileExists(path: string): Promise; + readFileHash(path: string): Promise; + listFilesRecursive(dirPath: string): Promise; + + /** Whether whoever is running this can execute the file — `access(X_OK)`, never a + * permission bit. Windows records no execute bit at all: every readable file reports + * `0o666` and runs through `sh` regardless, so `mode & 0o111` reports a hook git would + * happily run as one it would refuse. */ + isExecutable(path: string): Promise; + + /** + * Resolves every symlink and `..` segment in `path` to where it actually points. `clean` + * needs it before deleting a user-scope plugin's files: a syntactic prefix match cannot + * tell a directory that became a symlink after install, or a `..` a corrupted manifest + * entry carries, from a path that never left its own tree. Throws `ENOENT` for a path that + * does not exist, so a caller checks `fileExists` first when that matters. + */ + realpath(path: string): Promise; +} diff --git a/cli/src/domain/ports/file-writer.ts b/cli/src/kernel/ports/file-writer.ts similarity index 100% rename from cli/src/domain/ports/file-writer.ts rename to cli/src/kernel/ports/file-writer.ts diff --git a/cli/src/kernel/ports/hasher.ts b/cli/src/kernel/ports/hasher.ts new file mode 100644 index 000000000..e78b699f9 --- /dev/null +++ b/cli/src/kernel/ports/hasher.ts @@ -0,0 +1,5 @@ +import type { FileHash } from "../file.js"; + +export interface Hasher { + hash(content: string): FileHash; +} diff --git a/cli/src/domain/ports/logger.ts b/cli/src/kernel/ports/logger.ts similarity index 100% rename from cli/src/domain/ports/logger.ts rename to cli/src/kernel/ports/logger.ts diff --git a/cli/src/domain/ports/prompter.ts b/cli/src/kernel/ports/prompter.ts similarity index 100% rename from cli/src/domain/ports/prompter.ts rename to cli/src/kernel/ports/prompter.ts diff --git a/cli/src/domain/ports/version-reader.ts b/cli/src/kernel/ports/version-reader.ts similarity index 100% rename from cli/src/domain/ports/version-reader.ts rename to cli/src/kernel/ports/version-reader.ts diff --git a/cli/src/kernel/reading/confined-file-name.ts b/cli/src/kernel/reading/confined-file-name.ts new file mode 100644 index 000000000..7785265a8 --- /dev/null +++ b/cli/src/kernel/reading/confined-file-name.ts @@ -0,0 +1,10 @@ +import { basename } from "node:path"; + +/** Whether `fileName` names exactly one entry directly inside the directory it will be joined + * to — never a walk out of it, the directory itself, or a smuggled absolute path. `join` + * normalises a `..` away visually yet still deletes where the result lands, so a name failing + * this is refused before it reaches `rm`. */ +export function isBareFileName(fileName: string): boolean { + if (fileName === "" || fileName === "." || fileName === "..") return false; + return basename(fileName) === fileName; +} diff --git a/cli/src/kernel/reading/home-dir.ts b/cli/src/kernel/reading/home-dir.ts new file mode 100644 index 000000000..8d2af2d53 --- /dev/null +++ b/cli/src/kernel/reading/home-dir.ts @@ -0,0 +1,27 @@ +import { homedir as osHomedir } from "node:os"; +import { join } from "node:path"; + +/** `HOME` first, because `os.homedir()` never reads it on Windows: a `HOME` set under Git + * Bash or by a test sandbox is ignored by a bare call there. Every site naming a tool's + * session files, the telemetry sink or the identity file resolves through here. */ +export function resolveHomeDir( + env: NodeJS.ProcessEnv = process.env, + osHomedirFn: () => string = osHomedir +): string { + return env.HOME || osHomedirFn(); +} + +/** + * `/.config/aidd` on POSIX, `%APPDATA%/aidd` on Windows — the directory a + * *person's own choice* lives under, never a project's. It refuses `AIDD_USER_CONFIG_DIR` + * on purpose: a repository or a CI job can set that variable, so reaching the identity file + * through it would not be this person's own choice to make. The telemetry sink is + * deliberately not a caller — `TelemetrySinkAdapter` honours that variable itself, and + * falls back to a legacy POSIX-shaped directory on Windows. + */ +export function resolveAiddConfigDir(): string { + if (process.platform === "win32" && process.env.APPDATA) { + return join(process.env.APPDATA, "aidd"); + } + return join(resolveHomeDir(), ".config", "aidd"); +} diff --git a/cli/src/kernel/reading/json-file.ts b/cli/src/kernel/reading/json-file.ts new file mode 100644 index 000000000..28a2d4537 --- /dev/null +++ b/cli/src/kernel/reading/json-file.ts @@ -0,0 +1,12 @@ +/** A parsed JSON value narrowed to a plain object — `null`, an array or a primitive all + * answer `{}` rather than throwing, so a caller reads a missing or wrong-shaped field as + * absent instead of guarding the narrowing itself. */ +export function asPlainObjectOrEmpty(value: unknown): Record { + return value !== null && typeof value === "object" && !Array.isArray(value) + ? (value as Record) + : {}; +} + +export function isErrnoException(error: unknown): error is NodeJS.ErrnoException { + return error instanceof Error && "code" in error; +} diff --git a/cli/src/domain/formats/jsonc.ts b/cli/src/kernel/reading/jsonc.ts similarity index 100% rename from cli/src/domain/formats/jsonc.ts rename to cli/src/kernel/reading/jsonc.ts diff --git a/cli/src/kernel/reading/plain-object.ts b/cli/src/kernel/reading/plain-object.ts new file mode 100644 index 000000000..cc8c5c12c --- /dev/null +++ b/cli/src/kernel/reading/plain-object.ts @@ -0,0 +1,7 @@ +/** Narrows a value fresh out of `JSON.parse` to a plain object, `null` for anything else — + * an array, a primitive, or `null` itself. */ +export function asPlainObject(value: unknown): Record | null { + return value !== null && typeof value === "object" && !Array.isArray(value) + ? (value as Record) + : null; +} diff --git a/cli/src/kernel/reading/repository-root.ts b/cli/src/kernel/reading/repository-root.ts new file mode 100644 index 000000000..26c7d904f --- /dev/null +++ b/cli/src/kernel/reading/repository-root.ts @@ -0,0 +1,16 @@ +import { existsSync } from "node:fs"; +import { dirname, join } from "node:path"; + +/** The checkout `start` sits in, or `start` itself when it sits in none — the anchor every + * run-journal path is relative to, never the directory a command was run from. Walked, not + * shelled to `git`: `.git` counts as a directory or as a worktree's `gitdir:` pointer file, + * and the walk stops at the filesystem root rather than climbing into a stranger's journal. */ +export function repositoryRootAbove(start: string): string { + let current = start; + for (;;) { + if (existsSync(join(current, ".git"))) return current; + const parent = dirname(current); + if (parent === current) return start; + current = parent; + } +} diff --git a/cli/src/kernel/scope.ts b/cli/src/kernel/scope.ts new file mode 100644 index 000000000..61662912b --- /dev/null +++ b/cli/src/kernel/scope.ts @@ -0,0 +1,4 @@ +/** Where a registration lives: bound to one project, or to the user across all of them. + * Kernel vocabulary because a tool's plugin CLI is driven with a scope and must name it + * without importing the context that fetches content. */ +export type MarketplaceScope = "project" | "user"; diff --git a/cli/src/kernel/semver.ts b/cli/src/kernel/semver.ts new file mode 100644 index 000000000..862d951dd --- /dev/null +++ b/cli/src/kernel/semver.ts @@ -0,0 +1,73 @@ +/** Build metadata is parsed and then discarded: semver gives it no bearing on precedence. */ +interface ParsedSemver { + readonly major: number; + readonly minor: number; + readonly patch: number; + /** Empty for a release version. A non-empty list always orders below the same + * release with none — semver.org's own precedence rule. */ + readonly prerelease: readonly string[]; +} + +const SEMVER_PATTERN = /^v?(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?(?:\+[0-9A-Za-z.-]+)?$/; + +function parseSemver(v: string): ParsedSemver | undefined { + const match = v.match(SEMVER_PATTERN); + if (!match) return undefined; + const [, major, minor, patch, prerelease] = match; + return { + major: Number(major), + minor: Number(minor), + patch: Number(patch), + prerelease: prerelease === undefined ? [] : prerelease.split("."), + }; +} + +/** Anchored end to end: anything past the three numeric components and an optional + * pre-release/build suffix is not semver, not a loosely-matched prefix of one. */ +export function isSemver(s: string): boolean { + return parseSemver(s) !== undefined; +} + +export function compareSemver(a: string, b: string): -1 | 0 | 1 { + // Two unparseable strings compare equal rather than throwing: a caller gating on `<= 0` + // must see "no drift decided here", never a crash. + const zero: ParsedSemver = { major: 0, minor: 0, patch: 0, prerelease: [] }; + const pa = parseSemver(a) ?? zero; + const pb = parseSemver(b) ?? zero; + if (pa.major !== pb.major) return pa.major < pb.major ? -1 : 1; + if (pa.minor !== pb.minor) return pa.minor < pb.minor ? -1 : 1; + if (pa.patch !== pb.patch) return pa.patch < pb.patch ? -1 : 1; + return comparePrerelease(pa.prerelease, pb.prerelease); +} + +/** semver.org's own precedence rule: no pre-release outranks any pre-release; between + * two, each dot-separated identifier compares numerically when both sides are + * digits-only, lexically otherwise, and the shorter list loses a tie on a shared + * prefix. */ +function comparePrerelease(a: readonly string[], b: readonly string[]): -1 | 0 | 1 { + if (a.length === 0 && b.length === 0) return 0; + if (a.length === 0) return 1; + if (b.length === 0) return -1; + const length = Math.max(a.length, b.length); + for (let i = 0; i < length; i++) { + if (a[i] === undefined) return -1; + if (b[i] === undefined) return 1; + const cmp = comparePrereleaseIdentifier(a[i], b[i]); + if (cmp !== 0) return cmp; + } + return 0; +} + +const NUMERIC_IDENTIFIER = /^\d+$/; + +function comparePrereleaseIdentifier(a: string, b: string): -1 | 0 | 1 { + const aIsNumeric = NUMERIC_IDENTIFIER.test(a); + const bIsNumeric = NUMERIC_IDENTIFIER.test(b); + if (aIsNumeric && bIsNumeric) { + const [an, bn] = [Number(a), Number(b)]; + return an === bn ? 0 : an < bn ? -1 : 1; + } + if (aIsNumeric !== bIsNumeric) return aIsNumeric ? -1 : 1; + if (a === b) return 0; + return a < b ? -1 : 1; +} diff --git a/cli/src/kernel/source.ts b/cli/src/kernel/source.ts new file mode 100644 index 000000000..621f4f25d --- /dev/null +++ b/cli/src/kernel/source.ts @@ -0,0 +1,256 @@ +import { isAbsolute } from "node:path"; +import { InvalidPluginSourceError } from "./errors.js"; +export const GITHUB_REPO_REGEX = /^[a-zA-Z0-9_.-]+\/[a-zA-Z0-9_.-]+$/; + +// npm package name grammar: unscoped my-pkg or scoped @scope/name. +// Leading `-` or `.` is forbidden (would be parsed as a pnpm flag or relative path). +export const NPM_PACKAGE_NAME_REGEX = /^(?:@[a-z0-9][a-z0-9-._]*\/)?[a-z0-9][a-z0-9-._]*$/; +export const SHA_REGEX = /^[a-f0-9]{40}$/; + +export interface PluginSourceGitHub { + kind: "github"; + repo: string; + ref?: string; + sha?: string; +} + +export interface PluginSourceUrl { + kind: "url"; + url: string; + ref?: string; + sha?: string; +} + +export interface PluginSourceGitSubdir { + kind: "git-subdir"; + url: string; + path: string; + ref?: string; + sha?: string; +} + +export interface PluginSourceNpm { + kind: "npm"; + package: string; + version?: string; + registry?: string; +} + +export interface PluginSourceLocal { + kind: "local"; + path: string; +} + +export type PluginSource = + | PluginSourceGitHub + | PluginSourceUrl + | PluginSourceGitSubdir + | PluginSourceNpm + | PluginSourceLocal; + +function assertString(value: unknown, field: string): string { + if (typeof value !== "string" || value.length === 0) { + throw new InvalidPluginSourceError(`"${field}" must be a non-empty string.`); + } + return value; +} + +function optionalString(raw: Record, field: string): string | undefined { + const value = raw[field]; + if (value === undefined) return undefined; + if (typeof value !== "string") { + throw new InvalidPluginSourceError(`"${field}" must be a string.`); + } + return value; +} + +function optionalSha(raw: Record): string | undefined { + const value = optionalString(raw, "sha"); + if (value !== undefined && !SHA_REGEX.test(value)) { + throw new InvalidPluginSourceError(`"sha" must be a 40-character lowercase hex string.`); + } + return value; +} + +function parseGitHub(raw: Record): PluginSourceGitHub { + const repo = assertString(raw.repo, "repo"); + if (!GITHUB_REPO_REGEX.test(repo)) { + throw new InvalidPluginSourceError(`"repo" must match owner/repo format.`); + } + return { + kind: "github", + repo, + ref: optionalString(raw, "ref"), + sha: optionalSha(raw), + }; +} + +function parseUrl(raw: Record): PluginSourceUrl { + return { + kind: "url", + url: assertString(raw.url, "url"), + ref: optionalString(raw, "ref"), + sha: optionalSha(raw), + }; +} + +function parseGitSubdir(raw: Record): PluginSourceGitSubdir { + return { + kind: "git-subdir", + url: assertString(raw.url, "url"), + path: assertString(raw.path, "path"), + ref: optionalString(raw, "ref"), + sha: optionalSha(raw), + }; +} + +function assertNpmPackageName(raw: Record): string { + const pkg = assertString(raw.package, "package"); + if (!NPM_PACKAGE_NAME_REGEX.test(pkg)) { + throw new InvalidPluginSourceError( + `"package" must be a valid npm package name (e.g. my-plugin or @scope/my-plugin). Got: "${pkg}"` + ); + } + return pkg; +} + +function parseNpm(raw: Record): PluginSourceNpm { + return { + kind: "npm", + package: assertNpmPackageName(raw), + version: optionalString(raw, "version"), + registry: optionalString(raw, "registry"), + }; +} + +function parseLocal(raw: Record): PluginSourceLocal { + return { + kind: "local", + path: assertString(raw.path, "path"), + }; +} + +export function parsePluginSource(raw: unknown): PluginSource { + if (typeof raw === "string") return parseStringPluginSource(raw); + if (raw === null || typeof raw !== "object" || Array.isArray(raw)) { + throw new InvalidPluginSourceError("expected an object."); + } + return parseObjectPluginSource(raw as Record); +} + +function parseStringPluginSource(raw: string): PluginSource { + // `isAbsolute` also catches a Windows-rooted path (`C:\...`, `\\server\share`), which + // starts with neither `/` nor `./`. + if (raw.startsWith("./") || isAbsolute(raw)) return { kind: "local", path: raw }; + if (GITHUB_REPO_REGEX.test(raw)) return { kind: "github", repo: raw }; + throw new InvalidPluginSourceError(`string source "${raw}" is not a recognized path or repo.`); +} + +function parseObjectPluginSource(obj: Record): PluginSource { + const kind = obj.kind; + switch (kind) { + case "github": + return parseGitHub(obj); + case "url": + return parseUrl(obj); + case "git-subdir": + return parseGitSubdir(obj); + case "npm": + return parseNpm(obj); + case "local": + return parseLocal(obj); + default: + throw new InvalidPluginSourceError( + `unknown kind "${String(kind)}". Expected: github, url, git-subdir, npm, local.` + ); + } +} + +const GITLAB_PREFIX = "gitlab:"; + +export function parsePluginSourceShorthand(raw: string): PluginSource { + if (raw.startsWith("https://") || raw.startsWith("http://")) return { kind: "url", url: raw }; + if (raw.startsWith("git@")) return { kind: "url", url: raw }; + // `isAbsolute` also catches a Windows-rooted path (`C:\...`, `\\server\share`), which + // starts with neither `/` nor `./`. + if (raw.startsWith("./") || isAbsolute(raw)) return { kind: "local", path: raw }; + if (raw.startsWith(GITLAB_PREFIX)) return parseGitLabShorthand(raw.slice(GITLAB_PREFIX.length)); + if (GITHUB_REPO_REGEX.test(raw)) return { kind: "github", repo: raw }; + const versioned = parseGitHubVersionedShorthand(raw); + if (versioned !== null) return versioned; + try { + return parsePluginSource(JSON.parse(raw)); + } catch (err) { + if (err instanceof InvalidPluginSourceError) throw err; + throw new InvalidPluginSourceError(`unrecognized source format: "${raw}"`); + } +} + +function parseGitHubVersionedShorthand(raw: string): PluginSourceGitHub | null { + const atIndex = raw.lastIndexOf("@"); + if (atIndex <= 0) return null; + const repo = raw.slice(0, atIndex); + const ref = raw.slice(atIndex + 1); + if (!GITHUB_REPO_REGEX.test(repo)) return null; + return { kind: "github", repo, ref }; +} + +function parseGitLabShorthand(raw: string): PluginSourceUrl { + const atIndex = raw.lastIndexOf("@"); + const repo = atIndex > 0 ? raw.slice(0, atIndex) : raw; + const ref = atIndex > 0 ? raw.slice(atIndex + 1) : undefined; + if (!GITHUB_REPO_REGEX.test(repo)) { + throw new InvalidPluginSourceError( + `"gitlab:${raw}" must match gitlab:owner/repo or gitlab:owner/repo@ref` + ); + } + const result: PluginSourceUrl = { kind: "url", url: `https://gitlab.com/${repo}.git` }; + if (ref !== undefined) result.ref = ref; + return result; +} + +export function describePluginSource(src: PluginSource): string { + switch (src.kind) { + case "github": + return `https://github.com/${src.repo}${src.ref ? `@${src.ref}` : ""}`; + case "url": + return src.url; + case "git-subdir": + return `${src.url}#${src.path}`; + case "npm": + return `npm:${src.package}${src.version ? `@${src.version}` : ""}`; + case "local": + return src.path; + } +} + +export function serializePluginSource(src: PluginSource): Record { + const result: Record = { kind: src.kind }; + switch (src.kind) { + case "github": + result.repo = src.repo; + if (src.ref !== undefined) result.ref = src.ref; + if (src.sha !== undefined) result.sha = src.sha; + break; + case "url": + result.url = src.url; + if (src.ref !== undefined) result.ref = src.ref; + if (src.sha !== undefined) result.sha = src.sha; + break; + case "git-subdir": + result.url = src.url; + result.path = src.path; + if (src.ref !== undefined) result.ref = src.ref; + if (src.sha !== undefined) result.sha = src.sha; + break; + case "npm": + result.package = src.package; + if (src.version !== undefined) result.version = src.version; + if (src.registry !== undefined) result.registry = src.registry; + break; + case "local": + result.path = src.path; + break; + } + return result; +} diff --git a/cli/src/kernel/tool.ts b/cli/src/kernel/tool.ts new file mode 100644 index 000000000..291d92bba --- /dev/null +++ b/cli/src/kernel/tool.ts @@ -0,0 +1,32 @@ +import { UnknownAiToolIdError } from "./errors.js"; + +export type AiToolId = "claude" | "cursor" | "copilot" | "opencode" | "codex"; +export type IdeToolId = "vscode"; +export type ToolId = AiToolId | IdeToolId; +export type ToolCategory = "ai" | "ide"; + +export const AI_TOOL_IDS: readonly AiToolId[] = [ + "claude", + "cursor", + "copilot", + "opencode", + "codex", +]; +export const IDE_TOOL_IDS: readonly IdeToolId[] = ["vscode"]; +export const VALID_TOOL_IDS: readonly ToolId[] = [...AI_TOOL_IDS, ...IDE_TOOL_IDS]; + +export function isAiToolId(id: string): id is AiToolId { + return AI_TOOL_IDS.includes(id as AiToolId); +} + +export function parseToolOption(tool: string | undefined): AiToolId[] | "all" { + if (tool === undefined || tool === "all") return "all"; + return [tool as AiToolId]; +} + +export function assertValidAiToolId(tool: string | undefined): void { + if (tool === undefined || tool === "all") return; + if (!isAiToolId(tool)) { + throw new UnknownAiToolIdError(tool, AI_TOOL_IDS); + } +} diff --git a/cli/src/application/commands/auth.ts b/cli/src/presentation/commands/auth.ts similarity index 77% rename from cli/src/application/commands/auth.ts rename to cli/src/presentation/commands/auth.ts index d386ff237..be9cb59d4 100644 --- a/cli/src/application/commands/auth.ts +++ b/cli/src/presentation/commands/auth.ts @@ -1,12 +1,13 @@ import type { Command } from "commander"; -import type { AuthCredential, AuthLevel } from "../../domain/models/auth.js"; -import { AIDD_DIR } from "../../domain/models/paths.js"; -import { createDeps } from "../../infrastructure/deps.js"; +import { InputRequiredError } from "../../kernel/errors.js"; +import { AIDD_DIR } from "../../kernel/paths.js"; +import type { AuthCredential, AuthLevel } from "../../runtime/auth/auth.js"; +import { AuthLoginUseCase } from "../../runtime/auth/auth-login-use-case.js"; +import { AuthLogoutUseCase } from "../../runtime/auth/auth-logout-use-case.js"; +import { AuthStatusUseCase } from "../../runtime/auth/auth-status-use-case.js"; +import { createDeps } from "../../runtime/wiring/framework.js"; +import { printAuthenticated, printAuthStatus, printLogoutResult } from "../display/auth-display.js"; import { ErrorHandler } from "../error-handler.js"; -import { InputRequiredError } from "../errors.js"; -import { AuthLoginUseCase } from "../use-cases/auth/auth-login-use-case.js"; -import { AuthLogoutUseCase } from "../use-cases/auth/auth-logout-use-case.js"; -import { AuthStatusUseCase } from "../use-cases/auth/auth-status-use-case.js"; import { parseGlobalOptions } from "./global-options.js"; export function registerAuthCommand(program: Command): void { @@ -70,7 +71,7 @@ export function registerAuthCommand(program: Command): void { credential, level, }); - output.success(`Authenticated as ${result.login} (${result.level})`); + printAuthenticated(output, result.login, result.level); } catch (error) { errorHandler.handle(error); } @@ -86,19 +87,7 @@ export function registerAuthCommand(program: Command): void { try { const deps = await createDeps(projectRoot, { verbose }, output); const result = await new AuthLogoutUseCase(deps.credentialStore).execute(); - - if (!result.found) { - output.info("Not authenticated."); - return; - } - - if (result.hint === "external-provider-cleanup") { - output.info( - "To fully logout, run the external provider's logout command (e.g. gh auth logout)." - ); - } - - output.success(`Logged out (${result.level})`); + printLogoutResult(output, result); } catch (error) { errorHandler.handle(error); } @@ -114,11 +103,7 @@ export function registerAuthCommand(program: Command): void { try { const deps = await createDeps(projectRoot, { verbose }, output); const result = await new AuthStatusUseCase(deps.credentialStore).execute(); - if (!result.authenticated) { - output.info("Not authenticated."); - return; - } - output.success(`Authenticated as ${result.login} (${result.level})`); + printAuthStatus(output, result); } catch (error) { errorHandler.handle(error); } diff --git a/cli/src/presentation/commands/clean.ts b/cli/src/presentation/commands/clean.ts new file mode 100644 index 000000000..081434994 --- /dev/null +++ b/cli/src/presentation/commands/clean.ts @@ -0,0 +1,75 @@ +import type { Command } from "commander"; +import { createDeps } from "../../runtime/wiring/framework.js"; +import { printProjectCleanOutcome, printUserScopeCleanOutcome } from "../display/clean-display.js"; +import { ErrorHandler } from "../error-handler.js"; +import type { CLIOutput } from "../output.js"; +import { parseGlobalOptions, parseScopeFlag } from "./global-options.js"; + +type Deps = Awaited>; + +interface CleanCmdOptions { + force: boolean; + scope?: string; +} + +async function runProjectScopeClean( + deps: Deps, + output: CLIOutput, + projectRoot: string, + cmdOptions: CleanCmdOptions +): Promise { + const interactive = process.stdout.isTTY === true; + const result = await deps.cleanUseCase.execute({ + projectRoot, + force: cmdOptions.force, + interactive, + }); + + printProjectCleanOutcome(output, result, interactive); +} + +async function runUserScopeClean( + deps: Deps, + output: CLIOutput, + projectRoot: string, + cmdOptions: CleanCmdOptions +): Promise { + const interactive = process.stdout.isTTY === true; + const result = await deps.cleanUserScopeUseCase.execute({ + projectRoot, + force: cmdOptions.force, + interactive, + }); + + printUserScopeCleanOutcome(output, result, interactive); +} + +export function registerCleanCommand(program: Command): void { + program + .command("clean") + .description( + "Remove all AIDD-managed files from the project — retires every part of AIDD; see `framework remove`, which removes the framework only" + ) + .option("--force", "Confirm file removal (skip dry-run)", false) + .option( + "--scope ", + "project (default) cleans this project alone; user undoes the machine-wide " + + "registration setup --scope user wrote and purges the shared source itself" + ) + .action(async (cmdOptions: CleanCmdOptions) => { + const { verbose, output, projectRoot } = parseGlobalOptions(program); + const errorHandler = new ErrorHandler(output); + const scope = parseScopeFlag(cmdOptions.scope, output) ?? "project"; + + try { + const deps = await createDeps(projectRoot, { verbose }, output); + if (scope === "user") { + await runUserScopeClean(deps, output, projectRoot, cmdOptions); + } else { + await runProjectScopeClean(deps, output, projectRoot, cmdOptions); + } + } catch (error) { + errorHandler.handle(error); + } + }); +} diff --git a/cli/src/presentation/commands/doctor.ts b/cli/src/presentation/commands/doctor.ts new file mode 100644 index 000000000..de8aba506 --- /dev/null +++ b/cli/src/presentation/commands/doctor.ts @@ -0,0 +1,194 @@ +import type { Command } from "commander"; +import type { DoctorReport } from "../../contexts/framework/domain/doctor.js"; +import { userMachineLocalFilesOf } from "../../contexts/tools/domain/registry.js"; +import { UserScopeFilterUnsupportedError } from "../../kernel/errors.js"; +import type { ToolCategory, ToolId } from "../../kernel/tool.js"; +import { isAiToolId } from "../../kernel/tool.js"; +import { createDeps } from "../../runtime/wiring/framework.js"; +import { + printAllToolsDrift, + printInventory, + printPluginIssues, + printReportErrors, + printScopeIssues, + printToolDrift, + printUserScopeTools, +} from "../display/doctor-display.js"; +import { ErrorHandler } from "../error-handler.js"; +import type { CLIOutput } from "../output.js"; +import { parseGlobalOptions, parseScopeFlag } from "./global-options.js"; + +type Deps = Awaited>; + +function categoryOf(toolId: ToolId): ToolCategory { + return isAiToolId(toolId) ? "ai" : "ide"; +} + +async function runFullDoctor( + deps: Deps, + output: CLIOutput, + projectRoot: string, + pluginName: string | undefined +): Promise { + const doctorResult = await deps.doctorAllUseCase.execute(projectRoot, pluginName); + const statusResult = await deps.statusAllUseCase.execute(projectRoot); + printReportErrors(output, doctorResult.errors); + + printInventory(output, "AI", doctorResult.ai, statusResult.aiTools.tools); + printInventory(output, "IDE", doctorResult.ide, statusResult.ideTools.tools); + + printAllToolsDrift(output, statusResult); + + // Before the health gate and unconditional: an `info` issue must survive a healthy run, + // never be held back until something else fails. + if (pluginName === undefined) { + printScopeIssues(output, "AI", doctorResult.ai); + printScopeIssues(output, "IDE", doctorResult.ide); + } + printPluginIssues(output, doctorResult.pluginIssues); + + // Drift is informational and never gates the exit code; only structural health issues do. + // `--plugin` narrows that gate to one plugin's issues, so a warning this view never prints + // cannot flip the exit code — the silent-exit-1 shape the narrowing exists to prevent. + const healthy = + pluginName !== undefined ? doctorResult.pluginIssues.length === 0 : doctorResult.healthy; + if (healthy) { + output.success("\nInstallation is healthy"); + return; + } + process.exit(1); +} + +async function runScopedDoctor( + deps: Deps, + output: CLIOutput, + projectRoot: string, + toolId: ToolId, + pluginName: string | undefined +): Promise { + const category = categoryOf(toolId); + // DoctorUseCase scopes by category, never by tool: the inventory below narrows to the exact + // tool, the issue list stays category-wide. + const doctorReport = await deps.doctorUseCase.execute({ projectRoot, category, pluginName }); + const statusReport = await deps.statusUseCase.execute({ + projectRoot, + filterToolId: toolId, + pluginName, + }); + + const scopedReport: DoctorReport = { + ...doctorReport, + toolHealth: doctorReport.toolHealth.filter((h) => h.toolId === toolId), + }; + printInventory(output, toolId, scopedReport, statusReport.tools); + + printToolDrift(output, statusReport); + + // Unconditional, before the health gate — see the unscoped path above. + if (pluginName === undefined) { + printScopeIssues(output, toolId, doctorReport); + } + printPluginIssues(output, doctorReport.pluginIssues); + + // Same plugin-scoped gate as the unscoped path above — see the comment there. + const healthy = + pluginName !== undefined ? doctorReport.pluginIssues.length === 0 : doctorReport.healthy; + if (healthy) { + output.success("\nInstallation is healthy"); + return; + } + process.exit(1); +} + +/** A user-scope install writes nothing under any project, so the only check left is + * `doctorRegistrationUseCase` against the user manifest: registrations versus a host's own + * registry file, the one check that is not project-file-shaped. */ +async function runUserScopeDoctor( + deps: Deps, + output: CLIOutput, + projectRoot: string, + cmdOptions: DoctorCmdOptions +): Promise { + // No plugin is tracked at user scope yet — there is nothing `--plugin` could narrow — + // so it is refused rather than silently read and discarded. + if (cmdOptions.plugin !== undefined) { + throw new UserScopeFilterUnsupportedError("--plugin", "doctor --plugin "); + } + const manifest = await deps.userManifestRepo.load(); + if (manifest === null) { + output.success("Nothing registered at user scope yet — run `aidd setup --scope user` first."); + return; + } + const toolId = cmdOptions.tool as ToolId | undefined; + const toolIds = toolId === undefined ? manifest.getInstalledToolIds() : [toolId]; + printUserScopeTools( + output, + toolIds.map((id) => { + const settingsPaths = userMachineLocalFilesOf(id, deps.homedir(), (name) => + deps.environment.get(name) + ); + return { + toolId: id, + version: manifest.getToolVersion(id) ?? "unknown", + settings: settingsPaths.length > 0 ? settingsPaths[0] : "no user-scope settings file", + }; + }) + ); + const allowedIds = toolId === undefined ? null : new Set([toolId]); + const issues = await deps.doctorRegistrationUseCase.execute({ + manifest, + projectRoot, + allowedIds, + }); + printScopeIssues(output, "User scope", { issues }); + const healthy = issues.every((i) => i.severity !== "error"); + if (healthy) { + output.success("\nUser-scope installation is healthy"); + return; + } + process.exit(1); +} + +interface DoctorCmdOptions { + tool?: string; + plugin?: string; + scope?: string; +} + +export function registerDoctorCommand(program: Command): void { + program + .command("doctor") + .description( + "Detected and equipped tools, plugins, drift, and problems — across all tools or one" + ) + .option("--tool ", "Limit to a specific AI or IDE tool") + .option("--plugin ", "Limit plugin checks to a specific plugin") + .option( + "--scope ", + "project (default) checks this project's own manifest; user checks the " + + "machine-wide manifest --scope user setup wrote" + ) + .action(async (cmdOptions: DoctorCmdOptions) => { + const { verbose, output, projectRoot } = parseGlobalOptions(program); + const errorHandler = new ErrorHandler(output); + try { + const deps = await createDeps(projectRoot, { verbose }, output); + const scope = parseScopeFlag(cmdOptions.scope, output) ?? "project"; + if (scope === "user") { + await runUserScopeDoctor(deps, output, projectRoot, cmdOptions); + } else if (cmdOptions.tool !== undefined) { + await runScopedDoctor( + deps, + output, + projectRoot, + cmdOptions.tool as ToolId, + cmdOptions.plugin + ); + } else { + await runFullDoctor(deps, output, projectRoot, cmdOptions.plugin); + } + } catch (error) { + errorHandler.handle(error); + } + }); +} diff --git a/cli/src/presentation/commands/framework.ts b/cli/src/presentation/commands/framework.ts new file mode 100644 index 000000000..2ce15518a --- /dev/null +++ b/cli/src/presentation/commands/framework.ts @@ -0,0 +1,245 @@ +import type { Command } from "commander"; +import { Manifest } from "../../contexts/framework/domain/manifest.js"; +import { isIdeToolId } from "../../contexts/tools/domain/registry.js"; +import type { AiToolId, IdeToolId, ToolId } from "../../kernel/tool.js"; +import { isAiToolId, VALID_TOOL_IDS } from "../../kernel/tool.js"; +import { createDeps } from "../../runtime/wiring/framework.js"; +import { + printToolAlreadyInstalled, + printToolInstalled, + printToolRemoved, + printUpdateResult, +} from "../display/framework-display.js"; +import { + printInstalledRules, + printInstalledRulesJson, +} from "../display/installed-rules-display.js"; +import { ErrorHandler } from "../error-handler.js"; +import type { CLIOutput } from "../output.js"; +import { parseGlobalOptions } from "./global-options.js"; +import { reportSyncActivation } from "./sync-native-activation.js"; + +type Deps = Awaited>; + +export function assertKnownToolId(toolId: string): asserts toolId is ToolId { + if (!isAiToolId(toolId) && !isIdeToolId(toolId)) { + throw new Error(`Unknown tool: ${toolId}. Valid tools: ${VALID_TOOL_IDS.join(", ")}`); + } +} + +async function runFrameworkInstall( + deps: Deps, + output: CLIOutput, + projectRoot: string, + toolId: ToolId, + cmdOptions: { force: boolean; plugins: boolean } +): Promise { + assertKnownToolId(toolId); + if (isAiToolId(toolId)) { + await installAiTool(deps, output, projectRoot, toolId, cmdOptions); + } else { + await installIdeTool(deps, output, projectRoot, toolId, cmdOptions); + } +} + +async function installAiTool( + deps: Deps, + output: CLIOutput, + projectRoot: string, + toolId: AiToolId, + cmdOptions: { force: boolean; plugins: boolean } +): Promise { + const version = deps.currentVersionProvider.get(); + const result = await deps.installAiToolUseCase.execute({ + toolId, + projectRoot, + force: cmdOptions.force, + version, + propagatePlugins: cmdOptions.plugins, + }); + if (result.runtimeResult.skipped) { + printToolAlreadyInstalled(output, toolId); + return; + } + printToolInstalled(output, toolId, result.runtimeResult.fileCount, [ + ...result.runtimeResult.warnings, + ...result.propagationWarnings, + ]); + if (result.activation !== undefined) reportSyncActivation(output, result.activation); +} + +async function installIdeTool( + deps: Deps, + output: CLIOutput, + projectRoot: string, + toolId: IdeToolId, + cmdOptions: { force: boolean } +): Promise { + const manifest = (await deps.manifestRepo.load()) ?? Manifest.create(); + const version = deps.currentVersionProvider.get(); + const result = await deps.installIdeToolUseCase.execute({ + toolId, + projectRoot, + manifest, + force: cmdOptions.force, + version, + }); + if (result.skipped) { + printToolAlreadyInstalled(output, result.toolId); + return; + } + printToolInstalled(output, result.toolId, result.fileCount, result.warnings); +} + +async function runFrameworkRemove( + deps: Deps, + output: CLIOutput, + projectRoot: string, + toolId: ToolId +): Promise { + assertKnownToolId(toolId); + if (isAiToolId(toolId)) { + const results = await deps.uninstallUseCase.execute({ + toolIds: [toolId], + projectRoot, + mcpFilter: [], + }); + const totalFileCount = results.reduce((sum, r) => sum + r.fileCount, 0); + printToolRemoved(output, results[0].toolId, totalFileCount); + return; + } + const result = await deps.uninstallIdeUseCase.execute({ toolId, projectRoot }); + printToolRemoved(output, result.toolId, result.fileCount); +} + +async function runFrameworkUpdate( + deps: Deps, + output: CLIOutput, + projectRoot: string, + toolId: ToolId | undefined, + cmdOptions: { force: boolean } +): Promise { + if (toolId !== undefined) assertKnownToolId(toolId); + const interactive = process.stdout.isTTY ?? false; + + if (toolId !== undefined) { + if (isAiToolId(toolId)) { + const result = await deps.updateAiToolsUseCase.execute({ + toolArg: toolId, + projectRoot, + userForce: cmdOptions.force, + interactive, + }); + printUpdateResult(output, result.updatedTools, result.errors); + } else { + const result = await deps.updateIdeToolsUseCase.execute({ + toolArg: toolId as IdeToolId, + projectRoot, + userForce: cmdOptions.force, + interactive, + }); + printUpdateResult(output, result.updatedTools, result.errors); + } + return; + } + + // No `--tool`: fan out across both categories — every installed AI and IDE tool. + const ai = await deps.updateAiToolsUseCase.execute({ + projectRoot, + userForce: cmdOptions.force, + interactive, + }); + const ide = await deps.updateIdeToolsUseCase.execute({ + projectRoot, + userForce: cmdOptions.force, + interactive, + }); + printUpdateResult( + output, + [...ai.updatedTools, ...ide.updatedTools], + [...ai.errors, ...ide.errors] + ); +} + +export function registerFrameworkCommand(program: Command): void { + const framework = program + .command("framework") + .description("Manage the framework's lifecycle on installed tools"); + + framework + .command("install") + .description( + "Install a tool's runtime configuration from bundled assets — acts on the framework alone (see `setup`, which bootstraps the whole project)" + ) + .requiredOption("--tool ", "AI or IDE tool ID") + .option("-f, --force", "Overwrite already-installed tool", false) + .option("--no-plugins", "Skip propagation of already-installed plugins onto the new tool") + .action(async (cmdOptions: { tool: string; force: boolean; plugins: boolean }) => { + const { verbose, output, projectRoot } = parseGlobalOptions(program); + const errorHandler = new ErrorHandler(output); + try { + const deps = await createDeps(projectRoot, { verbose }, output); + await runFrameworkInstall(deps, output, projectRoot, cmdOptions.tool as ToolId, cmdOptions); + } catch (error) { + errorHandler.handle(error); + } + }); + + framework + .command("remove") + .description( + "Remove a tool's generated configuration files — removes the framework only (see `clean`, which removes all of AIDD)" + ) + .requiredOption("--tool ", "AI or IDE tool ID") + .action(async (cmdOptions: { tool: string }) => { + const { verbose, output, projectRoot } = parseGlobalOptions(program); + const errorHandler = new ErrorHandler(output); + try { + const deps = await createDeps(projectRoot, { verbose }, output); + await runFrameworkRemove(deps, output, projectRoot, cmdOptions.tool as ToolId); + } catch (error) { + errorHandler.handle(error); + } + }); + + framework + .command("update") + .description( + "Re-install tool configs from bundled CLI assets, moving to a new version (all installed tools if --tool is omitted; see `marketplace refresh`, which re-fetches catalogs instead)" + ) + .option("--tool ", "Limit update to a specific AI or IDE tool") + .option("-f, --force", "Overwrite modified files without prompting", false) + .action(async (cmdOptions: { tool?: string; force: boolean }) => { + const { verbose, output, projectRoot } = parseGlobalOptions(program); + const errorHandler = new ErrorHandler(output); + try { + const deps = await createDeps(projectRoot, { verbose }, output); + await runFrameworkUpdate( + deps, + output, + projectRoot, + cmdOptions.tool as ToolId | undefined, + cmdOptions + ); + } catch (error) { + errorHandler.handle(error); + } + }); + + framework + .command("rules") + .description("List the rules installed in this project, across every AI tool") + .option("--json", "Print the inventory as JSON") + .action(async (cmdOptions: { json?: boolean }) => { + const { verbose, output, projectRoot } = parseGlobalOptions(program); + const errorHandler = new ErrorHandler(output); + try { + const deps = await createDeps(projectRoot, { verbose }, output); + const { rules } = await deps.listInstalledRulesUseCase.execute({ projectRoot }); + if (cmdOptions.json) printInstalledRulesJson(output, rules); + else printInstalledRules(output, rules); + } catch (error) { + errorHandler.handle(error); + } + }); +} diff --git a/cli/src/presentation/commands/global-options.ts b/cli/src/presentation/commands/global-options.ts new file mode 100644 index 000000000..c5f31167e --- /dev/null +++ b/cli/src/presentation/commands/global-options.ts @@ -0,0 +1,30 @@ +import type { Command } from "commander"; +import type { MarketplaceScope } from "../../kernel/scope.js"; +import { CLIOutput } from "../output.js"; + +export interface GlobalOptions { + verbose: boolean; + output: CLIOutput; + projectRoot: string; +} + +export function parseGlobalOptions(program: Command): GlobalOptions { + const opts = program.opts<{ verbose?: boolean }>(); + const verbose = opts.verbose ?? false; + return { + verbose, + output: new CLIOutput(verbose), + projectRoot: process.cwd(), + }; +} + +/** Returns the flag as given, `undefined` included: each caller applies its own default + * rather than this validation guessing one for all of them. */ +export function parseScopeFlag( + raw: string | undefined, + output: CLIOutput +): MarketplaceScope | undefined { + if (raw === undefined || raw === "project" || raw === "user") return raw; + output.error(`Invalid --scope "${raw}" — expected "project" or "user".`); + process.exit(1); +} diff --git a/cli/src/presentation/commands/marketplace.ts b/cli/src/presentation/commands/marketplace.ts new file mode 100644 index 000000000..2dfc2aa20 --- /dev/null +++ b/cli/src/presentation/commands/marketplace.ts @@ -0,0 +1,170 @@ +import type { Command } from "commander"; +import type { MarketplaceScope } from "../../kernel/scope.js"; +import { parsePluginSourceShorthand } from "../../kernel/source.js"; +import { createDeps, createMenuDeps } from "../../runtime/wiring/framework.js"; +import { + printMarketplaceCheck, + printMarketplaceRegistered, + printMarketplaceRemoved, + printRefreshResults, + printRegisteredMarketplaces, +} from "../display/marketplace-display.js"; +import { ErrorHandler } from "../error-handler.js"; +import { parseGlobalOptions } from "./global-options.js"; +import { spawnCliCommand } from "./spawn-cli-command.js"; +import { syncNativeActivation } from "./sync-native-activation.js"; + +export function registerMarketplaceCommand(program: Command): void { + const marketplace = program.command("marketplace").description("Manage plugin marketplaces"); + + marketplace.action(async () => { + if (!process.stdout.isTTY) { + marketplace.help(); + return; + } + const { prompter } = createMenuDeps(process.cwd()); + const choice = await prompter.select("marketplace: what do you want to do?", [ + { name: "List marketplaces", value: "list" }, + { name: "Add marketplace", value: "add" }, + { name: "Refresh marketplaces", value: "refresh" }, + { name: "Remove marketplace", value: "remove", description: "requires name arg" }, + { name: "Check marketplaces", value: "check" }, + ]); + await spawnCliCommand(["marketplace", choice]); + }); + + marketplace + .command("add [name] [source]") + .description("Register a plugin marketplace") + .option("--scope ", "Registration scope (default: project)", "project") + .option("--yes", "Skip the trust + cleanup prompts") + .option("--overwrite", "Replace an existing marketplace with the same name") + .option("--token ", "Auth token (host detected from source URL at fetch time)") + .action( + async ( + nameArg: string | undefined, + sourceArg: string | undefined, + cmdOptions: { + scope?: string; + yes?: boolean; + overwrite?: boolean; + token?: string; + } + ) => { + const { verbose, output, projectRoot } = parseGlobalOptions(program); + const errorHandler = new ErrorHandler(output); + const interactive = process.stdout.isTTY; + if (!interactive && (!nameArg || !sourceArg)) { + output.error("name and source are required in non-interactive mode."); + process.exit(1); + } + if ( + cmdOptions.scope !== undefined && + cmdOptions.scope !== "project" && + cmdOptions.scope !== "user" + ) { + output.error(`Invalid --scope '${cmdOptions.scope}'. Expected 'project' or 'user'.`); + process.exit(1); + } + try { + if (cmdOptions.token) process.env.AIDD_TOKEN = cmdOptions.token; + const scope: MarketplaceScope = cmdOptions.scope === "user" ? "user" : "project"; + const deps = await createDeps(projectRoot, { verbose }, output); + const name = nameArg ?? (await deps.prompter.input("Marketplace name:")); + const rawSource = sourceArg ?? (await deps.prompter.input("Source (path or user/repo):")); + const source = parsePluginSourceShorthand(rawSource); + const result = await deps.marketplaceAddUseCase.execute({ + source, + name, + scope, + projectRoot, + autoTrust: cmdOptions.yes ?? false, + overwrite: cmdOptions.overwrite ?? false, + }); + await syncNativeActivation(deps, output, projectRoot, [result.marketplace.name]); + printMarketplaceRegistered(output, result.marketplace.name); + } catch (error) { + errorHandler.handle(error); + } + } + ); + + marketplace + .command("list") + .description("List registered plugin marketplaces") + .option("--plugins", "Also fetch and print all plugins from each marketplace catalog") + .action(async (cmdOptions: { plugins?: boolean }) => { + const { verbose, output, projectRoot } = parseGlobalOptions(program); + const errorHandler = new ErrorHandler(output); + try { + const deps = await createDeps(projectRoot, { verbose }, output); + const { marketplaces, catalogs } = await deps.marketplaceListUseCase.execute({ + projectRoot, + withCatalogs: cmdOptions.plugins ?? false, + }); + printRegisteredMarketplaces(output, marketplaces, catalogs); + } catch (error) { + errorHandler.handle(error); + } + }); + + marketplace + .command("remove ") + .description("Remove a registered plugin marketplace") + .option("--yes", "Skip the orphan-cleanup prompt") + .action(async (name: string, cmdOptions: { yes?: boolean }) => { + const { verbose, output, projectRoot } = parseGlobalOptions(program); + const errorHandler = new ErrorHandler(output); + try { + const deps = await createDeps(projectRoot, { verbose }, output); + const result = await deps.marketplaceRemoveUseCase.execute({ + name, + projectRoot, + autoConfirm: cmdOptions.yes ?? false, + }); + await syncNativeActivation(deps, output, projectRoot); + printMarketplaceRemoved(output, result.marketplace.name, result.removedPluginCount); + } catch (error) { + errorHandler.handle(error); + } + }); + + marketplace + .command("refresh [name]") + .description( + "Refresh registered marketplaces — re-fetches catalogs; see `framework update`, which moves installed tools to a new version instead" + ) + .option("--force", "Clear cache before re-fetching") + .action(async (name: string | undefined, cmdOptions: { force?: boolean }) => { + const { verbose, output, projectRoot } = parseGlobalOptions(program); + const errorHandler = new ErrorHandler(output); + try { + const deps = await createDeps(projectRoot, { verbose }, output); + const { results, failedCount } = await deps.marketplaceRefreshUseCase.execute({ + projectRoot, + name, + force: cmdOptions.force, + }); + await syncNativeActivation(deps, output, projectRoot); + printRefreshResults(output, results); + if (failedCount > 0) process.exit(1); + } catch (error) { + errorHandler.handle(error); + } + }); + + marketplace + .command("check") + .description("Report stale marketplaces and upstream-removed plugins") + .action(async () => { + const { verbose, output, projectRoot } = parseGlobalOptions(program); + const errorHandler = new ErrorHandler(output); + try { + const deps = await createDeps(projectRoot, { verbose }, output); + const result = await deps.marketplaceCheckUseCase.execute({ projectRoot }); + printMarketplaceCheck(output, result); + } catch (error) { + errorHandler.handle(error); + } + }); +} diff --git a/cli/src/presentation/commands/menu.ts b/cli/src/presentation/commands/menu.ts new file mode 100644 index 000000000..780210899 --- /dev/null +++ b/cli/src/presentation/commands/menu.ts @@ -0,0 +1,48 @@ +import readline from "node:readline"; +import { resolveProjectRoot } from "../../runtime/project-root/project-root.js"; +import { createMenuDeps } from "../../runtime/wiring/framework.js"; +import { printBanner } from "../display/menu-display.js"; +import { ErrorHandler } from "../error-handler.js"; +import { CLIOutput } from "../output.js"; +import { InteractiveMenuUseCase } from "../prompts/menu-use-case.js"; +import { spawnCliCommand } from "./spawn-cli-command.js"; + +async function waitForEnter(): Promise { + const rl = readline.createInterface({ input: process.stdin, output: process.stdout }); + await new Promise((resolve) => { + rl.question("\nPress ENTER to continue...", () => { + rl.close(); + resolve(); + }); + }); +} + +/** The name inquirer gives the error it throws when the user hits Ctrl-C at a prompt. */ +const USER_ABORT_ERROR_NAME = "ExitPromptError"; + +export function isUserAbort(error: unknown): boolean { + return error instanceof Error && error.name === USER_ABORT_ERROR_NAME; +} + +export function routeMenuError(error: unknown, errorHandler: ErrorHandler): never { + if (isUserAbort(error)) process.exit(0); + return errorHandler.handle(error); +} + +export async function runMenuLoop(): Promise { + const output = new CLIOutput(); + printBanner(output); + const { manifestRepo, prompter } = createMenuDeps(resolveProjectRoot()); + const errorHandler = new ErrorHandler(output); + for (;;) { + try { + const result = await new InteractiveMenuUseCase(manifestRepo, prompter).execute(); + if (result.command[0] === "exit") process.exit(0); + const exitCode = await spawnCliCommand(result.command); + await waitForEnter(); + if (exitCode !== 0 && result.command[0] === "setup") process.exit(exitCode); + } catch (error) { + routeMenuError(error, errorHandler); + } + } +} diff --git a/cli/src/presentation/commands/plugin.ts b/cli/src/presentation/commands/plugin.ts new file mode 100644 index 000000000..b6d1d11dc --- /dev/null +++ b/cli/src/presentation/commands/plugin.ts @@ -0,0 +1,168 @@ +import type { Command } from "commander"; +import { parseInstallScope } from "../../contexts/framework/domain/install-scope.js"; +import { assertValidAiToolId, parseToolOption } from "../../kernel/tool.js"; +import { createDeps, createMenuDeps } from "../../runtime/wiring/framework.js"; +import { + printInstalledPlugins, + printPluginInstallOutcome, + printPluginRemoved, + printPluginSearchHits, + printPluginsUpdated, +} from "../display/plugin-display.js"; +import { ErrorHandler } from "../error-handler.js"; +import { parseGlobalOptions } from "./global-options.js"; +import { spawnCliCommand } from "./spawn-cli-command.js"; +import { syncNativeActivation } from "./sync-native-activation.js"; + +export function registerPluginCommand(program: Command): void { + const plugin = program.command("plugin").description("Manage plugins for AI tools"); + + plugin.action(async () => { + if (!process.stdout.isTTY) { + plugin.help(); + return; + } + const { prompter } = createMenuDeps(process.cwd()); + const choice = await prompter.select("plugin: what do you want to do?", [ + { name: "Install plugin", value: "install" }, + { name: "List installed plugins", value: "list" }, + { name: "Search plugins", value: "search", description: "requires query arg" }, + { name: "Update plugins", value: "update" }, + { name: "Remove a plugin", value: "remove", description: "requires name arg" }, + ]); + await spawnCliCommand(["plugin", choice]); + }); + + plugin + .command("remove ") + .description("Remove a plugin from one or all AI tools") + .option("--tool ", "Target AI tool (default: all installed)") + .action(async (name: string, cmdOptions: { tool?: string }) => { + const { verbose, output, projectRoot } = parseGlobalOptions(program); + const errorHandler = new ErrorHandler(output); + try { + assertValidAiToolId(cmdOptions.tool); + const deps = await createDeps(projectRoot, { verbose }, output); + await deps.pluginRemoveUseCase.execute({ + pluginName: name, + toolIds: parseToolOption(cmdOptions.tool), + projectRoot, + }); + await syncNativeActivation(deps, output, projectRoot); + printPluginRemoved(output, name); + } catch (error) { + errorHandler.handle(error); + } + }); + + plugin + .command("list") + .description("List installed plugins for one or all AI tools") + .option("--tool ", "Target AI tool (default: all installed)") + .action(async (cmdOptions: { tool?: string }) => { + const { verbose, output, projectRoot } = parseGlobalOptions(program); + const errorHandler = new ErrorHandler(output); + try { + assertValidAiToolId(cmdOptions.tool); + const deps = await createDeps(projectRoot, { verbose }, output); + const result = await deps.pluginListUseCase.execute({ + toolIds: parseToolOption(cmdOptions.tool), + }); + printInstalledPlugins(output, result); + } catch (error) { + errorHandler.handle(error); + } + }); + + plugin + .command("install [plugin]") + .description("Install a plugin (marketplace name, local path, or interactive pick)") + .option("--from ", "Marketplace name (when multiple match)") + .option("--tool ", "Target AI tool (default: all installed)") + .option("--token ", "Auth token (host detected from source URL at fetch time)") + .option("--scope ", "Install scope; must match the tool's supported scope") + .option("--yes", "Auto-resolve interactive prompts (CI mode)") + .action( + async ( + pluginArg: string | undefined, + cmdOptions: { + from?: string; + tool?: string; + token?: string; + scope?: string; + yes?: boolean; + } + ) => { + const { verbose, output, projectRoot } = parseGlobalOptions(program); + const errorHandler = new ErrorHandler(output); + try { + assertValidAiToolId(cmdOptions.tool); + const scope = parseInstallScope(cmdOptions.scope); + const deps = await createDeps(projectRoot, { verbose }, output); + const result = await deps.pluginInstallUseCase.execute({ + pluginArg, + toolIds: parseToolOption(cmdOptions.tool), + projectRoot, + interactive: process.stdout.isTTY, + fromMarketplace: cmdOptions.from, + token: cmdOptions.token, + yes: cmdOptions.yes, + scope, + }); + await syncNativeActivation( + deps, + output, + projectRoot, + cmdOptions.from !== undefined ? [cmdOptions.from] : undefined + ); + printPluginInstallOutcome(output, result); + } catch (error) { + errorHandler.handle(error); + } + } + ); + + plugin + .command("search ") + .description("Search registered marketplaces for plugins") + .option("--recommended", "Show only recommended plugins") + .option("--marketplace ", "Limit to a single marketplace") + .action(async (query: string, cmdOptions: { recommended?: boolean; marketplace?: string }) => { + const { verbose, output, projectRoot } = parseGlobalOptions(program); + const errorHandler = new ErrorHandler(output); + try { + const deps = await createDeps(projectRoot, { verbose }, output); + const { hits } = await deps.pluginSearchUseCase.execute({ + query, + recommendedOnly: cmdOptions.recommended ?? false, + marketplace: cmdOptions.marketplace, + projectRoot, + }); + printPluginSearchHits(output, hits); + } catch (error) { + errorHandler.handle(error); + } + }); + + plugin + .command("update [name]") + .description("Update one or all plugins for one or all AI tools") + .option("--tool ", "Target AI tool (default: all installed)") + .action(async (name: string | undefined, cmdOptions: { tool?: string }) => { + const { verbose, output, projectRoot } = parseGlobalOptions(program); + const errorHandler = new ErrorHandler(output); + try { + assertValidAiToolId(cmdOptions.tool); + const deps = await createDeps(projectRoot, { verbose }, output); + const updated = await deps.pluginUpdateUseCase.execute({ + pluginNames: name !== undefined ? [name] : undefined, + toolIds: parseToolOption(cmdOptions.tool), + projectRoot, + }); + await syncNativeActivation(deps, output, projectRoot); + printPluginsUpdated(output, updated); + } catch (error) { + errorHandler.handle(error); + } + }); +} diff --git a/cli/src/presentation/commands/setup.ts b/cli/src/presentation/commands/setup.ts new file mode 100644 index 000000000..3fdf558b4 --- /dev/null +++ b/cli/src/presentation/commands/setup.ts @@ -0,0 +1,184 @@ +import { resolve } from "node:path"; +import type { Command } from "commander"; +import { MarketplaceSourceMode } from "../../contexts/distribution/domain/marketplace-source-mode.js"; +import { SetupUseCase } from "../../contexts/framework/application/setup-use-case.js"; +import { SetupFlow } from "../../contexts/framework/domain/setup-flow.js"; +import { assertToolIdsMatchCategory } from "../../contexts/tools/domain/registry.js"; +import type { ToolId } from "../../kernel/tool.js"; +import { AI_TOOL_IDS, IDE_TOOL_IDS } from "../../kernel/tool.js"; +import { createDeps } from "../../runtime/wiring/framework.js"; +import { + printDetectedContext, + printNextSteps, + printSetupOutcome, + printWelcomeBanner, +} from "../display/setup-display.js"; +import { ErrorHandler } from "../error-handler.js"; +import type { CLIOutput } from "../output.js"; +import { parseGlobalOptions, parseScopeFlag } from "./global-options.js"; +import { reportSyncActivation } from "./sync-native-activation.js"; + +interface SetupCmdOptions { + source?: "remote" | "local"; + path?: string; + release?: string; + ai?: string; + ide?: string; + plugins?: string; + yes?: boolean; + defaultMarketplace?: boolean; + scope?: string; +} + +export function parseSourceFlag( + cmdOptions: SetupCmdOptions, + output: CLIOutput +): MarketplaceSourceMode | undefined { + if (!cmdOptions.source) return undefined; + if (cmdOptions.source === "local") { + if (!cmdOptions.path) { + output.error("--source local requires --path "); + process.exit(1); + } + return MarketplaceSourceMode.local(resolve(cmdOptions.path)); + } + return MarketplaceSourceMode.remote(undefined, cmdOptions.release); +} + +export function expandAllKeyword(raw: string | undefined, all: readonly ToolId[]): ToolId[] { + if (raw === undefined) return []; + if (raw.trim() === "all") return [...all]; + return raw + .split(",") + .map((s) => s.trim()) + .filter(Boolean) as ToolId[]; +} + +export function parseToolIds( + cmdOptions: SetupCmdOptions, + errorHandler: ErrorHandler +): { aiTools: ToolId[]; ideTools: ToolId[] } | null { + const aiIds = expandAllKeyword(cmdOptions.ai, AI_TOOL_IDS); + const ideIds = expandAllKeyword(cmdOptions.ide, IDE_TOOL_IDS); + try { + if (aiIds.length > 0 && cmdOptions.ai?.trim() !== "all") + assertToolIdsMatchCategory(aiIds, "ai"); + if (ideIds.length > 0 && cmdOptions.ide?.trim() !== "all") + assertToolIdsMatchCategory(ideIds, "ide"); + } catch (e) { + errorHandler.handle(e); + return null; + } + return { aiTools: aiIds, ideTools: ideIds }; +} + +type PluginsMode = "interactive" | "all" | "recommended" | "named" | "none"; + +export function parsePluginsFlag( + raw: string | undefined, + interactive: boolean +): { mode: PluginsMode; names: string[] } { + if (raw === undefined) return { mode: interactive ? "interactive" : "none", names: [] }; + const value = raw.trim(); + if (value === "none") return { mode: "none", names: [] }; + if (value === "all") return { mode: "all", names: [] }; + if (value === "recommended") return { mode: "recommended", names: [] }; + const names = value + .split(",") + .map((s) => s.trim()) + .filter(Boolean); + return { mode: "named", names }; +} + +export function registerSetupCommand(program: Command): void { + program + .command("setup") + .description( + "Set up or update the project to a correct state — bootstraps the whole project (marketplace, framework, tools, plugins); see `framework install`, which acts on the framework alone" + ) + .option("--source ", "Framework source: remote or local") + .option("--path ", "Absolute path to local framework (required with --source local)") + .option("--release ", "Marketplace release tag to fetch (e.g., v1.2.3)") + .option("--ai ", "Comma-separated AI tool IDs, or 'all' (e.g., claude,cursor or all)") + .option("--ide ", "Comma-separated IDE tool IDs, or 'all' (e.g., vscode or all)") + .option( + "--plugins ", + "Plugin install mode: none | all | recommended | comma-separated names" + ) + .option( + "--no-default-marketplace", + "Skip auto-registering aidd-framework (no source prompt, no plugin install)" + ) + .option("--yes", "Accept defaults without prompting") + .option( + "--scope ", + "project (default) installs into this project alone; user registers the shared " + + "framework source and native activation machine-wide, writing nothing under this project" + ) + .action(async (cmdOptions: SetupCmdOptions) => { + const { verbose, output, projectRoot } = parseGlobalOptions(program); + const errorHandler = new ErrorHandler(output); + + const source = parseSourceFlag(cmdOptions, output); + const scope = parseScopeFlag(cmdOptions.scope, output); + const toolIds = parseToolIds(cmdOptions, errorHandler); + if (toolIds === null) return; + + const hasScriptingFlags = !!( + cmdOptions.source || + cmdOptions.release || + cmdOptions.ai || + cmdOptions.ide || + cmdOptions.plugins || + cmdOptions.yes + ); + const interactive = process.stdout.isTTY && !hasScriptingFlags; + + const { mode: pluginMode, names: pluginNames } = parsePluginsFlag( + cmdOptions.plugins, + interactive + ); + + const registerDefaultMarketplace = cmdOptions.defaultMarketplace !== false; + const flow = new SetupFlow({ + projectRoot, + source, + aiTools: toolIds.aiTools, + ideTools: toolIds.ideTools, + pluginMode, + pluginNames, + interactive, + force: false, + registerDefaultMarketplace, + scope, + }); + + if (interactive) printWelcomeBanner(output); + + try { + const deps = await createDeps(projectRoot, { verbose }, output); + + const result = await new SetupUseCase( + deps.fs, + deps.manifestRepo, + deps.setupMarketplaceRegistration, + deps.marketplaceSyncSettingsUseCase, + deps.setupToolsUseCase, + deps.setupPluginsPromptUseCase, + deps.currentVersionProvider, + deps.setupToolsPromptUseCase, + deps.projectContextDetector, + deps.setupMachineScopeUseCase + ).execute(flow); + + if (interactive && result.context !== undefined) { + printDetectedContext(output, result.context.describe()); + } + printSetupOutcome(output, result, verbose); + if (interactive) printNextSteps(output, result.install.results.length > 0); + reportSyncActivation(output, result.activation); + } catch (error) { + errorHandler.handle(error); + } + }); +} diff --git a/cli/src/application/commands/shared/spawn-cli-command.ts b/cli/src/presentation/commands/spawn-cli-command.ts similarity index 100% rename from cli/src/application/commands/shared/spawn-cli-command.ts rename to cli/src/presentation/commands/spawn-cli-command.ts diff --git a/cli/src/presentation/commands/sync-native-activation.ts b/cli/src/presentation/commands/sync-native-activation.ts new file mode 100644 index 000000000..d16fa368f --- /dev/null +++ b/cli/src/presentation/commands/sync-native-activation.ts @@ -0,0 +1,36 @@ +import { SyncFailedError } from "../../kernel/errors.js"; +import type { createDeps } from "../../runtime/wiring/framework.js"; +import { printScopedFailures } from "../display/framework-display.js"; +import type { CLIOutput } from "../output.js"; + +/** Named structurally rather than imported: `marketplace-sync-settings-use-case.ts` is + * internal to `framework`, and every real caller's result already satisfies this shape. */ +interface SyncActivationOutcome { + readonly errors: readonly { scope: string; message: string }[]; +} + +/** Drives native activation and surfaces what it did: a result thrown away turns a refusal + * into exit 0 with nothing printed and a plugin that never loads. Every command driving + * activation reads it here, never a second way. */ +export async function syncNativeActivation( + deps: Awaited>, + output: CLIOutput, + projectRoot: string, + /** Narrows activation to these marketplaces alone; omitted, every registered one is + * re-driven. */ + marketplaceNames?: readonly string[] +): Promise { + const activation = await deps.marketplaceSyncSettingsUseCase.execute({ + projectRoot, + marketplaceNames, + }); + reportSyncActivation(output, activation); +} + +/** The print-and-throw half of {@link syncNativeActivation}, for a caller whose own use case + * already ran `execute`: calling it a second time here would run every tool's own CLI twice + * for one command. */ +export function reportSyncActivation(output: CLIOutput, activation: SyncActivationOutcome): void { + printScopedFailures(output, activation.errors); + if (activation.errors.length > 0) throw new SyncFailedError(activation.errors); +} diff --git a/cli/src/presentation/commands/sync.ts b/cli/src/presentation/commands/sync.ts new file mode 100644 index 000000000..fd7faf47b --- /dev/null +++ b/cli/src/presentation/commands/sync.ts @@ -0,0 +1,151 @@ +import type { Command } from "commander"; +import { + NoManifestError, + SyncFailedError, + UserScopeFilterUnsupportedError, +} from "../../kernel/errors.js"; +import type { ToolId } from "../../kernel/tool.js"; +import { createDeps } from "../../runtime/wiring/framework.js"; +import { + printActivationOutcome, + printRestoreOutcome, + printToolRestoreOutcome, + printUserScopeSyncOutcome, +} from "../display/sync-display.js"; +import { ErrorHandler } from "../error-handler.js"; +import type { CLIOutput } from "../output.js"; +import { parseGlobalOptions, parseScopeFlag } from "./global-options.js"; + +interface SyncCmdOptions { + force: boolean; + tool?: string; + plugin?: string; + scope?: string; +} + +/** A user-scope install writes nothing under any project, so this skips `restoreAllUseCase` + * and drives only native activation, through `deps.userManifestRepo` and never this + * project's own manifest. */ +async function runUserScopeSync( + deps: Awaited>, + output: CLIOutput, + projectRoot: string, + fileArgs: string[], + cmdOptions: SyncCmdOptions +): Promise { + // Neither has a user-scope counterpart, so both are refused rather than read and + // silently discarded. + if (cmdOptions.plugin !== undefined) { + throw new UserScopeFilterUnsupportedError("--plugin", "sync --plugin "); + } + if (fileArgs.length > 0) { + throw new UserScopeFilterUnsupportedError("a file argument", "sync "); + } + const toolIds = cmdOptions.tool !== undefined ? [cmdOptions.tool as ToolId] : undefined; + const activation = await deps.marketplaceSyncSettingsUseCase.execute({ + projectRoot, + scope: "user", + manifestRepo: deps.userManifestRepo, + toolIds, + recreateFrameworkIfMissing: true, + }); + printActivationOutcome(output, activation); + if (activation.errors.length > 0) throw new SyncFailedError(activation.errors); + printUserScopeSyncOutcome(output, activation.activated); +} + +async function runSyncAction( + program: Command, + fileArgs: string[], + cmdOptions: SyncCmdOptions +): Promise { + const { verbose, output, projectRoot } = parseGlobalOptions(program); + const errorHandler = new ErrorHandler(output); + try { + const deps = await createDeps(projectRoot, { verbose }, output); + const scope = parseScopeFlag(cmdOptions.scope, output) ?? "project"; + + if (scope === "user") { + await runUserScopeSync(deps, output, projectRoot, fileArgs, cmdOptions); + return; + } + + if (cmdOptions.tool !== undefined) { + await runScopedSync(deps, output, projectRoot, fileArgs, cmdOptions); + return; + } + + const interactive = !cmdOptions.force && process.stdout.isTTY; + const result = await deps.restoreAllUseCase.execute(projectRoot, cmdOptions.force, interactive); + printRestoreOutcome(output, result); + + // After restoration, never before: activation drives the host CLI that writes into the + // settings file restoration regenerates, which must be on disk before that CLI runs. + const activation = await deps.marketplaceSyncSettingsUseCase.execute({ + projectRoot, + recreateFrameworkIfMissing: true, + }); + printActivationOutcome(output, activation); + + // A run that errored synced nothing for that scope, so reporting success would name the + // unhealthy state healthy. `errorHandler` turns this into the non-zero exit. + const errors = [...result.errors, ...activation.errors]; + if (errors.length > 0) throw new SyncFailedError(errors); + } catch (error) { + errorHandler.handle(error); + } +} + +async function runScopedSync( + deps: Awaited>, + output: CLIOutput, + projectRoot: string, + fileArgs: string[], + cmdOptions: SyncCmdOptions +): Promise { + const toolId = cmdOptions.tool as ToolId; + const manifest = await deps.manifestRepo.load(); + if (!manifest) throw new NoManifestError(); + const version = manifest.getToolVersion(toolId) ?? deps.currentVersionProvider.get(); + const result = await deps.restoreUseCase.execute({ + version, + projectRoot, + toolIds: [toolId], + files: fileArgs.length > 0 ? fileArgs : undefined, + force: cmdOptions.force, + interactive: process.stdout.isTTY, + manifest, + pluginName: cmdOptions.plugin, + }); + printToolRestoreOutcome(output, result); + + // Same order as the full sync, narrowed to this one tool so fixing it re-drives no other + // installed tool's activation. + const activation = await deps.marketplaceSyncSettingsUseCase.execute({ + projectRoot, + toolIds: [toolId], + recreateFrameworkIfMissing: true, + }); + printActivationOutcome(output, activation); + if (activation.errors.length > 0) throw new SyncFailedError(activation.errors); +} + +export function registerSyncCommand(program: Command): void { + program + .command("sync") + .description( + "Rewrite owned files from what is already there — regenerate tracked files, driven by the manifest (see `translate`, which converts a source without recording anything)" + ) + .argument("[files...]", "Limit sync to specific tracked files") + .option("-f, --force", "Sync without prompting", false) + .option("--tool ", "Limit sync to a specific tool") + .option("--plugin ", "Limit sync to a specific plugin") + .option( + "--scope ", + "project (default) resolves this project's own manifest; user resolves the " + + "machine-wide manifest --scope user setup wrote, restoring no project files" + ) + .action(async (fileArgs: string[], cmdOptions: SyncCmdOptions) => { + await runSyncAction(program, fileArgs, cmdOptions); + }); +} diff --git a/cli/src/presentation/commands/telemetry.ts b/cli/src/presentation/commands/telemetry.ts new file mode 100644 index 000000000..90b0ffffb --- /dev/null +++ b/cli/src/presentation/commands/telemetry.ts @@ -0,0 +1,317 @@ +import type { Command } from "commander"; +import { toCostReportEnvelope } from "../../contexts/telemetry/domain/cost-report-envelope.js"; +import { + DEFAULT_REPORT_DAYS, + resolveReportPeriod, +} from "../../contexts/telemetry/domain/report-period.js"; +import { telemetryRemovalIsEmpty } from "../../contexts/telemetry/domain/telemetry-removal.js"; +import { createDeps } from "../../runtime/wiring/framework.js"; +import { ARTEFACT_AXES, buildCostReportArtefact } from "../display/cost-report-artefact.js"; +import { printCostReport } from "../display/cost-report-display.js"; +import { printTelemetryCheckReport } from "../display/telemetry-check-display.js"; +import { + printLocalCostReadReport, + printPersonIdentityLink, + printPersonIdentityOff, + printPersonIdentityStatus, + printPersonIdentityUnlink, + printPersonIdentityUse, + printTelemetryOffReport, + printTelemetryOnReport, + warnIfFiguresMoveTheTokenToo, +} from "../display/telemetry-display.js"; +import { + printTelemetryForgetPreview, + printTelemetryForgetRefused, + printTelemetryForgetResult, +} from "../display/telemetry-forget-display.js"; +import { ErrorHandler } from "../error-handler.js"; +import { parseGlobalOptions } from "./global-options.js"; + +export function registerTelemetryCommand(program: Command): void { + const telemetry = program + .command("telemetry") + .description("Control whether AIDD may measure this project"); + + telemetry + .command("on") + .description("Turn on the AIDD telemetry switch and git-ignore the run journal") + .option( + "--yes", + "Confirm writing the git-tracked switch — this turns measurement on for everyone who clones", + false + ) + .action(async (cmdOptions: { yes: boolean }) => { + const { verbose, output, projectRoot } = parseGlobalOptions(program); + const errorHandler = new ErrorHandler(output); + try { + const deps = await createDeps(projectRoot, { verbose }, output); + const result = await deps.telemetryOnUseCase.execute({ + projectRoot, + confirmed: cmdOptions.yes, + }); + printTelemetryOnReport(output, result); + } catch (error) { + errorHandler.handle(error); + } + }); + + telemetry + .command("read") + .description( + "Read what sessions cost from the files their tools already wrote, with no process running" + ) + .option( + "--session ", + "One session to read. Omitted, every session the run journal knows is read" + ) + .action(async (cmdOptions: { session?: string }) => { + const { verbose, output, projectRoot } = parseGlobalOptions(program); + const errorHandler = new ErrorHandler(output); + try { + const deps = await createDeps(projectRoot, { verbose }, output); + warnIfFiguresMoveTheTokenToo(output, deps.telemetrySink); + const result = await deps.readLocalCostUseCase.execute({ + projectRoot, + env: process.env, + ...(cmdOptions.session === undefined ? {} : { sessionId: cmdOptions.session }), + }); + printLocalCostReadReport(output, result); + } catch (error) { + errorHandler.handle(error); + } + }); + + registerTelemetryIdentityCommand(telemetry, program); + registerTelemetryCheckCommand(telemetry, program); + + telemetry + .command("report") + .description( + "Report what a period, or one task inside it, cost — tokens, models and steps, with how strongly each was attributed" + ) + .option("--from ", "First UTC day to report, as YYYY-MM-DD") + .option("--to ", "Last UTC day to report, as YYYY-MM-DD (default today)") + .option( + "--days ", + `How many days back to report, ending at --to (default ${DEFAULT_REPORT_DAYS})` + ) + .option( + "--task ", + "Restrict to the sessions that wrote into this task, as /" + ) + .option("--project ", "Restrict to this project") + .option("--step ", "Restrict to this step") + .option("--model ", "Restrict to this model") + .option("--tool ", "Restrict to this tool") + .option( + "--axis ", + `Print one axis as a table to paste elsewhere: ${ARTEFACT_AXES.join(" | ")}` + ) + .option("--json", "Print one object a program can parse, instead of text for a person") + .action( + async (cmdOptions: { + from?: string; + to?: string; + days?: string; + task?: string; + project?: string; + step?: string; + model?: string; + tool?: string; + axis?: string; + json?: boolean; + }) => { + const { verbose, output, projectRoot } = parseGlobalOptions(program); + const errorHandler = new ErrorHandler(output); + try { + // The clock is read once, here, and never again: everything downstream works from + // the two absolute days this resolves to, so the same call answers the same twice. + const period = resolveReportPeriod(cmdOptions, new Date()); + const deps = await createDeps(projectRoot, { verbose }, output); + warnIfFiguresMoveTheTokenToo(output, deps.telemetrySink); + const report = await deps.reportCostUseCase.execute({ + period, + projectRoot, + env: process.env, + ...(cmdOptions.task === undefined ? {} : { task: cmdOptions.task }), + filters: { + ...(cmdOptions.project === undefined ? {} : { project: cmdOptions.project }), + ...(cmdOptions.step === undefined ? {} : { step: cmdOptions.step }), + ...(cmdOptions.model === undefined ? {} : { model: cmdOptions.model }), + ...(cmdOptions.tool === undefined ? {} : { tool: cmdOptions.tool }), + }, + }); + // One value, three renderings, none deriving a figure the others cannot see: both + // `--json` and `--axis` read the envelope the terminal rendering is built from. + if (cmdOptions.json) output.print(JSON.stringify(toCostReportEnvelope(report), null, 2)); + else if (cmdOptions.axis !== undefined) + output.print(buildCostReportArtefact(toCostReportEnvelope(report), cmdOptions.axis)); + else printCostReport(output, report); + } catch (error) { + errorHandler.handle(error); + } + } + ); + + telemetry + .command("off") + .description( + "Turn off the AIDD telemetry switch, warning if a tool's own settings file still exports" + ) + .action(async () => { + const { verbose, output, projectRoot } = parseGlobalOptions(program); + const errorHandler = new ErrorHandler(output); + try { + const deps = await createDeps(projectRoot, { verbose }, output); + const result = await deps.telemetryOffUseCase.execute({ projectRoot }); + printTelemetryOffReport(output, result); + } catch (error) { + errorHandler.handle(error); + } + }); + + telemetry + .command("forget") + .description( + "Irreversibly remove what this tool measured: this project's run journal, this " + + "machine's stored records, and this machine's identity file" + ) + .option( + "--yes", + "Confirm removal after seeing what would go — without it, nothing is removed", + false + ) + .action(async (cmdOptions: { yes: boolean }) => { + const { verbose, output, projectRoot } = parseGlobalOptions(program); + const errorHandler = new ErrorHandler(output); + try { + const deps = await createDeps(projectRoot, { verbose }, output); + const preview = await deps.forgetTelemetryUseCase.preview({ projectRoot }); + printTelemetryForgetPreview(output, preview); + if (telemetryRemovalIsEmpty(preview)) return; + if (!cmdOptions.yes) { + printTelemetryForgetRefused(output); + return; + } + const result = await deps.forgetTelemetryUseCase.remove(preview); + printTelemetryForgetResult(output, result); + } catch (error) { + errorHandler.handle(error); + } + }); +} + +/** Whether the measurement chain is actually recording, not merely installed: a hook that + * fired, a session that closed, a tool's own files that can be read, and the two joining. */ +function registerTelemetryCheckCommand(telemetry: Command, program: Command): void { + telemetry + .command("check") + .description("Check whether the measurement chain is actually recording for this project") + .action(async () => { + const { verbose, output, projectRoot } = parseGlobalOptions(program); + const errorHandler = new ErrorHandler(output); + try { + const deps = await createDeps(projectRoot, { verbose }, output); + const result = await deps.diagnoseTelemetryUseCase.execute({ + projectRoot, + env: process.env, + }); + printTelemetryCheckReport(output, result); + // A gated run judges nothing (measurement off, no repository), so it never fails + // the process; only a claim this run actually judged and found wanting does. + if (result.gate === undefined && result.claims.some((claim) => claim.verdict === "fail")) { + process.exitCode = 1; + } + } catch (error) { + errorHandler.handle(error); + } + }); +} + +/** Whether this person's own identifier is attached to what `aidd telemetry read` stores: + * never a project's choice, and never the `telemetry on`/`off` switch beside it. */ +function registerTelemetryIdentityCommand(telemetry: Command, program: Command): void { + const identity = telemetry + .command("identity") + .description("Whether this person's own identifier is attached to records read locally"); + // The bare noun is a question, so it answers with state rather than a help screen. + // `--help` still prints the help. + identity.action(async () => { + const { verbose, output, projectRoot } = parseGlobalOptions(program); + const errorHandler = new ErrorHandler(output); + try { + const deps = await createDeps(projectRoot, { verbose }, output); + printPersonIdentityStatus(output, await deps.personIdentityUseCase.status()); + } catch (error) { + errorHandler.handle(error); + } + }); + + identity + .command("use [identifier]") + .description( + "Mint this person's identifier, or take one minted on another machine. --name attaches a display name" + ) + .option("--name ", "A display name for whichever identifier this call settles on") + .action(async (identifier: string | undefined, cmdOptions: { name?: string }) => { + const { verbose, output, projectRoot } = parseGlobalOptions(program); + const errorHandler = new ErrorHandler(output); + try { + const deps = await createDeps(projectRoot, { verbose }, output); + printPersonIdentityUse( + output, + await deps.personIdentityUseCase.use({ + ...(identifier === undefined ? {} : { identifier }), + ...(cmdOptions.name === undefined ? {} : { displayName: cmdOptions.name }), + }) + ); + } catch (error) { + errorHandler.handle(error); + } + }); + + identity + .command("off") + .description("Opt out: new records carry no person, from now on") + .action(async () => { + const { verbose, output, projectRoot } = parseGlobalOptions(program); + const errorHandler = new ErrorHandler(output); + try { + const deps = await createDeps(projectRoot, { verbose }, output); + printPersonIdentityOff(output, await deps.personIdentityUseCase.off()); + } catch (error) { + errorHandler.handle(error); + } + }); + + identity + .command("link ") + .description( + "Add an identifier this person cannot choose onto this same person - one row, not two, in a report" + ) + .action(async (rawIdentity: string) => { + const { verbose, output, projectRoot } = parseGlobalOptions(program); + const errorHandler = new ErrorHandler(output); + try { + const deps = await createDeps(projectRoot, { verbose }, output); + printPersonIdentityLink(output, await deps.personIdentityUseCase.link(rawIdentity)); + } catch (error) { + errorHandler.handle(error); + } + }); + + identity + .command("unlink ") + .description("Withdraw an added identifier from this person") + .action(async (rawIdentity: string) => { + const { verbose, output, projectRoot } = parseGlobalOptions(program); + const errorHandler = new ErrorHandler(output); + try { + const deps = await createDeps(projectRoot, { verbose }, output); + printPersonIdentityUnlink(output, await deps.personIdentityUseCase.unlink(rawIdentity)); + } catch (error) { + errorHandler.handle(error); + } + }); +} diff --git a/cli/src/presentation/commands/translate.ts b/cli/src/presentation/commands/translate.ts new file mode 100644 index 000000000..ba6dd806f --- /dev/null +++ b/cli/src/presentation/commands/translate.ts @@ -0,0 +1,108 @@ +import { resolve } from "node:path"; +import type { Command } from "commander"; +import type { FrameworkBuildMode } from "../../contexts/tools/domain/registry.js"; +import { + type FrameworkBuildTarget, + supportedBuildTargets, +} from "../../contexts/translate/domain/build-target.js"; +import { createDeps } from "../../runtime/wiring/framework.js"; +import { createFrameworkBuildUseCase } from "../../runtime/wiring/translate.js"; +import { printTranslateResult } from "../display/translate-display.js"; +import { ErrorHandler } from "../error-handler.js"; +import type { CLIOutput } from "../output.js"; +import { parseGlobalOptions } from "./global-options.js"; + +interface TranslateExecutionParams { + projectRoot: string; + verbose: boolean; + output: CLIOutput; + sourceDir: string; + outDir: string; + target: FrameworkBuildTarget; + mode: FrameworkBuildMode; + force: boolean; +} + +/** The build+report core, once `--to`/`--as`/`--out` flags are validated and resolved. */ +async function runTranslateCore(params: TranslateExecutionParams): Promise { + const errorHandler = new ErrorHandler(params.output); + try { + const deps = await createDeps(params.projectRoot, { verbose: params.verbose }, params.output); + const useCase = createFrameworkBuildUseCase(deps, { + target: params.target, + mode: params.mode, + outDir: params.outDir, + force: params.force, + }); + if (useCase === undefined) { + params.output.error( + `Unsupported target/mode combination: ${params.target} (${params.mode}).` + ); + process.exit(1); + } + const result = await useCase.execute({ + sourceDir: params.sourceDir, + outDir: params.outDir, + target: params.target, + mode: params.mode, + }); + printTranslateResult(params.output, params.mode, { + pluginCount: result.plugins.length, + totalFiles: result.totalFiles, + outDir: result.outDir, + }); + } catch (error) { + errorHandler.handle(error); + } +} + +interface TranslateCmdOptions { + to: string; + out: string; + as?: string; + force?: boolean; +} + +export function registerTranslateCommand(program: Command): void { + program + .command("translate") + .description( + "Convert an arbitrary source into a target-native plugin tree — records nothing (see `sync` for the manifest-driven, tracked version)" + ) + .argument("", "Path to the source framework directory") + .requiredOption("--to ", "Conversion target (claude, cursor, copilot, codex, opencode)") + .requiredOption("--out ", "Output directory (marketplace dist or project root)") + .option("--as ", "Output layout", "marketplace") + .option("--force", "Overwrite existing files at canonical paths under --out") + .action(async (source: string, cmdOptions: TranslateCmdOptions) => { + const { verbose, output, projectRoot } = parseGlobalOptions(program); + + const targets = supportedBuildTargets(); + if (!(targets as readonly string[]).includes(cmdOptions.to)) { + output.error( + `Unsupported target '${cmdOptions.to}'. Supported targets: ${targets.join(", ")}.` + ); + process.exit(1); + } + if ( + cmdOptions.as !== undefined && + cmdOptions.as !== "marketplace" && + cmdOptions.as !== "flat" + ) { + output.error(`Invalid --as '${cmdOptions.as}'. Expected 'marketplace' or 'flat'.`); + process.exit(1); + } + const mode: FrameworkBuildMode = cmdOptions.as === "flat" ? "flat" : "marketplace"; + + await runTranslateCore({ + projectRoot, + verbose, + output, + sourceDir: resolve(projectRoot, source), + outDir: resolve(projectRoot, cmdOptions.out), + target: cmdOptions.to as FrameworkBuildTarget, + mode, + force: cmdOptions.force ?? false, + }); + }); +} diff --git a/cli/src/presentation/commands/update.ts b/cli/src/presentation/commands/update.ts new file mode 100644 index 000000000..88621f35b --- /dev/null +++ b/cli/src/presentation/commands/update.ts @@ -0,0 +1,46 @@ +import type { Command } from "commander"; +import { createDeps } from "../../runtime/wiring/framework.js"; +import { printSelfUpdateResult } from "../display/update-display.js"; +import { ErrorHandler } from "../error-handler.js"; +import { parseGlobalOptions } from "./global-options.js"; + +interface UpdateCmdOptions { + check: boolean; + dryRun: boolean; + force: boolean; +} + +/** A bare verb with no subject means the CLI itself, the convention Claude Code and Codex + * share. The project-wide sweep lives at `framework update`, `plugin update` and + * `marketplace refresh`. */ +async function runUpdateAction(program: Command, cmdOptions: UpdateCmdOptions): Promise { + const { verbose, output, projectRoot } = parseGlobalOptions(program); + const errorHandler = new ErrorHandler(output); + + try { + const deps = await createDeps(projectRoot, { verbose }, output); + + const result = await deps.selfUpdateUseCase.execute({ + check: cmdOptions.check, + dryRun: cmdOptions.dryRun, + force: cmdOptions.force, + }); + + printSelfUpdateResult(output, result); + } catch (error) { + errorHandler.handle(error); + } +} + +export function registerUpdateCommand(program: Command): void { + program + .command("update") + .alias("upgrade") + .description("Update the aidd CLI itself to the latest version") + .option("--check", "Check if a newer version is available without installing", false) + .option("--dry-run", "Preview the update without installing", false) + .option("-f, --force", "Reinstall even if already up to date", false) + .action(async (cmdOptions: UpdateCmdOptions) => { + await runUpdateAction(program, cmdOptions); + }); +} diff --git a/cli/src/presentation/display/auth-display.ts b/cli/src/presentation/display/auth-display.ts new file mode 100644 index 000000000..d1576506c --- /dev/null +++ b/cli/src/presentation/display/auth-display.ts @@ -0,0 +1,28 @@ +import type { AuthLevel } from "../../runtime/auth/auth.js"; +import type { AuthLogoutResult, AuthStatus } from "../../runtime/auth/ports/credential-store.js"; +import type { CLIOutput } from "../output.js"; + +export function printAuthenticated(output: CLIOutput, login: string, level: AuthLevel): void { + output.success(`Authenticated as ${login} (${level})`); +} + +export function printAuthStatus(output: CLIOutput, status: AuthStatus): void { + if (!status.authenticated) { + output.info("Not authenticated."); + return; + } + printAuthenticated(output, status.login, status.level); +} + +export function printLogoutResult(output: CLIOutput, result: AuthLogoutResult): void { + if (!result.found) { + output.info("Not authenticated."); + return; + } + if (result.hint === "external-provider-cleanup") { + output.info( + "To fully logout, run the external provider's logout command (e.g. gh auth logout)." + ); + } + output.success(`Logged out (${result.level})`); +} diff --git a/cli/src/presentation/display/clean-display.ts b/cli/src/presentation/display/clean-display.ts new file mode 100644 index 000000000..081e4e24a --- /dev/null +++ b/cli/src/presentation/display/clean-display.ts @@ -0,0 +1,120 @@ +import { FRAMEWORK_MARKETPLACE_NAME } from "../../contexts/distribution/domain/marketplace.js"; +import type { CLIOutput } from "../output.js"; + +interface ProjectCleanResult { + readonly manifestFound: boolean; + readonly dryRun: boolean; + readonly fileCount: number; + readonly preview: { + readonly tools: readonly { readonly toolId: string; readonly fileCount: number }[]; + readonly nativeRegistrations: readonly { + readonly toolId: string; + readonly binary: string; + readonly marketplaceCount: number; + readonly pluginRefCount: number; + readonly cachePaths: readonly string[]; + }[]; + readonly sharedSourceOtherProjects?: readonly string[]; + readonly totalFileCount: number; + }; +} + +interface UserScopeCleanResult { + readonly dryRun: boolean; + readonly manifestFound: boolean; + readonly preview: { + readonly toolIds: readonly string[]; + readonly builtVersions: readonly string[]; + readonly referencingProjects: readonly string[]; + }; +} + +function printProjectPreview(output: CLIOutput, preview: ProjectCleanResult["preview"]): void { + output.print("The following will be removed:"); + for (const tool of preview.tools) { + output.print(` ${tool.toolId}: ${tool.fileCount} files`); + } + output.print(" manifest: .aidd/ (config.json, if present, is kept)"); + for (const registration of preview.nativeRegistrations) { + output.print( + ` ${registration.toolId}: ${registration.binary} will be asked to unregister ${registration.pluginRefCount} plugin ref(s) and ${registration.marketplaceCount} marketplace(s)` + ); + for (const cachePath of registration.cachePaths) { + output.print(` cache to purge once unregistered: ${cachePath}`); + } + } + if (preview.sharedSourceOtherProjects !== undefined) { + const otherProjects = preview.sharedSourceOtherProjects; + const projects = otherProjects.length > 0 ? otherProjects.join(", ") : "no other project"; + output.print(` aidd-framework: shared source, still referenced by: ${projects}`); + } +} + +export function printProjectCleanOutcome( + output: CLIOutput, + result: ProjectCleanResult, + interactive: boolean +): void { + if (!result.manifestFound) { + output.success("Nothing to clean"); + return; + } + + if (result.dryRun) { + printProjectPreview(output, result.preview); + const toolCount = result.preview.tools.length; + if (interactive) { + output.print("No files removed."); + } else { + output.success( + `Would remove ${result.preview.totalFileCount} ${result.preview.totalFileCount === 1 ? "file" : "files"} across ${toolCount} ${toolCount === 1 ? "tool" : "tools"}. Use --force to confirm.` + ); + } + return; + } + + output.success(`Cleaned all AIDD files (${result.fileCount} files removed)`); +} + +/** Names what `--scope user` is about to purge before anything is removed: the shared + * source's versions and the projects `references.json` names, existing paths only. */ +function printUserScopePreview(output: CLIOutput, result: UserScopeCleanResult): void { + const { preview } = result; + output.print("The following will be removed for this machine:"); + for (const toolId of preview.toolIds) { + output.print(` ${toolId}: registration will be undone through its own CLI`); + } + const versions = + preview.builtVersions.length > 0 ? preview.builtVersions.join(", ") : "none built yet"; + output.print(` ${FRAMEWORK_MARKETPLACE_NAME}: shared source (versions: ${versions})`); + const projects = + preview.referencingProjects.length > 0 + ? preview.referencingProjects.join(", ") + : "no other project"; + output.print(` still referenced by: ${projects}`); +} + +export function printUserScopeCleanOutcome( + output: CLIOutput, + result: UserScopeCleanResult, + interactive: boolean +): void { + if (result.dryRun) { + printUserScopePreview(output, result); + if (interactive) { + output.print("No files removed."); + } else { + output.success("Use --force to confirm."); + } + return; + } + + if (!result.manifestFound) { + // The use case already logged that no user-scope manifest existed, so no host + // registration was there to undo; this names the whitelist purge alone. + output.success(`Purged the shared ${FRAMEWORK_MARKETPLACE_NAME} source's machine-local state`); + return; + } + + output.success(`Cleaned the shared ${FRAMEWORK_MARKETPLACE_NAME} source for this machine`); +} diff --git a/cli/src/presentation/display/cost-report-artefact.ts b/cli/src/presentation/display/cost-report-artefact.ts new file mode 100644 index 000000000..d1ecc46a5 --- /dev/null +++ b/cli/src/presentation/display/cost-report-artefact.ts @@ -0,0 +1,451 @@ +import type { + CostReportEmptySelection, + CostReportFilterName, + CostReportFilters, +} from "../../contexts/telemetry/domain/cost-report.js"; +import { fromMicroUsd } from "../../contexts/telemetry/domain/cost-report.js"; +import type { + CostReportEnvelope, + CostReportEnvelopePersonRow, + CostReportEnvelopeTotals, +} from "../../contexts/telemetry/domain/cost-report-envelope.js"; +import { bareOrchestratingSkillNames } from "../../contexts/telemetry/domain/flow-attribution.js"; +import type { PersonResolution } from "../../contexts/telemetry/domain/person-resolution.js"; +import { getAiToolConfig } from "../../contexts/tools/domain/registry.js"; +import { + ATTRIBUTION_LABELS, + BACKLOG_DECLARATION_LABELS, + TASK_ATTRIBUTION_LABELS, + TASK_UNATTRIBUTED_LABELS, +} from "./cost-report-display.js"; + +/** One axis, as a markdown table a person pastes elsewhere: it drops the share column the + * terminal rendering adds, since a table leaving the session that made it carries figures and + * not a percentage of them. It reads the envelope, the shape a consumer already parses. */ +export const ARTEFACT_AXES = [ + "total", + "day", + "step", + "model", + "agent", + "prompt", + "task", + "backlog", + "flow", + "tool", + "project", + "person", +] as const; + +export type ArtefactAxis = (typeof ARTEFACT_AXES)[number]; + +const NO_PROMPT_LABEL = "no prompt named"; +const UNKNOWN_AMOUNT = "amount unknown"; +const NOTHING_MEASURED = "nothing in this period"; +const NOTHING_IN_SELECTION = "nothing in this selection"; +const SESSION_TOTAL_LABEL = "session total, not requests"; +const NO_KNOWN_PROJECT = "no known project"; +const NO_KNOWN_MODEL = "no known model"; +// Not "no agent": the main thread is where a session starts, not an absence. +const MAIN_THREAD = "the main thread"; +// A tool that names no agent has said nothing about which one ran, so calling that row "the +// main thread" would state a fact nothing observed. +const AGENT_NOT_STATED = "the tool names no agent"; +// Distinct on purpose: an unresolved row names a real but unplaced identity and repeats once +// per identity, while the no-identity row is singular. Neither is a shared bucket. +const NO_PERSON_IDENTIFIER = "no identity — nobody opted in"; +function unresolvedPersonLabel(identity: string): string { + return `unresolved — not mapped to anyone (${identity})`; +} + +const UNKNOWN_REASON: Partial> = { + task: "no journal has ever declared it or written into it", + tool: "it is not one of the tools this build knows", +}; + +function count(value: number): string { + return value.toLocaleString("en-US"); +} + +function amount(microUsd: number): string { + return `$${fromMicroUsd(microUsd).toFixed(2)}`; +} + +/** The four counters are disjoint on every reader here, so adding them counts nothing twice. */ +function envelopeTokens(totals: CostReportEnvelopeTotals): number { + return ( + (totals.input_tokens ?? 0) + + (totals.output_tokens ?? 0) + + (totals.cache_read_tokens ?? 0) + + (totals.cache_creation_tokens ?? 0) + ); +} + +function hasSelection(envelope: CostReportEnvelope): boolean { + return envelope.task !== undefined || envelope.filters !== undefined; +} + +function nothingLabel(envelope: CostReportEnvelope): string { + return hasSelection(envelope) ? NOTHING_IN_SELECTION : NOTHING_MEASURED; +} + +function figure(totals: CostReportEnvelopeTotals, envelope: CostReportEnvelope): string { + if (totals.requests === 0) return nothingLabel(envelope); + const cost = totals.cost_micro_usd === undefined ? UNKNOWN_AMOUNT : amount(totals.cost_micro_usd); + return `${cost} — ${count(envelopeTokens(totals))} tokens, ${count(totals.requests)} requests`; +} + +function filtersSuffix(filters: CostReportFilters | undefined): string { + if (!filters) return ""; + const parts = Object.entries(filters).map(([name, value]) => `${name}=${value}`); + return parts.length === 0 ? "" : `, filters: ${parts.join(", ")}`; +} + +/** Carried on every axis's own header, so a figure copied out of the session that made it + * stays placeable without the command that produced it. Never worded "measurement is off" + * bare: the sink is scoped to this person, not this project, so an off switch contradicts no + * figure beside it. */ +function measurementSuffix(envelope: CostReportEnvelope): string { + return envelope.measurement_enabled + ? "" + : " — this project's switch is off, figures are the whole sink, not scoped to it"; +} + +function header(envelope: CostReportEnvelope, axisLabel: string): string { + const { from_day, to_day } = envelope.period; + const task = envelope.task === undefined ? "" : `, task ${envelope.task}`; + return ( + `period ${from_day} to ${to_day}${task}${filtersSuffix(envelope.filters)} — axis: ${axisLabel}` + + measurementSuffix(envelope) + ); +} + +function unknownReason(filter: CostReportFilterName): string { + return UNKNOWN_REASON[filter] ?? `no record has ever named this ${filter}`; +} + +function emptySelectionMessage({ + filter, + value, + known, + combination, +}: CostReportEmptySelection): string { + if (!known) return `${filter} '${value}' matched nothing — ${unknownReason(filter)}`; + if (combination) + return `${filter} '${value}' matched nothing combined with the rest of this selection`; + return `${filter} '${value}' matched nothing in this selection — known, but no work here`; +} + +/** What the read could not do travels with what it did: a total assembled from a partial read + * is otherwise indistinguishable from a complete one. `identity_unusable === "absent"` is the + * ordinary default rather than damage, so only the person axis states it. */ +function caveats( + envelope: CostReportEnvelope, + { includeAbsentIdentityCaveat = false }: { includeAbsentIdentityCaveat?: boolean } = {} +): readonly string[] { + const lines: string[] = []; + if (envelope.empty_selection !== undefined) { + lines.push(emptySelectionMessage(envelope.empty_selection)); + } + if (envelope.read.undated_records > 0) { + lines.push( + `${count(envelope.read.undated_records)} records carry no moment and are in no period` + ); + } + if (envelope.read.unreadable_lines > 0) { + lines.push(`${count(envelope.read.unreadable_lines)} lines could not be read`); + } + if (envelope.read.identity_unusable === "unreadable") { + lines.push( + "this machine's own identity could not be read; every identifier is reported unresolved" + ); + } else if (envelope.read.identity_unusable === "absent" && includeAbsentIdentityCaveat) { + lines.push("no identity was declared; every identifier is reported unresolved"); + } + return lines; +} + +function table( + envelope: CostReportEnvelope, + axisLabel: string, + column: string, + rows: readonly string[] +): string { + return [ + header(envelope, axisLabel), + "", + `| ${column} | Total |`, + "| --- | --- |", + ...rows, + ...caveats(envelope), + ].join("\n"); +} + +/** One total, in a line: the answer to "what did this cost". */ +function totalArtefact(envelope: CostReportEnvelope): string { + return [ + header(envelope, "total"), + "", + figure(envelope.totals, envelope), + ...caveats(envelope), + ].join("\n"); +} + +/** Every day the period spans, gaps included and never capped the way a terminal caps a long + * series: dropping rows in a file is the false continuity the cap exists to prevent. */ +function dayArtefact(envelope: CostReportEnvelope): string { + return table( + envelope, + "by day", + "Day", + envelope.by_day.map((row) => `| ${row.day} | ${figure(row.totals, envelope)} |`) + ); +} + +/** Two rows can share one step name — the same skill reached from a tool's own statement and + * from a journal interval is two claims, never one — so this axis carries a third column: + * without it the pair is indistinguishable from one step double-counted. */ +function stepArtefact(envelope: CostReportEnvelope): string { + const rows = envelope.by_step.map((row) => { + const step = row.step ?? "unattributed"; + return `| ${step} | ${ATTRIBUTION_LABELS[row.attribution]} | ${figure(row.totals, envelope)} |`; + }); + return [ + header(envelope, "by step"), + "", + "| Step | Attribution | Total |", + "| --- | --- | --- |", + ...rows, + ...caveats(envelope), + ].join("\n"); +} + +/** A third column for the same reason `stepArtefact` carries one: a named task's row rests on + * a closed interval and the table must say so. A row for what fell in no declared interval + * carries no attribution, only its reason. */ +function taskArtefact(envelope: CostReportEnvelope): string { + const rows = envelope.by_task.map((row) => { + const task = row.task ?? (row.reason === undefined ? "" : TASK_UNATTRIBUTED_LABELS[row.reason]); + const strength = row.attribution === undefined ? "—" : TASK_ATTRIBUTION_LABELS[row.attribution]; + return `| ${task} | ${strength} | ${figure(row.totals, envelope)} |`; + }); + return [ + header(envelope, "by task"), + "", + "| Task | Attribution | Total |", + "| --- | --- | --- |", + ...rows, + ...caveats(envelope), + ].join("\n"); +} + +/** No third column, unlike `taskArtefact`: every named backlog row rests on the same single + * route, so there is no second strength to distinguish. */ +function backlogArtefact(envelope: CostReportEnvelope): string { + const rows = envelope.by_backlog.map((row) => { + const name = + row.backlog ?? + (row.declaration !== undefined + ? BACKLOG_DECLARATION_LABELS[row.declaration] + : row.reason !== undefined + ? TASK_UNATTRIBUTED_LABELS[row.reason] + : ""); + return `| ${name} | ${figure(row.totals, envelope)} |`; + }); + return table(envelope, "by backlog", "Backlog item", rows); +} + +const OUTSIDE_EVERY_FLOW_LABEL = "outside any flow"; + +/** Standing properties of how a flow is read, never a damaged read the way `caveats()`'s lines + * are, which is why they are assembled apart. Each set is gated on a row it describes: a limit + * about a mechanism that never ran is noise. */ +function flowLimits(envelope: CostReportEnvelope): readonly string[] { + return [...journalFlowLimits(envelope), ...toolStatedFlowLimits(envelope)]; +} + +/** Both properties of walking the journal's own step sequence, so both are gated on a row + * that walk produced. */ +function journalFlowLimits(envelope: CostReportEnvelope): readonly string[] { + if (!envelope.by_flow.some((row) => row.attribution === "journal-interval")) return []; + return [ + "a skill run by hand while a flow was open is counted inside it: the orchestrator's own " + + "call and a person's write the identical step_start line", + `a skill of this project named ${orAny(bareOrchestratingSkillNames())} opens a flow of ` + + "its own: outside a plugin a host names a skill by its folder alone, and this axis " + + "has only that name to go on", + ]; +} + +/** A flow no interval bounded is a name, and a name cannot say how many runs it stands for: + * a reader taking it for one run reads its total as one orchestration's cost. */ +function toolStatedFlowLimits(envelope: CostReportEnvelope): readonly string[] { + if (!envelope.by_flow.some((row) => row.attribution === "tool-stated")) return []; + return [ + "a flow only a record's own tool named is every run of that skill at once: its journal " + + "opened no flow to bound one run from the next, so the row has no opening moment and " + + "its total is not one orchestration's", + ]; +} + +/** `a`, `a or b`, `a, b or c`: however many names there are, the sentence stays grammatical. */ +function orAny(names: readonly string[]): string { + if (names.length <= 1) return names[0] ?? ""; + return `${names.slice(0, -1).join(", ")} or ${names[names.length - 1]}`; +} + +/** Two extra columns, for the same reason `stepArtefact` carries one: two rows can share a + * `flow` name, and `Attribution` plus `Opened at` are what keep them from reading as one flow + * double-counted. A `tool-stated` row is a bucket with no single opening moment, so it prints + * an em dash there, as does work outside every flow. */ +function flowArtefact(envelope: CostReportEnvelope): string { + const rows = envelope.by_flow.map((row) => { + const flow = row.flow ?? OUTSIDE_EVERY_FLOW_LABEL; + const openedAt = row.started_at ?? "—"; + const attribution = ATTRIBUTION_LABELS[row.attribution]; + return `| ${flow} | ${attribution} | ${openedAt} | ${figure(row.totals, envelope)} |`; + }); + return [ + header(envelope, "by flow"), + "", + "| Flow | Attribution | Opened at | Total |", + "| --- | --- | --- | --- |", + ...rows, + ...flowLimits(envelope), + ...caveats(envelope), + ].join("\n"); +} + +function agentArtefact(envelope: CostReportEnvelope): string { + return table( + envelope, + "by agent", + "Agent", + envelope.by_agent.map( + (row) => + `| ${row.agent ?? (row.attribution === "main-thread" ? MAIN_THREAD : AGENT_NOT_STATED)} | ${figure(row.totals, envelope)} |` + ) + ); +} + +/** The id alone is opaque; the moment its turn began is what a person greps for in their own + * transcript. `—` where a row carries none, never a moment borrowed from another turn. */ +function promptArtefact(envelope: CostReportEnvelope): string { + const rows = envelope.by_prompt.map( + (row) => + `| ${row.prompt ?? NO_PROMPT_LABEL} | ${row.started_at ?? "—"} | ${figure(row.totals, envelope)} |` + ); + return [ + header(envelope, "by prompt"), + "", + "| Prompt | Started at | Total |", + "| --- | --- | --- |", + ...rows, + ...caveats(envelope), + ].join("\n"); +} + +function modelArtefact(envelope: CostReportEnvelope): string { + return table( + envelope, + "by model", + "Model", + envelope.by_model.map( + (row) => `| ${row.model ?? NO_KNOWN_MODEL} | ${figure(row.totals, envelope)} |` + ) + ); +} + +/** A mapped row's own label: its display name when one was set, its canonical identifier + * otherwise — never a raw identity, since a mapped row may carry several. */ +function mappedPersonLabel(row: CostReportEnvelopePersonRow): string { + return row.display_name ?? row.person ?? ""; +} + +/** A `Record` rather than an if-chain with a fallback, so a value added to `PersonResolution` + * fails to compile here instead of reaching a reader as "nobody opted in". */ +const PERSON_LABELS: Record string> = { + mapped: mappedPersonLabel, + // The same label a mapped row gets: it is the same person, and `resolution` already carries + // how it was reached. + "this-machine": mappedPersonLabel, + unresolved: (row) => unresolvedPersonLabel(row.identities[0] ?? ""), + none: () => NO_PERSON_IDENTIFIER, +}; + +function personLabel(row: CostReportEnvelopePersonRow): string { + return PERSON_LABELS[row.resolution](row); +} + +/** A third column, because an auditable person line carries its own evidence: the raw + * identities behind it, not only its label. */ +function personArtefact(envelope: CostReportEnvelope): string { + const rows = envelope.by_person.map((row) => { + const identities = row.identities.length > 0 ? row.identities.join(", ") : "—"; + return `| ${personLabel(row)} | ${identities} | ${figure(row.totals, envelope)} |`; + }); + return [ + header(envelope, "by person"), + "", + "| Person | Identities | Total |", + "| --- | --- | --- |", + ...rows, + ...caveats(envelope, { includeAbsentIdentityCaveat: true }), + ].join("\n"); +} + +function projectArtefact(envelope: CostReportEnvelope): string { + return table( + envelope, + "by project", + "Project", + envelope.by_project.map( + (row) => `| ${row.project ?? NO_KNOWN_PROJECT} | ${figure(row.totals, envelope)} |` + ) + ); +} + +/** A tool that cannot be read is never a zero. One carrying only a session total prints that + * rather than "nothing in this period": measured, but not a sum of requests. */ +function toolArtefact(envelope: CostReportEnvelope): string { + const rows = envelope.by_tool.map((row) => { + const because = row.reason ? ` — ${row.reason}` : ""; + let value: string; + if (row.coverage === "not-covered") { + value = `not covered${because}`; + } else if (row.totals.requests === 0 && row.session_totals) { + value = `${count(envelopeTokens(row.session_totals))} tokens (${SESSION_TOTAL_LABEL})${because}`; + } else { + value = `${figure(row.totals, envelope)}${because}`; + } + return `| ${getAiToolConfig(row.tool).displayName} | ${value} |`; + }); + return table(envelope, "by tool", "Tool", rows); +} + +const BUILDERS: Record string> = { + total: totalArtefact, + day: dayArtefact, + step: stepArtefact, + model: modelArtefact, + agent: agentArtefact, + prompt: promptArtefact, + task: taskArtefact, + backlog: backlogArtefact, + flow: flowArtefact, + tool: toolArtefact, + project: projectArtefact, + person: personArtefact, +}; + +export function isArtefactAxis(value: string): value is ArtefactAxis { + return (ARTEFACT_AXES as readonly string[]).includes(value); +} + +/** An unknown axis is refused by name, with the ones this knows, never guessed at. */ +export function buildCostReportArtefact(envelope: CostReportEnvelope, axis: string): string { + if (!isArtefactAxis(axis)) { + throw new Error(`Unknown axis '${axis}'. Expected one of: ${ARTEFACT_AXES.join(", ")}.`); + } + return BUILDERS[axis](envelope); +} diff --git a/cli/src/presentation/display/cost-report-display.ts b/cli/src/presentation/display/cost-report-display.ts new file mode 100644 index 000000000..b72386e93 --- /dev/null +++ b/cli/src/presentation/display/cost-report-display.ts @@ -0,0 +1,521 @@ +import type { + CostReport, + CostReportAttributionRow, + CostReportBacklogRow, + CostReportDayRow, + CostReportEmptySelection, + CostReportFilterName, + CostReportFilters, + CostReportProjectRow, + CostReportStepRow, + CostReportTaskAttributionRow, + CostReportTaskRow, + CostReportToolRow, + CostTotals, +} from "../../contexts/telemetry/domain/cost-report.js"; +import { fromMicroUsd } from "../../contexts/telemetry/domain/cost-report.js"; +import type { StepAttributionSource } from "../../contexts/telemetry/domain/step-attribution.js"; +import type { + TaskAttributionSource, + TaskUnattributedReason, +} from "../../contexts/telemetry/domain/task-attribution.js"; +import { getAiToolConfig } from "../../contexts/tools/domain/registry.js"; +import type { CLIOutput } from "../output.js"; + +/** `unattributed` says nothing could attribute this, never that the work ran outside every + * step: on at least one measured tool the two are indistinguishable. */ +export const ATTRIBUTION_LABELS: Record = { + "tool-stated": "stated by the tool", + "prompt-matched": "matched on the prompt", + "journal-interval": "from a journal interval", + unattributed: "unattributed", +}; + +export const TASK_ATTRIBUTION_LABELS: Record = { + declared: "declared by the flow", + inferred: "inferred from a written file", +}; + +/** One label per reason, never one standing in for all of them. The wording keeps three kinds + * apart: a fact about the read ("no usable run journal", meaning none was attachable at all, + * against "no usable task declaration", meaning one was read and named nothing), a fact about + * a record's own age, and facts about how the work behaved. A resumed transcript's inherited + * turns were billed before this session opened a journal, so their row is worded as age and + * never as a complaint about declaring. */ +export const TASK_UNATTRIBUTED_LABELS: Record = { + "no-journal": "no usable run journal for this session", + "precedes-journal": "older than anything this session's journal witnessed", + "no-declaration": "no usable task declaration in this session", + "precedes-declaration": "before the next task this session declares", + "journal-silent": "the journal falls silent before this record", +}; + +/** A known task that names no item or whose declaration could not be parsed — distinct from + * `TASK_UNATTRIBUTED_LABELS`, which is about belonging to no task at all. */ +export const BACKLOG_DECLARATION_LABELS: Record<"none" | "unreadable", string> = { + none: "this task declares no backlog item", + unreadable: "this task's backlog declaration could not be read", +}; + +/** Never `$0.00`: a tool whose own files carry no amount has an unknown cost, not a free one. + * Exported so a second renderer prints the identical words. */ +export const UNKNOWN_AMOUNT = "amount unknown"; +/** Distinct from an unknown amount and from a zero: this one really did measure nothing. Not + * exported — outside readers go through `nothingLabel`, which picks between the two. */ +const NOTHING_MEASURED = "nothing in this period"; +/** The same zero under a narrowing selection: `task` and every filter cut the record set + * before any breakdown runs, so saying "period" there would be false about time. */ +const NOTHING_IN_SELECTION = "nothing in this selection"; +/** Never merged into the request-based figure beside it, and never called "cost" or + * "requests", being neither. */ +const SESSION_TOTAL_LABEL = "session total, not requests"; +const LABEL_WIDTH = 26; +const NO_KNOWN_PROJECT = "no known project"; +const NO_KNOWN_MODEL = "no known model"; +// Not "no agent": the main thread is where a session starts, not an absence. +const MAIN_THREAD = "the main thread"; +// Nor the main thread: a tool that names no agent has said nothing about which one ran. +const AGENT_NOT_STATED = "the tool names no agent"; + +/** What a row that names no agent is called, by which of the two silences it is. */ +export function agentRowLabel(row: CostReport["byAgents"][number]): string { + if (row.agent !== undefined) return row.agent; + return row.attribution === "main-thread" ? MAIN_THREAD : AGENT_NOT_STATED; +} + +// A prompt id is a uuid, wider than `LABEL_WIDTH`, and `padTo` never truncates. +const PROMPT_WIDTH = 38; +const NO_PROMPT = "no prompt named"; +// One row per turn, unbounded where every other axis has a small vocabulary. Truncated +// rather than suppressed the way `printDays` suppresses: a top N of a ranking is honest +// where a partial series is not, and the line below says how many were withheld. +const MAX_PRINTED_PROMPTS = 10; + +// A year by day is 365 rows: above this the text rendering names the count and points at +// --json rather than printing a screen nobody can scan. The envelope still carries them all. +const MAX_PRINTED_DAYS = 31; + +/** Exported alongside `formatAmount` and `totalTokens` so a second renderer formats the same + * figures through the same routine. */ +export function formatCount(value: number): string { + return value.toLocaleString("en-US"); +} + +export function formatAmount(microUsd: number): string { + return `$${fromMicroUsd(microUsd).toFixed(2)}`; +} + +/** The four counters are disjoint on every reader here — `input` excludes the cache figures — + * so adding them counts nothing twice. */ +export function totalTokens(totals: CostTotals): number { + return ( + (totals.inputTokens ?? 0) + + (totals.outputTokens ?? 0) + + (totals.cacheReadTokens ?? 0) + + (totals.cacheCreationTokens ?? 0) + ); +} + +/** Cost where the period has one, tokens where it does not, so a period of tools that carry + * no amount still breaks down. Named in the output, so nobody has to guess which. */ +export function shareBasis(totals: CostTotals): { readonly label: string; readonly of: number } { + return totals.costMicroUsd === undefined + ? { label: "of tokens", of: totalTokens(totals) } + : { label: "of cost", of: totals.costMicroUsd }; +} + +/** Exported for the same reason `shareBasis` is. */ +export function shareOf(totals: CostTotals, basis: number, useCost: boolean): string { + if (basis === 0) return " - "; + const part = useCost ? (totals.costMicroUsd ?? 0) : totalTokens(totals); + return `${Math.round((part / basis) * 100) + .toString() + .padStart(3)}%`; +} + +/** `padEnd` returns a longer string unchanged, so a label wider than the column runs straight + * into what follows it — a project id is a whole git remote. Exported so every column-padded + * reader of a label shares the one guarantee rather than each risking that collision. */ +export function padTo(label: string, width: number): string { + return label.length >= width ? `${label} ` : label.padEnd(width); +} + +function pad(label: string): string { + return padTo(label, LABEL_WIDTH); +} + +/** `task` and the generic filters both narrow the record set before any breakdown runs, so + * either one means every zero downstream is the selection talking, not the period. */ +function hasSelection(report: Pick): boolean { + return report.task !== undefined || report.filters !== undefined; +} + +/** Never a bare `0`, and never `NOTHING_MEASURED` under a selection, whose own narrowing is + * what emptied it. Exported so a second renderer tells the two absences apart the same way. */ +export function nothingLabel(report: Pick): string { + return hasSelection(report) ? NOTHING_IN_SELECTION : NOTHING_MEASURED; +} + +/** In the fixed order `cost-report.ts` gives them; empty for an unfiltered period. */ +function filtersSuffix(filters: CostReportFilters | undefined): string { + if (!filters) return ""; + const parts = Object.entries(filters).map(([name, value]) => `${name}=${value}`); + return parts.length === 0 ? "" : ` filters: ${parts.join(", ")}`; +} + +// `task` and `tool` are checked against journals and a declared list, never against a record, +// so "no record" would claim a check this layer never ran. +const UNKNOWN_REASON: Partial> = { + task: "no journal has ever declared it or written into it", + tool: "it is not one of the tools this build knows", +}; + +function unknownReason(filter: CostReportFilterName): string { + return UNKNOWN_REASON[filter] ?? `no record has ever named this ${filter}`; +} + +/** A period that genuinely holds no work never reaches here: an `emptySelection` is carried + * only when a filter, never the period, is what emptied it. */ +export function emptySelectionMessage({ + filter, + value, + known, + combination, +}: CostReportEmptySelection): string { + if (!known) return ` ${filter} '${value}' matched nothing — ${unknownReason(filter)}`; + if (combination) + return ` ${filter} '${value}' matched nothing combined with the rest of this selection`; + return ` ${filter} '${value}' matched nothing in this selection — known, but no work here`; +} + +/** Never a bare `0`, the same refusal `requests` makes below: a session count is no less a + * claim about what was measured. */ +export function sessionsFigure(report: CostReport): string { + return report.sessions === 0 ? nothingLabel(report) : formatCount(report.sessions); +} + +/** `0` where there are no tokens to divide, never `NaN`. `tokens` is a parameter because + * every caller already holds it, and this stays the one place the rounding happens. */ +export function cacheReadSharePercent(totals: CostTotals, tokens: number): number { + return tokens === 0 ? 0 : Math.round(((totals.cacheReadTokens ?? 0) / tokens) * 100); +} + +function printTotals(output: CLIOutput, report: CostReport): void { + const { totals } = report; + if (totals.requests === 0) { + output.print(` ${pad("sessions")}${sessionsFigure(report)}`); + output.print(` ${pad("requests")}${nothingLabel(report)}`); + return; + } + const tokens = totalTokens(totals); + const cacheShare = cacheReadSharePercent(totals, tokens); + output.print(` ${pad("sessions")}${sessionsFigure(report)}`); + output.print(` ${pad("requests")}${formatCount(totals.requests)}`); + output.print(` ${pad("tokens")}${formatCount(tokens)} ${cacheShare}% cache`); + output.print( + ` ${pad("cost")}${totals.costMicroUsd === undefined ? UNKNOWN_AMOUNT : formatAmount(totals.costMicroUsd)}` + ); + if (report.activeTimeSeconds !== undefined) { + const minutes = Math.round(report.activeTimeSeconds / 60); + output.print( + ` ${pad("active time")}${formatCount(minutes)} min per session; not attributable to steps` + ); + } +} + +function figureFor(totals: CostTotals, useCost: boolean): string { + if (!useCost) return `${formatCount(totalTokens(totals))} tokens`; + return totals.costMicroUsd === undefined ? UNKNOWN_AMOUNT : formatAmount(totals.costMicroUsd); +} + +function printStepRows( + output: CLIOutput, + rows: readonly CostReportStepRow[], + basis: number, + useCost: boolean +): void { + for (const row of rows) { + const name = row.step ?? ATTRIBUTION_LABELS.unattributed; + const strength = row.step === undefined ? "" : ` ${ATTRIBUTION_LABELS[row.attribution]}`; + output.print( + ` ${pad(name)}${shareOf(row.totals, basis, useCost)} ${figureFor(row.totals, useCost)}${strength}` + ); + } +} + +function printAttributionRows( + output: CLIOutput, + rows: readonly CostReportAttributionRow[], + basis: number, + useCost: boolean +): void { + for (const row of rows) { + output.print( + ` ${pad(ATTRIBUTION_LABELS[row.attribution])}${shareOf(row.totals, basis, useCost)}` + ); + } +} + +/** Every declared tool, including the ones that can say nothing: a tool missing from the list + * reads as one that did nothing, which for an unreadable tool is a false zero. */ +function printToolRows( + output: CLIOutput, + rows: readonly CostReportToolRow[], + report: Pick +): void { + for (const row of rows) { + const name = getAiToolConfig(row.tool).displayName; + if (row.coverage === "not-covered") { + output.print(` ${pad(name)}not covered${row.reason ? ` — ${row.reason}` : ""}`); + continue; + } + if (row.totals.requests === 0 && row.sessionTotals) { + const tokens = `${formatCount(totalTokens(row.sessionTotals))} tokens (${SESSION_TOTAL_LABEL})`; + output.print(` ${pad(name)}${tokens}${row.reason ? ` — ${row.reason}` : ""}`); + continue; + } + if (row.totals.requests === 0) { + output.print( + ` ${pad(name)}${nothingLabel(report)}${row.reason ? ` — ${row.reason}` : ""}` + ); + continue; + } + const figure = + row.totals.costMicroUsd === undefined + ? UNKNOWN_AMOUNT + : formatAmount(row.totals.costMicroUsd); + const tokens = `${formatCount(totalTokens(row.totals))} tokens`; + output.print(` ${pad(name)}${figure} ${tokens}${row.reason ? ` — ${row.reason}` : ""}`); + } +} + +function printCaveats(output: CLIOutput, report: CostReport): void { + if (report.undatedRecords > 0) { + output.print( + ` ${formatCount(report.undatedRecords)} records carry no moment and are in no period` + ); + } + if (report.unreadableLines > 0) { + output.print(` ${formatCount(report.unreadableLines)} lines could not be read`); + } +} + +/** An empty group prints nothing at all, never a heading over silence. */ +interface Basis { + readonly label: string; + readonly of: number; + readonly useCost: boolean; +} + +/** Only where `--task` narrowed the report: without one there is no per-record task identity + * to break down. */ +function printTaskAttribution(output: CLIOutput, report: CostReport, basis: Basis): void { + if (report.taskAttributionMix === undefined) return; + output.print(""); + output.print(` ticket known ${basis.label}`); + printTaskAttributionRows(output, report.taskAttributionMix, basis.of, basis.useCost); +} + +function printTaskAttributionRows( + output: CLIOutput, + rows: readonly CostReportTaskAttributionRow[], + basis: number, + useCost: boolean +): void { + for (const row of rows) { + output.print( + ` ${pad(TASK_ATTRIBUTION_LABELS[row.attribution])}${shareOf(row.totals, basis, useCost)}` + ); + } +} + +function printStepsAndAttribution(output: CLIOutput, report: CostReport, basis: Basis): void { + if (report.bySteps.length === 0) return; + output.print(""); + output.print(` by step ${basis.label}`); + printStepRows(output, report.bySteps, basis.of, basis.useCost); + output.print(""); + output.print(` attribution ${basis.label}`); + printAttributionRows(output, report.attributionMix, basis.of, basis.useCost); +} + +/** Straight after the steps, because it answers what they cannot: on a session that + * delegates, the step axis names a few percent and this one names the rest. */ +function printAgents(output: CLIOutput, report: CostReport, basis: Basis): void { + if (report.byAgents.length === 0) return; + output.print(""); + output.print(` by agent ${basis.label}`); + for (const row of report.byAgents) { + const name = agentRowLabel(row); + const share = shareOf(row.totals, basis.of, basis.useCost); + output.print( + ` ${padTo(name, LABEL_WIDTH)}${share} ${figureFor(row.totals, basis.useCost)}` + ); + } +} + +/** The one axis no host limit can empty: every record the transcript reader resolves carries + * a `prompt_id`, where a skill name, an identity and a declaration each may be missing. */ +function printPrompts(output: CLIOutput, report: CostReport, basis: Basis): void { + if (report.byPrompts.length === 0) return; + output.print(""); + output.print(` by prompt ${basis.label}`); + for (const row of report.byPrompts.slice(0, MAX_PRINTED_PROMPTS)) { + const share = shareOf(row.totals, basis.of, basis.useCost); + output.print( + ` ${padTo(row.prompt ?? NO_PROMPT, PROMPT_WIDTH)}${padTo(row.startedAt ?? "", 22)}${share} ${figureFor(row.totals, basis.useCost)}` + ); + } + const withheld = report.byPrompts.length - MAX_PRINTED_PROMPTS; + if (withheld > 0) { + output.print(` ${formatCount(withheld)} more prompts — see --json for all of them`); + } +} + +function printModels(output: CLIOutput, report: CostReport, basis: Basis): void { + if (report.byModels.length === 0) return; + output.print(""); + output.print(` by model ${basis.label}`); + for (const row of report.byModels) { + const name = row.model ?? NO_KNOWN_MODEL; + const share = shareOf(row.totals, basis.of, basis.useCost); + output.print(` ${pad(name)}${share} ${figureFor(row.totals, basis.useCost)}`); + } +} + +function printProjects( + output: CLIOutput, + rows: readonly CostReportProjectRow[], + basis: Basis +): void { + if (rows.length === 0) return; + output.print(""); + output.print(` by project ${basis.label}`); + for (const row of rows) { + const name = row.project ?? NO_KNOWN_PROJECT; + const share = shareOf(row.totals, basis.of, basis.useCost); + output.print(` ${pad(name)}${share} ${figureFor(row.totals, basis.useCost)}`); + } +} + +/** One row per task a record's moment fell inside, then one per reason present, in + * `TASK_UNATTRIBUTED_REASONS`' fixed order after every named task. The attribution sits beside + * a named row for the same reason `printStepRows` puts it there. */ +function printTasks(output: CLIOutput, rows: readonly CostReportTaskRow[], basis: Basis): void { + if (rows.length === 0) return; + output.print(""); + output.print(` by task ${basis.label}`); + for (const row of rows) { + const name = row.task ?? (row.reason === undefined ? "" : TASK_UNATTRIBUTED_LABELS[row.reason]); + const strength = + row.attribution === undefined ? "" : ` ${TASK_ATTRIBUTION_LABELS[row.attribution]}`; + output.print( + ` ${pad(name)}${shareOf(row.totals, basis.of, basis.useCost)} ${figureFor(row.totals, basis.useCost)}${strength}` + ); + } +} + +/** Named items, then the two rows for a known task that named none or could not be read, then + * the reason rows — the tail order `printTasks` uses. No attribution column: a backlog row + * rests on one route only. */ +function printBacklog( + output: CLIOutput, + rows: readonly CostReportBacklogRow[], + basis: Basis +): void { + if (rows.length === 0) return; + output.print(""); + output.print(` by backlog item ${basis.label}`); + for (const row of rows) { + const name = + row.backlog ?? + (row.declaration !== undefined + ? BACKLOG_DECLARATION_LABELS[row.declaration] + : row.reason !== undefined + ? TASK_UNATTRIBUTED_LABELS[row.reason] + : ""); + output.print( + ` ${pad(name)}${shareOf(row.totals, basis.of, basis.useCost)} ${figureFor(row.totals, basis.useCost)}` + ); + } +} + +/** Chronological, never sorted by size: a series read out of order is not a series. Past + * `MAX_PRINTED_DAYS` a count replaces the rows, since dropping some would be false + * continuity. */ +function printDays( + output: CLIOutput, + rows: readonly CostReportDayRow[], + report: Pick +): void { + if (rows.length === 0) return; + output.print(""); + output.print(" by day"); + if (rows.length > MAX_PRINTED_DAYS) { + output.print( + ` ${formatCount(rows.length)} days in this period — see --json for the daily breakdown` + ); + return; + } + for (const row of rows) { + if (row.totals.requests === 0) { + output.print(` ${pad(row.day)}${nothingLabel(report)}`); + continue; + } + const figure = + row.totals.costMicroUsd === undefined + ? UNKNOWN_AMOUNT + : formatAmount(row.totals.costMicroUsd); + output.print(` ${pad(row.day)}${figure} ${formatCount(totalTokens(row.totals))} tokens`); + } +} + +/** Prints no amount it was not given — the rates live outside this repository — and no + * prompt, code, diff or file path: a task appears by its identity alone. */ +function printHeader(output: CLIOutput, report: CostReport): void { + const scope = report.task === undefined ? "period" : `task ${report.task}`; + output.print(`${scope} ${report.fromDay} to ${report.toDay}${filtersSuffix(report.filters)}`); + // Only the off state is worth a line: with the switch on, every figure below already shows + // it working. Never worded "measurement is off for this project" — the sink is scoped to + // this person, so the figures can be real work from anywhere the switch was ever on, and a + // sentence claiming nothing was measured would contradict a genuine count beside it. + if (!report.measurementEnabled) { + output.print( + "this project's own switch is off — the figures below are not scoped to it, they are " + + "the whole sink; turn this project's measurement on with `aidd telemetry on`" + ); + } + output.print(""); + if (report.emptySelection !== undefined) { + output.print(emptySelectionMessage(report.emptySelection)); + output.print(""); + } +} + +// A filter-emptied selection has nothing to break down: every row would read "nothing in this +// period", the false zero this layer refuses. +function printBreakdowns(output: CLIOutput, report: CostReport): void { + const basis: Basis = { + ...shareBasis(report.totals), + useCost: report.totals.costMicroUsd !== undefined, + }; + printTaskAttribution(output, report, basis); + printStepsAndAttribution(output, report, basis); + printAgents(output, report, basis); + printPrompts(output, report, basis); + printModels(output, report, basis); + printProjects(output, report.byProjects, basis); + printTasks(output, report.byTasks, basis); + printBacklog(output, report.byBacklog, basis); + output.print(""); + output.print(" by tool"); + printToolRows(output, report.byTools, report); + printDays(output, report.byDays, report); +} + +export function printCostReport(output: CLIOutput, report: CostReport): void { + printHeader(output, report); + printTotals(output, report); + if (report.emptySelection === undefined) printBreakdowns(output, report); + printCaveats(output, report); +} diff --git a/cli/src/presentation/display/doctor-display.ts b/cli/src/presentation/display/doctor-display.ts new file mode 100644 index 000000000..8861e5fc0 --- /dev/null +++ b/cli/src/presentation/display/doctor-display.ts @@ -0,0 +1,120 @@ +import type { CLIOutput } from "../output.js"; +import { printPluginDrift, printScopeReport } from "./status-display.js"; + +type PluginIssue = { pluginName: string; toolId: string; issue: string; filePath?: string }; + +interface ScopeDriftReport { + tools: { + toolId: string; + version: string; + drifted: { status: string; relativePath: string }[]; + }[]; +} + +interface PluginDriftReport { + pluginDrift: { + pluginName: string; + toolId: string; + driftedFiles: string[]; + notInstalledOnMachine: boolean; + }[]; +} + +/** Which tools are equipped and how much they carry, independent of health and drift, which + * are reported separately. Versions come from the status report already fetched for drift. */ +export function printInventory( + output: CLIOutput, + label: string, + doctorReport: { + readonly toolHealth: readonly { + readonly toolId: string; + readonly fileCount: number; + readonly mergeFileCount: number; + }[]; + } | null, + statusTools: readonly { toolId: string; version: string }[] +): void { + const health = doctorReport?.toolHealth ?? []; + if (health.length === 0) return; + output.print(`\n${label} tools:`); + for (const h of health) { + const version = statusTools.find((t) => t.toolId === h.toolId)?.version ?? "unknown"; + output.print( + ` ${h.toolId} (v${version}): ${h.fileCount} files, ${h.mergeFileCount} merge files` + ); + } +} + +export function printReportErrors( + output: CLIOutput, + errors: readonly { scope: string; message: string }[] +): void { + for (const e of errors) output.warn(`[${e.scope}] ${e.message}`); +} + +export function printAllToolsDrift( + output: CLIOutput, + status: { aiTools: ScopeDriftReport; ideTools: ScopeDriftReport } & PluginDriftReport +): void { + output.print("\nDrift:"); + output.print("AI tools:"); + printScopeReport(output, status.aiTools); + output.print("IDE tools:"); + printScopeReport(output, status.ideTools); + output.print("Plugins:"); + printPluginDrift(output, { pluginDrift: status.pluginDrift }); +} + +export function printToolDrift( + output: CLIOutput, + status: ScopeDriftReport & PluginDriftReport +): void { + output.print("\nDrift:"); + printScopeReport(output, status); + output.print("Plugins:"); + printPluginDrift(output, { pluginDrift: status.pluginDrift }); +} + +export function printUserScopeTools( + output: CLIOutput, + tools: readonly { toolId: string; version: string; settings: string }[] +): void { + output.print("User-scope tools:"); + for (const tool of tools) { + output.print(` ${tool.toolId} (v${tool.version}): expects activation in ${tool.settings}`); + } +} + +export function printScopeIssues( + output: CLIOutput, + label: string, + report: { + issues: { severity: string; message: string; fix: string }[]; + } | null +): void { + if (report === null || report.issues.length === 0) return; + output.print(`\n${label}:`); + for (const issue of report.issues.filter((i) => i.severity === "info")) { + output.warn(` ${issue.message}\n Fix: ${issue.fix}`); + } + for (const issue of report.issues.filter((i) => i.severity !== "info")) { + const text = ` ${issue.message}\n Fix: ${issue.fix}`; + if (issue.severity === "error") output.error(text); + else output.warn(text); + } +} + +export function printPluginIssues(output: CLIOutput, pluginIssues: readonly PluginIssue[]): void { + if (pluginIssues.length === 0) return; + output.print("\nPlugins:"); + const notInstalled = pluginIssues.filter((pi) => pi.issue === "not-installed-on-machine"); + const toolIds = new Set(notInstalled.map((pi) => pi.toolId)); + for (const toolId of toolIds) { + output.error(` ${toolId}: plugins not installed on this machine, run \`aidd sync\``); + } + for (const pi of pluginIssues.filter((pi) => pi.issue !== "not-installed-on-machine")) { + output.error( + ` Plugin ${pi.pluginName} (${pi.toolId}): ${pi.issue} — ${pi.filePath}\n Fix: Run \`aidd sync\`` + ); + } +} diff --git a/cli/src/presentation/display/framework-display.ts b/cli/src/presentation/display/framework-display.ts new file mode 100644 index 000000000..c40c97e0b --- /dev/null +++ b/cli/src/presentation/display/framework-display.ts @@ -0,0 +1,49 @@ +import type { ToolId } from "../../kernel/tool.js"; +import type { CLIOutput } from "../output.js"; + +interface ScopedFailure { + readonly scope: string; + readonly message: string; +} + +interface UpdatedTool { + readonly toolId: ToolId; + readonly fileCount: number; +} + +export function printToolAlreadyInstalled(output: CLIOutput, toolId: ToolId): void { + output.warn(`${toolId} is already installed. Use \`--force\` to reinstall.`); +} + +export function printToolInstalled( + output: CLIOutput, + toolId: ToolId, + fileCount: number, + warnings: readonly string[] +): void { + for (const warning of warnings) output.warn(warning); + output.success(`Installed ${toolId} (${fileCount} files)`); +} + +export function printToolRemoved(output: CLIOutput, toolId: ToolId, fileCount: number): void { + output.success(`Removed ${toolId} (${fileCount} files removed)`); +} + +export function printScopedFailures(output: CLIOutput, failures: readonly ScopedFailure[]): void { + for (const failure of failures) output.warn(`[${failure.scope}] ${failure.message}`); +} + +export function printUpdateResult( + output: CLIOutput, + updatedTools: readonly UpdatedTool[], + errors: readonly ScopedFailure[] +): void { + if (updatedTools.length === 0 && errors.length === 0) { + output.info("No tools installed."); + return; + } + for (const tool of updatedTools) { + output.success(`Updated ${tool.toolId} (${tool.fileCount} files)`); + } + printScopedFailures(output, errors); +} diff --git a/cli/src/presentation/display/installed-rules-display.ts b/cli/src/presentation/display/installed-rules-display.ts new file mode 100644 index 000000000..ade272d29 --- /dev/null +++ b/cli/src/presentation/display/installed-rules-display.ts @@ -0,0 +1,23 @@ +import type { InstalledRule } from "../../contexts/framework/domain/installed-rule.js"; +import type { CLIOutput } from "../output.js"; + +/** The contract the explore skill reads, field for field; the two-space indent and the + * trailing newline are part of it. */ +export function printInstalledRulesJson(output: CLIOutput, rules: readonly InstalledRule[]): void { + output.print(JSON.stringify(rules, null, 2)); +} + +/** A project with no rule says so rather than printing nothing: on a terminal an empty + * answer and a command that never ran look identical. */ +export function printInstalledRules(output: CLIOutput, rules: readonly InstalledRule[]): void { + if (rules.length === 0) { + output.info("No rules installed for any AI tool."); + return; + } + for (const rule of rules) { + const scope = rule.paths === undefined ? "every file" : rule.paths.join(", "); + output.print(`${rule.tool} ${rule.path}`); + output.print(` ${rule.description === "" ? "(no description)" : rule.description}`); + output.print(` applies to: ${scope}`); + } +} diff --git a/cli/src/presentation/display/marketplace-display.ts b/cli/src/presentation/display/marketplace-display.ts new file mode 100644 index 000000000..b95bcfc0b --- /dev/null +++ b/cli/src/presentation/display/marketplace-display.ts @@ -0,0 +1,82 @@ +import type { RefreshEntryResult } from "../../contexts/distribution/application/marketplace-refresh-use-case.js"; +import type { PluginCatalog } from "../../contexts/distribution/domain/catalog.js"; +import type { Marketplace } from "../../contexts/distribution/domain/marketplace.js"; +import { describePluginSource } from "../../kernel/source.js"; +import type { CLIOutput } from "../output.js"; + +interface MarketplaceCheckOutcome { + readonly stale: readonly { readonly name: string }[]; + readonly upstreamRemoved: readonly { + readonly marketplace: string; + readonly plugin: string; + readonly toolId: string; + }[]; + readonly skipped: readonly { readonly marketplace: string; readonly error: string }[]; +} + +export function printCatalogEntries( + output: CLIOutput, + marketplaceName: string, + catalogs: ReadonlyMap +): void { + const catalog = catalogs.get(marketplaceName); + if (catalog === undefined) { + output.warn(` (could not fetch catalog for '${marketplaceName}')`); + return; + } + for (const entry of catalog.plugins) { + const flag = entry.recommended ? " (recommended)" : ""; + output.print( + ` ${entry.name}@${entry.version ?? "?"} — ${entry.description ?? ""} — ${describePluginSource(entry.source)}${flag}` + ); + } +} + +export function printRegisteredMarketplaces( + output: CLIOutput, + marketplaces: readonly Marketplace[], + catalogs: ReadonlyMap | undefined +): void { + if (marketplaces.length === 0) output.info("No marketplaces registered."); + for (const marketplace of marketplaces) { + const version = marketplace.version !== undefined ? ` v${marketplace.version}` : ""; + output.print(`${marketplace.name}${version} [${marketplace.scope}]`); + if (catalogs !== undefined) printCatalogEntries(output, marketplace.name, catalogs); + } +} + +export function printMarketplaceRegistered(output: CLIOutput, name: string): void { + output.success(`Marketplace '${name}' registered.`); +} + +export function printMarketplaceRemoved( + output: CLIOutput, + name: string, + removedPluginCount: number +): void { + output.success(`Marketplace '${name}' removed (${removedPluginCount} plugin(s) cleaned up).`); +} + +export function printRefreshResults( + output: CLIOutput, + results: readonly RefreshEntryResult[] +): void { + for (const result of results) { + output.print(`${result.name}: ${result.status}${result.error ? ` (${result.error})` : ""}`); + } +} + +export function printMarketplaceCheck(output: CLIOutput, result: MarketplaceCheckOutcome): void { + for (const marketplace of result.stale) output.print(`stale: ${marketplace.name}`); + for (const removed of result.upstreamRemoved) { + output.print(`removed: ${removed.marketplace}/${removed.plugin} (${removed.toolId})`); + } + for (const skip of result.skipped) output.warn(`skipped: ${skip.marketplace} — ${skip.error}`); + if ( + result.stale.length === 0 && + result.upstreamRemoved.length === 0 && + result.skipped.length === 0 + ) { + output.success("All marketplaces fresh."); + } +} diff --git a/cli/src/presentation/display/menu-display.ts b/cli/src/presentation/display/menu-display.ts new file mode 100644 index 000000000..24b600bd5 --- /dev/null +++ b/cli/src/presentation/display/menu-display.ts @@ -0,0 +1,13 @@ +import type { CLIOutput } from "../output.js"; + +const BANNER = ` + _ ___ ___ ___ + /_\\ |_ _| \\| \\ + / _ \\ | || |) | |) | +/_/ \\_\\|___|___/|___/ + + AI-Driven Development CLI`; + +export function printBanner(output: CLIOutput): void { + output.print(BANNER); +} diff --git a/cli/src/presentation/display/plugin-display.ts b/cli/src/presentation/display/plugin-display.ts new file mode 100644 index 000000000..71b8aa675 --- /dev/null +++ b/cli/src/presentation/display/plugin-display.ts @@ -0,0 +1,69 @@ +import type { PluginCatalogEntry } from "../../contexts/distribution/domain/catalog.js"; +import type { CLIOutput } from "../output.js"; + +interface NamedPlugin { + readonly name: string; + readonly version: string; +} + +interface PluginSearchHit { + readonly entry: PluginCatalogEntry; + readonly marketplace: { readonly name: string }; +} + +interface PluginInstallOutcome { + readonly kind: "picked" | "local" | "marketplace"; + readonly installed: readonly string[]; +} + +export function printInstalledPlugins( + output: CLIOutput, + byTool: ReadonlyMap +): void { + let printed = false; + for (const [toolId, plugins] of byTool) { + if (plugins.length === 0) continue; + output.print(`${toolId}:`); + for (const plugin of plugins) output.print(` ${plugin.name}@${plugin.version}`); + printed = true; + } + if (!printed) output.info("No plugins installed."); +} + +export function printPluginInstallOutcome(output: CLIOutput, result: PluginInstallOutcome): void { + if (result.kind === "picked") { + if (result.installed.length === 0) { + output.info("No plugins selected."); + } else { + output.success( + `Installed ${result.installed.length} plugin(s): ${result.installed.join(", ")}` + ); + } + } else if (result.kind === "local") { + output.success("Plugin added successfully."); + } else { + output.success(`Installed '${result.installed[0]}'.`); + } +} + +export function printPluginSearchHits(output: CLIOutput, hits: readonly PluginSearchHit[]): void { + if (hits.length === 0) output.info("No matches."); + for (const hit of hits) { + const flag = hit.entry.recommended ? " (recommended)" : ""; + output.print( + `${hit.entry.name}@${hit.entry.version ?? "?"} — ${hit.entry.description ?? ""} — marketplace: ${hit.marketplace.name}${flag}` + ); + } +} + +export function printPluginsUpdated(output: CLIOutput, updated: readonly string[]): void { + if (updated.length === 0) { + output.success("All plugins are up to date."); + return; + } + output.success(`Updated: ${updated.join(", ")}.`); +} + +export function printPluginRemoved(output: CLIOutput, name: string): void { + output.success(`Plugin '${name}' removed.`); +} diff --git a/cli/src/presentation/display/restore-display.ts b/cli/src/presentation/display/restore-display.ts new file mode 100644 index 000000000..0a6e52241 --- /dev/null +++ b/cli/src/presentation/display/restore-display.ts @@ -0,0 +1,20 @@ +import type { ToolId } from "../../kernel/tool.js"; +import type { CLIOutput } from "../output.js"; + +export function printUnrestorable(output: CLIOutput, unrestorable: readonly string[]): void { + if (unrestorable.length === 0) return; + output.warn( + `Could not restore ${unrestorable.length} file(s) no longer part of the current distribution: ${unrestorable.join(", ")}` + ); +} + +/** A binary off PATH is a fact, not a failure, so this warns: the settings written for that + * tool will not load until its own CLI has run. */ +export function printNativeActivation( + output: CLIOutput, + binaryMissing: readonly { toolId: ToolId; binary: string }[] +): void { + for (const { toolId, binary } of binaryMissing) { + output.warn(`${toolId}: the plugin will not load until the ${binary} CLI has run.`); + } +} diff --git a/cli/src/presentation/display/setup-display.ts b/cli/src/presentation/display/setup-display.ts new file mode 100644 index 000000000..d6889f5f2 --- /dev/null +++ b/cli/src/presentation/display/setup-display.ts @@ -0,0 +1,73 @@ +import type { CLIOutput } from "../output.js"; + +interface ToolInstallOutcome { + readonly toolId: string; + readonly fileCount: number; + readonly files: readonly { readonly relativePath: string }[]; + readonly skipped: boolean; + readonly warnings: readonly string[]; +} + +interface SetupOutcome { + readonly kind: "initialized" | "up-to-date"; + readonly install: { readonly results: readonly ToolInstallOutcome[] }; +} + +function displayInstall( + output: CLIOutput, + results: readonly ToolInstallOutcome[], + verbose: boolean +): void { + const skipped = results.filter((r) => r.skipped); + const installed = results.filter((r) => !r.skipped); + for (const r of skipped) output.warn(`${r.toolId} is already installed.`); + for (const r of installed) for (const w of r.warnings) output.warn(w); + if (verbose) { + for (const r of installed) { + output.debug(`Tool: ${r.toolId}`); + for (const f of r.files) output.debug(` + ${f.relativePath}`); + } + } + if (installed.length === 1) { + output.success(`Installed ${installed[0].toolId} (${installed[0].fileCount} files)`); + } else if (installed.length > 1) { + const total = installed.reduce((s, r) => s + r.fileCount, 0); + output.success(`Installed ${installed.map((r) => r.toolId).join(", ")} (${total} files)`); + } +} + +export function printSetupOutcome(output: CLIOutput, result: SetupOutcome, verbose: boolean): void { + switch (result.kind) { + case "initialized": { + output.success("Project initialized."); + displayInstall(output, result.install.results, verbose); + break; + } + case "up-to-date": { + output.info("Project is up to date."); + displayInstall(output, result.install.results, verbose); + break; + } + } +} + +export function printDetectedContext(output: CLIOutput, description: string): void { + output.info(`Detected: ${description}.`); +} + +export function printWelcomeBanner(output: CLIOutput): void { + output.print(""); + output.print("AI-Driven Development setup"); + output.print("Wires your AI tools, registers the framework marketplace, installs plugins."); + output.print("Press Ctrl-C any time to abort."); + output.print(""); +} + +export function printNextSteps(output: CLIOutput, installedAnything: boolean): void { + output.print(""); + output.print("Next steps:"); + if (installedAnything) output.print(" aidd doctor # verify drift"); + output.print(" aidd marketplace list # see registered marketplaces"); + output.print(" aidd plugin install # add plugins"); + output.print(" aidd --help # explore commands"); +} diff --git a/cli/src/presentation/display/status-display.ts b/cli/src/presentation/display/status-display.ts new file mode 100644 index 000000000..80ff919bc --- /dev/null +++ b/cli/src/presentation/display/status-display.ts @@ -0,0 +1,72 @@ +import type { CLIOutput } from "../output.js"; + +const STATUS_SYMBOL: Record = { + modified: "~", + deleted: "-", + added: "+", +}; + +export function printDriftStats(output: CLIOutput, drifted: { status: string }[]): void { + const modified = drifted.filter((f) => f.status === "modified").length; + const deleted = drifted.filter((f) => f.status === "deleted").length; + const added = drifted.filter((f) => f.status === "added").length; + output.print(` ${modified} modified, ${deleted} deleted, ${added} added`); +} + +export function printScopeReport( + output: CLIOutput, + report: { + tools: { + toolId: string; + version: string; + drifted: { status: string; relativePath: string }[]; + }[]; + } +): void { + if (report.tools.length === 0) { + output.print(" (none installed)"); + return; + } + for (const tool of report.tools) { + if (tool.drifted.length === 0) { + output.print(` ${tool.toolId} (v${tool.version}): in sync`); + continue; + } + output.print(` ${tool.toolId} (v${tool.version}):`); + for (const file of tool.drifted) { + output.print(` ${STATUS_SYMBOL[file.status] ?? "?"} ${file.relativePath}`); + } + printDriftStats(output, tool.drifted); + } +} + +interface PluginDriftLine { + pluginName: string; + toolId: string; + driftedFiles: string[]; + /** Every tracked file missing because it lives in a user-scope directory this machine never + * populated — reported as one line, not one per file. */ + notInstalledOnMachine: boolean; +} + +export function printPluginDrift( + output: CLIOutput, + report: { pluginDrift: PluginDriftLine[] } +): void { + if (report.pluginDrift.length === 0) { + output.print(" (all in sync)"); + return; + } + const notInstalledTools = new Set( + report.pluginDrift.filter((e) => e.notInstalledOnMachine).map((e) => e.toolId) + ); + for (const toolId of notInstalledTools) { + output.print(` ${toolId}: plugins not installed on this machine, run \`aidd sync\``); + } + for (const entry of report.pluginDrift.filter((e) => !e.notInstalledOnMachine)) { + output.print(` plugin ${entry.pluginName} (${entry.toolId}):`); + for (const f of entry.driftedFiles) { + output.print(` ~ ${f}`); + } + } +} diff --git a/cli/src/presentation/display/sync-display.ts b/cli/src/presentation/display/sync-display.ts new file mode 100644 index 000000000..75964f28a --- /dev/null +++ b/cli/src/presentation/display/sync-display.ts @@ -0,0 +1,76 @@ +import type { ToolId } from "../../kernel/tool.js"; +import type { CLIOutput } from "../output.js"; +import { printNativeActivation, printUnrestorable } from "./restore-display.js"; + +interface ScopedError { + readonly scope: string; + readonly message: string; +} + +interface RestoreAllResult { + readonly errors: readonly ScopedError[]; + readonly totalRestored: number; + readonly totalKept: number; + readonly pluginNamesRestored: readonly string[]; + readonly unrestorable: readonly string[]; +} + +interface ToolRestoreResult { + readonly tools: readonly { readonly nothingToRestore: boolean }[]; + readonly totalRestored: number; + readonly totalKept: number; + readonly unrestorable: readonly string[]; +} + +interface ActivationOutcome { + readonly binaryMissing: readonly { readonly toolId: ToolId; readonly binary: string }[]; + readonly errors: readonly ScopedError[]; +} + +export function printRestoreOutcome(output: CLIOutput, result: RestoreAllResult): void { + for (const e of result.errors) output.warn(`[${e.scope}] ${e.message}`); + + const nothingToRestore = + result.errors.length === 0 && + result.totalRestored === 0 && + result.pluginNamesRestored.length === 0 && + result.unrestorable.length === 0; + if (nothingToRestore) { + output.success("Nothing to restore — all files are unmodified."); + return; + } + if (result.totalRestored > 0) { + output.success(`Restored ${result.totalRestored} file(s), kept ${result.totalKept} file(s)`); + } + if (result.pluginNamesRestored.length > 0) { + output.success(`Restored plugins: ${result.pluginNamesRestored.join(", ")}`); + } + printUnrestorable(output, result.unrestorable); +} + +export function printToolRestoreOutcome(output: CLIOutput, result: ToolRestoreResult): void { + const nothingDone = result.tools.every((t) => t.nothingToRestore); + if (nothingDone) { + output.success("Nothing to restore — all files are unmodified."); + return; + } + output.success( + `Restored ${result.totalRestored} ${result.totalRestored === 1 ? "file" : "files"}, kept ${result.totalKept} ${result.totalKept === 1 ? "file" : "files"}` + ); + printUnrestorable(output, result.unrestorable); +} + +/** Every line native activation produced, in the order a host's own CLI produced them: a + * missing binary first, then whatever the run refused. The refusal itself stays the caller's. */ +export function printActivationOutcome(output: CLIOutput, activation: ActivationOutcome): void { + printNativeActivation(output, activation.binaryMissing); + for (const e of activation.errors) output.warn(`[${e.scope}] ${e.message}`); +} + +export function printUserScopeSyncOutcome(output: CLIOutput, activated: readonly string[]): void { + if (activated.length === 0) { + output.success("Nothing to sync — no tool is registered at user scope yet."); + } else { + output.success(`Synced native activation for: ${activated.join(", ")}`); + } +} diff --git a/cli/src/presentation/display/telemetry-check-display.ts b/cli/src/presentation/display/telemetry-check-display.ts new file mode 100644 index 000000000..74787c653 --- /dev/null +++ b/cli/src/presentation/display/telemetry-check-display.ts @@ -0,0 +1,265 @@ +import type { + DiagnoseTelemetryResult, + DiagnoseTelemetryUncoveredTool, +} from "../../contexts/telemetry/application/diagnose-telemetry-use-case.js"; +import { + SESSION_TRAILER_DELEGATE_FILE, + sessionTrailerManagerInstruction, + sessionTrailerManagerSnippet, +} from "../../contexts/telemetry/domain/formats/commit-session-trailer.js"; +import type { + TelemetryClaim, + TelemetryClaimId, + TelemetryClaimVerdict, +} from "../../contexts/telemetry/domain/telemetry-claim.js"; +import type { TelemetryExportLeftover } from "../../contexts/telemetry/domain/telemetry-export-leftover.js"; +import type { + TelemetryAllowedSetup, + TelemetryCommitTrailerSetup, + TelemetryHostRegistrationSetup, + TelemetryIdentitySetup, + TelemetryPluginVersionSetup, + TelemetryRecorderDeclarationSetup, + TelemetrySetup, +} from "../../contexts/telemetry/domain/telemetry-setup.js"; +import type { HostRegistrationAnswer } from "../../contexts/tools/domain/host-plugin-registration.js"; +import type { CLIOutput } from "../output.js"; + +const LABEL_WIDTH = 22; + +// The only names a check report prints, now that the plugin's own `diagnose.cjs` these were +// once pinned to word for word is gone: changing one changes every report and nothing else. +const CLAIM_LABELS: Record = { + "hook-fired": "hook fired", + "session-journalled": "session journalled", + "tool-files-readable": "tool files readable", + "records-join": "records join", +}; + +const VERDICT_TOKENS: Record = { + ok: "ok", + fail: "FAIL", + unknown: "--", +}; + +function pad(label: string): string { + return label.padEnd(LABEL_WIDTH); +} + +// A sentence naming where a fact came from, never the claims' `ok`/`FAIL`/`--` column: that +// vocabulary is reserved for a grade, and nothing here is graded. +function printSetupRow(output: CLIOutput, label: string, detail: string): void { + output.print(` ${pad(label)}${detail}`); +} + +function describeAllowed(allowed: TelemetryAllowedSetup): string { + if (!allowed.readable) return `could not be read — ${allowed.location}`; + if (allowed.decidedBy === "person-refusal") { + return `no — this person's own refusal (${allowed.location})`; + } + return `${allowed.allowed ? "yes" : "no"} — ${allowed.location}`; +} + +function describeIdentity(identity: TelemetryIdentitySetup): string { + if (!identity.readable) return `could not be read — ${identity.path}`; + return `${identity.attached ? "yes" : "no"} — ${identity.path}`; +} + +function indentedPaths(paths: readonly string[]): string { + return paths.map((path) => `\n ${path}`).join(""); +} + +function describeRecorderDeclaration(declaration: TelemetryRecorderDeclarationSetup): string { + if (declaration.declared) return `yes — ${declaration.declaredAt.join(", ")}`; + if (declaration.unreadable.length > 0) { + return `could not be read — ${declaration.unreadable.join(", ")}`; + } + // The row a person reads to go add the declaration somewhere, so each candidate gets its + // own line rather than five absolute paths comma-joined into one. + return `nowhere this build checks — looked in:${indentedPaths(declaration.locationsChecked)}`; +} + +/** Read back out of the journal rather than re-derived, so it can only say what the hook + * itself said. `"unrecorded"` is the one real problem — a hook that ran and could not name + * its own build, which happens when the plugin was copied in by neither install route. */ +function describePluginVersion(plugin: TelemetryPluginVersionSetup): string { + if (plugin.kind === "recorded") return `${plugin.version} (as the hook recorded it)`; + if (plugin.kind === "nothing-journalled") return "no session journalled yet"; + return ( + "unknown — no journalled session names one. The plugin's own manifest was not beside " + + "its hooks and no `aidd` install recorded it; `aidd plugin install aidd-telemetry` " + + "would make it known." + ); +} + +/** Whether the host will act on the declaration. One line per plugin, since the answer is + * genuinely per plugin, ordered so what must be fixed comes first: a reader who stops after + * one line has still read the problem. Nothing installed is a healthy answer and says so. */ +function describeHostRegistration(registration: TelemetryHostRegistrationSetup): string { + if (registration.manifestUnreadable !== undefined) { + return `AIDD's own manifest could not be read — ${registration.manifestUnreadable}`; + } + const entries = registration.entries; + if (entries.length === 0) return "no plugin recorded for any tool"; + // Keyed on the answer type, never `string`: a fifth answer must fail to compile here rather + // than sort silently last, below the ones that are fine. + const rank: Record = { + "not-registered": 0, + "registered-disabled": 1, + unanswerable: 2, + registered: 3, + }; + const ordered = [...entries].sort((a, b) => rank[a.answer] - rank[b.answer]); + // A sentence first, then the lines. Every other setup row leads with one, and a label + // followed by padding and a newline reads as a value the command failed to produce. + const trouble = ordered.filter((entry) => entry.answer !== "registered").length; + const headline = + trouble === 0 + ? `all ${ordered.length} will load` + : `${trouble} of ${ordered.length} will not load, or could not be answered`; + return ordered.reduce( + (text, entry) => + `${text}\n ${entry.tool}/${entry.plugin}: ${entry.answer} — ${entry.detail}`, + headline + ); +} + +/** Leads with the only fact about the chain rather than its parts — how many recent commits + * carry it — so one line is the answer and the pieces below it are the why. */ +function describeCommitTrailer(trailer: TelemetryCommitTrailerSetup): string { + // Outside a repository there is nothing to say about hooks; "nothing installed" would + // describe a repository this project is not in. + if (trailer.hooksDirMissing === "no-repository") { + return "no repository here, so no hook to carry it"; + } + // A git that rejects `--git-path` still has a history, and the count is the fact that + // matters: saying "no repository" would replace one true fact with a false one. + if (trailer.hooksDir === undefined) { + return `${describeTrailerCount(trailer)} — git could not say where it runs hooks from`; + } + + // The manager comes from a root marker file, never from reading the hook, and under one + // `callSite: "missing"` is the ordinary shape rather than a fault. + if (trailer.hookManager !== undefined) return describeManagedCommitTrailer(trailer); + + const parts: string[] = []; + if (trailer.delegate === "absent") parts.push("nothing installed to write it"); + if (trailer.delegate === "not-executable") { + parts.push("its script is not executable, so git will not run it"); + } + if (trailer.callSite === "missing") parts.push("prepare-commit-msg does not call it"); + if (trailer.hookExecutable === false) { + parts.push("prepare-commit-msg is not executable, so git ignores it"); + } + if (trailer.callSite === "no-hook-file") parts.push("there is no prepare-commit-msg"); + // Said, never named. Which tool owns the file changes nothing a person does about it, and + // naming one would be a guess read out of its contents. + if (trailer.hookHasOtherContent) parts.push("that hook is somebody else's too"); + + return `${describeTrailerCount(trailer)}${parts.length === 0 ? "" : ` — ${parts.join("; ")}`}\n hooks run from ${trailer.hooksDir}`; +} + +/** The row for a repository lefthook or husky owns: wired reports the chain through the + * manager, not wired prints the job to add. Neither reads `callSite: "missing"` as a fault — + * that field describes an absolute-path line a manager never calls the delegate through. */ +function describeManagedCommitTrailer(trailer: TelemetryCommitTrailerSetup): string { + const manager = trailer.hookManager; + if (manager === undefined) throw new Error("describeManagedCommitTrailer needs a manager"); + const count = describeTrailerCount(trailer); + if (trailer.managerCallsDelegate === true) { + // Half the chain: the delegate still has to be there and executable. Reporting "wired" + // from `managerCallsDelegate` alone calls a checkout healthy where `telemetry on` never + // ran, leaving a reader no reason to run the one command that fixes it. + if (trailer.delegate !== "executable") { + const state = + trailer.delegate === "absent" + ? "nothing installed to write it" + : "its script is not executable, so git will not run it"; + return `${count} — wired through ${manager}, but ${state}; run \`aidd telemetry on\`\n hooks run from ${trailer.hooksDir}`; + } + return `${count} — wired through ${manager}'s own prepare-commit-msg\n hooks run from ${trailer.hooksDir}`; + } + const { targetFile, snippet } = sessionTrailerManagerSnippet( + manager, + SESSION_TRAILER_DELEGATE_FILE + ); + return `${count} — ${manager} owns prepare-commit-msg here; ${sessionTrailerManagerInstruction(manager, targetFile)}:\n${snippet}\n hooks run from ${trailer.hooksDir}`; +} + +/** A commit no session made carries no trailer by design, so a number below the total is not + * itself a fault though a bare "4 of 20" reads as one. The qualifier is added exactly when it + * could mislead: some commits carrying it, every part in place. */ +function describeTrailerCount(trailer: TelemetryCommitTrailerSetup): string { + const carried = trailer.recentlyCarrying; + if (carried === undefined) return "no commit history to read"; + const count = `${carried.carrying} of the last ${carried.examined} commits carry it`; + const everyPartWorks = + trailer.delegate === "executable" && + (trailer.callSite === "present" || trailer.managerCallsDelegate === true); + // `> 0`, never `>= 0`: zero with every part in place is the finding this row exists to + // surface, and must never be excused as by-design. + const someCarry = carried.carrying > 0 && carried.carrying < carried.examined; + if (!everyPartWorks || !someCarry) return count; + return `${count} — a commit no session made carries none, by design`; +} + +function printSetup(output: CLIOutput, setup: TelemetrySetup): void { + printSetupRow(output, "measurement allowed", describeAllowed(setup.allowed)); + printSetupRow(output, "identity attached", describeIdentity(setup.identity)); + printSetupRow( + output, + "records kept at", + `${setup.recordsLocation.path} (override with AIDD_TELEMETRY_DIR)` + ); + printSetupRow( + output, + "recorder declared", + describeRecorderDeclaration(setup.recorderDeclaration) + ); + printSetupRow(output, "plugins registered", describeHostRegistration(setup.hostRegistration)); + printSetupRow(output, "commit trailer", describeCommitTrailer(setup.commitTrailer)); + printSetupRow(output, "cli version", setup.versions.cli); + printSetupRow(output, "plugin version", describePluginVersion(setup.versions.plugin)); + output.print(""); +} + +function printClaim(output: CLIOutput, claim: TelemetryClaim): void { + const label = pad(CLAIM_LABELS[claim.claim]); + const verdict = VERDICT_TOKENS[claim.verdict].padEnd(4); + output.print(` ${label}${verdict} ${claim.detail}`); +} + +function printUncovered(output: CLIOutput, uncovered: DiagnoseTelemetryUncoveredTool): void { + const label = pad(`not covered: ${uncovered.tool}`); + output.print(` ${label}${"--".padEnd(4)} ${uncovered.reason}`); +} + +// Never a claim: a stale export lives in a tool's own settings file, nothing the hook, the +// journal or a reader can see, so it is warned on stderr rather than folded into the health +// count. +function printLeftoverExportConfig( + output: CLIOutput, + leftovers: readonly TelemetryExportLeftover[] +): void { + for (const leftover of leftovers) { + output.warn( + `${leftover.path} still sets ${leftover.keys.join(", ")} — delete these keys from ` + + "its `env` block by hand to stop that export; nothing here can do it for you." + ); + } +} + +export function printTelemetryCheckReport( + output: CLIOutput, + result: DiagnoseTelemetryResult +): void { + printSetup(output, result.setup); + if (result.gate !== undefined) { + output.print(` ${result.gate}`); + printLeftoverExportConfig(output, result.leftoverExportConfig); + return; + } + for (const claim of result.claims) printClaim(output, claim); + for (const uncovered of result.uncovered) printUncovered(output, uncovered); + printLeftoverExportConfig(output, result.leftoverExportConfig); +} diff --git a/cli/src/application/display/telemetry-display.ts b/cli/src/presentation/display/telemetry-display.ts similarity index 75% rename from cli/src/application/display/telemetry-display.ts rename to cli/src/presentation/display/telemetry-display.ts index 1105779d9..24148e2db 100644 --- a/cli/src/application/display/telemetry-display.ts +++ b/cli/src/presentation/display/telemetry-display.ts @@ -1,19 +1,19 @@ -import type { TelemetrySink } from "../../domain/ports/telemetry-sink.js"; -import { getAiToolConfig } from "../../domain/tools/registry.js"; -import type { CLIOutput } from "../output.js"; import type { PersonIdentityLinkResult, PersonIdentityOffResult, PersonIdentityStatusResult, PersonIdentityUnlinkResult, PersonIdentityUseResult, -} from "../use-cases/telemetry/person-identity-use-case.js"; +} from "../../contexts/telemetry/application/person-identity-use-case.js"; import type { LocalCostToolStatus, ReadLocalCostResult, -} from "../use-cases/telemetry/read-local-cost-use-case.js"; -import type { TelemetryOffResult } from "../use-cases/telemetry/telemetry-off-use-case.js"; -import type { TelemetryOnResult } from "../use-cases/telemetry/telemetry-on-use-case.js"; +} from "../../contexts/telemetry/application/read-local-cost-use-case.js"; +import type { TelemetryOffResult } from "../../contexts/telemetry/application/telemetry-off-use-case.js"; +import type { TelemetryOnResult } from "../../contexts/telemetry/application/telemetry-on-use-case.js"; +import type { TelemetrySink } from "../../contexts/telemetry/domain/ports/telemetry-sink.js"; +import { getAiToolConfig } from "../../contexts/tools/domain/registry.js"; +import type { CLIOutput } from "../output.js"; const LOCAL_COST_STATUS_LABELS: Record = { found: "read", @@ -25,11 +25,8 @@ const LOCAL_COST_STATUS_LABELS: Record = { // is wrong. Distinct from "no session found", where nothing is known and nothing is wrong. unreadable: "could not be read", "not-covered": "not covered", - // Never "no session found": the journal named another tool, so this reader was not run. - // Nothing was observed about it, and nothing is wrong. Worded to stay true at both - // scales — this line is printed per tool for a whole sweep, so a session-shaped label - // ("not this session's tool") would be a claim about one session on a line summarising - // several. + // Never "no session found": the journal named another tool, so this reader never ran. + // Worded for a whole sweep, where a session-shaped label would claim too much. "not-asked": "no session read belongs to it", }; @@ -65,9 +62,8 @@ export function printLocalCostReadReport(output: CLIOutput, result: ReadLocalCos output.print(` ${result.refusedReason}`); return; } - // A sweep prints one line per tool, never one per tool per session: twenty sessions - // times five tools is a hundred lines nobody reads. How many sessions it covered is the - // fact that changes, so it leads. + // One line per tool, never one per tool per session: twenty sessions across five tools is + // a hundred lines nobody reads. The session count leads, being the fact that changes. const yielded = result.sessions.filter((session) => session.toolReports.some((report) => report.recordsFound > 0) ).length; @@ -115,16 +111,13 @@ export function printPersonIdentityStatus( } } -/** One outcome word, three sentences — and the sentence a person needs is different for - * each. A minted identifier is a new fact about this machine and gets the disclosure that - * used to belong to `on`; an adopted one replaces something and has to say what happened to - * what it replaced; an unchanged one must not claim anything was written. */ +/** One outcome word, three sentences: minted discloses what it attaches to, adopted says what + * happened to what it replaced, and unchanged must not claim anything was written. */ export function printPersonIdentityUse(output: CLIOutput, result: PersonIdentityUseResult): void { const at = `(${result.filePath})`; if (result.outcome === "unchanged") { - // "already in effect" is true of the identifier and false of the file whenever a name - // came with the call: something was written, and the line a person reads first must not - // say otherwise. + // "already in effect" is true of the identifier and false of the file when a name came + // with the call: something was written, and the first line must not say otherwise. const alsoNamed = result.displayNameSet === undefined ? "" : ", display name set"; output.success( `AIDD identity: ${result.identity.personId} already in effect${alsoNamed} ${at}` @@ -194,24 +187,9 @@ export function printPersonIdentityUnlink( output.success(`AIDD identity: unlinked '${result.identity}' (${result.filePath})`); } -/** - * Says, once per command that touches the figures, that this machine locates them through a - * variable which also moves its GitHub token. - * - * Not, as a first draft of this claimed, "the people who followed the plugin README when it - * said to share `AIDD_USER_CONFIG_DIR`". That README has never been released — the whole - * telemetry layer is absent from `main` — so outside this branch that population is empty, - * and a warning written for nobody is the `person-mapping.json` mistake again. - * - * The real audience is larger and outlives the split: anyone who sets - * `AIDD_USER_CONFIG_DIR` for the reason it has always existed — relocating a machine's aidd - * config, which a CI job or a test harness legitimately does — and thereby moves their - * figures into the same directory as their token without ever intending to. They are not - * following bad advice; they are using a variable that does two things, and only this line - * tells them the second one. - * - * `warn` writes to stderr, so a `--json` caller's stdout stays one parseable object. - */ +/** Says, once per command that touches the figures, that this machine locates them through a + * variable which also moves its GitHub token — a second effect nothing else tells anyone + * about. `warn` writes to stderr, so a `--json` caller's stdout stays one parseable object. */ export function warnIfFiguresMoveTheTokenToo(output: CLIOutput, sink: TelemetrySink): void { if (sink.locatedBy !== "user-config-dir") return; output.warn( diff --git a/cli/src/application/display/telemetry-forget-display.ts b/cli/src/presentation/display/telemetry-forget-display.ts similarity index 95% rename from cli/src/application/display/telemetry-forget-display.ts rename to cli/src/presentation/display/telemetry-forget-display.ts index 4d5a4c1b6..e7a9321d4 100644 --- a/cli/src/application/display/telemetry-forget-display.ts +++ b/cli/src/presentation/display/telemetry-forget-display.ts @@ -1,15 +1,15 @@ +import type { + TelemetryRemovalFailure, + TelemetryRemovalOutcome, + TelemetryRemovalResult, +} from "../../contexts/telemetry/application/forget-telemetry-use-case.js"; import type { TelemetryHistoryReading, TelemetryMachineIdentityRemoval, TelemetryRemovalPreview, -} from "../../domain/models/telemetry-removal.js"; -import { telemetryRemovalIsEmpty } from "../../domain/models/telemetry-removal.js"; +} from "../../contexts/telemetry/domain/telemetry-removal.js"; +import { telemetryRemovalIsEmpty } from "../../contexts/telemetry/domain/telemetry-removal.js"; import type { CLIOutput } from "../output.js"; -import type { - TelemetryRemovalFailure, - TelemetryRemovalOutcome, - TelemetryRemovalResult, -} from "../use-cases/telemetry/forget-telemetry-use-case.js"; function identityPreviewLine(identity: TelemetryMachineIdentityRemoval): string { if (!identity.present) return ` This machine's identity (${identity.path}): nothing to remove`; diff --git a/cli/src/presentation/display/translate-display.ts b/cli/src/presentation/display/translate-display.ts new file mode 100644 index 000000000..48ce63f2f --- /dev/null +++ b/cli/src/presentation/display/translate-display.ts @@ -0,0 +1,24 @@ +import type { FrameworkBuildMode } from "../../contexts/tools/domain/registry.js"; +import type { CLIOutput } from "../output.js"; + +interface TranslateOutcome { + readonly pluginCount: number; + readonly totalFiles: number; + readonly outDir: string; +} + +export function printTranslateResult( + output: CLIOutput, + mode: FrameworkBuildMode, + outcome: TranslateOutcome +): void { + if (mode === "flat") { + output.success( + `Flat-installed ${outcome.pluginCount} plugins, ${outcome.totalFiles} files written under ${outcome.outDir}` + ); + return; + } + output.success( + `Built ${outcome.pluginCount} plugins, ${outcome.totalFiles} files written to ${outcome.outDir}` + ); +} diff --git a/cli/src/presentation/display/update-display.ts b/cli/src/presentation/display/update-display.ts new file mode 100644 index 000000000..6ecd1c014 --- /dev/null +++ b/cli/src/presentation/display/update-display.ts @@ -0,0 +1,27 @@ +import type { SelfUpdateResult } from "../../runtime/self-update/self-update-use-case.js"; +import type { CLIOutput } from "../output.js"; + +export function printSelfUpdateResult(output: CLIOutput, result: SelfUpdateResult): void { + switch (result.kind) { + case "up-to-date": + case "check-current": + output.success(`Already up to date (${result.version})`); + break; + case "check-available": + output.info( + `New version available: ${result.latestVersion} (current: ${result.currentVersion})` + ); + break; + case "dry-run": + output.info(`Would install @ai-driven-dev/cli@${result.latestVersion}`); + break; + case "updated": { + const binaryPart = result.binaryPath ? ` (${result.binaryPath})` : ""; + output.success(`Successfully updated to version ${result.latestVersion}${binaryPart}`); + if (result.changelog) { + output.info(`\nChangelog:\n${result.changelog}`); + } + break; + } + } +} diff --git a/cli/src/application/error-handler.ts b/cli/src/presentation/error-handler.ts similarity index 100% rename from cli/src/application/error-handler.ts rename to cli/src/presentation/error-handler.ts diff --git a/cli/src/application/output.ts b/cli/src/presentation/output.ts similarity index 82% rename from cli/src/application/output.ts rename to cli/src/presentation/output.ts index 5342b7c2e..50c7db050 100644 --- a/cli/src/application/output.ts +++ b/cli/src/presentation/output.ts @@ -1,4 +1,4 @@ -import type { Logger } from "../domain/ports/logger.js"; +import type { Logger } from "../kernel/ports/logger.js"; export class CLIOutput implements Logger { readonly verbose: boolean; @@ -7,8 +7,6 @@ export class CLIOutput implements Logger { this.verbose = verbose || process.env.AIDD_VERBOSE === "true"; } - // Logger interface — used by use-cases and infrastructure adapters - debug(message: string): void { if (this.verbose) process.stderr.write(`[verbose] ${message}\n`); } @@ -21,8 +19,6 @@ export class CLIOutput implements Logger { process.stderr.write(`Warning: ${message}\n`); } - // Command output - print(message: string): void { process.stdout.write(`${message}\n`); } diff --git a/cli/src/presentation/prompts/menu-use-case.ts b/cli/src/presentation/prompts/menu-use-case.ts new file mode 100644 index 000000000..3e8833bb6 --- /dev/null +++ b/cli/src/presentation/prompts/menu-use-case.ts @@ -0,0 +1,299 @@ +import type { ManifestRepository } from "../../contexts/framework/domain/ports/manifest-repository.js"; +import type { Prompter } from "../../kernel/ports/prompter.js"; + +interface MenuLeaf { + name: string; + value: string; + description?: string; + command: string[]; + inputPrompt?: string; + commandSuffix?: string[]; +} + +interface MenuBranch { + name: string; + value: string; + description?: string; + children: MenuNode[]; +} + +type MenuNode = MenuLeaf | MenuBranch; + +function isBranch(node: MenuNode): node is MenuBranch { + return "children" in node; +} + +function toChoice(node: MenuNode): { name: string; value: string; description?: string } { + return { name: node.name, value: node.value, description: node.description }; +} + +const INSTALLED_NODES: MenuNode[] = [ + { + name: "Inspect", + value: "inspect", + description: "Check status, health and installed items", + children: [ + { + name: "Doctor", + value: "doctor", + description: "Tool inventory, drift, plugins, and structural health", + command: ["doctor"], + }, + { + name: "Doctor (one tool)", + value: "doctor-tool", + description: "Scope the report to a single AI or IDE tool", + command: ["doctor", "--tool"], + inputPrompt: "Tool (e.g. claude, cursor, copilot, codex, opencode, vscode)", + }, + { + name: "Plugins", + value: "plugin-list", + description: "Show installed plugins per tool", + command: ["plugin", "list"], + }, + ], + }, + { + name: "Manage tools", + value: "manage-tools", + description: "Install, remove and update AI or IDE tools", + children: [ + { + name: "Install", + value: "framework-install", + description: "Add a tool to this project", + command: ["framework", "install", "--tool"], + inputPrompt: "Tool (e.g. claude, cursor, copilot, codex, opencode, vscode)", + }, + { + name: "Remove", + value: "framework-remove", + description: "Remove an installed tool", + command: ["framework", "remove", "--tool"], + inputPrompt: "Tool to remove", + }, + { + name: "Update all", + value: "framework-update-all", + description: "Re-install every installed tool's configs from bundled assets", + command: ["framework", "update"], + }, + { + name: "Update one", + value: "framework-update-one", + description: "Re-install one tool's configs from bundled assets", + command: ["framework", "update", "--tool"], + inputPrompt: "Tool to update", + }, + ], + }, + { + name: "Manage plugins", + value: "manage-plugins", + description: "Browse, install and manage AI tool plugins", + children: [ + { + name: "Install plugin", + value: "plugin-install", + description: "Install a plugin by name, local path, or interactive pick", + command: ["plugin", "install"], + inputPrompt: "Plugin name, path, or leave empty for interactive pick", + }, + { + name: "Search", + value: "plugin-search", + description: "Search plugins across all registered marketplaces", + command: ["plugin", "search"], + inputPrompt: "Search query", + }, + { + name: "Update", + value: "plugin-update", + description: "Update all installed plugins to latest version", + command: ["plugin", "update"], + }, + { + name: "Remove", + value: "plugin-remove", + description: "Remove an installed plugin", + command: ["plugin", "remove"], + inputPrompt: "Plugin name to remove", + }, + { + name: "List", + value: "plugin-list-2", + description: "Show all installed plugins per tool", + command: ["plugin", "list"], + }, + { + name: "Doctor", + value: "plugin-doctor", + description: "Check one plugin's installation health", + command: ["doctor", "--plugin"], + inputPrompt: "Plugin name", + }, + ], + }, + { + name: "Marketplaces", + value: "marketplaces", + description: "Manage plugin marketplace registrations", + children: [ + { + name: "List", + value: "marketplace-list", + description: "Show all registered marketplaces", + command: ["marketplace", "list"], + }, + { + name: "Add", + value: "marketplace-add", + description: "Register a new plugin marketplace", + command: ["marketplace", "add"], + }, + { + name: "Refresh", + value: "marketplace-refresh", + description: "Refresh all registered marketplaces", + command: ["marketplace", "refresh"], + }, + { + name: "Remove", + value: "marketplace-remove", + description: "Unregister a marketplace", + command: ["marketplace", "remove"], + inputPrompt: "Marketplace name to remove", + }, + { + name: "Check freshness", + value: "marketplace-check", + description: "Report stale marketplaces", + command: ["marketplace", "check"], + }, + ], + }, + { + name: "Maintain & repair", + value: "maintain", + description: "Update tools, sync tracked files, and clean everything", + children: [ + { + name: "Update all tools", + value: "framework-update-maintain", + description: "Re-install every installed tool's configs from bundled assets", + command: ["framework", "update"], + }, + { + name: "Sync everything", + value: "sync-all", + description: "Regenerate tracked files across all installed tools, driven by the manifest", + command: ["sync"], + }, + { + name: "Clean (nuke .aidd)", + value: "clean", + description: "Remove all AIDD-managed files from this project", + command: ["clean"], + }, + ], + }, + { + name: "System", + value: "system", + description: "CLI update and authentication", + children: [ + { + name: "Update CLI", + value: "self-update", + description: "Update the AIDD CLI binary itself (bare `update`)", + command: ["update"], + }, + { + name: "Authentication", + value: "auth", + description: "Manage authentication credentials", + children: [ + { + name: "Status", + value: "auth-status", + description: "Show current authentication status", + command: ["auth", "status"], + }, + { + name: "Login", + value: "auth-login", + description: "Authenticate with your credentials", + command: ["auth", "login"], + }, + { + name: "Logout", + value: "auth-logout", + description: "Remove stored credentials", + command: ["auth", "logout"], + }, + ], + }, + ], + }, +]; + +const BACK = { name: "← Back", value: "back" } as const; +const EXIT = { name: "Exit", value: "exit" } as const; + +type NavResult = { type: "command"; command: string[] } | { type: "back" } | { type: "exit" }; + +export type InteractiveMenuOptions = Record; + +export interface InteractiveMenuResult { + command: string[]; +} + +export class InteractiveMenuUseCase { + constructor( + private readonly manifestRepo: ManifestRepository, + private readonly prompter: Prompter + ) {} + + async execute(_options?: InteractiveMenuOptions): Promise { + const manifest = await this.manifestRepo.load(); + if (manifest === null) return this.handleFreshInstall(); + const result = await this.showMenu(INSTALLED_NODES, "What would you like to do?", []); + if (result.type !== "command") return { command: ["exit"] }; + return { command: result.command }; + } + + private async handleFreshInstall(): Promise { + const confirmed = await this.prompter.confirm("AIDD not initialized. Run setup now?", true); + return { command: confirmed ? ["setup"] : ["exit"] }; + } + + private async showMenu( + nodes: MenuNode[], + label: string, + breadcrumb: string[] + ): Promise { + const nav = breadcrumb.length > 0 ? [BACK, EXIT] : [EXIT]; + const picked = await this.prompter.select(label, [...nodes.map(toChoice), ...nav]); + if (picked === "exit") return { type: "exit" }; + if (picked === "back") return { type: "back" }; + + const node = nodes.find((n) => n.value === picked); + if (!node) return { type: "exit" }; + if (isBranch(node)) { + const result = await this.showMenu(node.children, node.name, [...breadcrumb, node.value]); + if (result.type === "back") return this.showMenu(nodes, label, breadcrumb); + return result; + } + + return { type: "command", command: await this.resolveCommand(node) }; + } + + private async resolveCommand(node: MenuLeaf): Promise { + if (node.inputPrompt !== undefined) { + const input = await this.prompter.input(node.inputPrompt); + return [...node.command, input, ...(node.commandSuffix ?? [])]; + } + return node.command; + } +} diff --git a/cli/src/application/use-cases/plugin/plugin-pick-use-case.ts b/cli/src/presentation/prompts/plugin-pick-use-case.ts similarity index 81% rename from cli/src/application/use-cases/plugin/plugin-pick-use-case.ts rename to cli/src/presentation/prompts/plugin-pick-use-case.ts index e3d3eb31e..75ebbd159 100644 --- a/cli/src/application/use-cases/plugin/plugin-pick-use-case.ts +++ b/cli/src/presentation/prompts/plugin-pick-use-case.ts @@ -1,15 +1,18 @@ +import type { ResolveMarketplaceUseCase } from "../../contexts/distribution/application/resolve-marketplace-use-case.js"; +import type { + PluginCatalog, + PluginCatalogEntry, +} from "../../contexts/distribution/domain/catalog.js"; +import type { Marketplace } from "../../contexts/distribution/domain/marketplace.js"; +import type { MarketplaceRegistry } from "../../contexts/distribution/domain/ports/marketplace-registry.js"; +import type { PluginAdd } from "../../contexts/framework/application/plugin/plugin-add-use-case.js"; import { InteractiveOnlyError, InvalidPluginManifestError, NoMarketplacesRegisteredError, -} from "../../../domain/errors.js"; -import type { Marketplace } from "../../../domain/models/marketplace.js"; -import type { PluginCatalog, PluginCatalogEntry } from "../../../domain/models/plugin-catalog.js"; -import type { AiToolId } from "../../../domain/models/tool-ids.js"; -import type { MarketplaceRegistry } from "../../../domain/ports/marketplace-registry.js"; -import type { Prompter } from "../../../domain/ports/prompter.js"; -import type { ResolveMarketplaceUseCase } from "../shared/resolve-marketplace-use-case.js"; -import type { PluginAddUseCase } from "./plugin-add-use-case.js"; +} from "../../kernel/errors.js"; +import type { Prompter } from "../../kernel/ports/prompter.js"; +import type { AiToolId } from "../../kernel/tool.js"; export interface PluginPickOptions { toolIds: AiToolId[] | "all"; @@ -31,7 +34,7 @@ export class PluginPickUseCase implements PluginPick { constructor( private readonly registry: MarketplaceRegistry, private readonly resolveMarketplace: ResolveMarketplaceUseCase, - private readonly pluginAddUseCase: PluginAddUseCase, + private readonly pluginAddUseCase: PluginAdd, private readonly prompter: Prompter ) {} diff --git a/cli/src/application/use-cases/setup/setup-plugins-prompt-use-case.ts b/cli/src/presentation/prompts/setup-plugins-prompt-use-case.ts similarity index 81% rename from cli/src/application/use-cases/setup/setup-plugins-prompt-use-case.ts rename to cli/src/presentation/prompts/setup-plugins-prompt-use-case.ts index aa0038fe3..7d5483547 100644 --- a/cli/src/application/use-cases/setup/setup-plugins-prompt-use-case.ts +++ b/cli/src/presentation/prompts/setup-plugins-prompt-use-case.ts @@ -1,10 +1,9 @@ -import type { PluginCatalogEntry } from "../../../domain/models/plugin-catalog.js"; -import type { PluginInstallMode } from "../../../domain/models/setup-flow.js"; -import type { MarketplaceRegistry } from "../../../domain/ports/marketplace-registry.js"; -import type { PluginInstallFromMarketplace } from "../plugin/plugin-install-from-marketplace-use-case.js"; -import type { PluginPick } from "../plugin/plugin-pick-use-case.js"; -import type { ResolveMarketplace } from "../shared/resolve-marketplace-use-case.js"; - +import type { ResolveMarketplace } from "../../contexts/distribution/application/resolve-marketplace-use-case.js"; +import type { PluginCatalogEntry } from "../../contexts/distribution/domain/catalog.js"; +import type { MarketplaceRegistry } from "../../contexts/distribution/domain/ports/marketplace-registry.js"; +import type { PluginInstallFromMarketplace } from "../../contexts/framework/application/plugin/plugin-install-from-marketplace-use-case.js"; +import type { PluginInstallMode } from "../../contexts/framework/domain/setup-flow.js"; +import type { PluginPick } from "./plugin-pick-use-case.js"; export interface SetupPluginsPromptOptions { projectRoot: string; mode: PluginInstallMode; diff --git a/cli/src/application/use-cases/setup/setup-tools-prompt-use-case.ts b/cli/src/presentation/prompts/setup-tools-prompt-use-case.ts similarity index 82% rename from cli/src/application/use-cases/setup/setup-tools-prompt-use-case.ts rename to cli/src/presentation/prompts/setup-tools-prompt-use-case.ts index e3eec8bea..beb6f1075 100644 --- a/cli/src/application/use-cases/setup/setup-tools-prompt-use-case.ts +++ b/cli/src/presentation/prompts/setup-tools-prompt-use-case.ts @@ -1,15 +1,10 @@ -import type { ProjectContext } from "../../../domain/models/project-context.js"; -import { - AI_TOOL_IDS, - type AiToolId, - IDE_TOOL_IDS, - type IdeToolId, -} from "../../../domain/models/tool-ids.js"; +import type { ProjectContext } from "../../contexts/framework/domain/project-context.js"; import { recommendAiTools, recommendIdeTools, -} from "../../../domain/models/tool-recommendations.js"; -import type { Prompter } from "../../../domain/ports/prompter.js"; +} from "../../contexts/framework/domain/tool-recommendations.js"; +import type { Prompter } from "../../kernel/ports/prompter.js"; +import { AI_TOOL_IDS, type AiToolId, IDE_TOOL_IDS, type IdeToolId } from "../../kernel/tool.js"; export interface SetupToolsPromptOptions { interactive: boolean; diff --git a/cli/src/presentation/prompts/sync-conflict-resolver-use-case.ts b/cli/src/presentation/prompts/sync-conflict-resolver-use-case.ts new file mode 100644 index 000000000..48d50ca5b --- /dev/null +++ b/cli/src/presentation/prompts/sync-conflict-resolver-use-case.ts @@ -0,0 +1,66 @@ +import type { FileReader } from "../../kernel/ports/file-reader.js"; + +/** Decides a conflict, never prompts: recording one is the caller's. */ +export class SyncConflictResolverUseCase { + constructor(private readonly fs: FileReader) {} + + async isConflict( + diskTargetPath: string, + diskTargetExists: boolean, + targetRelativePath: string, + targetManifestMap: Map + ): Promise { + if (!diskTargetExists) return false; + const diskTargetHash = await this.fs.readFileHash(diskTargetPath); + const targetManifestHash = targetManifestMap.get(targetRelativePath); + return targetManifestHash !== undefined && diskTargetHash.value !== targetManifestHash.value; + } + + /** `conflict` is true even for a `"write"`, when `force` overrode a detected conflict. */ + async resolveWriteOutcome(opts: { + diskTargetPath: string; + diskTargetExists: boolean; + targetRelativePath: string; + targetManifestMap: Map; + targetContent: string; + force: boolean; + }): Promise<{ outcome: "skipped" | "conflict" | "write"; conflict: boolean }> { + const { + diskTargetPath, + diskTargetExists, + targetRelativePath, + targetManifestMap, + targetContent, + force, + } = opts; + + if (diskTargetExists && (await this.fs.readFile(diskTargetPath)) === targetContent) { + return { outcome: "skipped", conflict: false }; + } + + const conflict = await this.isConflict( + diskTargetPath, + diskTargetExists, + targetRelativePath, + targetManifestMap + ); + + if (conflict && !force) return { outcome: "conflict", conflict: true }; + return { outcome: "write", conflict }; + } + + /** Plugin propagation has no manifest hash to compare, so any existing target file counts + * as a potential overwrite. */ + async resolvePluginWriteOutcome(opts: { + diskTargetPath: string; + targetContent: string; + force: boolean; + }): Promise<"skipped" | "conflict" | "write"> { + const { diskTargetPath, targetContent, force } = opts; + const exists = await this.fs.fileExists(diskTargetPath); + + if (exists && (await this.fs.readFile(diskTargetPath)) === targetContent) return "skipped"; + if (!force && exists) return "conflict"; + return "write"; + } +} diff --git a/cli/src/infrastructure/assets/asset-loader.ts b/cli/src/runtime/assets/asset-loader.ts similarity index 87% rename from cli/src/infrastructure/assets/asset-loader.ts rename to cli/src/runtime/assets/asset-loader.ts index d3eb1d0ae..a6dba9ac1 100644 --- a/cli/src/infrastructure/assets/asset-loader.ts +++ b/cli/src/runtime/assets/asset-loader.ts @@ -12,17 +12,9 @@ import vscodeKeybindings from "../../../assets/configs/vscode/keybindings.json" type: "json", }; import vscodeSettings from "../../../assets/configs/vscode/settings.json" with { type: "json" }; -import defaultMarketplaceJson from "../../../assets/marketplaces/default.json" with { - type: "json", -}; -import type { ToolId } from "../../domain/models/tool-ids.js"; -import type { - AssetProvider, - ConfigAsset, - DefaultMarketplace, - SchemaName, -} from "../../domain/ports/asset-provider.js"; -import { AssetNotFoundError } from "../errors.js"; +import { AssetNotFoundError } from "../../kernel/errors.js"; +import type { AssetProvider, ConfigAsset, SchemaName } from "../../kernel/ports/asset-provider.js"; +import type { ToolId } from "../../kernel/tool.js"; const SCHEMA_FILE = "claude-code-plugin-manifest.json"; const MARKETPLACE_SCHEMA_FILE = "copilot-plugin-marketplace.json"; @@ -62,10 +54,6 @@ export class BundledAssetProviderAdapter implements AssetProvider { return asset; } - loadDefaultMarketplace(): DefaultMarketplace { - return defaultMarketplaceJson as DefaultMarketplace; - } - loadSchema(name: SchemaName): object { const cached = this.schemaCache.get(name); if (cached !== undefined) return cached; diff --git a/cli/src/infrastructure/assets/text-assets.d.ts b/cli/src/runtime/assets/text-assets.d.ts similarity index 100% rename from cli/src/infrastructure/assets/text-assets.d.ts rename to cli/src/runtime/assets/text-assets.d.ts diff --git a/cli/src/runtime/auth/auth-login-use-case.ts b/cli/src/runtime/auth/auth-login-use-case.ts new file mode 100644 index 000000000..03a938a3c --- /dev/null +++ b/cli/src/runtime/auth/auth-login-use-case.ts @@ -0,0 +1,15 @@ +import type { AuthCredential, AuthLevel } from "./auth.js"; +import type { AuthLoginResult, CredentialStore } from "./ports/credential-store.js"; + +interface AuthLoginOptions { + credential: AuthCredential; + level: AuthLevel; +} + +export class AuthLoginUseCase { + constructor(private readonly authProvider: CredentialStore) {} + + async execute(options: AuthLoginOptions): Promise { + return await this.authProvider.login(options.credential, options.level); + } +} diff --git a/cli/src/runtime/auth/auth-logout-use-case.ts b/cli/src/runtime/auth/auth-logout-use-case.ts new file mode 100644 index 000000000..68cc94126 --- /dev/null +++ b/cli/src/runtime/auth/auth-logout-use-case.ts @@ -0,0 +1,9 @@ +import type { AuthLogoutResult, CredentialStore } from "./ports/credential-store.js"; + +export class AuthLogoutUseCase { + constructor(private readonly authProvider: CredentialStore) {} + + async execute(): Promise { + return await this.authProvider.logout(); + } +} diff --git a/cli/src/infrastructure/adapters/auth-provider-adapter.ts b/cli/src/runtime/auth/auth-provider-adapter.ts similarity index 86% rename from cli/src/infrastructure/adapters/auth-provider-adapter.ts rename to cli/src/runtime/auth/auth-provider-adapter.ts index 201a11737..fa269caf2 100644 --- a/cli/src/infrastructure/adapters/auth-provider-adapter.ts +++ b/cli/src/runtime/auth/auth-provider-adapter.ts @@ -1,18 +1,18 @@ -import { AuthenticationError } from "../../domain/errors.js"; -import type { AuthConfig, AuthCredential, AuthLevel } from "../../domain/models/auth.js"; +import { AuthenticationError } from "../../kernel/errors.js"; +import type { AuthConfig, AuthCredential, AuthLevel } from "./auth.js"; +import type { CredentialFileStore } from "./ports/credential-file-store.js"; import type { AuthLoginResult, AuthLogoutHint, AuthLogoutResult, AuthStatus, CredentialStore, -} from "../../domain/ports/credential-store.js"; -import type { CliAuthProvider, TokenAuthProvider } from "../../domain/ports/oauth-provider.js"; -import type { AuthStorage } from "../auth/auth-storage.js"; +} from "./ports/credential-store.js"; +import type { CliAuthProvider, TokenAuthProvider } from "./ports/oauth-provider.js"; export class AuthProviderAdapter implements CredentialStore { constructor( - private readonly storage: AuthStorage, + private readonly storage: CredentialFileStore, private readonly externalProviders: Map, private readonly tokenVerifier: TokenAuthProvider, private readonly projectRoot: string diff --git a/cli/src/infrastructure/adapters/auth-reader-adapter.ts b/cli/src/runtime/auth/auth-reader-adapter.ts similarity index 86% rename from cli/src/infrastructure/adapters/auth-reader-adapter.ts rename to cli/src/runtime/auth/auth-reader-adapter.ts index d30cf0318..5d497d348 100644 --- a/cli/src/infrastructure/adapters/auth-reader-adapter.ts +++ b/cli/src/runtime/auth/auth-reader-adapter.ts @@ -1,8 +1,8 @@ -import type { AuthConfig, AuthLevel, AuthMethod } from "../../domain/models/auth.js"; -import type { Logger } from "../../domain/ports/logger.js"; -import type { TokenResolver } from "../../domain/ports/oauth-provider.js"; -import type { TokenProvider } from "../../domain/ports/token-provider.js"; -import type { AuthStorage } from "../auth/auth-storage.js"; +import type { Logger } from "../../kernel/ports/logger.js"; +import type { AuthConfig, AuthLevel, AuthMethod } from "./auth.js"; +import type { AuthStorage } from "./auth-storage.js"; +import type { TokenResolver } from "./ports/oauth-provider.js"; +import type { TokenProvider } from "./ports/token-provider.js"; export interface AuthContext { token: string; diff --git a/cli/src/runtime/auth/auth-status-use-case.ts b/cli/src/runtime/auth/auth-status-use-case.ts new file mode 100644 index 000000000..c078c5063 --- /dev/null +++ b/cli/src/runtime/auth/auth-status-use-case.ts @@ -0,0 +1,9 @@ +import type { AuthStatus, CredentialStore } from "./ports/credential-store.js"; + +export class AuthStatusUseCase { + constructor(private readonly authProvider: CredentialStore) {} + + async execute(): Promise { + return await this.authProvider.status(); + } +} diff --git a/cli/src/runtime/auth/auth-storage.ts b/cli/src/runtime/auth/auth-storage.ts new file mode 100644 index 000000000..488569557 --- /dev/null +++ b/cli/src/runtime/auth/auth-storage.ts @@ -0,0 +1,115 @@ +import { execFileSync } from "node:child_process"; +import { chmod, mkdir, readFile, rm, writeFile } from "node:fs/promises"; +import { dirname, join } from "node:path"; +import { AuthStorageError } from "../../kernel/errors.js"; +import { AIDD_DIR } from "../../kernel/paths.js"; +import { userConfigDir } from "../user-config-dir.js"; +import type { AuthConfig, AuthCredential, AuthLevel } from "./auth.js"; + +interface SaveOptions { + credential: AuthCredential; + level: AuthLevel; + projectRoot: string; +} + +export class AuthStorage { + private static readonly AUTH_FILE = "auth.json"; + + userConfigPath(): string { + return join(userConfigDir(), AuthStorage.AUTH_FILE); + } + + projectConfigPath(projectRoot: string): string { + return join(projectRoot, AIDD_DIR, AuthStorage.AUTH_FILE); + } + + async read(path: string): Promise { + try { + const content = await readFile(path, "utf-8"); + const parsed = JSON.parse(content) as unknown; + if (!isAuthConfig(parsed)) return null; + return parsed; + } catch { + return null; + } + } + + async write(path: string, config: AuthConfig): Promise { + await mkdir(dirname(path), { recursive: true }); + await writeFile(path, JSON.stringify(config, null, 2), "utf-8"); + if (process.platform === "win32") { + try { + // An argument list, never a command line: `path` comes from an environment variable + // or a project root, either of which could close the quoting and append a command. + // With no shell to expand `%USERNAME%`, the account is read here instead. + execFileSync("icacls", [path, "/inheritance:r", "/grant:r", `${windowsAccount()}:(R,W)`], { + stdio: ["ignore", "ignore", "pipe"], + }); + } catch (err) { + throw new AuthStorageError( + `Failed to set restrictive permissions on ${path}: ${err instanceof Error ? err.message : String(err)}` + ); + } + } else { + await chmod(path, 0o600); + } + } + + async delete(path: string): Promise { + await rm(path, { force: true }); + } + + async readActive(projectRoot: string): Promise { + const envToken = process.env.AIDD_TOKEN; + if (envToken) { + return { + version: 1, + method: "stored", + level: "user", + token: envToken, + createdAt: new Date().toISOString(), + }; + } + const projectConfig = await this.read(this.projectConfigPath(projectRoot)); + if (projectConfig !== null) return projectConfig; + return this.read(this.userConfigPath()); + } + + async save(options: SaveOptions): Promise { + const config: AuthConfig = { + version: 1, + method: options.credential.method, + level: options.level, + createdAt: new Date().toISOString(), + ...(options.credential.method === "stored" + ? { token: options.credential.token } + : { provider: options.credential.provider }), + }; + const path = + options.level === "project" + ? this.projectConfigPath(options.projectRoot) + : this.userConfigPath(); + await this.write(path, config); + } +} + +/** The account icacls grants to. Absent, the grant would name nobody, so it fails here + * rather than leaving a credential file with inheritance stripped and no grant at all. */ +function windowsAccount(): string { + const account = process.env.USERNAME; + if (account === undefined || account === "") { + throw new AuthStorageError("USERNAME is not set, so no account can be granted access"); + } + return account; +} + +function isAuthConfig(value: unknown): value is AuthConfig { + if (typeof value !== "object" || value === null) return false; + const obj = value as Record; + return ( + obj.version === 1 && + (obj.method === "external" || obj.method === "stored") && + (obj.level === "user" || obj.level === "project") && + typeof obj.createdAt === "string" + ); +} diff --git a/cli/src/domain/models/auth.ts b/cli/src/runtime/auth/auth.ts similarity index 100% rename from cli/src/domain/models/auth.ts rename to cli/src/runtime/auth/auth.ts diff --git a/cli/src/infrastructure/adapters/gh-cli-adapter.ts b/cli/src/runtime/auth/gh-cli-adapter.ts similarity index 87% rename from cli/src/infrastructure/adapters/gh-cli-adapter.ts rename to cli/src/runtime/auth/gh-cli-adapter.ts index 2b4fb3f64..6ebfa86bb 100644 --- a/cli/src/infrastructure/adapters/gh-cli-adapter.ts +++ b/cli/src/runtime/auth/gh-cli-adapter.ts @@ -1,7 +1,6 @@ import { spawnSync } from "node:child_process"; -import { AuthenticationError } from "../../domain/errors.js"; -import type { CliAuthProvider } from "../../domain/ports/oauth-provider.js"; -import { GhCliError } from "../errors.js"; +import { AuthenticationError, GhCliError } from "../../kernel/errors.js"; +import type { CliAuthProvider } from "./ports/oauth-provider.js"; export class GhCliAdapter implements CliAuthProvider { resolve(): string | null { diff --git a/cli/src/infrastructure/adapters/gh-token-adapter.ts b/cli/src/runtime/auth/gh-token-adapter.ts similarity index 76% rename from cli/src/infrastructure/adapters/gh-token-adapter.ts rename to cli/src/runtime/auth/gh-token-adapter.ts index 5315ae7cf..244b992ec 100644 --- a/cli/src/infrastructure/adapters/gh-token-adapter.ts +++ b/cli/src/runtime/auth/gh-token-adapter.ts @@ -1,7 +1,6 @@ -import { AuthenticationError } from "../../domain/errors.js"; -import type { TokenAuthProvider } from "../../domain/ports/oauth-provider.js"; +import { AuthenticationError } from "../../kernel/errors.js"; import type { HttpGet } from "../http/http-client.js"; - +import type { TokenAuthProvider } from "./ports/oauth-provider.js"; export class GhTokenAdapter implements TokenAuthProvider { constructor(private readonly http: HttpGet) {} diff --git a/cli/src/runtime/auth/ports/credential-file-store.ts b/cli/src/runtime/auth/ports/credential-file-store.ts new file mode 100644 index 000000000..ca9eccb1a --- /dev/null +++ b/cli/src/runtime/auth/ports/credential-file-store.ts @@ -0,0 +1,18 @@ +import type { AuthConfig, AuthCredential, AuthLevel } from "../auth.js"; + +export interface CredentialFileSaveOptions { + credential: AuthCredential; + level: AuthLevel; + projectRoot: string; +} + +/** Declared as a port so a test's stand-in is held to the same signatures the real store + * has: a cast around the whole class let one drift out of shape, and nothing said so. */ +export interface CredentialFileStore { + userConfigPath(): string; + projectConfigPath(projectRoot: string): string; + read(path: string): Promise; + readActive(projectRoot: string): Promise; + save(options: CredentialFileSaveOptions): Promise; + delete(path: string): Promise; +} diff --git a/cli/src/domain/ports/credential-store.ts b/cli/src/runtime/auth/ports/credential-store.ts similarity index 89% rename from cli/src/domain/ports/credential-store.ts rename to cli/src/runtime/auth/ports/credential-store.ts index cbf0215ef..386f2a3bb 100644 --- a/cli/src/domain/ports/credential-store.ts +++ b/cli/src/runtime/auth/ports/credential-store.ts @@ -1,4 +1,4 @@ -import type { AuthCredential, AuthLevel } from "../models/auth.js"; +import type { AuthCredential, AuthLevel } from "../auth.js"; export type AuthLogoutHint = "external-provider-cleanup"; diff --git a/cli/src/domain/ports/oauth-provider.ts b/cli/src/runtime/auth/ports/oauth-provider.ts similarity index 100% rename from cli/src/domain/ports/oauth-provider.ts rename to cli/src/runtime/auth/ports/oauth-provider.ts diff --git a/cli/src/domain/ports/token-provider.ts b/cli/src/runtime/auth/ports/token-provider.ts similarity index 100% rename from cli/src/domain/ports/token-provider.ts rename to cli/src/runtime/auth/ports/token-provider.ts diff --git a/cli/src/runtime/filesystem/atomic-write.ts b/cli/src/runtime/filesystem/atomic-write.ts new file mode 100644 index 000000000..3aac000e9 --- /dev/null +++ b/cli/src/runtime/filesystem/atomic-write.ts @@ -0,0 +1,12 @@ +import { randomBytes } from "node:crypto"; +import { rename, writeFile } from "node:fs/promises"; + +/** A temporary sibling then `rename`, the one POSIX-atomic step a direct `writeFile` cannot + * offer: a concurrent reader never sees a half-written file, and a crash mid-write orphans + * the temporary rather than truncating the real one. The pid and random suffix keep two + * concurrent writers to the same `path` off each other's temporary file. */ +export async function atomicWriteFile(path: string, content: string): Promise { + const tmpPath = `${path}.${process.pid}-${randomBytes(6).toString("hex")}.tmp`; + await writeFile(tmpPath, content, "utf-8"); + await rename(tmpPath, path); +} diff --git a/cli/src/infrastructure/adapters/file-adapter.ts b/cli/src/runtime/filesystem/file-adapter.ts similarity index 79% rename from cli/src/infrastructure/adapters/file-adapter.ts rename to cli/src/runtime/filesystem/file-adapter.ts index 15f96cc6d..52c6a2b99 100644 --- a/cli/src/infrastructure/adapters/file-adapter.ts +++ b/cli/src/runtime/filesystem/file-adapter.ts @@ -1,30 +1,31 @@ +import { constants } from "node:fs"; import { access, chmod, - constants, - copyFile, mkdir, readdir, readFile, + realpath, rm, rmdir, stat, - writeFile, } from "node:fs/promises"; -import { dirname, join, relative } from "node:path"; -import { stripJsonComments } from "../../domain/formats/jsonc.js"; -import type { FileHash } from "../../domain/models/file.js"; +import { dirname, join } from "node:path"; +import type { FileMerger } from "../../contexts/tools/domain/ports/file-merger.js"; +import { JsonParseError } from "../../kernel/errors.js"; +import type { FileHash } from "../../kernel/file.js"; import { isPerKeyMergeStrategy, type MergeStrategy, type PerKeyMergeStrategy, -} from "../../domain/models/merge.js"; -import type { FileMerger } from "../../domain/ports/file-merger.js"; -import type { FileReader } from "../../domain/ports/file-reader.js"; -import type { FileWriter } from "../../domain/ports/file-writer.js"; -import type { Hasher } from "../../domain/ports/hasher.js"; -import type { Logger } from "../../domain/ports/logger.js"; -import { JsonParseError } from "../errors.js"; +} from "../../kernel/merge.js"; +import { posixRelative } from "../../kernel/paths.js"; +import type { FileReader } from "../../kernel/ports/file-reader.js"; +import type { FileWriter } from "../../kernel/ports/file-writer.js"; +import type { Hasher } from "../../kernel/ports/hasher.js"; +import type { Logger } from "../../kernel/ports/logger.js"; +import { stripJsonComments } from "../../kernel/reading/jsonc.js"; +import { atomicWriteFile } from "./atomic-write.js"; export class FileAdapter implements FileReader, FileWriter, FileMerger { async isExecutable(path: string): Promise { @@ -43,14 +44,15 @@ export class FileAdapter implements FileReader, FileWriter, FileMerger { async writeFile(path: string, content: string): Promise { await mkdir(dirname(path), { recursive: true }); - await writeFile(path, content, "utf-8"); + await atomicWriteFile(path, content); } async deleteFile(path: string): Promise { try { await rm(path, { force: true }); } catch { - // No error if missing + // `force` already covers absence; what is swallowed here is a refusal to delete + // something that is there, which must not fail an uninstall midway. } } @@ -106,7 +108,7 @@ export class FileAdapter implements FileReader, FileWriter, FileMerger { if (entry.isDirectory()) { await this.collectFiles(baseDir, fullPath, results); } else { - results.push(relative(baseDir, fullPath)); + results.push(posixRelative(baseDir, fullPath)); } } } @@ -120,6 +122,10 @@ export class FileAdapter implements FileReader, FileWriter, FileMerger { } } + async realpath(path: string): Promise { + return realpath(path); + } + async readFileHash(path: string): Promise { const content = await this.readFile(path); return this.hasher.hash(content); @@ -133,22 +139,6 @@ export class FileAdapter implements FileReader, FileWriter, FileMerger { await chmod(path, 0o755); } - async hasLocalChanges(path: string, knownHash: FileHash): Promise { - if (!(await this.fileExists(path))) return false; - const diskHash = await this.readFileHash(path); - return diskHash.value !== knownHash.value; - } - - async backup(absolutePath: string): Promise { - const timestamp = new Date() - .toISOString() - .slice(0, 19) - .replace(/[^0-9T]/g, ""); - const backupPath = `${absolutePath}.bak.${timestamp}`; - await copyFile(absolutePath, backupPath); - return backupPath; - } - async listFilesRecursive(dirPath: string): Promise { const results: string[] = []; await this.collectAbsolutePaths(dirPath, results); @@ -205,8 +195,7 @@ export class FileAdapter implements FileReader, FileWriter, FileMerger { } } -// Intentionally shallow: each key's value is taken wholesale from either existing or incoming. -// No deep merge — nested objects are replaced, not recursively merged. +// Intentionally shallow: a nested object is replaced wholesale, never merged into. function mergePerKey( existing: Record, incoming: Record, @@ -239,7 +228,7 @@ function deepMerge( const existing = result[key]; if (Array.isArray(value) && Array.isArray(existing)) { - // Deduplicate arrays by JSON-serialized key — works for both primitives and objects + // A JSON-serialized key, so objects deduplicate as well as primitives. const combined = [...existing, ...value]; result[key] = [...new Map(combined.map((v) => [JSON.stringify(v), v])).values()]; } else if (isPlainObject(value) && isPlainObject(existing)) { @@ -248,7 +237,6 @@ function deepMerge( value as Record ); } else { - // Scalars from new data override result[key] = value; } } diff --git a/cli/src/runtime/filesystem/hasher-adapter.ts b/cli/src/runtime/filesystem/hasher-adapter.ts new file mode 100644 index 000000000..ed4531f44 --- /dev/null +++ b/cli/src/runtime/filesystem/hasher-adapter.ts @@ -0,0 +1,10 @@ +import { createHash } from "node:crypto"; +import { FileHash } from "../../kernel/file.js"; +import type { Hasher } from "../../kernel/ports/hasher.js"; + +export class HasherAdapter implements Hasher { + hash(content: string): FileHash { + const hex = createHash("md5").update(content, "utf-8").digest("hex"); + return new FileHash(hex); + } +} diff --git a/cli/src/runtime/git/git-adapter.ts b/cli/src/runtime/git/git-adapter.ts new file mode 100644 index 000000000..87ad87360 --- /dev/null +++ b/cli/src/runtime/git/git-adapter.ts @@ -0,0 +1,378 @@ +import { spawnSync } from "node:child_process"; +import { join, resolve } from "node:path"; +import { + SESSION_TRAILER_HOOK_HEADER, + sessionTrailerHookLine, +} from "../../contexts/telemetry/domain/formats/commit-session-trailer.js"; +import type { + CommitMessageDelegateInstall, + CommitMessageDelegateRemoval, + VersionControl, +} from "../../contexts/telemetry/domain/ports/version-control.js"; +import { + detectHookManager, + HOOK_MANAGER_MARKER_NAMES, + type HookManager, + HUSKY_MARKER_NAME, + LEFTHOOK_MARKER_NAMES, + type TelemetryCommitTrailerSetup, +} from "../../contexts/telemetry/domain/telemetry-setup.js"; +import type { FileReader } from "../../kernel/ports/file-reader.js"; +import type { FileWriter } from "../../kernel/ports/file-writer.js"; +import { environmentWithoutGitVariables } from "./git-environment.js"; + +const PREPARE_COMMIT_MSG_HOOK = "prepare-commit-msg"; + +export class GitAdapter implements VersionControl { + constructor(private readonly fs: FileReader & FileWriter) {} + + async installCommitMessageDelegate( + projectRoot: string, + delegateFile: string, + script: string + ): Promise { + const managerFacts = await this.detectHookManagerFacts(projectRoot, delegateFile); + // A manager that owns prepare-commit-msg regenerates it from its own config on every + // install, silently wiping any line appended here — so nothing is appended. The delegate + // still lands in `resolveDelegateDir`'s manager-aware directory, the fixed location the + // printed job resolves against at commit time. + if (managerFacts.hookManager !== undefined) { + const commonHooksDir = await this.resolveDelegateDir(projectRoot, managerFacts.hookManager); + // Outside a git repository there is nowhere for the delegate to land and nothing for a + // hand-added job to resolve against, so reporting `hookManager` would print a job that + // can never run. + if (commonHooksDir === null) return { lineAdded: false }; + await this.writeDelegate(commonHooksDir, join(commonHooksDir, delegateFile), script); + return { lineAdded: false, ...managerFacts }; + } + + const hooksDir = await this.resolveDelegateDir(projectRoot, undefined); + if (hooksDir === null) return { lineAdded: false }; + + const delegatePath = join(hooksDir, delegateFile); + await this.writeDelegate(hooksDir, delegatePath, script); + const lineAdded = await this.callDelegateFromHook( + hooksDir, + sessionTrailerHookLine(delegatePath) + ); + return { lineAdded }; + } + + async removeCommitMessageDelegate( + projectRoot: string, + delegateFile: string + ): Promise { + // `off` must look wherever `on` actually wrote: `resolveHooksDir` alone diverges the + // moment `core.hooksPath` does, and would report nothing removed on a project that has + // something to remove. + const managerFacts = await this.detectHookManagerFacts(projectRoot, delegateFile); + const hooksDir = await this.resolveDelegateDir(projectRoot, managerFacts.hookManager); + if (hooksDir === null) return { removed: false, ...managerFacts }; + + const delegatePath = join(hooksDir, delegateFile); + const lineDropped = await this.stopCallingDelegate( + hooksDir, + sessionTrailerHookLine(delegatePath) + ); + const fileDeleted = await this.deleteDelegate(delegatePath); + // Either half alone counts as something removed: a hand-edited hook, or a hand-deleted + // delegate, leaves the other behind, and "nothing to remove" there is unactionable. + return { removed: lineDropped || fileDeleted, ...managerFacts }; + } + + /** Rewritten on every install, never only when absent, so an older CLI's delegate is + * brought up to date. This file is ours outright, unlike the hook that calls it. */ + private async writeDelegate(hooksDir: string, delegatePath: string, script: string) { + await this.fs.createDirectory(hooksDir); + await this.fs.writeFile(delegatePath, script); + await this.fs.chmodExecutable(delegatePath); + } + + /** An existing hook is kept whole and gains a line at the end; only a repository with no + * hook at all gets one written from scratch. */ + private async callDelegateFromHook(hooksDir: string, line: string): Promise { + const hookPath = join(hooksDir, PREPARE_COMMIT_MSG_HOOK); + const existing = (await this.fs.fileExists(hookPath)) + ? await this.fs.readFile(hookPath) + : `${SESSION_TRAILER_HOOK_HEADER}\n`; + if (existing.includes(line)) return false; + + const separator = existing.endsWith("\n") ? "" : "\n"; + await this.fs.writeFile(hookPath, `${existing}${separator}${line}\n`); + await this.fs.chmodExecutable(hookPath); + return true; + } + + /** Drops that one line and leaves every other byte of the hook alone. */ + private async stopCallingDelegate(hooksDir: string, line: string): Promise { + const hookPath = join(hooksDir, PREPARE_COMMIT_MSG_HOOK); + if (!(await this.fs.fileExists(hookPath))) return false; + + const content = await this.fs.readFile(hookPath); + if (!content.includes(line)) return false; + + const kept = content.split("\n").filter((entry) => entry.trim() !== line); + await this.fs.writeFile(hookPath, kept.join("\n")); + return true; + } + + private async deleteDelegate(delegatePath: string): Promise { + if (!(await this.fs.fileExists(delegatePath))) return false; + await this.fs.deleteFile(delegatePath); + return true; + } + + // A non-zero exit or a thrown spawn error both read as "not a repository", never a throw: + // `aidd telemetry check` gates on this before judging anything else. + async isRepository(cwd: string): Promise { + try { + const result = spawnSync("git", ["rev-parse", "--show-toplevel"], { + cwd, + encoding: "utf8", + env: environmentWithoutGitVariables(), + }); + return result.status === 0 && result.stdout.trim() !== ""; + } catch { + return false; + } + } + + // A non-zero exit — no repository, or git itself missing — reads as "nothing tracked", + // never a throw: turning telemetry on must not depend on being inside a git repository. + async listTrackedFiles(repoRoot: string, pathspec: string): Promise { + try { + const result = spawnSync("git", ["ls-files", "--", pathspec], { + cwd: repoRoot, + encoding: "utf8", + env: environmentWithoutGitVariables(), + }); + if (result.status !== 0) return []; + return result.stdout.split("\n").filter((line) => line.trim() !== ""); + } catch { + return []; + } + } + + // `git log` on a pathspec, not `git ls-files`: the index and history are different + // questions, and this asks the second. A zero-commit repository and one where `pathspec` + // was only ever staged both read as no history, the honest answer for both. + async hasHistoryFor(repoRoot: string, pathspec: string): Promise { + try { + const result = spawnSync("git", ["log", "--oneline", "-1", "--", pathspec], { + cwd: repoRoot, + encoding: "utf8", + env: environmentWithoutGitVariables(), + }); + return result.status === 0 && result.stdout.trim() !== ""; + } catch { + return false; + } + } + + /** Each field is answered independently: an unresolvable hooks directory leaves the file + * facts absent rather than guessed, and unreadable history leaves the count absent rather + * than zero, which is also what a genuinely broken install looks like. */ + async readCommitTrailerSetup( + projectRoot: string, + delegateFile: string, + trailerToken: string, + limit: number + ): Promise { + const hooksDir = await this.resolveHooksDir(projectRoot); + const recentlyCarrying = this.countCommitsCarrying(projectRoot, trailerToken, limit); + const history = recentlyCarrying === null ? {} : { recentlyCarrying }; + const managerFacts = await this.detectHookManagerFacts(projectRoot, delegateFile); + // The delegate is checked wherever `install` wrote it — `resolveDelegateDir`'s + // manager-aware answer, never `hooksDir`, from which husky's layout diverges. + const delegateDir = await this.resolveDelegateDir(projectRoot, managerFacts.hookManager); + if (hooksDir === null) { + return { + ...(await this.withoutHooksDir(projectRoot, delegateDir, delegateFile)), + ...managerFacts, + ...history, + }; + } + return { + ...(await this.hookFacts(hooksDir, delegateDir, delegateFile)), + hooksDir, + ...managerFacts, + ...history, + }; + } + + /** Decided from root marker names alone, never from the hook's contents, which a manager + * regenerates from its own config on every install. Both config files are read, never written. */ + private async detectHookManagerFacts( + projectRoot: string, + delegateFile: string + ): Promise> { + const presentMarkers: string[] = []; + for (const name of HOOK_MANAGER_MARKER_NAMES) { + if (await this.fs.fileExists(join(projectRoot, name))) presentMarkers.push(name); + } + const hookManager = detectHookManager(presentMarkers); + if (hookManager === undefined) return {}; + + const configPath = this.managerConfigPath(projectRoot, hookManager, presentMarkers); + const config = await this.readIfPresent(configPath); + // A mention, not a parse: a config naming the delegate anywhere, a comment included, + // reads as wired — the one case this cannot tell apart from a real call. + const managerCallsDelegate = config?.includes(delegateFile) ?? false; + return { hookManager, managerCallsDelegate }; + } + + private managerConfigPath( + projectRoot: string, + manager: HookManager, + presentMarkers: readonly string[] + ): string { + if (manager === "husky") return join(projectRoot, HUSKY_MARKER_NAME, PREPARE_COMMIT_MSG_HOOK); + const lefthookFile = + presentMarkers.find((name) => (LEFTHOOK_MARKER_NAMES as readonly string[]).includes(name)) ?? + LEFTHOOK_MARKER_NAMES[0]; + return join(projectRoot, lefthookFile); + } + + /** The one home install, removal and check all resolve through, so the three can never + * point at different directories. Under a manager, `core.hooksPath` is deliberately ignored + * — husky routes it under `.husky/`, the file this CLI must never write — and the fixed + * common-dir location is what a hand-added job resolves against at commit time. */ + private async resolveDelegateDir( + projectRoot: string, + hookManager: HookManager | undefined + ): Promise { + return hookManager !== undefined + ? this.gitDirVia(projectRoot, ["rev-parse", "--git-common-dir"], true) + : this.gitDirVia(projectRoot, ["rev-parse", "--git-path", "hooks"], false); + } + + /** `null` on any failure to run or to answer: installing or reading a hook is never + * allowed to be the reason a command fails. */ + private async gitDirVia( + projectRoot: string, + args: readonly string[], + appendHooks: boolean + ): Promise { + try { + const result = spawnSync("git", [...args], { + cwd: projectRoot, + encoding: "utf8", + env: environmentWithoutGitVariables(), + }); + if (result.status !== 0) return null; + const answer = result.stdout.trim(); + if (answer === "") return null; + const resolved = resolve(projectRoot, answer); + return appendHooks ? join(resolved, "hooks") : resolved; + } catch { + return null; + } + } + + /** Which of the two causes left no hooks directory, asked rather than assumed: only a + * project outside git means "no hook to carry anything". `delegateDir` is asked + * independently and can still answer when `hooksDir` cannot, so whether the delegate is + * actually there — the one fact a person can act on — is not lost with it. */ + private async withoutHooksDir( + projectRoot: string, + delegateDir: string | null, + delegateFile: string + ): Promise> { + const inRepository = await this.isRepository(projectRoot); + return { + delegate: + delegateDir === null ? "absent" : await this.delegateState(join(delegateDir, delegateFile)), + callSite: "no-hook-file", + hookHasOtherContent: false, + hooksDirMissing: inRepository ? "unresolved" : "no-repository", + }; + } + + private async hookFacts( + hooksDir: string, + delegateDir: string | null, + delegateFile: string + ): Promise> { + const hookPath = join(hooksDir, PREPARE_COMMIT_MSG_HOOK); + const line = sessionTrailerHookLine(join(hooksDir, delegateFile)); + const hook = await this.readIfPresent(hookPath); + return { + delegate: + delegateDir === null ? "absent" : await this.delegateState(join(delegateDir, delegateFile)), + // The hook's own bit, not the delegate's. Git refuses to run a `prepare-commit-msg` it + // cannot execute; reported rather than fixed, since the repair must never quietly widen + // a file this project did not write. + ...(hook === null ? {} : { hookExecutable: await this.fs.isExecutable(hookPath) }), + callSite: callSiteState(hook, line), + hookHasOtherContent: holdsSomebodyElsesLines(hook, line), + }; + } + + /** Git will not run a hook it cannot execute, so a delegate that is there but unrunnable + * is a distinct answer from one that is missing. */ + private async delegateState(path: string): Promise { + if (!(await this.fs.fileExists(path))) return "absent"; + return (await this.fs.isExecutable(path)) ? "executable" : "not-executable"; + } + + private async readIfPresent(path: string): Promise { + return (await this.fs.fileExists(path)) ? await this.fs.readFile(path) : null; + } + + /** `%(trailers:key=…)` is git's own reader, so this agrees with `git log` by construction + * rather than by a regex of ours. `null`, never `0`, when there is no history to read: a + * repository with no commits and one whose every commit is unstamped are different facts. */ + private countCommitsCarrying( + projectRoot: string, + trailerToken: string, + limit: number + ): { carrying: number; examined: number } | null { + try { + const result = spawnSync( + "git", + [ + "log", + `-${limit}`, + // The delegate refuses merges by design, so counting them would put commits in the + // denominator that can never be in the numerator. + "--no-merges", + `--format=%(trailers:key=${trailerToken},valueonly)%x00`, + ], + { cwd: projectRoot, encoding: "utf8", env: environmentWithoutGitVariables() } + ); + if (result.status !== 0) return null; + // No guard for an empty list: `git log` exits non-zero in a repository with no commits, + // so the branch above already answers `null`, and an all-merge repository cannot exist. + const commits = result.stdout.split("\u0000").slice(0, -1); + return { + carrying: commits.filter((one) => one.trim() !== "").length, + examined: commits.length, + }; + } catch { + return null; + } + } + + /** Asked of git rather than assembled from `.git`: `--git-path hooks` returns + * `core.hooksPath` when one is set — under which a hook written to `.git/hooks` is silently + * never run — and a linked worktree's *common* git dir, which is where git looks. The + * answer can be relative, and git prints it against the cwd, here `projectRoot`. */ + private async resolveHooksDir(projectRoot: string): Promise { + return this.gitDirVia(projectRoot, ["rev-parse", "--git-path", "hooks"], false); + } +} + +function callSiteState(hook: string | null, line: string): TelemetryCommitTrailerSetup["callSite"] { + if (hook === null) return "no-hook-file"; + return hook.includes(line) ? "present" : "missing"; +} + +/** `#!/bin/sh` alone is the file the CLI writes when a repository had none, so a hook + * holding only that is still ours. */ +function holdsSomebodyElsesLines(hook: string | null, line: string): boolean { + if (hook === null) return false; + return hook + .split("\n") + .map((entry) => entry.trim()) + .some((entry) => entry !== "" && entry !== line && entry !== SESSION_TRAILER_HOOK_HEADER); +} diff --git a/cli/src/runtime/git/git-environment.ts b/cli/src/runtime/git/git-environment.ts new file mode 100644 index 000000000..b83690378 --- /dev/null +++ b/cli/src/runtime/git/git-environment.ts @@ -0,0 +1,8 @@ +/** git exports `GIT_DIR`, `GIT_WORK_TREE` and friends into every process it spawns. Left in + * place, a `git` call from inside a hook or a CI step reads the repository the environment + * names rather than the one at `cwd` — silently, with a plausible wrong answer. */ +export function environmentWithoutGitVariables( + env: NodeJS.ProcessEnv = process.env +): NodeJS.ProcessEnv { + return Object.fromEntries(Object.entries(env).filter(([key]) => !key.startsWith("GIT_"))); +} diff --git a/cli/src/runtime/git/inject-token.ts b/cli/src/runtime/git/inject-token.ts new file mode 100644 index 000000000..d95f32277 --- /dev/null +++ b/cli/src/runtime/git/inject-token.ts @@ -0,0 +1,41 @@ +interface HostMatcher { + /** The forge's registered domain. Matched against the URL's own host, never its text. */ + host: string; + authPrefix: string; +} + +const HOST_MATCHERS: readonly HostMatcher[] = [ + { host: "github.com", authPrefix: "x-access-token:" }, + { host: "gitlab.com", authPrefix: "oauth2:" }, + { host: "bitbucket.org", authPrefix: "x-token-auth:" }, + { host: "dev.azure.com", authPrefix: ":" }, +]; + +/** The host a URL addresses, never its text: `evil.example/github.com/x` and + * `notgithub.com/x` both contain `github.com` and must get no GitHub credential. */ +function hostnameOf(url: string): string | null { + try { + return new URL(url).hostname.toLowerCase(); + } catch { + return null; + } +} + +function matcherFor(hostname: string): HostMatcher | undefined { + return HOST_MATCHERS.find((m) => hostname === m.host || hostname.endsWith(`.${m.host}`)); +} + +export function injectTokenIntoUrl(url: string, token: string | undefined): string { + if (!token || !url.startsWith("https://")) return url; + const hostname = hostnameOf(url); + if (hostname === null) return url; + const matcher = matcherFor(hostname); + const authPrefix = matcher?.authPrefix ?? ""; + return url.replace("https://", `https://${authPrefix}${token}@`); +} + +/** A credential typed into a source URL otherwise travels into the error a failed clone + * prints and into the cache directory's name, where it is written to disk and stays. */ +export function withoutCredentials(url: string): string { + return url.replace(/^(https?:\/\/)[^/@]*@/, "$1"); +} diff --git a/cli/src/infrastructure/http/http-client.ts b/cli/src/runtime/http/http-client.ts similarity index 89% rename from cli/src/infrastructure/http/http-client.ts rename to cli/src/runtime/http/http-client.ts index f21e0126f..a4f3f116b 100644 --- a/cli/src/infrastructure/http/http-client.ts +++ b/cli/src/runtime/http/http-client.ts @@ -1,8 +1,12 @@ import type { IncomingMessage } from "node:http"; import * as http from "node:http"; import * as https from "node:https"; -import { AuthenticationError } from "../../domain/errors.js"; -import { HttpError, HttpNotFoundError, HttpRedirectError } from "../errors.js"; +import { + AuthenticationError, + HttpError, + HttpNotFoundError, + HttpRedirectError, +} from "../../kernel/errors.js"; export interface HttpGetOptions { token?: string; @@ -15,7 +19,6 @@ export interface HttpResponse { contentType: string; } -/** One GET over HTTP, as the adapters that fetch releases and catalogs need it. */ export interface HttpGet { get(url: string, options?: HttpGetOptions): Promise; } @@ -69,9 +72,9 @@ export class HttpClient implements HttpGet { if (!location) { throw new HttpRedirectError(url); } - // Consume the body to free the socket + // Consume the body to free the socket. await collectBuffer(response); - // Do not forward token or accept: redirect targets (S3/CDN) use signed URLs + // Neither token nor accept is forwarded: a redirect target is a signed URL. const redirected = await doGet(location, undefined, undefined); return this.parseResponse(redirected, location); } diff --git a/cli/src/runtime/platform/platform-adapter.ts b/cli/src/runtime/platform/platform-adapter.ts new file mode 100644 index 000000000..4314a9d9a --- /dev/null +++ b/cli/src/runtime/platform/platform-adapter.ts @@ -0,0 +1,7 @@ +import type { Platform } from "./platform.js"; + +export class PlatformAdapter implements Platform { + current(): string { + return process.platform; + } +} diff --git a/cli/src/domain/ports/platform.ts b/cli/src/runtime/platform/platform.ts similarity index 100% rename from cli/src/domain/ports/platform.ts rename to cli/src/runtime/platform/platform.ts diff --git a/cli/src/infrastructure/project-root.ts b/cli/src/runtime/project-root/project-root.ts similarity index 100% rename from cli/src/infrastructure/project-root.ts rename to cli/src/runtime/project-root/project-root.ts diff --git a/cli/src/infrastructure/adapters/prompter-adapter.ts b/cli/src/runtime/prompter/prompter-adapter.ts similarity index 96% rename from cli/src/infrastructure/adapters/prompter-adapter.ts rename to cli/src/runtime/prompter/prompter-adapter.ts index 6ef376972..1b1c9e3f3 100644 --- a/cli/src/infrastructure/adapters/prompter-adapter.ts +++ b/cli/src/runtime/prompter/prompter-adapter.ts @@ -1,6 +1,6 @@ import { checkbox, confirm, input, select } from "@inquirer/prompts"; -import { InputRequiredError } from "../../application/errors.js"; -import type { Prompter } from "../../domain/ports/prompter.js"; +import { InputRequiredError } from "../../kernel/errors.js"; +import type { Prompter } from "../../kernel/ports/prompter.js"; type PromptContext = { input?: NodeJS.ReadableStream; diff --git a/cli/src/runtime/self-update/check-update-use-case.ts b/cli/src/runtime/self-update/check-update-use-case.ts new file mode 100644 index 000000000..b25ddd906 --- /dev/null +++ b/cli/src/runtime/self-update/check-update-use-case.ts @@ -0,0 +1,76 @@ +import { join } from "node:path"; +import type { FileReader } from "../../kernel/ports/file-reader.js"; +import type { FileWriter } from "../../kernel/ports/file-writer.js"; +import type { Logger } from "../../kernel/ports/logger.js"; +import type { VersionReader } from "../../kernel/ports/version-reader.js"; +import { compareSemver, isSemver } from "../../kernel/semver.js"; +import { userConfigDir } from "../user-config-dir.js"; +import type { SelfUpdater } from "./self-updater.js"; + +interface CachedCheck { + checkedAt: number; + latest: string; +} + +function isOutdated(version: string, latest: string): boolean { + return isSemver(version) && compareSemver(version, latest) < 0; +} + +/** Under `cache/`, beside every other disposable thing: nothing here is a choice a person + * made. The resolution is `userConfigDir()`'s, so the file a machine-scope `clean` purges is + * the file this writes, whatever the machine's own configuration says. */ +function resolveCachePath(): string { + return join(userConfigDir(), "cache", "update-check.json"); +} + +/** Read when the current path holds nothing, never written to, so an existing install is not + * made to refetch. */ +function legacyCachePath(): string { + return join(userConfigDir(), "update-check.json"); +} + +export class CheckUpdateUseCase { + constructor( + private readonly cliUpdater: SelfUpdater, + private readonly versionReader: VersionReader, + private readonly logger: Logger, + private readonly fs: FileReader & FileWriter + ) {} + + /** Hot path: the cached value only, fresh or stale, never the network. */ + async printFromCacheOnly(): Promise { + const cached = await this.readCacheRaw(); + if (cached === null) return; + const current = this.versionReader.get(); + if (!isOutdated(current, cached.latest)) return; + this.logger.warn( + `CLI update available: v${current.replace(/^v/, "")} → v${cached.latest.replace(/^v/, "")}` + ); + this.logger.warn("Run `aidd update`."); + } + + async refresh(): Promise { + const { version: latest } = await this.cliUpdater.fetchLatestRelease(); + await this.writeCache(latest); + } + + private async readCacheRaw(): Promise { + return (await this.readCacheAt(resolveCachePath())) ?? this.readCacheAt(legacyCachePath()); + } + + private async readCacheAt(path: string): Promise { + if (!(await this.fs.fileExists(path))) return null; + try { + const raw = await this.fs.readFile(path); + return JSON.parse(raw) as CachedCheck; + } catch { + return null; + } + } + + private async writeCache(latest: string): Promise { + const path = resolveCachePath(); + await this.fs.createDirectory(join(path, "..")); + await this.fs.writeFile(path, JSON.stringify({ checkedAt: Date.now(), latest })); + } +} diff --git a/cli/src/runtime/self-update/current-version-adapter.ts b/cli/src/runtime/self-update/current-version-adapter.ts new file mode 100644 index 000000000..032fe31fb --- /dev/null +++ b/cli/src/runtime/self-update/current-version-adapter.ts @@ -0,0 +1,8 @@ +import pkg from "../../../package.json" with { type: "json" }; +import type { VersionReader } from "../../kernel/ports/version-reader.js"; + +export class CurrentVersionAdapter implements VersionReader { + get(): string { + return pkg.version; + } +} diff --git a/cli/src/runtime/self-update/github-release-resolver-adapter.ts b/cli/src/runtime/self-update/github-release-resolver-adapter.ts new file mode 100644 index 000000000..a23fb496d --- /dev/null +++ b/cli/src/runtime/self-update/github-release-resolver-adapter.ts @@ -0,0 +1,74 @@ +import { + AuthenticationError, + CatalogFetchAuthError, + CatalogFetchError, + HttpNotFoundError, +} from "../../kernel/errors.js"; +import type { TokenProvider } from "../auth/ports/token-provider.js"; +import type { HttpGet } from "../http/http-client.js"; +import type { LatestReleaseResolver } from "./latest-release-resolver.js"; + +const GITHUB_API_BASE = "https://api.github.com"; + +/** Root release tag: `v` followed by a digit (`v4.0.0`, `v3.7.3-pm.1`). */ +const ROOT_RELEASE_TAG_REGEX = /^v\d/; + +export class GitHubReleaseResolverAdapter implements LatestReleaseResolver { + constructor( + private readonly http: HttpGet, + private readonly tokenProvider?: TokenProvider + ) {} + + async resolveLatest(repo: string): Promise { + // `/releases?per_page=1`, not `/releases/latest`, which excludes prereleases: the most + // recent published release of any kind is wanted, so beta tags resolve too. + const url = `${GITHUB_API_BASE}/repos/${repo}/releases?per_page=1`; + const token = (await this.tokenProvider?.resolve()) ?? undefined; + try { + const response = await this.http.get(url, { token }); + const body = response.body as unknown[]; + if (!Array.isArray(body) || body.length === 0) return null; + const first = body[0] as Record; + return typeof first.tag_name === "string" ? first.tag_name : null; + } catch (err) { + return this.handleError(err, url); + } + } + + async listRootReleases(repo: string): Promise { + // `per_page=100`, GitHub's maximum, so root tags are not buried under release-please's + // per-component tags on a busy repository. + const url = `${GITHUB_API_BASE}/repos/${repo}/releases?per_page=100`; + const token = (await this.tokenProvider?.resolve()) ?? undefined; + try { + const response = await this.http.get(url, { token }); + const body = response.body as unknown[]; + if (!Array.isArray(body)) return []; + return body + .map((r) => (r as Record).tag_name) + .filter((t): t is string => typeof t === "string" && ROOT_RELEASE_TAG_REGEX.test(t)); + } catch (err) { + this.handleError(err, url); + return []; + } + } + + async isRepoPublic(repo: string): Promise { + // Deliberately tokenless: only a 404 unambiguously means "auth required", so any other + // error resolves true and a rate-limited or offline public user is not sent to login. + const url = `${GITHUB_API_BASE}/repos/${repo}`; + try { + await this.http.get(url); + return true; + } catch (err) { + return !(err instanceof HttpNotFoundError); + } + } + + private handleError(err: unknown, url: string): never | null { + if (err instanceof HttpNotFoundError) return null; + if (err instanceof AuthenticationError) throw new CatalogFetchAuthError(url); + const detail = err instanceof Error ? err.message : String(err); + throw new CatalogFetchError(url, detail); + } +} diff --git a/cli/src/runtime/self-update/latest-release-resolver.ts b/cli/src/runtime/self-update/latest-release-resolver.ts new file mode 100644 index 000000000..7d1b2391d --- /dev/null +++ b/cli/src/runtime/self-update/latest-release-resolver.ts @@ -0,0 +1,9 @@ +export interface LatestReleaseResolver { + resolveLatest(repo: string): Promise; + /** Bare `v` tags only, newest first: release-please's per-component tags are not + * install units, since the marketplace manifest lives at the repository root. */ + listRootReleases(repo: string): Promise; + /** A private or missing repository answers 404 unauthenticated; any other failure + * (network, rate-limit) resolves true, so a public user is never wrongly gated. */ + isRepoPublic(repo: string): Promise; +} diff --git a/cli/src/application/use-cases/self-update-use-case.ts b/cli/src/runtime/self-update/self-update-use-case.ts similarity index 87% rename from cli/src/application/use-cases/self-update-use-case.ts rename to cli/src/runtime/self-update/self-update-use-case.ts index 22bdf6ef7..4e87c77a0 100644 --- a/cli/src/application/use-cases/self-update-use-case.ts +++ b/cli/src/runtime/self-update/self-update-use-case.ts @@ -1,6 +1,6 @@ -import { compareSemver } from "../../domain/models/semver.js"; -import type { SelfUpdater } from "../../domain/ports/self-updater.js"; -import type { VersionReader } from "../../domain/ports/version-reader.js"; +import type { VersionReader } from "../../kernel/ports/version-reader.js"; +import { compareSemver } from "../../kernel/semver.js"; +import type { SelfUpdater } from "./self-updater.js"; export interface SelfUpdateInput { check: boolean; diff --git a/cli/src/infrastructure/adapters/self-updater-adapter.ts b/cli/src/runtime/self-update/self-updater-adapter.ts similarity index 87% rename from cli/src/infrastructure/adapters/self-updater-adapter.ts rename to cli/src/runtime/self-update/self-updater-adapter.ts index ab2009da3..f86450411 100644 --- a/cli/src/infrastructure/adapters/self-updater-adapter.ts +++ b/cli/src/runtime/self-update/self-updater-adapter.ts @@ -5,13 +5,17 @@ import { FrameworkResolutionError, PackageManagerDetectionError, UpdateError, -} from "../../domain/errors.js"; -import type { Logger } from "../../domain/ports/logger.js"; -import type { CliRelease, SelfUpdater } from "../../domain/ports/self-updater.js"; -import type { TokenProvider } from "../../domain/ports/token-provider.js"; +} from "../../kernel/errors.js"; +import type { Logger } from "../../kernel/ports/logger.js"; +import type { TokenProvider } from "../auth/ports/token-provider.js"; import type { HttpGet } from "../http/http-client.js"; +import type { CliRelease, SelfUpdater } from "./self-updater.js"; -const CLI_REPO = "ai-driven-dev/aidd-cli"; +// release-please tags this package per component: `cli-v`, never a bare `v`, +// which is the root marketplace's. `fetchChangelog` swallows its own 404, so a wrong tag here +// costs the changelog silently. +const CLI_REPO = "ai-driven-dev/framework"; +const CLI_TAG_PREFIX = "cli-v"; const CLI_PACKAGE = "@ai-driven-dev/cli"; const DEFAULT_GITHUB_API_BASE = "https://api.github.com"; const DEFAULT_NPM_REGISTRY_BASE = "https://registry.npmjs.org"; @@ -30,12 +34,11 @@ function detectPackageManager(): { pm: PackageManager; binaryPath: string } { let binaryPath: string; try { const raw = execSync(whichCommand, { encoding: "utf8" }); - // `where` on Windows may return multiple matches (one per line) — keep only the first + // `where` on Windows may return one match per line. binaryPath = raw.trim().split(/\r?\n/)[0].trim(); } catch { throw new PackageManagerDetectionError(Object.values(PM_INSTALL_COMMANDS)); } - // Normalise Windows backslashes so path checks work cross-platform const normalised = binaryPath.replace(/\\/g, "/"); if (normalised.includes("/pnpm/")) return { pm: "pnpm", binaryPath }; // yarn: ~/.yarn/bin (Unix) or AppData/Local/Yarn/bin (Windows) @@ -48,7 +51,6 @@ function detectPackageManager(): { pm: PackageManager; binaryPath: string } { return { pm: "npm", binaryPath }; } -/** Read a string property from a parsed JSON body, or null when absent or not a string. */ function readString(body: unknown, key: string): string | null { const value = (body as Record | null | undefined)?.[key]; return typeof value === "string" ? value : null; @@ -82,8 +84,8 @@ export class SelfUpdaterAdapter implements SelfUpdater { return { version, changelog: await this.fetchChangelog(version) }; } - // Version comes from npm — the registry `npm install -g` actually pulls from, - // reachable without a token whether the GitHub repo is public or private. + // npm, not GitHub: it is the registry `npm install -g` pulls from, and it is reachable + // without a token whether the GitHub repo is public or private. private async resolveLatestVersion(): Promise { const url = `${this.npmRegistryBase}/-/package/${CLI_PACKAGE}/dist-tags`; try { @@ -106,7 +108,7 @@ export class SelfUpdaterAdapter implements SelfUpdater { // Changelog is best-effort: the GitHub release body enriches the update notice // but is optional. A private repo without a token 404s here — swallow it. private async fetchChangelog(version: string): Promise { - const url = `${this.githubApiBase}/repos/${CLI_REPO}/releases/tags/v${version}`; + const url = `${this.githubApiBase}/repos/${CLI_REPO}/releases/tags/${CLI_TAG_PREFIX}${version}`; const token = (await this.tokenProvider?.resolve()) ?? undefined; try { const response = await this.http.get(url, { token }); diff --git a/cli/src/runtime/self-update/self-updater.ts b/cli/src/runtime/self-update/self-updater.ts new file mode 100644 index 000000000..557e28378 --- /dev/null +++ b/cli/src/runtime/self-update/self-updater.ts @@ -0,0 +1,10 @@ +export interface CliRelease { + version: string; + /** `null` where the release body could not be read — a private repository with no token. */ + changelog: string | null; +} + +export interface SelfUpdater { + fetchLatestRelease(): Promise; + install(): string; +} diff --git a/cli/src/runtime/user-config-dir.ts b/cli/src/runtime/user-config-dir.ts new file mode 100644 index 000000000..f0238681e --- /dev/null +++ b/cli/src/runtime/user-config-dir.ts @@ -0,0 +1,11 @@ +import { homedir } from "node:os"; +import { join } from "node:path"; + +/** What belongs to the user rather than to a project. `AIDD_USER_CONFIG_DIR` overrides it + * outright, which is how the suites stay out of a real home directory; `XDG_CONFIG_HOME` + * names a config root a person already chose, honored before the `~/.config` default. */ +export function userConfigDir(): string { + if (process.env.AIDD_USER_CONFIG_DIR) return process.env.AIDD_USER_CONFIG_DIR; + if (process.env.XDG_CONFIG_HOME) return join(process.env.XDG_CONFIG_HOME, "aidd"); + return join(homedir(), ".config", "aidd"); +} diff --git a/cli/src/runtime/wiring/distribution.ts b/cli/src/runtime/wiring/distribution.ts new file mode 100644 index 000000000..eaf9f87a4 --- /dev/null +++ b/cli/src/runtime/wiring/distribution.ts @@ -0,0 +1,89 @@ +import { FetchMarketplaceSourceUseCase } from "../../contexts/distribution/application/fetch-marketplace-source-use-case.js"; +import { MarketplaceListUseCase } from "../../contexts/distribution/application/marketplace-list-use-case.js"; +import { MarketplaceRefreshUseCase } from "../../contexts/distribution/application/marketplace-refresh-use-case.js"; +import { MarketplaceRegisterFrameworkUseCase } from "../../contexts/distribution/application/marketplace-register-framework-use-case.js"; +import { ResolveMarketplaceUseCase } from "../../contexts/distribution/application/resolve-marketplace-use-case.js"; +import type { MarketplaceRegistry } from "../../contexts/distribution/domain/ports/marketplace-registry.js"; +import type { MarketplaceTrustStore } from "../../contexts/distribution/domain/ports/marketplace-trust-store.js"; +import type { PluginCatalogRepository } from "../../contexts/distribution/domain/ports/plugin-catalog-repository.js"; +import type { PluginFetcher } from "../../contexts/distribution/domain/ports/plugin-fetcher.js"; +import { GitHubRawFetcherAdapter } from "../../contexts/distribution/infrastructure/github-raw-fetcher-adapter.js"; +import { MarketplaceCacheAdapter } from "../../contexts/distribution/infrastructure/marketplace-cache-adapter.js"; +import { MarketplaceRegistryAdapter } from "../../contexts/distribution/infrastructure/marketplace-registry-adapter.js"; +import { MarketplaceTrustStoreAdapter } from "../../contexts/distribution/infrastructure/marketplace-trust-store-adapter.js"; +import { PluginCatalogRepositoryAdapter } from "../../contexts/distribution/infrastructure/plugin-catalog-repository-adapter.js"; +import { PluginFetcherAdapter } from "../../contexts/distribution/infrastructure/plugin-fetcher-adapter.js"; +import type { FileMerger } from "../../contexts/tools/domain/ports/file-merger.js"; +import type { FileReader } from "../../kernel/ports/file-reader.js"; +import type { FileWriter } from "../../kernel/ports/file-writer.js"; +import type { Hasher } from "../../kernel/ports/hasher.js"; +import type { Logger } from "../../kernel/ports/logger.js"; +import type { AuthReaderAdapter } from "../auth/auth-reader-adapter.js"; +import type { HttpClient } from "../http/http-client.js"; + +export interface DistributionWiringShared { + fs: FileReader & FileWriter & FileMerger; + hasher: Hasher; + http: HttpClient; + authReader: AuthReaderAdapter; + logger: Logger; + projectRoot: string; +} + +export interface DistributionDeps { + pluginCatalogRepository: PluginCatalogRepository; + pluginFetcher: PluginFetcher; + marketplaceRegistry: MarketplaceRegistry; + marketplaceTrustStore: MarketplaceTrustStore; + resolveMarketplaceUseCase: ResolveMarketplaceUseCase; + marketplaceListUseCase: MarketplaceListUseCase; + marketplaceRefreshUseCase: MarketplaceRefreshUseCase; + marketplaceRegisterFrameworkUseCase: MarketplaceRegisterFrameworkUseCase; +} + +/** `marketplaceAddUseCase` is deliberately absent: it takes framework's + * `marketplaceRemoveUseCase`, so it is composed in `wiring/framework.ts` rather than pulling + * framework in here. */ +export function wireDistribution(shared: DistributionWiringShared): DistributionDeps { + const pluginCatalogRepository = new PluginCatalogRepositoryAdapter(shared.fs); + const marketplaceCache = new MarketplaceCacheAdapter(shared.projectRoot); + const marketplaceRegistry = new MarketplaceRegistryAdapter(); + const marketplaceTrustStore = new MarketplaceTrustStoreAdapter(shared.hasher); + const pluginFetcher = new PluginFetcherAdapter(shared.fs, shared.authReader); + const rawCatalogFetcher = new GitHubRawFetcherAdapter(shared.http, shared.authReader); + const fetchMarketplaceSource = new FetchMarketplaceSourceUseCase( + pluginFetcher, + rawCatalogFetcher, + shared.fs, + shared.logger + ); + const resolveMarketplaceUseCase = new ResolveMarketplaceUseCase( + fetchMarketplaceSource, + pluginCatalogRepository + ); + const marketplaceListUseCase = new MarketplaceListUseCase( + marketplaceRegistry, + resolveMarketplaceUseCase, + shared.logger + ); + const marketplaceRefreshUseCase = new MarketplaceRefreshUseCase( + marketplaceRegistry, + resolveMarketplaceUseCase, + marketplaceCache, + shared.logger, + shared.fs + ); + const marketplaceRegisterFrameworkUseCase = new MarketplaceRegisterFrameworkUseCase( + marketplaceRegistry + ); + return { + pluginCatalogRepository, + pluginFetcher, + marketplaceRegistry, + marketplaceTrustStore, + resolveMarketplaceUseCase, + marketplaceListUseCase, + marketplaceRefreshUseCase, + marketplaceRegisterFrameworkUseCase, + }; +} diff --git a/cli/src/runtime/wiring/framework.ts b/cli/src/runtime/wiring/framework.ts new file mode 100644 index 000000000..b17bcdfee --- /dev/null +++ b/cli/src/runtime/wiring/framework.ts @@ -0,0 +1,580 @@ +import { homedir } from "node:os"; +import "../../contexts/tools/domain/profiles/claude/profile.js"; +import "../../contexts/tools/domain/profiles/codex/profile.js"; +import "../../contexts/tools/domain/profiles/copilot/profile.js"; +import "../../contexts/tools/domain/profiles/cursor/profile.js"; +import "../../contexts/tools/domain/profiles/opencode/profile.js"; +import "../../contexts/tools/domain/profiles/vscode/profile.js"; +import { MarketplaceAddUseCase } from "../../contexts/distribution/application/marketplace-add-use-case.js"; +import type { MarketplaceListUseCase } from "../../contexts/distribution/application/marketplace-list-use-case.js"; +import type { MarketplaceRefreshUseCase } from "../../contexts/distribution/application/marketplace-refresh-use-case.js"; +import type { MarketplaceRegisterFrameworkUseCase } from "../../contexts/distribution/application/marketplace-register-framework-use-case.js"; +import { CleanUserScopeUseCase } from "../../contexts/framework/application/clean/clean-user-scope-use-case.js"; +import { CleanUseCase } from "../../contexts/framework/application/clean-use-case.js"; +import { DoctorLayoutUseCase } from "../../contexts/framework/application/doctor/doctor-layout-use-case.js"; +import { DoctorMergeFilesUseCase } from "../../contexts/framework/application/doctor/doctor-merge-files-use-case.js"; +import { DoctorPluginUseCase } from "../../contexts/framework/application/doctor/doctor-plugin-use-case.js"; +import { DoctorReferencesUseCase } from "../../contexts/framework/application/doctor/doctor-references-use-case.js"; +import { DoctorRegistrationUseCase } from "../../contexts/framework/application/doctor/doctor-registration-use-case.js"; +import { DoctorTrackedFilesUseCase } from "../../contexts/framework/application/doctor/doctor-tracked-files-use-case.js"; +import { DoctorUseCase } from "../../contexts/framework/application/doctor/doctor-use-case.js"; +import { MarketplaceCheckUseCase } from "../../contexts/framework/application/flows/marketplace-check-use-case.js"; +import { MarketplaceRemoveUseCase } from "../../contexts/framework/application/flows/marketplace-remove-use-case.js"; +import { MarketplaceSyncSettingsUseCase } from "../../contexts/framework/application/flows/marketplace-sync-settings-use-case.js"; +import { GitignoreUseCase } from "../../contexts/framework/application/gitignore-use-case.js"; +import { DoctorAllUseCase } from "../../contexts/framework/application/global/doctor-all-use-case.js"; +import { ResolveUpdateDecisionUseCase } from "../../contexts/framework/application/global/resolve-update-decision-use-case.js"; +import { RestoreAllUseCase } from "../../contexts/framework/application/global/restore-all-use-case.js"; +import { StatusAllUseCase } from "../../contexts/framework/application/global/status-all-use-case.js"; +import { UpdateAiToolsUseCase } from "../../contexts/framework/application/global/update-ai-tools-use-case.js"; +import { UpdateIdeToolsUseCase } from "../../contexts/framework/application/global/update-ide-tools-use-case.js"; +import { UpdateOneToolUseCase } from "../../contexts/framework/application/global/update-one-tool-use-case.js"; +import { InstallAiToolUseCase } from "../../contexts/framework/application/install/install-ai-tool-use-case.js"; +import { InstallIdeConfigUseCase } from "../../contexts/framework/application/install/install-ide-config-use-case.js"; +import { InstallIdeToolUseCase } from "../../contexts/framework/application/install/install-ide-tool-use-case.js"; +import { InstallRuntimeConfigUseCase } from "../../contexts/framework/application/install/install-runtime-config-use-case.js"; +import { PostInstallPipelineUseCase } from "../../contexts/framework/application/install/post-install-pipeline-use-case.js"; +import { ListInstalledRulesUseCase } from "../../contexts/framework/application/list-installed-rules-use-case.js"; +import { PluginAddUseCase } from "../../contexts/framework/application/plugin/plugin-add-use-case.js"; +import { PluginInstallFromMarketplaceUseCase } from "../../contexts/framework/application/plugin/plugin-install-from-marketplace-use-case.js"; +import { PluginInstallUseCase } from "../../contexts/framework/application/plugin/plugin-install-use-case.js"; +import { PluginListUseCase } from "../../contexts/framework/application/plugin/plugin-list-use-case.js"; +import { PluginRemoveUseCase } from "../../contexts/framework/application/plugin/plugin-remove-use-case.js"; +import { PluginSearchUseCase } from "../../contexts/framework/application/plugin/plugin-search-use-case.js"; +import { PluginUpdateUseCase } from "../../contexts/framework/application/plugin/plugin-update-use-case.js"; +import { RestoreUseCase } from "../../contexts/framework/application/restore/restore-use-case.js"; +import { ProjectContextDetectorUseCase } from "../../contexts/framework/application/setup/project-context-detector-use-case.js"; +import { SetupMachineScopeUseCase } from "../../contexts/framework/application/setup/setup-machine-scope-use-case.js"; +import { SetupMarketplaceSourceUseCase } from "../../contexts/framework/application/setup/setup-marketplace-source-use-case.js"; +import { SetupToolsUseCase } from "../../contexts/framework/application/setup/setup-tools-use-case.js"; +import { DetectPluginDriftUseCase } from "../../contexts/framework/application/shared/detect-plugin-drift-use-case.js"; +import { + EnsureBuiltMarketplaceUseCase, + type FrameworkBuildFor, +} from "../../contexts/framework/application/shared/ensure-built-marketplace-use-case.js"; +import { SetupMarketplaceRegistrationUseCase } from "../../contexts/framework/application/shared/setup-marketplace-registration-use-case.js"; +import { StatusUseCase } from "../../contexts/framework/application/status-use-case.js"; +import { UninstallIdeUseCase } from "../../contexts/framework/application/uninstall/uninstall-ide-use-case.js"; +import { UninstallToolsUseCase } from "../../contexts/framework/application/uninstall/uninstall-tools-use-case.js"; +import { UninstallUseCase } from "../../contexts/framework/application/uninstall/uninstall-use-case.js"; +import type { Environment } from "../../contexts/framework/domain/ports/environment.js"; +import type { ManifestRepository } from "../../contexts/framework/domain/ports/manifest-repository.js"; +import type { UserSourceReferences } from "../../contexts/framework/domain/ports/user-source-references.js"; +import { EnvironmentAdapter } from "../../contexts/framework/infrastructure/environment-adapter.js"; +import { ManifestRepositoryAdapter } from "../../contexts/framework/infrastructure/manifest-repository-adapter.js"; +import { PluginDistributionReaderAdapter } from "../../contexts/framework/infrastructure/plugin-distribution-reader-adapter.js"; +import { UserManifestRepositoryAdapter } from "../../contexts/framework/infrastructure/user-manifest-repository-adapter.js"; +import { UserSourceReferencesAdapter } from "../../contexts/framework/infrastructure/user-source-references-adapter.js"; +import type { FileMerger } from "../../contexts/tools/domain/ports/file-merger.js"; +import { hostPluginRegistryReaders } from "../../contexts/tools/infrastructure/host-plugin-registry-reader-adapter.js"; +import type { AssetProvider } from "../../kernel/ports/asset-provider.js"; +import type { FileReader } from "../../kernel/ports/file-reader.js"; +import type { FileWriter } from "../../kernel/ports/file-writer.js"; +import type { Logger } from "../../kernel/ports/logger.js"; +import type { Prompter } from "../../kernel/ports/prompter.js"; +import type { VersionReader } from "../../kernel/ports/version-reader.js"; +import { CLIOutput } from "../../presentation/output.js"; +import { PluginPickUseCase } from "../../presentation/prompts/plugin-pick-use-case.js"; +import { SetupPluginsPromptUseCase } from "../../presentation/prompts/setup-plugins-prompt-use-case.js"; +import { SetupToolsPromptUseCase } from "../../presentation/prompts/setup-tools-prompt-use-case.js"; +import { SyncConflictResolverUseCase } from "../../presentation/prompts/sync-conflict-resolver-use-case.js"; +import { BundledAssetProviderAdapter } from "../assets/asset-loader.js"; +import { AuthProviderAdapter } from "../auth/auth-provider-adapter.js"; +import { AuthReaderAdapter } from "../auth/auth-reader-adapter.js"; +import { AuthStorage } from "../auth/auth-storage.js"; +import { GhCliAdapter } from "../auth/gh-cli-adapter.js"; +import { GhTokenAdapter } from "../auth/gh-token-adapter.js"; +import type { CredentialStore } from "../auth/ports/credential-store.js"; +import { FileAdapter } from "../filesystem/file-adapter.js"; +import { HasherAdapter } from "../filesystem/hasher-adapter.js"; +import { GitAdapter } from "../git/git-adapter.js"; +import { HttpClient } from "../http/http-client.js"; +import { PlatformAdapter } from "../platform/platform-adapter.js"; +import { InquirerPrompterAdapter, SilentPrompterAdapter } from "../prompter/prompter-adapter.js"; +import { CheckUpdateUseCase } from "../self-update/check-update-use-case.js"; +import { CurrentVersionAdapter } from "../self-update/current-version-adapter.js"; +import { GitHubReleaseResolverAdapter } from "../self-update/github-release-resolver-adapter.js"; +import type { LatestReleaseResolver } from "../self-update/latest-release-resolver.js"; +import { SelfUpdateUseCase } from "../self-update/self-update-use-case.js"; +import { SelfUpdaterAdapter } from "../self-update/self-updater-adapter.js"; +import { userConfigDir } from "../user-config-dir.js"; +import { wireDistribution } from "./distribution.js"; +import { type TelemetryDeps, wireTelemetry } from "./telemetry.js"; +import { wireTools } from "./tools.js"; +import { createFrameworkBuildUseCase } from "./translate.js"; + +interface GlobalOptions { + verbose: boolean; +} + +interface Deps extends TelemetryDeps { + fs: FileReader & FileWriter & FileMerger; + manifestRepo: ManifestRepository; + /** `--scope user`'s own manifest repository: `userConfigDir()/manifest.json`, never nested + * under a project's `.aidd/`. */ + userManifestRepo: ManifestRepository; + /** The one home-directory resolver presentation calls, never `os.homedir()` directly, so a + * test can point it elsewhere. */ + homedir: () => string; + environment: Environment; + logger: Logger; + currentVersionProvider: VersionReader; + prompter: Prompter; + authReader: AuthReaderAdapter; + credentialStore: CredentialStore; + pluginRemoveUseCase: PluginRemoveUseCase; + pluginListUseCase: PluginListUseCase; + pluginUpdateUseCase: PluginUpdateUseCase; + marketplaceAddUseCase: MarketplaceAddUseCase; + marketplaceListUseCase: MarketplaceListUseCase; + marketplaceRemoveUseCase: MarketplaceRemoveUseCase; + marketplaceRefreshUseCase: MarketplaceRefreshUseCase; + marketplaceCheckUseCase: MarketplaceCheckUseCase; + userSourceReferences: UserSourceReferences; + installAiToolUseCase: InstallAiToolUseCase; + installIdeToolUseCase: InstallIdeToolUseCase; + uninstallIdeUseCase: UninstallIdeUseCase; + assetProvider: AssetProvider; + pluginSearchUseCase: PluginSearchUseCase; + marketplaceRegisterFrameworkUseCase: MarketplaceRegisterFrameworkUseCase; + pluginInstallUseCase: PluginInstallUseCase; + marketplaceSyncSettingsUseCase: MarketplaceSyncSettingsUseCase; + doctorUseCase: DoctorUseCase; + /** The registration check alone, reused directly by `doctor --scope user`. */ + doctorRegistrationUseCase: DoctorRegistrationUseCase; + releaseResolver: LatestReleaseResolver; + setupMarketplaceSourceUseCase: SetupMarketplaceSourceUseCase; + setupToolsUseCase: SetupToolsUseCase; + setupPluginsPromptUseCase: SetupPluginsPromptUseCase; + setupToolsPromptUseCase: SetupToolsPromptUseCase; + projectContextDetector: ProjectContextDetectorUseCase; + setupMarketplaceRegistration: SetupMarketplaceRegistrationUseCase; + setupMachineScopeUseCase: SetupMachineScopeUseCase; + selfUpdateUseCase: SelfUpdateUseCase; + statusUseCase: StatusUseCase; + restoreUseCase: RestoreUseCase; + uninstallUseCase: UninstallUseCase; + statusAllUseCase: StatusAllUseCase; + restoreAllUseCase: RestoreAllUseCase; + updateAiToolsUseCase: UpdateAiToolsUseCase; + updateIdeToolsUseCase: UpdateIdeToolsUseCase; + cleanUseCase: CleanUseCase; + cleanUserScopeUseCase: CleanUserScopeUseCase; + doctorAllUseCase: DoctorAllUseCase; + listInstalledRulesUseCase: ListInstalledRulesUseCase; + checkUpdateUseCase: CheckUpdateUseCase; +} + +const _cache = new Map(); + +export function createMenuDeps(projectRoot: string): { + manifestRepo: ManifestRepository; + prompter: Prompter; +} { + return { + manifestRepo: new ManifestRepositoryAdapter(projectRoot), + prompter: process.stdout.isTTY ? new InquirerPrompterAdapter() : new SilentPrompterAdapter(), + }; +} + +export async function createDeps( + projectRoot: string, + options: GlobalOptions, + output?: CLIOutput +): Promise { + const cached = _cache.get(projectRoot); + if (cached !== undefined) return cached; + const hasher = new HasherAdapter(); + const logger = output ?? new CLIOutput(options.verbose); + const fs = new FileAdapter(hasher, logger); + const pluginDistributionReader = new PluginDistributionReaderAdapter(fs); + const manifestRepo = new ManifestRepositoryAdapter(projectRoot); + const userManifestRepo = new UserManifestRepositoryAdapter(userConfigDir); + const http = new HttpClient(); + const authStorage = new AuthStorage(); + const ghCliAdapter = new GhCliAdapter(); + const authReader = new AuthReaderAdapter(authStorage, projectRoot, logger, ghCliAdapter); + const credentialStore = new AuthProviderAdapter( + authStorage, + new Map([["gh", ghCliAdapter]]), + new GhTokenAdapter(http), + projectRoot + ); + const cliUpdater = new SelfUpdaterAdapter(http, { + tokenProvider: authReader, + githubApiBase: process.env.AIDD_SELF_UPDATE_API_BASE, + npmRegistryBase: process.env.AIDD_SELF_UPDATE_NPM_BASE, + logger, + }); + const currentVersionProvider = new CurrentVersionAdapter(); + const selfUpdateUseCase = new SelfUpdateUseCase(cliUpdater, currentVersionProvider); + const platform = new PlatformAdapter(); + const environment = new EnvironmentAdapter(); + const prompter = process.stdout.isTTY + ? new InquirerPrompterAdapter() + : new SilentPrompterAdapter(); + const { nativePluginActivators, hostMarketplaceRegistries } = wireTools(); + // Read once, reused wherever a use case needs the scope a plugin is actually registered + // at: removal, clean, and doctor's own registration check. + const hostPluginRegistries = hostPluginRegistryReaders(); + // Built ahead of every use case that reads a shared-source claim: it depends only on `fs` + // and `userConfigDir`, neither of which distribution's own wiring produces. + const userSourceReferences = new UserSourceReferencesAdapter(fs, userConfigDir); + const { + pluginFetcher, + marketplaceRegistry, + marketplaceTrustStore, + resolveMarketplaceUseCase, + marketplaceListUseCase, + marketplaceRefreshUseCase, + marketplaceRegisterFrameworkUseCase, + } = wireDistribution({ fs, hasher, http, authReader, logger, projectRoot }); + const pluginRemoveUseCase = new PluginRemoveUseCase( + fs, + manifestRepo, + logger, + nativePluginActivators, + hostPluginRegistries, + userSourceReferences, + marketplaceRegistry + ); + const pluginListUseCase = new PluginListUseCase(manifestRepo); + const marketplaceRemoveUseCase = new MarketplaceRemoveUseCase( + fs, + manifestRepo, + marketplaceRegistry, + prompter + ); + // `marketplace add --overwrite` removes before it adds, and removing deletes installed + // plugin files — framework work — so the orchestration belongs here rather than pulling + // framework into distribution's own wiring. + const marketplaceAddUseCase = new MarketplaceAddUseCase( + marketplaceRegistry, + marketplaceTrustStore, + resolveMarketplaceUseCase, + prompter, + marketplaceRemoveUseCase + ); + const marketplaceCheckUseCase = new MarketplaceCheckUseCase( + manifestRepo, + marketplaceRegistry, + resolveMarketplaceUseCase + ); + const assetProvider = new BundledAssetProviderAdapter(); + // `force: true` is safe here: `outDir` is always an aidd-owned disposable cache, never a + // user-owned directory, so a collision only means a previous build's cache exists. Its + // diagnostics drop to debug because this build runs behind almost every command, where + // they would report an implementation detail as news; `--verbose` still shows them. + const cacheBuildLogger: Logger = { + debug: (message) => logger.debug(message), + info: (message) => logger.debug(message), + warn: (message) => logger.debug(message), + }; + const frameworkBuildFor: FrameworkBuildFor = (target, mode, outDir) => + createFrameworkBuildUseCase( + { fs, assetProvider, logger: cacheBuildLogger }, + { target, mode, outDir, force: true } + ); + const ensureBuiltMarketplaceUseCase = new EnsureBuiltMarketplaceUseCase( + fs, + resolveMarketplaceUseCase, + frameworkBuildFor, + currentVersionProvider, + userConfigDir + ); + const marketplaceSyncSettingsUseCase = new MarketplaceSyncSettingsUseCase( + fs, + manifestRepo, + marketplaceRegistry, + hasher, + logger, + nativePluginActivators, + ensureBuiltMarketplaceUseCase, + hostMarketplaceRegistries, + userConfigDir, + marketplaceRegisterFrameworkUseCase, + userSourceReferences, + currentVersionProvider + ); + const pluginAddUseCase = new PluginAddUseCase( + fs, + manifestRepo, + pluginFetcher, + pluginDistributionReader, + hasher, + logger, + marketplaceRegistry, + ensureBuiltMarketplaceUseCase + ); + const gitignoreUseCase = new GitignoreUseCase(fs); + const git = new GitAdapter(fs); + const postInstallPipelineUseCase = new PostInstallPipelineUseCase(manifestRepo, gitignoreUseCase); + const installRuntimeConfigUseCase = new InstallRuntimeConfigUseCase( + fs, + hasher, + logger, + assetProvider, + postInstallPipelineUseCase + ); + const installIdeConfigUseCase = new InstallIdeConfigUseCase( + fs, + hasher, + logger, + assetProvider, + postInstallPipelineUseCase + ); + const installIdeToolUseCase = new InstallIdeToolUseCase( + installIdeConfigUseCase, + manifestRepo, + fs, + hasher, + postInstallPipelineUseCase, + assetProvider + ); + const uninstallIdeUseCase = new UninstallIdeUseCase( + manifestRepo, + new UninstallToolsUseCase(fs, logger) + ); + const pluginInstallFromMarketplaceUseCase = new PluginInstallFromMarketplaceUseCase( + resolveMarketplaceUseCase, + marketplaceRegistry, + pluginAddUseCase, + prompter, + logger + ); + const pluginSearchUseCase = new PluginSearchUseCase( + marketplaceRegistry, + resolveMarketplaceUseCase + ); + const pluginPickUseCase = new PluginPickUseCase( + marketplaceRegistry, + resolveMarketplaceUseCase, + pluginAddUseCase, + prompter + ); + const pluginInstallUseCase = new PluginInstallUseCase( + pluginPickUseCase, + pluginAddUseCase, + pluginInstallFromMarketplaceUseCase, + manifestRepo, + marketplaceTrustStore, + prompter, + environment + ); + const installAiToolUseCase = new InstallAiToolUseCase( + installRuntimeConfigUseCase, + manifestRepo, + pluginInstallFromMarketplaceUseCase, + marketplaceSyncSettingsUseCase, + logger + ); + const syncConflictResolverUseCase = new SyncConflictResolverUseCase(fs); + const doctorTrackedFilesUseCase = new DoctorTrackedFilesUseCase(fs); + const doctorMergeFilesUseCase = new DoctorMergeFilesUseCase(fs, hasher); + const detectPluginDriftUseCase = new DetectPluginDriftUseCase(fs); + const doctorPluginUseCase = new DoctorPluginUseCase(detectPluginDriftUseCase); + const doctorReferencesUseCase = new DoctorReferencesUseCase(fs); + const doctorLayoutUseCase = new DoctorLayoutUseCase(fs, authReader); + // Named so `doctor --scope user` reuses this instance: the check takes its manifest and + // project root per call, so nothing about it is project-scope-specific. + const doctorRegistrationUseCase = new DoctorRegistrationUseCase( + fs, + marketplaceRegistry, + nativePluginActivators, + hostPluginRegistries, + hostMarketplaceRegistries, + userConfigDir, + currentVersionProvider + ); + const doctorUseCase = new DoctorUseCase( + manifestRepo, + doctorTrackedFilesUseCase, + doctorMergeFilesUseCase, + doctorPluginUseCase, + doctorReferencesUseCase, + doctorLayoutUseCase, + doctorRegistrationUseCase + ); + const releaseResolver = new GitHubReleaseResolverAdapter(http, authReader); + const setupMarketplaceSourceUseCase = new SetupMarketplaceSourceUseCase( + prompter, + releaseResolver + ); + const setupToolsUseCase = new SetupToolsUseCase( + manifestRepo, + installRuntimeConfigUseCase, + installIdeConfigUseCase + ); + const setupPluginsPromptUseCase = new SetupPluginsPromptUseCase( + pluginPickUseCase, + pluginInstallFromMarketplaceUseCase, + marketplaceRegistry, + resolveMarketplaceUseCase + ); + const setupToolsPromptUseCase = new SetupToolsPromptUseCase(prompter); + const projectContextDetector = new ProjectContextDetectorUseCase(fs); + const setupMarketplaceRegistration = new SetupMarketplaceRegistrationUseCase( + fs, + setupMarketplaceSourceUseCase, + marketplaceRegisterFrameworkUseCase, + marketplaceRefreshUseCase, + currentVersionProvider, + logger, + environment, + authReader, + releaseResolver, + userSourceReferences + ); + const setupMachineScopeUseCase = new SetupMachineScopeUseCase( + userManifestRepo, + setupMarketplaceRegistration, + marketplaceSyncSettingsUseCase, + currentVersionProvider + ); + const statusUseCase = new StatusUseCase(fs, manifestRepo, hasher, detectPluginDriftUseCase); + // Restore re-materializes through the build pipeline, matching what install wrote: + // rewriting raw content instead would itself be drift. + const builtMaterializationDeps = { + ensureBuilt: ensureBuiltMarketplaceUseCase, + marketplaceRegistry, + homedir, + }; + const pluginUpdateUseCase = new PluginUpdateUseCase( + fs, + manifestRepo, + pluginFetcher, + pluginDistributionReader, + hasher, + builtMaterializationDeps + ); + const restoreUseCase = new RestoreUseCase( + fs, + manifestRepo, + hasher, + logger, + platform, + prompter, + pluginFetcher, + pluginDistributionReader, + assetProvider, + builtMaterializationDeps + ); + const uninstallUseCase = new UninstallUseCase(fs, manifestRepo, logger); + const statusAllUseCase = new StatusAllUseCase(statusUseCase); + const restoreAllUseCase = new RestoreAllUseCase( + manifestRepo, + prompter, + statusUseCase, + restoreUseCase + ); + const resolveUpdateDecisionUseCase = new ResolveUpdateDecisionUseCase(prompter); + const updateOneToolUseCase = new UpdateOneToolUseCase( + installRuntimeConfigUseCase, + installIdeConfigUseCase, + syncConflictResolverUseCase, + resolveUpdateDecisionUseCase, + fs + ); + const updateAiToolsUseCase = new UpdateAiToolsUseCase( + manifestRepo, + currentVersionProvider, + updateOneToolUseCase + ); + const updateIdeToolsUseCase = new UpdateIdeToolsUseCase( + manifestRepo, + currentVersionProvider, + updateOneToolUseCase + ); + const cleanUseCase = new CleanUseCase( + fs, + manifestRepo, + logger, + gitignoreUseCase, + nativePluginActivators, + marketplaceRegistry, + prompter, + hostMarketplaceRegistries, + undefined, + userSourceReferences, + hostPluginRegistries + ); + const cleanUserScopeUseCase = new CleanUserScopeUseCase( + fs, + userManifestRepo, + logger, + marketplaceRegistry, + userConfigDir, + nativePluginActivators, + hostMarketplaceRegistries, + homedir, + userSourceReferences, + prompter + ); + const doctorAllUseCase = new DoctorAllUseCase(doctorUseCase); + const listInstalledRulesUseCase = new ListInstalledRulesUseCase(fs); + const checkUpdateUseCase = new CheckUpdateUseCase(cliUpdater, currentVersionProvider, logger, fs); + const telemetry = wireTelemetry({ + fs, + logger, + git, + projectRoot, + gitignoreUseCase, + currentVersionProvider, + manifestRepo, + }); + const deps: Deps = { + ...telemetry, + fs, + manifestRepo, + userManifestRepo, + homedir, + environment, + logger, + currentVersionProvider, + prompter, + authReader, + credentialStore, + pluginRemoveUseCase, + pluginListUseCase, + pluginUpdateUseCase, + marketplaceAddUseCase, + marketplaceListUseCase, + marketplaceRemoveUseCase, + marketplaceRefreshUseCase, + marketplaceCheckUseCase, + userSourceReferences, + installAiToolUseCase, + installIdeToolUseCase, + uninstallIdeUseCase, + assetProvider, + pluginSearchUseCase, + marketplaceRegisterFrameworkUseCase, + pluginInstallUseCase, + marketplaceSyncSettingsUseCase, + doctorUseCase, + doctorRegistrationUseCase, + releaseResolver, + setupMarketplaceSourceUseCase, + setupToolsUseCase, + setupPluginsPromptUseCase, + setupToolsPromptUseCase, + projectContextDetector, + setupMarketplaceRegistration, + setupMachineScopeUseCase, + selfUpdateUseCase, + statusUseCase, + restoreUseCase, + uninstallUseCase, + statusAllUseCase, + restoreAllUseCase, + updateAiToolsUseCase, + updateIdeToolsUseCase, + cleanUseCase, + cleanUserScopeUseCase, + doctorAllUseCase, + listInstalledRulesUseCase, + checkUpdateUseCase, + }; + _cache.set(projectRoot, deps); + return deps; +} diff --git a/cli/src/runtime/wiring/installed-plugins-from-manifest.ts b/cli/src/runtime/wiring/installed-plugins-from-manifest.ts new file mode 100644 index 000000000..f882a460b --- /dev/null +++ b/cli/src/runtime/wiring/installed-plugins-from-manifest.ts @@ -0,0 +1,29 @@ +import type { ManifestRepository } from "../../contexts/framework/domain/ports/manifest-repository.js"; +import type { + InstalledPluginRef, + InstalledPluginsReader, +} from "../../contexts/telemetry/domain/ports/installed-plugins-reader.js"; +import { AI_TOOL_IDS, type AiToolId } from "../../kernel/tool.js"; + +/** In the composition root because it belongs to neither side: telemetry states what it + * needs, framework keeps the record, and either context translating for the other would be + * the reach into another's vocabulary the two ports exist to stop. */ +export function installedPluginsFromManifest(repo: ManifestRepository): InstalledPluginsReader { + return { + path: repo.path, + read: async () => { + const manifest = await repo.load(); + if (manifest === null) return null; + const byTool = new Map(); + for (const tool of AI_TOOL_IDS) { + const plugins = manifest.getPlugins(tool); + if (plugins.length === 0) continue; + byTool.set( + tool, + plugins.map((plugin) => ({ name: plugin.name, marketplace: plugin.marketplace })) + ); + } + return byTool; + }, + }; +} diff --git a/cli/src/runtime/wiring/telemetry.ts b/cli/src/runtime/wiring/telemetry.ts new file mode 100644 index 000000000..d3e640211 --- /dev/null +++ b/cli/src/runtime/wiring/telemetry.ts @@ -0,0 +1,137 @@ +import type { GitignoreUseCase } from "../../contexts/framework/application/gitignore-use-case.js"; +import type { ManifestRepository } from "../../contexts/framework/domain/ports/manifest-repository.js"; +import { DiagnoseTelemetryUseCase } from "../../contexts/telemetry/application/diagnose-telemetry-use-case.js"; +import { ForgetTelemetryUseCase } from "../../contexts/telemetry/application/forget-telemetry-use-case.js"; +import { PersonIdentityUseCase } from "../../contexts/telemetry/application/person-identity-use-case.js"; +import { ReadLocalCostUseCase } from "../../contexts/telemetry/application/read-local-cost-use-case.js"; +import { ReportCostUseCase } from "../../contexts/telemetry/application/report-cost-use-case.js"; +import { TelemetryOffUseCase } from "../../contexts/telemetry/application/telemetry-off-use-case.js"; +import { TelemetryOnUseCase } from "../../contexts/telemetry/application/telemetry-on-use-case.js"; +import { createClaudeCodeTranscriptAccumulator } from "../../contexts/telemetry/domain/formats/claude-code-transcript.js"; +import { createCodexRolloutAccumulator } from "../../contexts/telemetry/domain/formats/codex-rollout.js"; +import type { SessionCostReader } from "../../contexts/telemetry/domain/ports/session-cost-reader.js"; +import type { TelemetrySink } from "../../contexts/telemetry/domain/ports/telemetry-sink.js"; +import type { VersionControl } from "../../contexts/telemetry/domain/ports/version-control.js"; +import { CopilotCostReaderAdapter } from "../../contexts/telemetry/infrastructure/copilot-cost-reader-adapter.js"; +import { HookTrustReaderAdapter } from "../../contexts/telemetry/infrastructure/hook-trust-reader-adapter.js"; +import { OpencodeCostReaderAdapter } from "../../contexts/telemetry/infrastructure/opencode-cost-reader-adapter.js"; +import { PersonIdentityAdapter } from "../../contexts/telemetry/infrastructure/person-identity-adapter.js"; +import { RunJournalReaderAdapter } from "../../contexts/telemetry/infrastructure/run-journal-reader-adapter.js"; +import { TaskBacklogAdapter } from "../../contexts/telemetry/infrastructure/task-backlog-adapter.js"; +import { TelemetryEvidenceAdapter } from "../../contexts/telemetry/infrastructure/telemetry-evidence-adapter.js"; +import { TelemetrySinkAdapter } from "../../contexts/telemetry/infrastructure/telemetry-sink-adapter.js"; +import { TranscriptCostReaderAdapter } from "../../contexts/telemetry/infrastructure/transcript-cost-reader-adapter.js"; +import { CLAUDE_CODE_TRANSCRIPT_LOCATION } from "../../contexts/tools/domain/profiles/claude/claude-transcript-location.js"; +import { CODEX_ROLLOUT_LOCATION } from "../../contexts/tools/domain/profiles/codex/codex-transcript-location.js"; +import { hostPluginRegistryReaders } from "../../contexts/tools/infrastructure/host-plugin-registry-reader-adapter.js"; +import type { FileReader } from "../../kernel/ports/file-reader.js"; +import type { FileWriter } from "../../kernel/ports/file-writer.js"; +import type { Logger } from "../../kernel/ports/logger.js"; +import type { VersionReader } from "../../kernel/ports/version-reader.js"; +import { resolveHomeDir } from "../../kernel/reading/home-dir.js"; +import type { AiToolId } from "../../kernel/tool.js"; +import { installedPluginsFromManifest } from "./installed-plugins-from-manifest.js"; + +export interface TelemetryWiringShared { + fs: FileReader & FileWriter; + logger: Logger; + git: VersionControl; + projectRoot: string; + gitignoreUseCase: GitignoreUseCase; + currentVersionProvider: VersionReader; + manifestRepo: ManifestRepository; +} + +export interface TelemetryDeps { + telemetrySink: TelemetrySink; + telemetryOnUseCase: TelemetryOnUseCase; + telemetryOffUseCase: TelemetryOffUseCase; + readLocalCostUseCase: ReadLocalCostUseCase; + personIdentityUseCase: PersonIdentityUseCase; + diagnoseTelemetryUseCase: DiagnoseTelemetryUseCase; + reportCostUseCase: ReportCostUseCase; + forgetTelemetryUseCase: ForgetTelemetryUseCase; +} + +/** Tool identifiers appear here because a profile cannot name the adapter that reads it + * without putting infrastructure in the domain. `resolveHomeDir()` rather than a bare + * `homedir()`: on Windows the bare call ignores a `HOME` a person or a test sandbox set. */ +export function wireTelemetry(shared: TelemetryWiringShared): TelemetryDeps { + const { fs, logger, git, projectRoot, gitignoreUseCase, currentVersionProvider, manifestRepo } = + shared; + const telemetryEvidence = new TelemetryEvidenceAdapter(); + const telemetrySink = new TelemetrySinkAdapter(); + const runJournalReader = new RunJournalReaderAdapter(projectRoot); + const personIdentity = new PersonIdentityAdapter(); + + // The one place allowed to map a tool that declares `telemetryLocalRead: { kind: + // "declared" }` to the adapter that reads it. + const localCostReaders: ReadonlyMap = new Map< + AiToolId, + SessionCostReader + >([ + ["opencode", new OpencodeCostReaderAdapter()], + [ + "claude", + new TranscriptCostReaderAdapter( + resolveHomeDir(), + CLAUDE_CODE_TRANSCRIPT_LOCATION, + createClaudeCodeTranscriptAccumulator + ), + ], + [ + "codex", + new TranscriptCostReaderAdapter( + resolveHomeDir(), + CODEX_ROLLOUT_LOCATION, + createCodexRolloutAccumulator + ), + ], + ["copilot", new CopilotCostReaderAdapter(resolveHomeDir())], + ]); + + const readLocalCostUseCase = new ReadLocalCostUseCase( + telemetrySink, + localCostReaders, + runJournalReader, + personIdentity, + telemetryEvidence, + currentVersionProvider, + logger + ); + + return { + telemetrySink, + telemetryOnUseCase: new TelemetryOnUseCase(fs, logger, gitignoreUseCase, git, telemetrySink), + telemetryOffUseCase: new TelemetryOffUseCase(fs, logger, telemetryEvidence, git), + readLocalCostUseCase, + personIdentityUseCase: new PersonIdentityUseCase(personIdentity), + diagnoseTelemetryUseCase: new DiagnoseTelemetryUseCase( + telemetryEvidence, + git, + runJournalReader, + localCostReaders, + new HookTrustReaderAdapter(), + personIdentity, + telemetrySink, + currentVersionProvider, + installedPluginsFromManifest(manifestRepo), + hostPluginRegistryReaders() + ), + reportCostUseCase: new ReportCostUseCase( + telemetrySink, + runJournalReader, + personIdentity, + telemetryEvidence, + new TaskBacklogAdapter(projectRoot), + logger, + readLocalCostUseCase + ), + forgetTelemetryUseCase: new ForgetTelemetryUseCase( + telemetrySink, + runJournalReader, + personIdentity, + git + ), + }; +} diff --git a/cli/src/runtime/wiring/tools.ts b/cli/src/runtime/wiring/tools.ts new file mode 100644 index 000000000..997118393 --- /dev/null +++ b/cli/src/runtime/wiring/tools.ts @@ -0,0 +1,31 @@ +// Registers every tool profile as a side effect, so the registry `nativeActivationOf` +// reads is populated regardless of which other wiring module gets imported first. +import "../../contexts/tools/domain/profiles/claude/profile.js"; +import "../../contexts/tools/domain/profiles/codex/profile.js"; +import "../../contexts/tools/domain/profiles/copilot/profile.js"; +import "../../contexts/tools/domain/profiles/cursor/profile.js"; +import "../../contexts/tools/domain/profiles/opencode/profile.js"; +import "../../contexts/tools/domain/profiles/vscode/profile.js"; +import type { HostMarketplaceRegistryReader } from "../../contexts/tools/domain/ports/host-marketplace-registry-reader.js"; +import type { NativePluginActivator } from "../../contexts/tools/domain/ports/native-plugin-activator.js"; +import { nativeActivationOf } from "../../contexts/tools/domain/registry.js"; +import { hostMarketplaceRegistryReaders } from "../../contexts/tools/infrastructure/host-marketplace-registry-reader-adapter.js"; +import { NativePluginCliAdapter } from "../../contexts/tools/infrastructure/native-plugin-cli-adapter.js"; +import { AI_TOOL_IDS, type AiToolId } from "../../kernel/tool.js"; + +/** One adapter per tool whose profile declares an activation shape, read off the registry + * rather than listed by hand, so a new tool costs no edit here. */ +export function wireTools(): { + nativePluginActivators: Map; + hostMarketplaceRegistries: ReadonlyMap; +} { + const nativePluginActivators = new Map([ + ...AI_TOOL_IDS.map((id) => { + const activation = nativeActivationOf(id); + return activation === undefined + ? undefined + : ([activation.binary, new NativePluginCliAdapter(activation.binary, activation)] as const); + }).filter((entry): entry is NonNullable => entry !== undefined), + ]); + return { nativePluginActivators, hostMarketplaceRegistries: hostMarketplaceRegistryReaders() }; +} diff --git a/cli/src/runtime/wiring/translate.ts b/cli/src/runtime/wiring/translate.ts new file mode 100644 index 000000000..cc0b47211 --- /dev/null +++ b/cli/src/runtime/wiring/translate.ts @@ -0,0 +1,118 @@ +import { stat } from "node:fs/promises"; +// `FRAMEWORK_BUILD_REGISTRY` is built at module load from the tool registry, so this module +// registers the profiles itself rather than rely on import order elsewhere. +import "../../contexts/tools/domain/profiles/claude/profile.js"; +import "../../contexts/tools/domain/profiles/codex/profile.js"; +import "../../contexts/tools/domain/profiles/copilot/profile.js"; +import "../../contexts/tools/domain/profiles/cursor/profile.js"; +import "../../contexts/tools/domain/profiles/opencode/profile.js"; +import "../../contexts/tools/domain/profiles/vscode/profile.js"; +import type { ToolBuildContract } from "../../contexts/tools/domain/build-contract.js"; +import type { FileMerger } from "../../contexts/tools/domain/ports/file-merger.js"; +import { buildContractFor } from "../../contexts/tools/domain/registry.js"; +import { FlatBuildStrategy } from "../../contexts/translate/application/strategies/flat-build-strategy.js"; +import { MarketplaceBuildStrategy } from "../../contexts/translate/application/strategies/marketplace-build-strategy.js"; +import { FrameworkBuildUseCase } from "../../contexts/translate/application/translate-source.js"; +import { frameworkBuildTargetModes } from "../../contexts/translate/domain/build-target.js"; +import { AjvSchemaValidatorAdapter } from "../../contexts/translate/infrastructure/schema-validator.js"; +import type { AssetProvider } from "../../kernel/ports/asset-provider.js"; +import type { FileReader } from "../../kernel/ports/file-reader.js"; +import type { FileWriter } from "../../kernel/ports/file-writer.js"; +import type { Logger } from "../../kernel/ports/logger.js"; + +export interface FrameworkBuildDeps { + fs: FileReader & FileWriter & FileMerger; + assetProvider: AssetProvider; + logger: Logger; +} + +export interface FrameworkBuildContext { + readonly target: string; + readonly mode: string; + readonly outDir: string; + readonly force: boolean; +} + +type FrameworkBuildFactory = ( + deps: FrameworkBuildDeps, + ctx: FrameworkBuildContext +) => FrameworkBuildUseCase; + +function buildFrameworkUseCase( + deps: FrameworkBuildDeps, + makeStrategy: ( + deps: FrameworkBuildDeps, + av: AjvSchemaValidatorAdapter + ) => MarketplaceBuildStrategy | FlatBuildStrategy +): FrameworkBuildUseCase { + const av = new AjvSchemaValidatorAdapter(); + return new FrameworkBuildUseCase( + deps.fs, + av, + deps.assetProvider, + deps.logger, + makeStrategy(deps, av) + ); +} + +async function isDirectory(path: string): Promise { + try { + return (await stat(path)).isDirectory(); + } catch { + return false; + } +} + +function frameworkBuildFactoryFor( + buildContract: () => ToolBuildContract, + mode: "marketplace" | "flat" +): FrameworkBuildFactory { + if (mode === "marketplace") { + return (deps, ctx) => + buildFrameworkUseCase( + deps, + (d, av) => + new MarketplaceBuildStrategy(d.fs, av, d.assetProvider, buildContract(), ctx.force) + ); + } + return (deps, ctx) => + buildFrameworkUseCase( + deps, + (d, av) => + new FlatBuildStrategy( + d.fs, + av, + d.assetProvider, + buildContract(), + ctx.force, + ctx.outDir, + isDirectory, + d.logger + ) + ); +} + +/** One factory per pair `frameworkBuildTargetModes()` reports, so the wiring cannot offer a + * target the domain rejects nor miss one it accepts, and a new profile needs no edit here. */ +function frameworkBuildRegistryEntries(): (readonly [string, FrameworkBuildFactory])[] { + const entries: (readonly [string, FrameworkBuildFactory])[] = []; + for (const { target, mode } of frameworkBuildTargetModes()) { + const buildContract = buildContractFor(target, mode); + if (buildContract === undefined) continue; + entries.push([`${target}:${mode}`, frameworkBuildFactoryFor(buildContract, mode)]); + } + return entries; +} + +const FRAMEWORK_BUILD_REGISTRY: Record = Object.fromEntries( + frameworkBuildRegistryEntries() +); + +export function createFrameworkBuildUseCase( + deps: FrameworkBuildDeps, + ctx: FrameworkBuildContext +): FrameworkBuildUseCase | undefined { + const key = `${ctx.target}:${ctx.mode}`; + const factory = FRAMEWORK_BUILD_REGISTRY[key]; + return factory?.(deps, ctx); +} diff --git a/cli/stryker.conf.json b/cli/stryker.conf.json index 80447ed4a..252767561 100644 --- a/cli/stryker.conf.json +++ b/cli/stryker.conf.json @@ -3,10 +3,23 @@ "packageManager": "pnpm", "testRunner": "vitest", "plugins": ["@stryker-mutator/vitest-runner"], - "mutate": ["src/domain/models/manifest.ts"], + "ignorePatterns": ["reports"], "coverageAnalysis": "perTest", - "thresholds": { "high": 80, "low": 60, "break": 50 }, + "thresholds": { + "high": 80, + "low": 60, + "break": null + }, "reporters": ["html", "json", "progress"], - "htmlReporter": { "fileName": "reports/mutation/report.html" }, - "jsonReporter": { "fileName": "reports/mutation/mutation.json" } + "htmlReporter": { + "fileName": "reports/mutation/report.html" + }, + "jsonReporter": { + "fileName": "reports/mutation/mutation.json" + }, + "tsconfigFile": "", + "disableTypeChecks": false, + "vitest": { + "configFile": "vitest.mutation.config.ts" + } } diff --git a/cli/tests/application/display/cost-report-artefact.unit.test.ts b/cli/tests/application/display/cost-report-artefact.unit.test.ts deleted file mode 100644 index 25f0ecbb0..000000000 --- a/cli/tests/application/display/cost-report-artefact.unit.test.ts +++ /dev/null @@ -1,464 +0,0 @@ -import { describe, expect, it } from "vitest"; -import "../../../src/domain/tools/ai/claude.js"; -import "../../../src/domain/tools/ai/codex.js"; -import { - ARTEFACT_AXES, - buildCostReportArtefact, - isArtefactAxis, -} from "../../../src/application/display/cost-report-artefact.js"; -import { printCostReport } from "../../../src/application/display/cost-report-display.js"; -import { CLIOutput } from "../../../src/application/output.js"; -import { buildCostReport, type CostReportInput } from "../../../src/domain/models/cost-report.js"; -import { toCostReportEnvelope } from "../../../src/domain/models/cost-report-envelope.js"; -import { bareOrchestratingSkillNames } from "../../../src/domain/models/flow-attribution.js"; -import type { TelemetrySinkRecord } from "../../../src/domain/models/telemetry-sink-record.js"; -import type { PersonIdentity } from "../../../src/domain/ports/person-identity-reader.js"; - -/** Extends the real output rather than standing in for it — same reasoning as - * cost-report-display.unit.test.ts's own `CapturingOutput`. */ -class CapturingOutput extends CLIOutput { - readonly lines: string[] = []; - - override print(message: string): void { - this.lines.push(message); - } -} - -const NO_CAPABILITY = { - localRead: null, - export: null, - journalAttributable: false, - taskAttributable: false, -} as const; - -const BASE: TelemetrySinkRecord = { - sink_schema_version: 2, - kind: "request", - provenance: "local-read", - tool: "claude", - vendor_id: "s-1", - vendor_field: "sessionId", - step_attribution: "unattributed", -}; - -function request(overrides: Partial = {}): TelemetrySinkRecord { - return { ...BASE, ...overrides }; -} - -function envelopeOf(overrides: Partial = {}) { - return toCostReportEnvelope( - buildCostReport({ - fromDay: "2026-08-17", - toDay: "2026-08-21", - records: [], - journals: [], - declaredTools: [{ tool: "claude", coverage: "covered", capability: NO_CAPABILITY }], - undatedRecords: 0, - unreadableLines: 0, - measurementEnabled: true, - ...overrides, - }) - ); -} - -function onePersonMapping(): PersonIdentity { - return { personId: "person-a", origin: "adopted", alsoMe: ["machine-1"], displayName: "Ada" }; -} - -describe("buildCostReportArtefact", () => { - // The row a declared identity now names retroactively. Rendered as that person, never as - // "nobody opted in": `personLabel` used to fall through to the no-identifier label for - // every resolution it did not name, so a value added to `PersonResolution` reached a - // reader as its opposite without the compiler saying a word. - it("names a row this machine's identity claims after that person, not as nobody", () => { - const envelope = envelopeOf({ - records: [request({ turn_id: "a", event_timestamp: "2026-08-17T10:00:00Z" })], - identity: onePersonMapping(), - }); - - const artefact = buildCostReportArtefact(envelope, "person"); - - expect(artefact).toContain("Ada"); - expect(artefact).not.toContain("nobody opted in"); - }); - - it("lists person among the known axes", () => { - expect(ARTEFACT_AXES).toContain("person"); - expect(isArtefactAxis("person")).toBe(true); - }); - - it("answers the prompt axis with one dated row per prompt, and the remainder last", () => { - const artefact = buildCostReportArtefact( - envelopeOf({ - records: [ - request({ - turn_id: "a", - prompt_id: "p-1", - cost_usd: 2, - event_timestamp: "2026-08-18T09:00:00Z", - }), - request({ turn_id: "b", cost_usd: 1, event_timestamp: "2026-08-18T10:00:00Z" }), - ], - }), - "prompt" - ); - - expect(artefact).toContain("| Prompt | Started at | Total |"); - const lines = artefact - .split("\n") - .filter((line) => line.startsWith("| p-1") || line.includes("no prompt named")); - expect(lines[0]).toContain("| p-1 | 2026-08-18T09:00:00Z |"); - expect(lines[1]).toContain("| no prompt named | — |"); - }); - - it("refuses an unknown axis by name, listing the ones that exist", () => { - expect(() => buildCostReportArtefact(envelopeOf(), "bogus")).toThrow( - /Unknown axis 'bogus'.*person/su - ); - }); - - // Finding 3 (review.md, "one route, and every sentence about it true"): `--axis` had no - // way to say measurement was off either - a pasted table carried no word of it at all. - it("names the project's switch being off in its own header, on every axis", () => { - const off = envelopeOf({ measurementEnabled: false }); - for (const axis of ARTEFACT_AXES) { - expect(buildCostReportArtefact(off, axis)).toContain("this project's switch is off"); - } - }); - - it("says nothing about the switch in the header when it is on", () => { - const on = envelopeOf({ measurementEnabled: true }); - expect(buildCostReportArtefact(on, "total")).not.toContain("switch is off"); - }); - - it("prints one row per person with the identities behind it, mapped rows first", () => { - const envelope = envelopeOf({ - identity: onePersonMapping(), - records: [request({ turn_id: "a", person_id: "machine-1" })], - }); - - const artefact = buildCostReportArtefact(envelope, "person"); - - expect(artefact).toContain("Ada"); - expect(artefact).toContain("machine-1"); - }); - - it("prints two unplaced identifiers as two labelled rows, never one bucket", () => { - const envelope = envelopeOf({ - identity: onePersonMapping(), - records: [ - request({ turn_id: "a", person_id: "a-stranger" }), - request({ turn_id: "b", person_id: "another-stranger" }), - ], - }); - - const artefact = buildCostReportArtefact(envelope, "person"); - - expect(artefact).toContain("a-stranger"); - expect(artefact).toContain("another-stranger"); - const unresolvedLines = artefact.split("\n").filter((line) => line.includes("unresolved")); - expect(unresolvedLines).toHaveLength(2); - }); - - // No identity declared on this machine: only then is "nobody opted in" the truth for a - // record that carried no identifier. With one declared, that same record is this - // machine's own person - the case the test above pins. - it("labels the no-identifier row distinctly from an unresolved one", () => { - const envelope = envelopeOf({ - records: [request({ turn_id: "a" }), request({ turn_id: "b", person_id: "a-stranger" })], - }); - - const artefact = buildCostReportArtefact(envelope, "person"); - const rows = artefact - .split("\n") - .filter((line) => line.includes("nobody opted in") || line.includes("unresolved")); - - expect(rows).toHaveLength(2); - const [unresolvedRow] = rows.filter((line) => line.includes("unresolved")); - const [noneRow] = rows.filter((line) => line.includes("nobody opted in")); - // The two labels must never be interchangeable: neither row's label is a substring of - // the other's, so a reader can never mistake one bucket for the other. - expect(unresolvedRow).not.toContain("nobody opted in"); - expect(noneRow).not.toContain("unresolved"); - }); - - it("prints every figure and a caveat when the identity could not be read", () => { - const envelope = envelopeOf({ - records: [request({ turn_id: "a", cost_usd: 1, person_id: "machine-1" })], - identityUnusableCause: "unreadable", - }); - - const artefact = buildCostReportArtefact(envelope, "person"); - - expect(artefact).toContain("$1.00"); - expect(artefact).toMatch(/own identity could not be read/u); - }); - - it("prints every figure and a different caveat when no identity was declared at all", () => { - const envelope = envelopeOf({ - records: [request({ turn_id: "a", cost_usd: 1, person_id: "machine-1" })], - identityUnusableCause: "absent", - }); - - const artefact = buildCostReportArtefact(envelope, "person"); - - expect(artefact).toContain("$1.00"); - expect(artefact).toMatch(/no identity was declared/u); - }); - - it("prints no person caveat on the total axis when nobody opted in - that is the default state, not a degraded read", () => { - const envelope = envelopeOf({ - records: [request({ turn_id: "a", cost_usd: 1 })], - identityUnusableCause: "absent", - }); - - const artefact = buildCostReportArtefact(envelope, "total"); - - expect(artefact).toContain("$1.00"); - expect(artefact).not.toMatch(/no identity was declared/u); - }); - - it("still prints the unreadable caveat on the total axis - that one is real damage", () => { - const envelope = envelopeOf({ - records: [request({ turn_id: "a", cost_usd: 1 })], - identityUnusableCause: "unreadable", - }); - - const artefact = buildCostReportArtefact(envelope, "total"); - - expect(artefact).toMatch(/own identity could not be read/u); - }); - - it("names two different causes with two different caveats", () => { - const unreadable = buildCostReportArtefact( - envelopeOf({ identityUnusableCause: "unreadable" }), - "person" - ); - const absent = buildCostReportArtefact( - envelopeOf({ identityUnusableCause: "absent" }), - "person" - ); - - expect(unreadable).not.toBe(absent); - expect(unreadable).toMatch(/could not be read/u); - expect(absent).toMatch(/no identity was declared/u); - }); -}); - -// One skill reached once from the tool's own statement and once from a journal interval is -// two rows sharing one step name (`by_step` is keyed on step + attribution together) - the -// table this axis pastes elsewhere is the one place that column can be dropped silently, -// since the terminal rendering carries it inline beside each row already. -describe("buildCostReportArtefact — by step, two rows sharing one name", () => { - const STEP = "aidd-dev:02-implement"; - - function ambiguousStepInput(): CostReportInput { - return { - fromDay: "2026-08-17", - toDay: "2026-08-21", - records: [ - request({ - turn_id: "a", - step_attribution: "tool-stated", - step: STEP, - input_tokens: 1000, - }), - request({ - turn_id: "b", - step_attribution: "journal-interval", - step: STEP, - input_tokens: 500, - }), - ], - journals: [], - declaredTools: [{ tool: "claude", coverage: "covered", capability: NO_CAPABILITY }], - undatedRecords: 0, - unreadableLines: 0, - measurementEnabled: true, - }; - } - - it("carries the attribution on every row, so two rows for one step are distinguishable on their own", () => { - const report = buildCostReport(ambiguousStepInput()); - const artefact = buildCostReportArtefact(toCostReportEnvelope(report), "step"); - - const stepLines = artefact.split("\n").filter((line) => line.startsWith(`| ${STEP} |`)); - expect(stepLines).toHaveLength(2); - expect(stepLines.some((line) => line.includes("stated by the tool"))).toBe(true); - expect(stepLines.some((line) => line.includes("from a journal interval"))).toBe(true); - }); - - it("reconciles to what the terminal prints for that step, row for row", () => { - const report = buildCostReport(ambiguousStepInput()); - const artefact = buildCostReportArtefact(toCostReportEnvelope(report), "step"); - const output = new CapturingOutput(); - printCostReport(output, report); - const terminalText = output.lines.join("\n"); - - // Both renderings read the same `bySteps` data; the true total for the step (never - // itself printed as one line, by either renderer) is what a reader sums the rows to. - expect(terminalText).toContain(STEP); - expect(terminalText).toMatch(/stated by the tool/u); - expect(terminalText).toMatch(/from a journal interval/u); - - const toolStatedRow = artefact - .split("\n") - .find((line) => line.startsWith(`| ${STEP} |`) && line.includes("stated by the tool")); - const journalIntervalRow = artefact - .split("\n") - .find((line) => line.startsWith(`| ${STEP} |`) && line.includes("journal interval")); - expect(toolStatedRow).toContain("1,000 tokens"); - expect(journalIntervalRow).toContain("500 tokens"); - // 1,500 total input tokens across the two records - never printed as one row by either - // renderer, but recoverable from the two rows a reader is given. - }); -}); - -describe("buildCostReportArtefact — the agent axis names which silence a row is", () => { - const NAMES_AGENTS = { - localRead: { tokenCounters: true, amount: false, toolStatedStep: false, agentName: true }, - export: null, - journalAttributable: false, - taskAttributable: false, - } as const; - - // Two rows carry no agent name and mean opposite things. A table that printed "the main - // thread" for both would state, of a tool that never names an agent, a fact nothing - // observed — the reading this axis gave every Codex, Copilot and OpenCode record. - it("prints the main thread and a tool that names no agent as different rows", () => { - const envelope = envelopeOf({ - declaredTools: [ - { tool: "claude", coverage: "covered", capability: NAMES_AGENTS }, - { tool: "codex", coverage: "covered", capability: NO_CAPABILITY }, - ], - records: [ - request({ input_tokens: 10 }), - request({ tool: "codex", vendor_id: "s-codex", input_tokens: 10 }), - ], - }); - - const artefact = buildCostReportArtefact(envelope, "agent"); - - expect(artefact).toContain("| the main thread |"); - expect(artefact).toContain("| the tool names no agent |"); - }); - - it("prints the same two labels in the terminal rendering", () => { - const report = buildCostReport({ - fromDay: "2026-08-17", - toDay: "2026-08-21", - journals: [], - undatedRecords: 0, - unreadableLines: 0, - measurementEnabled: true, - declaredTools: [ - { tool: "claude", coverage: "covered", capability: NAMES_AGENTS }, - { tool: "codex", coverage: "covered", capability: NO_CAPABILITY }, - ], - records: [ - request({ input_tokens: 10 }), - request({ tool: "codex", vendor_id: "s-codex", input_tokens: 10 }), - ], - }); - const output = new CapturingOutput(); - - printCostReport(output, report); - - const printed = output.lines.join("\n"); - expect(printed).toContain("the main thread"); - expect(printed).toContain("the tool names no agent"); - }); -}); - -describe("buildCostReportArtefact — the flow axis states its own limits with the figures", () => { - const FLOW_JOURNAL = [ - { - vendorId: "s-1", - tool: "claude-code" as const, - writtenPaths: [], - taskIntervals: [], - flowIntervals: [ - { - skill: "aidd-orchestrator:01-sdlc", - startMs: Date.parse("2026-08-17T10:00:00Z"), - endMs: Date.parse("2026-08-17T11:00:00Z"), - closedBy: "boundary" as const, - }, - ], - }, - ]; - - function withOneFlow() { - return envelopeOf({ - records: [request({ event_timestamp: "2026-08-17T10:30:00Z", input_tokens: 10 })], - journals: FLOW_JOURNAL, - }); - } - - it("says a hand-run skill counts inside the flow it ran during", () => { - expect(buildCostReportArtefact(withOneFlow(), "flow")).toContain( - "a skill run by hand while a flow was open is counted inside it" - ); - }); - - it("says a same-named skill of the reader's own project opens a flow of its own", () => { - expect(buildCostReportArtefact(withOneFlow(), "flow")).toContain("opens a flow of its own"); - }); - - // The guard against writing the names out beside the set instead of reading them from it: - // a project adding a fourth orchestrator is promised it need change nothing here, and a - // hardcoded list of three would go on printing three while this turns red. - it("names every unqualified orchestrating skill the declared set holds, whatever it holds", () => { - const artefact = buildCostReportArtefact(withOneFlow(), "flow"); - const bare = bareOrchestratingSkillNames(); - - expect(bare.length).toBeGreaterThan(0); - for (const name of bare) expect(artefact).toContain(name); - }); - - it("says neither when the period names no flow at all - a limit that bit nothing is noise", () => { - const noFlow = envelopeOf({ - records: [request({ event_timestamp: "2026-08-17T10:30:00Z", input_tokens: 10 })], - journals: [], - }); - const artefact = buildCostReportArtefact(noFlow, "flow"); - expect(artefact).not.toContain("counted inside it"); - expect(artefact).not.toContain("opens a flow of its own"); - }); - - // A limit is a statement about a mechanism that ran. A period whose only flow came from a - // record's own tool never walked a step sequence, so the two limits of that walk describe - // nothing that happened here and must not be printed. - it("states no journal limit for a period whose only flow its own tool named", () => { - const statedOnly = envelopeOf({ - records: [ - request({ - event_timestamp: "2026-08-17T10:30:00Z", - input_tokens: 10, - step_attribution: "tool-stated", - step: "aidd-orchestrator:01-sdlc", - }), - ], - journals: [], - }); - - const artefact = buildCostReportArtefact(statedOnly, "flow"); - - expect(artefact).toContain("is every run of that skill at once"); - expect(artefact).not.toContain("counted inside it"); - expect(artefact).not.toContain("opens a flow of its own"); - }); - - it("states no tool-stated limit for a period whose flows the journal all witnessed", () => { - expect(buildCostReportArtefact(withOneFlow(), "flow")).not.toContain( - "is every run of that skill at once" - ); - }); - - it("states them on the flow axis alone, never on every axis", () => { - const envelope = withOneFlow(); - for (const axis of ARTEFACT_AXES.filter((name) => name !== "flow")) { - expect(buildCostReportArtefact(envelope, axis)).not.toContain("counted inside it"); - } - }); -}); diff --git a/cli/tests/application/display/cost-report-display.unit.test.ts b/cli/tests/application/display/cost-report-display.unit.test.ts deleted file mode 100644 index c68cc5279..000000000 --- a/cli/tests/application/display/cost-report-display.unit.test.ts +++ /dev/null @@ -1,489 +0,0 @@ -import { describe, expect, it } from "vitest"; -import "../../../src/domain/tools/ai/claude.js"; -import "../../../src/domain/tools/ai/codex.js"; -import "../../../src/domain/tools/ai/copilot.js"; -import "../../../src/domain/tools/ai/cursor.js"; -import "../../../src/domain/tools/ai/opencode.js"; -import { padTo, printCostReport } from "../../../src/application/display/cost-report-display.js"; -import { CLIOutput } from "../../../src/application/output.js"; -import { buildCostReport, type CostReportInput } from "../../../src/domain/models/cost-report.js"; -import type { TelemetrySinkRecord } from "../../../src/domain/models/telemetry-sink-record.js"; - -/** Extends the real output rather than standing in for it: a double built from an object - * literal would have to be widened to pass as a `CLIOutput`, and a widened double stops - * failing the day the class grows a method the printer starts calling. */ -class CapturingOutput extends CLIOutput { - readonly lines: string[] = []; - - override print(message: string): void { - this.lines.push(message); - } -} - -function record(overrides: Partial): TelemetrySinkRecord { - return { - sink_schema_version: 2, - kind: "request", - provenance: "local-read", - tool: "claude", - vendor_id: "s-1", - vendor_field: "sessionId", - step_attribution: "unattributed", - ...overrides, - }; -} - -/** What a tool can supply is not what these tests are about; they declare the minimum the - * type requires, and the declarations' own truth is checked in - * tests/domain/tools/telemetry-route-supply.unit.test.ts against captured files. */ -const NO_CAPABILITY = { - localRead: null, - export: null, - journalAttributable: false, - taskAttributable: false, -} as const; - -function printed(overrides: Partial = {}): string { - const output = new CapturingOutput(); - printCostReport( - output, - buildCostReport({ - fromDay: "2026-08-17", - toDay: "2026-08-21", - records: [], - journals: [], - declaredTools: [ - { tool: "claude", coverage: "covered", capability: NO_CAPABILITY }, - { tool: "codex", coverage: "covered", capability: NO_CAPABILITY }, - { - tool: "cursor", - coverage: "not-covered", - reason: "It writes no token count.", - capability: NO_CAPABILITY, - }, - ], - undatedRecords: 0, - unreadableLines: 0, - measurementEnabled: true, - ...overrides, - }) - ); - return output.lines.join("\n"); -} - -describe("printCostReport", () => { - it("answers the question before any breakdown is read", () => { - const out = printed({ - records: [record({ cost_usd: 4.2, input_tokens: 100, cache_read_tokens: 900 })], - }); - const [first, , sessions, requests, tokens, cost] = out.split("\n"); - - expect(first).toContain("2026-08-17 to 2026-08-21"); - expect(sessions).toContain("sessions"); - expect(requests).toContain("requests"); - expect(tokens).toContain("1,000"); - expect(tokens).toContain("90% cache"); - expect(cost).toContain("$4.20"); - }); - - it("says which selection it answered, in the header", () => { - const out = printed({ - records: [record({ cost_usd: 1, project_id: "acme/widgets" })], - filters: { project: "acme/widgets" }, - }); - - expect(out.split("\n")[0]).toContain("filters: project=acme/widgets"); - }); - - it("names the filter that emptied a selection, and suppresses the noise under it", () => { - const out = printed({ - records: [record({ cost_usd: 1, project_id: "acme/widgets" })], - knownValues: { projects: new Set(["acme/widgets"]), steps: new Set(), models: new Set() }, - filters: { project: "never-worked-here" }, - }); - - expect(out).toContain("no record has ever named this project"); - expect(out).not.toContain("by tool"); - expect(out).not.toContain("by day"); - }); - - it("says a task or a tool was never seen without claiming a record check it never ran", () => { - const out = printed({ - records: [record({ cost_usd: 1 })], - filters: { tool: "opencode" }, - }); - - expect(out).toContain("it is not one of the tools this build knows"); - expect(out).not.toContain("no record has ever named this tool"); - }); - - it("calls a zero row 'nothing in this selection', never 'this period', once a filter is active", () => { - // Codex only has a gadgets record - filtered to widgets alone, its row is zero, but - // the selection is why, not real idleness. - const out = printed({ - records: [ - record({ turn_id: "a", cost_usd: 1, tool: "claude", project_id: "acme/widgets" }), - record({ turn_id: "b", cost_usd: 1, tool: "codex", project_id: "acme/gadgets" }), - ], - filters: { project: "acme/widgets" }, - }); - - expect(out).toMatch(/Codex\s+nothing in this selection/u); - expect(out).not.toContain("nothing in this period"); - }); - - it("still calls a zero row 'nothing in this period' when the whole period, not a filter, is why", () => { - const out = printed({ records: [] }); - - expect(out).toContain("nothing in this period"); - expect(out).not.toContain("nothing in this selection"); - }); - - it("calls a task selection's own zero rows 'nothing in this selection' too", () => { - const out = printed({ - records: [record({ vendor_id: "s-1", cost_usd: 1, event_timestamp: "2026-08-17T10:00:00Z" })], - journals: [ - { - vendorId: "s-1", - tool: "claude", - writtenPaths: ["aidd_docs/tasks/2026_08/2026_08_01_x/plan.md"], - taskIntervals: [], - flowIntervals: [], - }, - ], - task: "2026_08/2026_08_01_x", - }); - - expect(out).toMatch(/2026-08-18\s+nothing in this selection/u); - }); - - it("labels active time as per-session and keeps it out of every breakdown", () => { - const out = printed({ - records: [ - record({ cost_usd: 1, step: "aidd-dev:02-implement", step_attribution: "tool-stated" }), - record({ kind: "session", active_time_s: 2820 }), - ], - }); - - expect(out).toContain("47 min"); - expect(out).toContain("not attributable to steps"); - const breakdown = out.slice(out.indexOf("by step")); - expect(breakdown).not.toContain("min"); - }); - - it("prints the three attribution shares together", () => { - const out = printed({ - records: [ - record({ turn_id: "a", cost_usd: 6, step: "s", step_attribution: "tool-stated" }), - record({ turn_id: "b", cost_usd: 3, step: "s", step_attribution: "journal-interval" }), - record({ turn_id: "c", cost_usd: 1 }), - ], - }); - const mix = out.slice(out.indexOf("attribution ")); - - expect(mix).toContain("stated by the tool"); - expect(mix).toContain("from a journal interval"); - expect(mix).toContain("unattributed"); - expect(mix).toContain(" 60%"); - expect(mix).toContain(" 30%"); - expect(mix).toContain(" 10%"); - }); - - it("never says work ran outside every step, and never calls it a residual", () => { - const out = printed({ records: [record({ cost_usd: 1 })] }); - - expect(out).toContain("unattributed"); - expect(out).not.toContain("residual"); - expect(out).not.toContain("no step"); - expect(out).not.toContain("outside"); - }); - - it("prints an unknown amount for a tool whose records carry none, never a zero", () => { - const out = printed({ records: [record({ tool: "codex", input_tokens: 8898 })] }); - - expect(out).toContain("amount unknown"); - expect(out).not.toContain("$0.00"); - }); - - it("prints a tool that cannot be read as not covered, with its own reason", () => { - const out = printed({ records: [record({ cost_usd: 1 })] }); - - expect(out).toContain("Cursor"); - expect(out).toContain("not covered — It writes no token count."); - }); - - it("prints a session total on its own tool row, not 'nothing in this period' (#697)", () => { - const output = new CapturingOutput(); - const COPILOT_CAPABILITY = { - localRead: { tokenCounters: true, amount: false, toolStatedStep: false, agentName: false }, - export: { tokenCounters: false, amount: false, toolStatedStep: false, agentName: false }, - journalAttributable: true, - taskAttributable: false, - } as const; - printCostReport( - output, - buildCostReport({ - fromDay: "2026-08-17", - toDay: "2026-08-21", - records: [ - record({ - tool: "copilot", - kind: "session", - provenance: "local-read", - input_tokens: 10, - output_tokens: 42, - cache_read_tokens: 0, - cache_creation_tokens: 21070, - }), - ], - journals: [], - declaredTools: [{ tool: "copilot", coverage: "covered", capability: COPILOT_CAPABILITY }], - undatedRecords: 0, - unreadableLines: 0, - measurementEnabled: true, - }) - ); - const out = output.lines.join("\n"); - - expect(out).toContain("21,122 tokens (session total, not requests)"); - const copilotRow = out.split("\n").find((line) => line.includes("Copilot")) ?? ""; - expect(copilotRow).not.toContain("nothing in this period"); - }); - - it("separates a tool that measured nothing from one that could not be read", () => { - const out = printed({ records: [record({ cost_usd: 1 })] }); - const codexRow = out.split("\n").find((line) => line.includes("Codex")) ?? ""; - const cursorRow = out.split("\n").find((line) => line.includes("Cursor")) ?? ""; - - expect(codexRow).toContain("nothing in this period"); - expect(cursorRow).toContain("not covered"); - expect(codexRow).not.toContain("not covered"); - }); - - it("prints an empty period as nothing measured, not as zeros", () => { - const out = printed(); - - expect(out).toContain("nothing in this period"); - expect(out).not.toContain("$0.00"); - expect(out).not.toContain("by step"); - }); - - it("says how much of the read it could not place or could not parse", () => { - const out = printed({ undatedRecords: 3, unreadableLines: 2 }); - - expect(out).toContain("3 records carry no moment and are in no period"); - expect(out).toContain("2 lines could not be read"); - }); - - it("breaks a period down by tokens when no amount exists anywhere in it", () => { - const out = printed({ - records: [record({ tool: "codex", model: "gpt-5.6-sol", input_tokens: 10 })], - }); - - expect(out).toContain("of tokens"); - expect(out).not.toContain("of cost"); - }); - - it("names a task by its identity, never by a path it was derived from", () => { - const out = printed({ - records: [record({ vendor_id: "s-1", cost_usd: 1 })], - journals: [ - { - vendorId: "s-1", - tool: "claude-code", - writtenPaths: ["aidd_docs/tasks/2026_08/2026_08_21_cost-reporter/plan.md"], - taskIntervals: [], - flowIntervals: [], - }, - ], - task: "2026_08/2026_08_21_cost-reporter", - }); - - expect(out).toContain("task 2026_08/2026_08_21_cost-reporter"); - expect(out).not.toContain("aidd_docs/"); - expect(out).not.toContain("plan.md"); - }); - - it("carries no prompt, code or diff, over records and journals that hold them", () => { - const out = printed({ - records: [ - record({ - cost_usd: 1, - model: "opus", - step: "aidd-dev:02-implement", - step_attribution: "tool-stated", - }), - ], - journals: [ - { - vendorId: "s-1", - tool: "claude-code", - projectId: "acme-widgets", - writtenPaths: ["aidd_docs/tasks/2026_08/2026_08_21_cost-reporter/plan.md"], - taskIntervals: [], - flowIntervals: [], - }, - ], - }); - - // Named rather than "no slash at all": a task's identity legitimately carries one, and - // an assertion that broke on it would say nothing about a leaked path. - expect(out).not.toContain("aidd_docs"); - expect(out).not.toContain(".md"); - expect(out).not.toContain("acme-widgets"); - }); - - it("prints a day with nothing as a row of zeros, never an omitted row", () => { - const out = printed({ - records: [record({ cost_usd: 1, event_timestamp: "2026-08-17T10:00:00Z" })], - }); - - expect(out).toMatch(/2026-08-18\s+nothing in this period/u); - }); - - it("names how many days a long period carries, rather than printing every row", () => { - const records = Array.from({ length: 40 }, (_, i) => - record({ - turn_id: `t-${i}`, - cost_usd: 1, - event_timestamp: `2026-01-${String((i % 27) + 1).padStart(2, "0")}T00:00:00Z`, - }) - ); - const out = printed({ fromDay: "2026-01-01", toDay: "2026-02-09", records }); - - expect(out).toContain("40 days in this period"); - expect(out).toContain("--json"); - expect(out).not.toContain("2026-01-15"); - }); - - it("prints the prompt that caused the work, dated, largest first", () => { - const out = printed({ - records: [ - record({ - turn_id: "a", - prompt_id: "p-1", - cost_usd: 2, - event_timestamp: "2026-08-18T09:00:00Z", - }), - record({ - turn_id: "b", - prompt_id: "p-2", - cost_usd: 1, - event_timestamp: "2026-08-18T10:00:00Z", - }), - ], - }); - - expect(out).toContain("by prompt"); - const prompts = out.split("\n").filter((line) => line.includes("p-1") || line.includes("p-2")); - expect(prompts[0]).toContain("p-1"); - expect(prompts[0]).toContain("2026-08-18T09:00:00Z"); - }); - - // The first axis whose cardinality is unbounded: one row per turn, 12 on a session and - // 31,435 over the measured history. Unlike `by day` this truncates rather than suppressing - // every row - a partial series is a lie about continuity, a top N of a ranking is not, and - // it says how many it withheld. - it("names how many prompts a long period carries beyond the ones it prints", () => { - const records = Array.from({ length: 30 }, (_, i) => - record({ turn_id: `t-${i}`, prompt_id: `p-${i}`, cost_usd: 30 - i }) - ); - const out = printed({ records }); - - expect(out).toContain("p-0"); - expect(out).not.toContain("p-29"); - expect(out).toContain("20 more prompts"); - expect(out).toContain("--json"); - }); - - it("gives a record with no project its own row, named as unknown", () => { - const out = printed({ - records: [ - record({ turn_id: "a", cost_usd: 2, project_id: "acme/widgets" }), - record({ turn_id: "b", cost_usd: 1 }), - ], - }); - const projects = out.slice(out.indexOf("by project")); - - expect(projects).toContain("acme/widgets"); - expect(projects).toContain("no known project"); - }); - - it("gives a record with no model its own row, named as unknown, rather than vanishing", () => { - const out = printed({ - records: [ - record({ turn_id: "a", cost_usd: 2, model: "opus" }), - record({ turn_id: "b", cost_usd: 1 }), - ], - }); - const models = out.slice(out.indexOf("by model"), out.indexOf("by project")); - - expect(models).toContain("opus"); - expect(models).toContain("no known model"); - }); -}); - -describe("printCostReport — a label wider than its column", () => { - // Measured on a real report: a project id is the repository's own remote, and - // `git@github.com:ai-driven-dev/framework.git` is 41 characters against a 26-wide column. - // `padEnd` returns a longer string unchanged, so the share ran straight into the label and - // printed `…framework.git100%`. No fixture had ever carried an identifier that long. - it("keeps a separator between an overlong label and its share", () => { - const long = "git@github.com:ai-driven-dev/framework.git"; - - const out = printed({ records: [record({ cost_usd: 1, project_id: long })] }); - const row = out.split("\n").find((line) => line.includes(long)); - - expect(row).toBeDefined(); - expect(row).not.toContain(`${long}100%`); - expect(row).toMatch(new RegExp(`${long.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&")}\\s`, "u")); - }); - - it("still separates a label exactly as wide as its column from what follows it", () => { - // 26 is `LABEL_WIDTH`, private to this module - the one length where `padTo`'s own - // branches diverge. Shorter, `padEnd` alone already reserves the gap; longer, both the - // `padEnd` branch and the "at least as wide" branch agree on a single trailing space. - // Exactly 26 is the only length where `>=` and `>` decide something different, and - // nothing above ever exercised it - the 41-character label is already past it, never - // sitting on the boundary itself. - const exact = "a".repeat(26); - - const padded = padTo(exact, 26); - - expect(padded).toBe(`${exact} `); - }); -}); - -describe("printCostReport — measurement is off", () => { - it("says the project's switch is off, on an empty period", () => { - const out = printed({ measurementEnabled: false }); - - expect(out).toMatch(/this project's own switch is off/u); - expect(out).not.toMatch(/\bsessions\s+0\b/u); - expect(out).toContain("nothing in this period"); - }); - - it("says nothing about the switch when it is on, even on an empty period", () => { - const out = printed({ measurementEnabled: true }); - - expect(out).not.toContain("switch is off"); - expect(out).not.toMatch(/\bsessions\s+0\b/u); - }); - - // Finding 2 (review.md, "one route, and every sentence about it true"): the sink is - // person-scoped, the switch is project-scoped - a genuine figure below an "off" claim is - // not a contradiction, it is the ordinary case of reporting from a project whose own - // switch never applied to work done anywhere else. The sentence must name its own scope - // rather than read as denying the figure beside it. - it("names the sink's real scope, never denying the figure it sits beside", () => { - const out = printed({ - measurementEnabled: false, - records: [record({ cost_usd: 4.2, input_tokens: 100 })], - }); - - expect(out).toMatch(/this project's own switch is off/u); - expect(out).toMatch(/not scoped to it/u); - expect(out).toContain("$4.20"); - expect(out).toMatch(/\bcost\s+\$4\.20/u); - }); -}); diff --git a/cli/tests/application/display/telemetry-check-display.unit.test.ts b/cli/tests/application/display/telemetry-check-display.unit.test.ts deleted file mode 100644 index a504b1b9c..000000000 --- a/cli/tests/application/display/telemetry-check-display.unit.test.ts +++ /dev/null @@ -1,449 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { printTelemetryCheckReport } from "../../../src/application/display/telemetry-check-display.js"; -import { CLIOutput } from "../../../src/application/output.js"; -import type { - TelemetryHostRegistrationEntry, - TelemetrySetup, -} from "../../../src/domain/models/telemetry-setup.js"; - -/** - * `aidd telemetry check` exists to send a person to the right place when nothing is being - * recorded. Everything asserted here is about not sending them to the wrong one: a gated - * run must not look like a judged one, an unreadable thing must not look like an absent - * one, and the setup a person needs in order to act has to survive the gate. - */ -class CapturingOutput extends CLIOutput { - readonly lines: string[] = []; - readonly warnings: string[] = []; - - override print(message: string): void { - this.lines.push(message); - } - override warn(message: string): void { - this.warnings.push(message); - } - - get text(): string { - return this.lines.join("\n"); - } -} - -function setup(overrides: Partial = {}): TelemetrySetup { - return { - allowed: { - allowed: true, - readable: true, - location: "/repo/.aidd/config.json", - decidedBy: "project-switch", - }, - identity: { attached: false, path: "/home/.config/aidd/identity.json", readable: true }, - recordsLocation: { path: "/home/.config/aidd/telemetry" }, - hostRegistration: { entries: [] }, - commitTrailer: { - delegate: "executable", - callSite: "present", - hookHasOtherContent: false, - hooksDir: "/repo/.git/hooks", - recentlyCarrying: { carrying: 3, examined: 5 }, - }, - recorderDeclaration: { - declared: true, - declaredAt: ["/repo/.aidd/manifest.json"], - locationsChecked: ["/repo/.aidd/manifest.json"], - unreadable: [], - }, - versions: { cli: "5.2.2", plugin: { kind: "recorded", version: "1.0.0" } }, - ...overrides, - }; -} - -describe("the setup a person reads before any claim", () => { - // A gated run judges nothing — but what is in place is exactly what a person switched off - // still needs to see, so the setup is printed on both sides of the gate. - it("prints the setup even when the run was gated before judging anything", () => { - const output = new CapturingOutput(); - - printTelemetryCheckReport(output, { - gate: "measurement is off — nothing to check until it is turned on", - setup: setup(), - leftoverExportConfig: [], - }); - - expect(output.text).toContain("measurement allowed"); - expect(output.text).toContain("records kept at"); - expect(output.text).toContain("measurement is off"); - }); - - // The person's own refusal is a different fact from a project that never turned it on, - // and only one of them is changed by editing the project's file. - it("names a person's own refusal rather than reporting the project as off", () => { - const output = new CapturingOutput(); - - printTelemetryCheckReport(output, { - gate: "measurement is off", - setup: setup({ - allowed: { - allowed: false, - readable: true, - location: "AIDD_TELEMETRY", - decidedBy: "person-refusal", - }, - }), - leftoverExportConfig: [], - }); - - expect(output.text).toContain("this person's own refusal"); - }); - - // Nothing declared is a person's cue to go and declare it somewhere, so the row lists - // every candidate rather than saying only that none matched. - it("lists every location it looked in when nothing declares the recorder", () => { - const output = new CapturingOutput(); - - printTelemetryCheckReport(output, { - setup: setup({ - recorderDeclaration: { - declared: false, - declaredAt: [], - locationsChecked: ["/repo/.aidd/manifest.json", "/repo/.claude/settings.json"], - unreadable: [], - }, - }), - claims: [], - uncovered: [], - leftoverExportConfig: [], - }); - - expect(output.text).toContain("nowhere this build checks"); - expect(output.text).toContain("/repo/.claude/settings.json"); - }); - - // A damaged file is something to fix; an absent declaration is an ordinary state. The row - // must not print the first as the second. - it("reads a damaged declaration location as unreadable, not as undeclared", () => { - const output = new CapturingOutput(); - - printTelemetryCheckReport(output, { - setup: setup({ - recorderDeclaration: { - declared: false, - declaredAt: [], - locationsChecked: [], - unreadable: ["/repo/.aidd/manifest.json"], - }, - }), - claims: [], - uncovered: [], - leftoverExportConfig: [], - }); - - expect(output.text).toContain("could not be read"); - expect(output.text).not.toContain("nowhere this build checks"); - }); - - it("names a plugin version nothing journalled apart from one that was never stamped", () => { - const nothing = new CapturingOutput(); - printTelemetryCheckReport(nothing, { - setup: setup({ versions: { cli: "5.2.2", plugin: { kind: "nothing-journalled" } } }), - claims: [], - uncovered: [], - leftoverExportConfig: [], - }); - const unstamped = new CapturingOutput(); - printTelemetryCheckReport(unstamped, { - setup: setup({ versions: { cli: "5.2.2", plugin: { kind: "unrecorded" } } }), - claims: [], - uncovered: [], - leftoverExportConfig: [], - }); - - const line = (o: CapturingOutput) => o.lines.find((l) => l.includes("plugin version")) ?? ""; - expect(line(nothing)).not.toBe(line(unstamped)); - }); -}); - -describe("the claims, and what is deliberately not one", () => { - it("prints a verdict and its detail for every claim judged", () => { - const output = new CapturingOutput(); - - printTelemetryCheckReport(output, { - setup: setup(), - claims: [ - { - claim: "hook-fired", - verdict: "ok", - reason: "session-anchored", - detail: "2 run file(s)", - }, - { - claim: "records-join", - verdict: "fail", - reason: "all-unattributed", - detail: "nothing joined", - }, - ], - uncovered: [], - leftoverExportConfig: [], - }); - - expect(output.text).toContain("2 run file(s)"); - expect(output.text).toContain("nothing joined"); - }); - - it("names a tool nothing can read with its own reason, never as a failing claim", () => { - const output = new CapturingOutput(); - - printTelemetryCheckReport(output, { - setup: setup(), - claims: [], - uncovered: [{ tool: "cursor", reason: "It writes no token count in any file it produces." }], - leftoverExportConfig: [], - }); - - expect(output.text).toContain("not covered: cursor"); - expect(output.text).toContain("no token count"); - }); - - // A stale export lives in a tool's own settings file, which no claim here can see — so it - // is a warning on stderr, never one of the judged four. - it("warns about a leftover export on stderr, on both sides of the gate", () => { - const leftoverExportConfig = [ - { path: "/repo/.claude/settings.json", keys: ["OTEL_EXPORTER_OTLP_ENDPOINT"] }, - ]; - - const judged = new CapturingOutput(); - printTelemetryCheckReport(judged, { - setup: setup(), - claims: [], - uncovered: [], - leftoverExportConfig, - }); - const gated = new CapturingOutput(); - printTelemetryCheckReport(gated, { - gate: "measurement is off", - setup: setup(), - leftoverExportConfig, - }); - - for (const output of [judged, gated]) { - expect(output.warnings.join("\n")).toContain("OTEL_EXPORTER_OTLP_ENDPOINT"); - expect(output.text).not.toContain("OTEL_EXPORTER_OTLP_ENDPOINT"); - } - }); -}); - -describe("the row saying whether the host will load what aidd installed", () => { - function report(hostRegistration: TelemetrySetup["hostRegistration"]): string { - const output = new CapturingOutput(); - printTelemetryCheckReport(output, { - gate: "measurement is off", - setup: setup({ hostRegistration }), - leftoverExportConfig: [], - }); - return output.text; - } - - const REGISTRY = "/home/dev/.claude/plugins/installed_plugins.json"; - - function entry( - answer: TelemetryHostRegistrationEntry["answer"], - plugin: string - ): TelemetryHostRegistrationEntry { - return { tool: "claude", plugin, answer, detail: REGISTRY }; - } - - // The one thing a person must not miss is what will not load. A reader who stops after the - // first line has still read the problem. - it("puts what will not load above what is fine", () => { - const text = report({ - entries: [entry("registered", "fine"), entry("not-registered", "broken")], - }); - - expect(text.indexOf("broken")).toBeLessThan(text.indexOf("fine")); - }); - - it("orders a disabled registration and an unanswerable one between the two", () => { - const text = report({ - entries: [ - entry("registered", "fine"), - entry("unanswerable", "unknown"), - entry("registered-disabled", "off"), - entry("not-registered", "broken"), - ], - }); - const at = (plugin: string) => text.indexOf(plugin); - - expect(at("broken")).toBeLessThan(at("off")); - expect(at("off")).toBeLessThan(at("unknown")); - expect(at("unknown")).toBeLessThan(at("fine")); - }); - - it("names the answer and the detail on each line, never a bare pass", () => { - const text = report({ - entries: [{ ...entry("not-registered", "aidd-telemetry"), detail: "does not carry it" }], - }); - - expect(text).toContain("claude/aidd-telemetry: not-registered — does not carry it"); - }); - - // A project with nothing installed is healthy, and an empty block would read as a failure - // to look rather than as an answer. - it("says a project has no plugin recorded rather than printing nothing", () => { - expect(report({ entries: [] })).toContain("no plugin recorded for any tool"); - }); - - // The crash guard, made visible: a manifest that cannot be parsed is its own sentence, and - // must never print as the empty case above — one is damage, the other is a normal state. - it("says the manifest could not be read, distinctly from having nothing installed", () => { - const text = report({ - entries: [], - manifestUnreadable: "Cannot read properties of undefined (reading 'map')", - }); - - expect(text).toContain("AIDD's own manifest could not be read"); - expect(text).not.toContain("no plugin recorded"); - }); -}); - -describe("the row saying whether commits carry their session", () => { - function report(commitTrailer: TelemetrySetup["commitTrailer"]): string { - const output = new CapturingOutput(); - printTelemetryCheckReport(output, { - gate: "measurement is off", - setup: setup({ commitTrailer }), - leftoverExportConfig: [], - }); - return output.text; - } - - const HEALTHY = { - delegate: "executable", - callSite: "present", - hookHasOtherContent: false, - hooksDir: "/repo/.git/hooks", - } as const; - - // The count leads, because it is the only fact here about the chain rather than its parts. - // A person who reads one line has read whether it is working. - it("leads with how many recent commits carry it", () => { - const text = report({ ...HEALTHY, recentlyCarrying: { carrying: 4, examined: 20 } }); - - expect(text).toContain("4 of the last 20 commits carry it"); - }); - - it("says nothing about pieces when every piece is in place", () => { - const text = report({ ...HEALTHY, recentlyCarrying: { carrying: 20, examined: 20 } }); - // Scoped to this row: every other setup row uses a dash of its own, so asserting over - // the whole report would only prove the report has dashes in it. - const row = text.split("\n").find((line) => line.includes("commit trailer")) ?? ""; - - expect(row).not.toContain("—"); - expect(text).toContain("hooks run from /repo/.git/hooks"); - }); - - it("names each missing piece after the count", () => { - const text = report({ - ...HEALTHY, - delegate: "absent", - callSite: "missing", - recentlyCarrying: { carrying: 0, examined: 20 }, - }); - - expect(text).toContain("0 of the last 20 commits carry it"); - expect(text).toContain("nothing installed to write it"); - expect(text).toContain("prepare-commit-msg does not call it"); - }); - - // Git will not run a hook it cannot execute, so present-but-not-executable is its own - // sentence rather than a shade of installed. - // Zero with every part in place is the finding this row exists to surface. Excusing it as - // by-design was the one outcome that had to be impossible, and the guard read `>= 0`. - it("never excuses zero, whatever else is in place", () => { - const text = report({ ...HEALTHY, recentlyCarrying: { carrying: 0, examined: 20 } }); - - expect(text).toContain("0 of the last 20 commits carry it"); - expect(text).not.toContain("by design"); - }); - - it("says a hook git will not run is not executable", () => { - const text = report({ ...HEALTHY, hookExecutable: false }); - - expect(text).toContain("prepare-commit-msg is not executable"); - }); - - it("says a delegate that is not executable will not be run", () => { - expect(report({ ...HEALTHY, delegate: "not-executable" })).toContain("not executable"); - }); - - // Said, never named. Which tool owns the file changes nothing a person does about it. - it("says the hook is somebody else's without naming a tool", () => { - const text = report({ ...HEALTHY, hookHasOtherContent: true }); - - expect(text).toContain("somebody else's"); - expect(text).not.toMatch(/lefthook|husky/iu); - }); - - /** - * A commit no session made carries no trailer by design, and merges are skipped outright. - * So a number below the total is not a fault, and a bare "4 of 20" reads like one. The - * qualifier appears exactly when it could mislead — some carrying, every part in place. - */ - it("says a shortfall is expected when every part is in place", () => { - const text = report({ ...HEALTHY, recentlyCarrying: { carrying: 4, examined: 20 } }); - - expect(text).toContain("a commit no session made carries none, by design"); - }); - - it("does not excuse a shortfall when a part is broken", () => { - const text = report({ - ...HEALTHY, - callSite: "missing", - recentlyCarrying: { carrying: 4, examined: 20 }, - }); - - expect(text).not.toContain("by design"); - expect(text).toContain("prepare-commit-msg does not call it"); - }); - - // Outside a repository there is no hook to carry anything, which the claims below already - // refuse to read as a failure. "nothing installed" would describe a repository we are not in. - it("says there is no repository rather than listing missing pieces", () => { - const text = report({ - delegate: "absent", - callSite: "no-hook-file", - hookHasOtherContent: false, - hooksDirMissing: "no-repository", - }); - - expect(text).toContain("no repository here"); - expect(text).not.toContain("nothing installed"); - }); - - /** - * A repository whose git could not name its hooks directory still has a history, and the - * count is the fact that matters. An earlier version printed "no repository here" for it — - * measured false on a git that rejects `--git-path`, inside a repository with commits, one - * of which carried the trailer. One true fact replaced by one false one. - */ - it("keeps the count when git could not name the hooks directory", () => { - const text = report({ - delegate: "absent", - callSite: "no-hook-file", - hookHasOtherContent: false, - hooksDirMissing: "unresolved", - recentlyCarrying: { carrying: 1, examined: 4 }, - }); - - expect(text).toContain("1 of the last 4 commits carry it"); - expect(text).not.toContain("no repository here"); - }); - - // No commits and no commits carrying it are different facts, and only the second is - // something to act on. - it("says there is no history to read rather than reporting zero", () => { - const text = report(HEALTHY); - - expect(text).toContain("no commit history to read"); - expect(text).not.toContain("0 of the last"); - }); -}); diff --git a/cli/tests/application/display/telemetry-display.unit.test.ts b/cli/tests/application/display/telemetry-display.unit.test.ts deleted file mode 100644 index 13080fcc8..000000000 --- a/cli/tests/application/display/telemetry-display.unit.test.ts +++ /dev/null @@ -1,412 +0,0 @@ -import { describe, expect, it } from "vitest"; -import "../../../src/domain/tools/ai/claude.js"; -import "../../../src/domain/tools/ai/codex.js"; -import "../../../src/domain/tools/ai/copilot.js"; -import "../../../src/domain/tools/ai/cursor.js"; -import "../../../src/domain/tools/ai/opencode.js"; -import { - printLocalCostReadReport, - printPersonIdentityLink, - printPersonIdentityOff, - printPersonIdentityStatus, - printPersonIdentityUnlink, - printPersonIdentityUse, - printTelemetryOffReport, - printTelemetryOnReport, - warnIfFiguresMoveTheTokenToo, -} from "../../../src/application/display/telemetry-display.js"; -import { CLIOutput } from "../../../src/application/output.js"; -import type { - LocalCostToolReport, - LocalCostToolStatus, - ReadLocalCostResult, -} from "../../../src/application/use-cases/telemetry/read-local-cost-use-case.js"; -import type { TelemetrySink } from "../../../src/domain/ports/telemetry-sink.js"; -import { InMemoryTelemetrySink } from "../../helpers/ports/in-memory-telemetry-sink.js"; - -/** Extends the real output rather than standing in for it — the same reasoning - * `cost-report-display.unit.test.ts` gives: a widened double stops failing the day the - * class grows a method the printer starts calling. */ -class CapturingOutput extends CLIOutput { - readonly lines: string[] = []; - - override print(message: string): void { - this.lines.push(message); - } - override info(message: string): void { - this.lines.push(message); - } - override success(message: string): void { - this.lines.push(message); - } - override warn(message: string): void { - this.lines.push(message); - } - - get text(): string { - return this.lines.join("\n"); - } -} - -function toolReport(overrides: Partial = {}): LocalCostToolReport { - return { - tool: "claude", - status: "found", - recordsFound: 0, - recordsStored: 0, - sessionsFailed: 0, - ...overrides, - }; -} - -function readResult(overrides: Partial = {}): ReadLocalCostResult { - return { sessions: [], toolReports: [], ...overrides }; -} - -describe("what `telemetry read` says about each tool", () => { - /** - * The six statuses are the "an unknown is never a zero" rule in printed form: five of - * them mean something different from "this tool billed nothing", and any two sharing a - * label would let a session that was never measured read as free. So the labels are held - * apart from each other, not matched one by one against a wording that may change. - */ - it("gives every status a label of its own, so no two can be read as the same fact", () => { - const statuses: readonly LocalCostToolStatus[] = [ - "found", - "empty", - "not-found", - "unreadable", - "not-covered", - "not-asked", - ]; - - const labels = statuses.map((status) => { - const output = new CapturingOutput(); - printLocalCostReadReport( - output, - readResult({ - sessions: [{ sessionId: "s-1", toolReports: [] }], - toolReports: [toolReport({ status })], - }) - ); - return output.lines[output.lines.length - 1]; - }); - - expect(new Set(labels).size).toBe(statuses.length); - }); - - it("never says a tool found nothing when nothing was ever asked of it", () => { - const output = new CapturingOutput(); - - printLocalCostReadReport( - output, - readResult({ - sessions: [{ sessionId: "s-1", toolReports: [] }], - toolReports: [toolReport({ status: "not-asked" })], - }) - ); - - expect(output.text).not.toMatch(/nothing found/u); - }); - - // A sweep that read nineteen sessions and failed the twentieth still reports the figures - // as read; the failure has to survive beside the status, never inside it. - it("names a session that could not be read beside a tool that otherwise read fine", () => { - const output = new CapturingOutput(); - - printLocalCostReadReport( - output, - readResult({ - sessions: [{ sessionId: "s-1", toolReports: [] }], - toolReports: [ - toolReport({ - status: "found", - recordsFound: 3, - recordsStored: 3, - sessionsFailed: 1, - failureReason: "EACCES", - }), - ], - }) - ); - - expect(output.text).toContain("1 session could not be read"); - expect(output.text).toContain("EACCES"); - }); - - // A refusal read nothing and stored nothing. "No session journalled yet" is a fact about - // the journal; conflating the two sends a person to look at the wrong thing. - it("tells a refusal apart from an empty journal", () => { - const refused = new CapturingOutput(); - printLocalCostReadReport(refused, readResult({ refusedReason: "measurement is off" })); - - const empty = new CapturingOutput(); - printLocalCostReadReport(empty, readResult()); - - expect(refused.text).toContain("measurement is off"); - expect(empty.text).toContain("No session journalled yet"); - expect(refused.text).not.toContain("No session journalled yet"); - }); - - it("leads with how many sessions it covered, not one line per tool per session", () => { - const output = new CapturingOutput(); - - printLocalCostReadReport( - output, - readResult({ - sessions: [ - { sessionId: "s-1", toolReports: [] }, - { sessionId: "s-2", toolReports: [] }, - ], - toolReports: [toolReport({ status: "found", recordsFound: 2, recordsStored: 2 })], - }) - ); - - expect(output.lines[0]).toContain("2 sessions read"); - }); -}); - -describe("what the switch says when it is flipped", () => { - it("says the file is tracked, because turning it on decides for everyone who clones", () => { - const output = new CapturingOutput(); - - printTelemetryOnReport(output, { switchPath: "/repo/.aidd/config.json", switchChanged: true }); - - expect(output.text).toContain("git-tracked"); - }); - - it("tells an already-on project from one it just turned on", () => { - const changed = new CapturingOutput(); - printTelemetryOnReport(changed, { switchPath: "/p/.aidd/config.json", switchChanged: true }); - const unchanged = new CapturingOutput(); - printTelemetryOnReport(unchanged, { switchPath: "/p/.aidd/config.json", switchChanged: false }); - - expect(changed.text).not.toContain("already on"); - expect(unchanged.text).toContain("already on"); - }); - - // Turning recording off is not erasing what was recorded, and a person who wanted the - // second has to be told which command does it. - it("says off stops new recording only, and names what removes the rest", () => { - const output = new CapturingOutput(); - - printTelemetryOffReport(output, { switchPath: "/p/.aidd/config.json", switchChanged: true }); - - expect(output.text).toContain("stops new recording only"); - expect(output.text).toContain("aidd telemetry forget"); - }); -}); - -describe("what the identity commands say", () => { - it("says records carry no person when nobody has chosen", () => { - const output = new CapturingOutput(); - - printPersonIdentityStatus(output, { filePath: "/h/identity.json", identity: null }); - - expect(output.text).toContain("records carry no person"); - }); - - it("tells an identifier minted here from one taken from another machine", () => { - const minted = new CapturingOutput(); - printPersonIdentityStatus(minted, { - filePath: "/h/identity.json", - identity: { personId: "p-1", origin: "minted", alsoMe: [] }, - }); - const adopted = new CapturingOutput(); - printPersonIdentityStatus(adopted, { - filePath: "/h/identity.json", - identity: { personId: "p-1", origin: "adopted", alsoMe: [] }, - }); - - expect(minted.text).toContain("minted on this machine"); - expect(adopted.text).toContain("taken from another machine"); - }); - - // Taking a different identifier does not rewrite what is already stored, and a person - // has to be told which identifier those records keep. - it("names the identifier that was replaced, when one was", () => { - const output = new CapturingOutput(); - - printPersonIdentityUse(output, { - filePath: "/h/identity.json", - identity: { personId: "p-2", origin: "adopted", alsoMe: [] }, - outcome: "adopted", - replacedPersonId: "p-1", - }); - - expect(output.text).toContain("p-1"); - }); - - it("says withdrawing takes the added identifiers with it", () => { - const output = new CapturingOutput(); - - printPersonIdentityOff(output, { - filePath: "/h/identity.json", - removed: true, - discardedDamaged: false, - addedIdentifiersRemoved: 2, - }); - - expect(output.text).toMatch(/2/u); - }); -}); - -describe("the warning about where the figures land", () => { - /** The real in-memory sink, with only the one field this printer reads set — never an - * object literal widened into the port, which `check-cli-layering.mjs` refuses and which - * would stop failing the day the port grows a member. */ - function sink(locatedBy: TelemetrySink["locatedBy"]): TelemetrySink { - const built = new InMemoryTelemetrySink(); - built.locatedBy = locatedBy; - return built; - } - - // `AIDD_USER_CONFIG_DIR` names the directory that also holds `auth.json`. A person who - // pointed it somewhere shared moved their GitHub token there too, and nothing else says so. - it("warns when the figures were placed by the variable that also moves the token", () => { - const output = new CapturingOutput(); - - warnIfFiguresMoveTheTokenToo(output, sink("user-config-dir")); - - expect(output.text).toContain("auth.json"); - }); - - it("says nothing when the directory was named outright, or defaulted", () => { - for (const locatedBy of ["telemetry-dir", "default"] as const) { - const output = new CapturingOutput(); - warnIfFiguresMoveTheTokenToo(output, sink(locatedBy)); - expect(output.lines).toEqual([]); - } - }); -}); - -describe("linking an identifier this person could not simply take as their own", () => { - // `link` is a claim the tool cannot verify — it never checks who is running it — so every - // path that writes one has to say so. - it("says a fresh link is a declaration nothing here can check", () => { - const output = new CapturingOutput(); - - printPersonIdentityLink(output, { - filePath: "/h/identity.json", - personId: "p-1", - identity: "machine-2", - alreadyListed: false, - }); - - expect(output.text).toContain("linked 'machine-2'"); - expect(output.text).toContain("cannot check"); - }); - - // Already listed is a no-op, not a second write, and a caller that links before reporting - // has to be able to tell the two apart. - it("reports one already listed as already listed, never as a fresh write", () => { - const output = new CapturingOutput(); - - printPersonIdentityLink(output, { - filePath: "/h/identity.json", - personId: "p-1", - identity: "machine-2", - alreadyListed: true, - }); - - expect(output.text).toContain("already listed"); - expect(output.text).not.toContain("linked 'machine-2'"); - }); - - it("reports unlinking one nobody listed as nothing to remove, never a failure", () => { - const output = new CapturingOutput(); - - printPersonIdentityUnlink(output, { - filePath: "/h/identity.json", - identity: "machine-9", - removed: false, - }); - - expect(output.text).toContain("nothing to remove"); - }); - - it("names the identifier it withdrew", () => { - const output = new CapturingOutput(); - - printPersonIdentityUnlink(output, { - filePath: "/h/identity.json", - identity: "machine-2", - removed: true, - }); - - expect(output.text).toContain("unlinked 'machine-2'"); - }); -}); - -describe("what minting says it does, and does not do", () => { - // The consent a person gives is to this sentence, so both halves of it are asserted. - it("names what the identifier attaches to, and what it never attaches to", () => { - const output = new CapturingOutput(); - - printPersonIdentityUse(output, { - filePath: "/h/identity.json", - identity: { personId: "p-1", origin: "minted", alsoMe: [] }, - outcome: "minted", - }); - - expect(output.text).toContain("records this machine reads locally"); - expect(output.text).toContain("Never attaches to"); - }); - - // "Already in effect" is true of the identifier and false of the file once a name came - // with the call: something was written, and the first line must not say otherwise. - it("does not claim nothing changed when a display name was set alongside", () => { - const output = new CapturingOutput(); - - printPersonIdentityUse(output, { - filePath: "/h/identity.json", - identity: { personId: "p-1", origin: "minted", alsoMe: [], displayName: "Ada" }, - outcome: "unchanged", - displayNameSet: "Ada", - }); - - expect(output.text).toContain("display name set"); - expect(output.text).toContain("Ada"); - }); - - it("says withdrawing never gives the same identifier back", () => { - const output = new CapturingOutput(); - - printPersonIdentityOff(output, { - filePath: "/h/identity.json", - removed: true, - discardedDamaged: false, - addedIdentifiersRemoved: 0, - }); - - expect(output.text).toContain("mints a fresh identifier, never this one back"); - }); - - it("reports nothing to withdraw when nobody had chosen", () => { - const output = new CapturingOutput(); - - printPersonIdentityOff(output, { - filePath: "/h/identity.json", - removed: false, - discardedDamaged: false, - addedIdentifiersRemoved: 0, - }); - - expect(output.text).toContain("already off"); - }); - - // Withdrawing has to work exactly when the file is too damaged to read, and say that it - // discarded rather than read it. - it("says a damaged file was discarded rather than left behind", () => { - const output = new CapturingOutput(); - - printPersonIdentityOff(output, { - filePath: "/h/identity.json", - removed: true, - discardedDamaged: true, - addedIdentifiersRemoved: 0, - }); - - expect(output.text).toContain("discarded rather than left behind"); - }); -}); diff --git a/cli/tests/application/display/telemetry-forget-display.unit.test.ts b/cli/tests/application/display/telemetry-forget-display.unit.test.ts deleted file mode 100644 index 9a0067dd2..000000000 --- a/cli/tests/application/display/telemetry-forget-display.unit.test.ts +++ /dev/null @@ -1,170 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { - printTelemetryForgetPreview, - printTelemetryForgetRefused, - printTelemetryForgetResult, -} from "../../../src/application/display/telemetry-forget-display.js"; -import { CLIOutput } from "../../../src/application/output.js"; -import type { TelemetryRemovalPreview } from "../../../src/domain/models/telemetry-removal.js"; - -/** - * The one command here that destroys something, so what it prints is the whole of what a - * person consents to. The preview's counts are what `--yes` is answered against; the - * result's counts are what they check the preview against afterwards. - */ -class CapturingOutput extends CLIOutput { - readonly lines: string[] = []; - - override print(message: string): void { - this.lines.push(message); - } - override success(message: string): void { - this.lines.push(message); - } - override warn(message: string): void { - this.lines.push(message); - } - - get text(): string { - return this.lines.join("\n"); - } -} - -function preview(overrides: Partial = {}): TelemetryRemovalPreview { - return { - journal: { scope: "project", path: "/repo/aidd_docs/runs", runFileNames: [] }, - sink: { scope: "machine", path: "/home/.config/aidd/telemetry", dayFileNames: [] }, - identity: { - scope: "machine", - path: "/home/.config/aidd/identity.json", - present: false, - unreadable: false, - }, - history: { certainty: "none" }, - ...overrides, - }; -} - -describe("what a person is shown before anything is removed", () => { - it("says there is nothing to remove when nothing was ever measured", () => { - const output = new CapturingOutput(); - - printTelemetryForgetPreview(output, preview()); - - expect(output.text).toContain("Nothing to remove"); - expect(output.text).not.toContain("This would remove:"); - }); - - // The sink is machine-wide while the journal is this project's. A person removing "what - // this tool measured" is removing every project's figures, and the sentence has to say so - // before they answer `--yes`. - it("says the stored records span every project measured on this machine", () => { - const output = new CapturingOutput(); - - printTelemetryForgetPreview( - output, - preview({ - sink: { - scope: "machine", - path: "/home/.config/aidd/telemetry", - dayFileNames: ["2026-03-02.jsonl", "2026-03-03.jsonl"], - }, - }) - ); - - expect(output.text).toContain("every project measured on this machine"); - expect(output.text).toContain("2 day file(s)"); - }); - - it("counts the run files it would remove, so the count can be checked afterwards", () => { - const output = new CapturingOutput(); - - printTelemetryForgetPreview( - output, - preview({ - journal: { - scope: "project", - path: "/repo/aidd_docs/runs", - runFileNames: ["a.jsonl", "b.jsonl", "c.jsonl"], - }, - }) - ); - - expect(output.text).toContain("3 run file(s)"); - }); - - // Git holds what git holds; removing local files does not reach it, and a person who - // committed their journal has to learn that here rather than discover it later. - it("names history that git already holds", () => { - const output = new CapturingOutput(); - - printTelemetryForgetPreview( - output, - preview({ history: { certainty: "committed", files: ["aidd_docs/runs/a.jsonl"] } }) - ); - - expect(output.text).toContain("aidd_docs/runs/a.jsonl"); - }); -}); - -describe("what a refusal says", () => { - // Looking and deciding not to is not an error, and the sentence has to name the flag that - // would have gone ahead. - it("reports nothing removed and names the flag, never a failure", () => { - const output = new CapturingOutput(); - - printTelemetryForgetRefused(output); - - expect(output.text).toContain("Nothing removed"); - expect(output.text).toContain("--yes"); - }); -}); - -describe("what is reported once it is done", () => { - it("counts each location separately, so the three can be checked against the preview", () => { - const output = new CapturingOutput(); - - printTelemetryForgetResult(output, { - journal: { removed: 3, failed: [] }, - sink: { removed: 2, failed: [] }, - identity: { removed: 1, failed: [] }, - history: { certainty: "none" }, - }); - - expect(output.text).toContain("This project's run journal: 3 removed"); - expect(output.text).toContain("This machine's stored records: 2 removed"); - expect(output.text).toContain("This machine's identity: 1 removed"); - }); - - // One undeletable file must not read as everything having gone. - it("names every file it could not remove, and why", () => { - const output = new CapturingOutput(); - - printTelemetryForgetResult(output, { - journal: { removed: 1, failed: [{ path: "b.jsonl", reason: "EACCES" }] }, - sink: { removed: 0, failed: [] }, - identity: { removed: 0, failed: [] }, - history: { certainty: "none" }, - }); - - expect(output.text).toContain("1 could not be removed"); - expect(output.text).toContain("b.jsonl"); - expect(output.text).toContain("EACCES"); - }); - - // Removing what was measured is not turning measurement off, and a person who wanted - // both has to be told the switch is still where it was. - it("says the switch was left alone, and how to turn measurement on again", () => { - const output = new CapturingOutput(); - - printTelemetryForgetResult(output, { - journal: { removed: 0, failed: [] }, - sink: { removed: 0, failed: [] }, - identity: { removed: 0, failed: [] }, - history: { certainty: "none" }, - }); - - expect(output.text).toContain("was not touched"); - expect(output.text).toContain("aidd telemetry on"); - }); -}); diff --git a/cli/tests/application/errors.unit.test.ts b/cli/tests/application/errors.unit.test.ts deleted file mode 100644 index 35ca4c6b1..000000000 --- a/cli/tests/application/errors.unit.test.ts +++ /dev/null @@ -1,148 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { - AdoptRequiresVersionError, - AiddFilesDetectedError, - AlreadyInitializedError, - InputRequiredError, - InvalidCategoryError, - NoManifestError, - NotAuthenticatedError, - ToolNotInstalledError, -} from "../../src/application/errors.js"; -import { FlatTargetExistsError, OutDirNotDirectoryError } from "../../src/domain/errors.js"; - -describe("NoManifestError", () => { - it("includes aidd setup hint in message", () => { - const error = new NoManifestError(); - expect(error.message).toContain("aidd setup"); - expect(error.name).toBe("NoManifestError"); - }); -}); - -describe("AiddFilesDetectedError", () => { - it("includes setup hint in message", () => { - const error = new AiddFilesDetectedError(); - expect(error.message).toContain("AIDD files detected but no manifest found"); - expect(error.message).toContain("aidd setup"); - expect(error.name).toBe("AiddFilesDetectedError"); - }); -}); - -describe("AdoptRequiresVersionError", () => { - it("includes adopt example in message", () => { - const error = new AdoptRequiresVersionError(); - expect(error.message).toContain("--from is required for adopt"); - expect(error.message).toContain("aidd setup --ai claude --from 3.6.0"); - expect(error.name).toBe("AdoptRequiresVersionError"); - }); - - it("appends diagnostic suffix when provided", () => { - const error = new AdoptRequiresVersionError("some diagnostic"); - expect(error.message).toContain("some diagnostic"); - }); -}); - -describe("FlatTargetExistsError", () => { - it("has correct error name", () => { - const error = new FlatTargetExistsError( - "/out/.github/agents/my-plugin/foo.agent.md", - "my-plugin" - ); - expect(error.name).toBe("FlatTargetExistsError"); - }); - - it("includes the conflicting path in the message", () => { - const error = new FlatTargetExistsError( - "/out/.github/agents/my-plugin/foo.agent.md", - "my-plugin" - ); - expect(error.message).toContain("/out/.github/agents/my-plugin/foo.agent.md"); - }); - - it("includes the plugin name in the message", () => { - const error = new FlatTargetExistsError( - "/out/.github/agents/my-plugin/foo.agent.md", - "my-plugin" - ); - expect(error.message).toContain("my-plugin"); - }); - - it("mentions --force hint in message", () => { - const error = new FlatTargetExistsError( - "/out/.github/agents/my-plugin/foo.agent.md", - "my-plugin" - ); - expect(error.message).toContain("--force"); - }); -}); - -describe("OutDirNotDirectoryError", () => { - it("has correct error name", () => { - const error = new OutDirNotDirectoryError("/tmp/some-out"); - expect(error.name).toBe("OutDirNotDirectoryError"); - }); - - it("includes the outDir path in the message", () => { - const error = new OutDirNotDirectoryError("/tmp/some-out"); - expect(error.message).toContain("/tmp/some-out"); - }); - - it("does not mention source directory in the message", () => { - const error = new OutDirNotDirectoryError("/tmp/some-out"); - expect(error.message).not.toContain("--source"); - expect(error.message).toContain("not a directory"); - }); -}); - -describe("NotAuthenticatedError", () => { - it("has correct error name and auth hint in message", () => { - const error = new NotAuthenticatedError(); - expect(error.name).toBe("NotAuthenticatedError"); - expect(error.message).toContain("aidd auth login"); - }); -}); - -describe("AlreadyInitializedError", () => { - it("has default message when no argument provided", () => { - const error = new AlreadyInitializedError(); - expect(error.name).toBe("AlreadyInitializedError"); - expect(error.message).toContain("Already initialized"); - }); - - it("uses provided message when given", () => { - const error = new AlreadyInitializedError("Custom message here."); - expect(error.message).toBe("Custom message here."); - }); -}); - -describe("InputRequiredError", () => { - it("carries the provided message", () => { - const error = new InputRequiredError("Prompt answer is required."); - expect(error.name).toBe("InputRequiredError"); - expect(error.message).toBe("Prompt answer is required."); - }); -}); - -describe("ToolNotInstalledError", () => { - it("includes tool ID in message without context", () => { - const error = new ToolNotInstalledError("claude"); - expect(error.name).toBe("ToolNotInstalledError"); - expect(error.message).toContain("claude"); - }); - - it("includes context and tool ID when context is provided", () => { - const error = new ToolNotInstalledError("cursor", "The target tool"); - expect(error.message).toContain("cursor"); - expect(error.message).toContain("The target tool"); - }); -}); - -describe("InvalidCategoryError", () => { - it("includes the invalid category in the message", () => { - const error = new InvalidCategoryError("invalid-cat"); - expect(error.name).toBe("InvalidCategoryError"); - expect(error.message).toContain("invalid-cat"); - expect(error.message).toContain("ai"); - expect(error.message).toContain("ide"); - }); -}); diff --git a/cli/tests/application/use-cases/clean-use-case.unit.test.ts b/cli/tests/application/use-cases/clean-use-case.unit.test.ts deleted file mode 100644 index 95cabee31..000000000 --- a/cli/tests/application/use-cases/clean-use-case.unit.test.ts +++ /dev/null @@ -1,171 +0,0 @@ -import { join } from "node:path"; -import { describe, expect, it } from "vitest"; -import "../../../src/domain/tools/ai/claude.js"; -import "../../../src/domain/tools/ide/vscode.js"; -import { CleanUseCase } from "../../../src/application/use-cases/clean-use-case.js"; -import type { ToolId } from "../../../src/domain/tools/registry.js"; -import { buildUnitDeps, initAndInstall } from "../../helpers/ports/build-unit-deps.js"; - -const PROJECT_ROOT = "/test-project"; - -describe("clean", () => { - it("with force removes .aidd/cache/ entry from .gitignore", async () => { - const deps = await buildUnitDeps(PROJECT_ROOT); - await initAndInstall(deps, PROJECT_ROOT, "claude" as ToolId); - - const gitignorePath = join(PROJECT_ROOT, ".gitignore"); - await deps.fs.writeFile(gitignorePath, "node_modules/\n.aidd/cache/\ndist/\n"); - - const useCase = new CleanUseCase( - deps.fs, - deps.manifestRepo, - deps.logger, - deps.gitignoreUseCase - ); - await useCase.execute({ projectRoot: PROJECT_ROOT, force: true }); - - const content = deps.fs.getFile(gitignorePath); - expect(content).not.toContain(".aidd/cache/"); - expect(content).toContain("node_modules/"); - expect(content).toContain("dist/"); - }); - - it("with force leaves .gitignore unchanged when entry absent", async () => { - const deps = await buildUnitDeps(PROJECT_ROOT); - await initAndInstall(deps, PROJECT_ROOT, "claude" as ToolId); - - const gitignorePath = join(PROJECT_ROOT, ".gitignore"); - await deps.fs.writeFile(gitignorePath, "node_modules/\n"); - - const useCase = new CleanUseCase( - deps.fs, - deps.manifestRepo, - deps.logger, - deps.gitignoreUseCase - ); - await useCase.execute({ projectRoot: PROJECT_ROOT, force: true }); - - const content = deps.fs.getFile(gitignorePath); - expect(content).toBe("node_modules/\n"); - }); - - it("preserves untracked user files", async () => { - const deps = await buildUnitDeps(PROJECT_ROOT); - await initAndInstall(deps, PROJECT_ROOT, "claude" as ToolId); - - const userFile = join(PROJECT_ROOT, "my-custom-file.txt"); - await deps.fs.writeFile(userFile, "user content"); - - const useCase = new CleanUseCase( - deps.fs, - deps.manifestRepo, - deps.logger, - deps.gitignoreUseCase - ); - await useCase.execute({ projectRoot: PROJECT_ROOT, force: true }); - - expect(deps.fs.has(userFile)).toBe(true); - }); - - it("keeps .aidd/config.json and deletes .aidd/cache/ when config.json exists", async () => { - const deps = await buildUnitDeps(PROJECT_ROOT); - await initAndInstall(deps, PROJECT_ROOT, "claude" as ToolId); - - const configPath = join(PROJECT_ROOT, ".aidd", "config.json"); - const cacheFile = join(PROJECT_ROOT, ".aidd", "cache", "built", "leftover.json"); - await deps.fs.writeFile(configPath, '{"telemetry":{"enabled":true}}'); - await deps.fs.writeFile(cacheFile, "{}"); - - const useCase = new CleanUseCase( - deps.fs, - deps.manifestRepo, - deps.logger, - deps.gitignoreUseCase - ); - await useCase.execute({ projectRoot: PROJECT_ROOT, force: true }); - - expect(deps.fs.has(configPath)).toBe(true); - expect(deps.fs.has(cacheFile)).toBe(false); - expect(deps.manifestRepo.getCurrent()).toBeNull(); - }); - - it("removes .aidd/plugin-cache/, which no install writes but plugin add does", async () => { - const deps = await buildUnitDeps(PROJECT_ROOT); - await initAndInstall(deps, PROJECT_ROOT, "claude" as ToolId); - - const pluginCacheFile = join(PROJECT_ROOT, ".aidd", "plugin-cache", "some-plugin", "x.json"); - await deps.fs.writeFile(pluginCacheFile, "{}"); - - const useCase = new CleanUseCase( - deps.fs, - deps.manifestRepo, - deps.logger, - deps.gitignoreUseCase - ); - await useCase.execute({ projectRoot: PROJECT_ROOT, force: true }); - - expect(deps.fs.has(pluginCacheFile)).toBe(false); - expect(deps.fs.listUnder(join(PROJECT_ROOT, ".aidd")).length).toBe(0); - }); - - it("removes .aidd/ entirely when nothing but the manifest and cache lived there", async () => { - const deps = await buildUnitDeps(PROJECT_ROOT); - await initAndInstall(deps, PROJECT_ROOT, "claude" as ToolId); - - const cacheFile = join(PROJECT_ROOT, ".aidd", "cache", "built", "leftover.json"); - await deps.fs.writeFile(cacheFile, "{}"); - - const useCase = new CleanUseCase( - deps.fs, - deps.manifestRepo, - deps.logger, - deps.gitignoreUseCase - ); - await useCase.execute({ projectRoot: PROJECT_ROOT, force: true }); - - expect(deps.fs.listUnder(join(PROJECT_ROOT, ".aidd")).length).toBe(0); - }); - - it("removes .aidd/marketplaces.json, which the CLI wrote and no person committed", async () => { - // The registry is this tool's own project-scope state, unlike config.json, which a - // person commits and clean therefore keeps. Left behind, `.aidd` survives a run that - // reported it had cleaned all AIDD files. - const deps = await buildUnitDeps(PROJECT_ROOT); - await initAndInstall(deps, PROJECT_ROOT, "claude" as ToolId); - - const registry = join(PROJECT_ROOT, ".aidd", "marketplaces.json"); - await deps.fs.writeFile(registry, JSON.stringify({ version: 1, marketplaces: [] })); - - const useCase = new CleanUseCase( - deps.fs, - deps.manifestRepo, - deps.logger, - deps.gitignoreUseCase - ); - await useCase.execute({ projectRoot: PROJECT_ROOT, force: true }); - - expect(deps.fs.has(registry)).toBe(false); - expect(deps.fs.listUnder(join(PROJECT_ROOT, ".aidd")).length).toBe(0); - }); - - it("still keeps .aidd/config.json when a registry lived beside it", async () => { - const deps = await buildUnitDeps(PROJECT_ROOT); - await initAndInstall(deps, PROJECT_ROOT, "claude" as ToolId); - - const config = join(PROJECT_ROOT, ".aidd", "config.json"); - const registry = join(PROJECT_ROOT, ".aidd", "marketplaces.json"); - await deps.fs.writeFile(config, JSON.stringify({ telemetry: { enabled: true } })); - await deps.fs.writeFile(registry, JSON.stringify({ version: 1, marketplaces: [] })); - - const useCase = new CleanUseCase( - deps.fs, - deps.manifestRepo, - deps.logger, - deps.gitignoreUseCase - ); - await useCase.execute({ projectRoot: PROJECT_ROOT, force: true }); - - expect(deps.fs.has(config)).toBe(true); - expect(deps.fs.has(registry)).toBe(false); - }); -}); diff --git a/cli/tests/application/use-cases/doctor-plugin.unit.test.ts b/cli/tests/application/use-cases/doctor-plugin.unit.test.ts deleted file mode 100644 index 7e46f6556..000000000 --- a/cli/tests/application/use-cases/doctor-plugin.unit.test.ts +++ /dev/null @@ -1,171 +0,0 @@ -import { homedir } from "node:os"; -import { join } from "node:path"; -import { describe, expect, it } from "vitest"; -import "../../../src/domain/tools/ai/claude.js"; -import "../../../src/domain/tools/ai/cursor.js"; -import { DoctorLayoutUseCase } from "../../../src/application/use-cases/doctor/doctor-layout-use-case.js"; -import { DoctorMergeFilesUseCase } from "../../../src/application/use-cases/doctor/doctor-merge-files-use-case.js"; -import { DoctorPluginUseCase } from "../../../src/application/use-cases/doctor/doctor-plugin-use-case.js"; -import { DoctorReferencesUseCase } from "../../../src/application/use-cases/doctor/doctor-references-use-case.js"; -import { DoctorTrackedFilesUseCase } from "../../../src/application/use-cases/doctor/doctor-tracked-files-use-case.js"; -import { DoctorUseCase } from "../../../src/application/use-cases/doctor/doctor-use-case.js"; -import { DetectPluginDriftUseCase } from "../../../src/application/use-cases/shared/detect-plugin-drift-use-case.js"; -import { FileHash } from "../../../src/domain/models/file.js"; -import { Manifest } from "../../../src/domain/models/manifest.js"; -import { Plugin } from "../../../src/domain/models/plugin.js"; -import type { FileReader } from "../../../src/domain/ports/file-reader.js"; -import type { Hasher } from "../../../src/domain/ports/hasher.js"; -import type { ManifestRepository } from "../../../src/domain/ports/manifest-repository.js"; - -const EXPECTED_HASH = "abc123abc123abc123abc123abc123ab"; -const DRIFTED_HASH = "def456def456def456def456def456de"; -const PLUGIN_FILE = ".claude/plugins/my-plugin/commands/cmd.md"; - -function makeManifest(pluginFileHash: string): Manifest { - const manifest = Manifest.create(); - manifest.addTool("claude", "1.0.0", []); - manifest.addPlugin( - "claude", - Plugin.fromJSON({ - name: "my-plugin", - source: { kind: "local", path: "/some/path" }, - version: "1.0.0", - strict: false, - files: { [PLUGIN_FILE]: pluginFileHash }, - }) - ); - return manifest; -} - -function makeFs(fileExists: boolean, diskHash: string): FileReader { - return { - fileExists: async () => fileExists, - isExecutable: async () => false, - readFileHash: async () => new FileHash(diskHash), - readFile: async () => "", - listDirectory: async () => [], - listFilesRecursive: async () => [], - }; -} - -function makeManifestRepo(manifest: Manifest): ManifestRepository { - return { - path: "/proj/.aidd/manifest.json", - load: async () => manifest, - save: async () => {}, - delete: async () => {}, - }; -} - -const noopHasher: Hasher = { - hash: () => new FileHash("00000000000000000000000000000000"), -}; - -function makeDoctorUseCase(fs: FileReader, manifest: Manifest): DoctorUseCase { - return new DoctorUseCase( - makeManifestRepo(manifest), - new DoctorTrackedFilesUseCase(fs), - new DoctorMergeFilesUseCase(fs, noopHasher), - new DoctorPluginUseCase(new DetectPluginDriftUseCase(fs)), - new DoctorReferencesUseCase(fs), - new DoctorLayoutUseCase(fs) - ); -} - -describe("DoctorUseCase — plugin integrity", () => { - describe("when plugin file is missing", () => { - it("reports a missing plugin issue", async () => { - const manifest = makeManifest(EXPECTED_HASH); - const fs = makeFs(false, EXPECTED_HASH); - const useCase = makeDoctorUseCase(fs, manifest); - - const report = await useCase.execute({ projectRoot: "/proj" }); - - expect(report.pluginIssues).toHaveLength(1); - expect(report.pluginIssues[0].issue).toBe("missing"); - expect(report.pluginIssues[0].pluginName).toBe("my-plugin"); - expect(report.pluginIssues[0].filePath).toBe(PLUGIN_FILE); - expect(report.healthy).toBe(false); - }); - }); - - describe("when plugin file has hash mismatch", () => { - it("reports a hash-mismatch plugin issue", async () => { - const manifest = makeManifest(EXPECTED_HASH); - const fs = makeFs(true, DRIFTED_HASH); - const useCase = makeDoctorUseCase(fs, manifest); - - const report = await useCase.execute({ projectRoot: "/proj" }); - - expect(report.pluginIssues).toHaveLength(1); - expect(report.pluginIssues[0].issue).toBe("hash-mismatch"); - expect(report.pluginIssues[0].toolId).toBe("claude"); - }); - }); - - describe("when all plugin files are present and correct", () => { - it("returns empty pluginIssues", async () => { - const manifest = makeManifest(EXPECTED_HASH); - const fs = makeFs(true, EXPECTED_HASH); - const useCase = makeDoctorUseCase(fs, manifest); - - const report = await useCase.execute({ projectRoot: "/proj" }); - - expect(report.pluginIssues).toHaveLength(0); - }); - }); - - describe("when pluginName filter is set", () => { - it("only checks the specified plugin", async () => { - const manifest = makeManifest(EXPECTED_HASH); - const fs = makeFs(false, EXPECTED_HASH); - const useCase = makeDoctorUseCase(fs, manifest); - - const report = await useCase.execute({ projectRoot: "/proj", pluginName: "other-plugin" }); - - expect(report.pluginIssues).toHaveLength(0); - }); - }); - - describe("when plugin is installed under user-scope (Cursor Mode B)", () => { - it("checks files under the resolved user-scope base dir, not projectRoot", async () => { - const manifest = Manifest.create(); - manifest.addTool("cursor", "1.0.0", []); - const cursorBaseDir = join(homedir(), ".cursor", "plugins", "local"); - const userScopeRelPath = "aidd-context/skills/06-discovery/SKILL.md"; - manifest.addPlugin( - "cursor", - Plugin.fromJSON({ - name: "aidd-context", - source: { kind: "local", path: "/some/path" }, - version: "1.0.0", - strict: false, - files: { [userScopeRelPath]: EXPECTED_HASH }, - }) - ); - const checkedPaths: string[] = []; - const fs: FileReader = { - fileExists: async (p: string) => { - checkedPaths.push(p); - return true; - }, - isExecutable: async () => false, - readFileHash: async () => new FileHash(EXPECTED_HASH), - readFile: async () => "", - listDirectory: async () => [], - listFilesRecursive: async () => [], - }; - const pluginUseCase = new DoctorPluginUseCase(new DetectPluginDriftUseCase(fs)); - - await pluginUseCase.execute({ - manifest, - projectRoot: "/proj", - allowedIds: null, - }); - - const expectedAbs = join(cursorBaseDir, userScopeRelPath); - expect(checkedPaths).toContain(expectedAbs); - expect(checkedPaths.every((p) => !p.startsWith("/proj/aidd-context"))).toBe(true); - }); - }); -}); diff --git a/cli/tests/application/use-cases/framework/default-plugin-catalog.unit.test.ts b/cli/tests/application/use-cases/framework/default-plugin-catalog.unit.test.ts deleted file mode 100644 index aaef4dc97..000000000 --- a/cli/tests/application/use-cases/framework/default-plugin-catalog.unit.test.ts +++ /dev/null @@ -1,254 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { - buildDefaultCatalogEntry, - buildDefaultMarketplace, - synthesizeDefaultPluginManifest, -} from "../../../../src/application/use-cases/framework/strategies/default-plugin-catalog.js"; -import type { PluginPresenceFlags } from "../../../../src/application/use-cases/framework/strategies/plugin-source-tree-reader.js"; - -const EMPTY_PRESENCE: PluginPresenceFlags = { - hasAgents: false, - agentsList: [], - skillsList: [], - hasHooksJson: false, - hasMcpJson: false, -}; - -const FULL_PRESENCE: PluginPresenceFlags = { - hasAgents: true, - agentsList: ["implementer.md", "planner.md", "reviewer.md"], - skillsList: ["commit", "plan"], - hasHooksJson: true, - hasMcpJson: true, -}; - -const BASE_SOURCE = { - name: "aidd-dev", - description: "AI Driven Dev plugin", - version: "1.2.3", - author: "Baptiste", - homepage: "https://example.com", - repository: "https://github.com/ai-driven-dev/aidd", - license: "MIT", - keywords: ["ai", "dev"], -}; - -describe("synthesizeDefaultPluginManifest", () => { - describe("passthrough fields", () => { - it("preserves name, description, version, author, homepage, repository, license, keywords", () => { - const result = synthesizeDefaultPluginManifest(BASE_SOURCE, EMPTY_PRESENCE, { - agentsField: true, - hooksField: true, - }); - expect(result.name).toBe("aidd-dev"); - expect(result.description).toBe("AI Driven Dev plugin"); - expect(result.version).toBe("1.2.3"); - expect(result.author).toBe("Baptiste"); - expect(result.homepage).toBe("https://example.com"); - expect(result.repository).toBe("https://github.com/ai-driven-dev/aidd"); - expect(result.license).toBe("MIT"); - expect(result.keywords).toEqual(["ai", "dev"]); - }); - - it("omits fields absent from source", () => { - const result = synthesizeDefaultPluginManifest({ name: "test" }, EMPTY_PRESENCE, { - agentsField: true, - hooksField: true, - }); - expect(result.description).toBeUndefined(); - expect(result.version).toBeUndefined(); - expect(result.author).toBeUndefined(); - }); - }); - - describe("agents field", () => { - it("includes agents as ./agents/*.md file paths when agentsField:true and agents present", () => { - const result = synthesizeDefaultPluginManifest(BASE_SOURCE, FULL_PRESENCE, { - agentsField: true, - hooksField: true, - }); - expect(result.agents).toEqual([ - "./agents/implementer.md", - "./agents/planner.md", - "./agents/reviewer.md", - ]); - }); - - it("omits agents when agentsField:true but no agents present", () => { - const result = synthesizeDefaultPluginManifest(BASE_SOURCE, EMPTY_PRESENCE, { - agentsField: true, - hooksField: true, - }); - expect(result.agents).toBeUndefined(); - }); - - it("omits agents when agentsField:false even if hasAgents:true", () => { - const result = synthesizeDefaultPluginManifest(BASE_SOURCE, FULL_PRESENCE, { - agentsField: false, - hooksField: true, - }); - expect(result.agents).toBeUndefined(); - }); - }); - - describe("conditional fields", () => { - it("includes skills array when skillsList is non-empty", () => { - const result = synthesizeDefaultPluginManifest(BASE_SOURCE, FULL_PRESENCE, { - agentsField: true, - hooksField: true, - }); - expect(result.skills).toEqual(["./skills/commit", "./skills/plan"]); - }); - - it("omits skills when skillsList is empty", () => { - const result = synthesizeDefaultPluginManifest(BASE_SOURCE, EMPTY_PRESENCE, { - agentsField: true, - hooksField: true, - }); - expect(result.skills).toBeUndefined(); - }); - - it("includes hooks when hasHooksJson:true", () => { - const result = synthesizeDefaultPluginManifest(BASE_SOURCE, FULL_PRESENCE, { - agentsField: true, - hooksField: true, - }); - expect(result.hooks).toBe("./hooks/hooks.json"); - }); - - it("omits hooks when hasHooksJson:false", () => { - const result = synthesizeDefaultPluginManifest(BASE_SOURCE, EMPTY_PRESENCE, { - agentsField: true, - hooksField: true, - }); - expect(result.hooks).toBeUndefined(); - }); - - it("includes mcpServers when hasMcpJson:true", () => { - const result = synthesizeDefaultPluginManifest(BASE_SOURCE, FULL_PRESENCE, { - agentsField: true, - hooksField: true, - }); - expect(result.mcpServers).toBe("./.mcp.json"); - }); - - it("omits mcpServers when hasMcpJson:false", () => { - const result = synthesizeDefaultPluginManifest(BASE_SOURCE, EMPTY_PRESENCE, { - agentsField: true, - hooksField: true, - }); - expect(result.mcpServers).toBeUndefined(); - }); - }); - - describe("manifestDir variants", () => { - it("accepts .cursor-plugin as manifestDir (field set unchanged)", () => { - const result = synthesizeDefaultPluginManifest(BASE_SOURCE, FULL_PRESENCE, { - agentsField: true, - hooksField: true, - }); - expect(result.agents).toEqual([ - "./agents/implementer.md", - "./agents/planner.md", - "./agents/reviewer.md", - ]); - expect(result.name).toBe("aidd-dev"); - }); - - it("accepts .plugin as manifestDir (field set unchanged)", () => { - const result = synthesizeDefaultPluginManifest(BASE_SOURCE, FULL_PRESENCE, { - agentsField: true, - hooksField: true, - }); - expect(result.agents).toEqual([ - "./agents/implementer.md", - "./agents/planner.md", - "./agents/reviewer.md", - ]); - }); - }); - - describe("key insertion order", () => { - it("emits keys in deterministic order: name, description, version, author, ..., agents, skills, hooks, mcpServers", () => { - const result = synthesizeDefaultPluginManifest(BASE_SOURCE, FULL_PRESENCE, { - agentsField: true, - hooksField: true, - }); - const keys = Object.keys(result); - const agentsIdx = keys.indexOf("agents"); - const skillsIdx = keys.indexOf("skills"); - const hooksIdx = keys.indexOf("hooks"); - const mcpIdx = keys.indexOf("mcpServers"); - expect(agentsIdx).toBeLessThan(skillsIdx); - expect(skillsIdx).toBeLessThan(hooksIdx); - expect(hooksIdx).toBeLessThan(mcpIdx); - expect(keys.indexOf("name")).toBe(0); - }); - }); -}); - -describe("buildDefaultMarketplace", () => { - const ENTRIES = [ - { name: "aidd-dev", source: "./plugins/aidd-dev", description: "Dev", version: "1.0.0" }, - ]; - - it("emits name, plugins as required fields", () => { - const result = buildDefaultMarketplace( - { name: "aidd-framework", owner: { name: "AIDD" } }, - ENTRIES - ); - expect(result.name).toBe("aidd-framework"); - expect(result.plugins).toEqual(ENTRIES); - }); - - it("includes version and description when present", () => { - const result = buildDefaultMarketplace( - { name: "aidd-fw", version: "2.0.0", description: "Full", owner: { name: "X" } }, - ENTRIES - ); - expect(result.version).toBe("2.0.0"); - expect(result.description).toBe("Full"); - }); - - it("omits version and description when absent", () => { - const result = buildDefaultMarketplace({ name: "fw", owner: { name: "X" } }, ENTRIES); - expect(result.version).toBeUndefined(); - expect(result.description).toBeUndefined(); - }); - - it("includes owner when present", () => { - const owner = { name: "AIDD" }; - const result = buildDefaultMarketplace({ name: "fw", owner }, ENTRIES); - expect(result.owner).toEqual(owner); - }); -}); - -describe("buildDefaultCatalogEntry", () => { - it("builds entry with name, source, description, version", () => { - const entry = buildDefaultCatalogEntry("aidd-dev", "AI Dev plugin", "1.0.0", undefined); - expect(entry.name).toBe("aidd-dev"); - expect(entry.source).toBe("./plugins/aidd-dev"); - expect(entry.description).toBe("AI Dev plugin"); - expect(entry.version).toBe("1.0.0"); - }); - - it("passes through strict and recommended when present", () => { - const entry = buildDefaultCatalogEntry("aidd-dev", "desc", "1.0.0", { - strict: true, - recommended: false, - }); - expect(entry.strict).toBe(true); - expect(entry.recommended).toBe(false); - }); - - it("omits strict and recommended when absent", () => { - const entry = buildDefaultCatalogEntry("aidd-dev", "desc", "1.0.0", undefined); - expect(entry.strict).toBeUndefined(); - expect(entry.recommended).toBeUndefined(); - }); - - it("only includes strict when it is boolean (not string/number)", () => { - const entry = buildDefaultCatalogEntry("aidd-dev", "desc", "1.0.0", { strict: true }); - expect(typeof entry.strict).toBe("boolean"); - }); -}); diff --git a/cli/tests/application/use-cases/framework/flat-build-strategy.integration.test.ts b/cli/tests/application/use-cases/framework/flat-build-strategy.integration.test.ts deleted file mode 100644 index b17756078..000000000 --- a/cli/tests/application/use-cases/framework/flat-build-strategy.integration.test.ts +++ /dev/null @@ -1,413 +0,0 @@ -import { resolve } from "node:path"; -import { beforeEach, describe, expect, it } from "vitest"; -import { FrameworkBuildUseCase } from "../../../../src/application/use-cases/framework/framework-build-use-case.js"; -import { FlatBuildStrategy } from "../../../../src/application/use-cases/framework/strategies/flat-build-strategy.js"; -import { - buildCopilotFlatContract, - buildOpencodeFlatContract, -} from "../../../../src/application/use-cases/framework/strategies/tool-contracts.js"; -import { - FlatTargetExistsError, - JsonSchemaValidationError, - OutDirNotDirectoryError, -} from "../../../../src/domain/errors.js"; -import type { AssetProvider } from "../../../../src/domain/ports/asset-provider.js"; -import type { JsonSchemaValidator } from "../../../../src/domain/ports/json-schema-validator.js"; -import { AjvSchemaValidatorAdapter } from "../../../../src/infrastructure/adapters/ajv-schema-validator-adapter.js"; -import { CapturingLogger } from "../../../helpers/ports/capturing-logger.js"; -import { InMemoryFileAdapter } from "../../../helpers/ports/in-memory-file-adapter.js"; -import { seedFromDirectory } from "../../../helpers/ports/seed-from-directory.js"; - -const FIXTURE_DIR = resolve(process.cwd(), "tests/fixtures/framework"); -// resolve(), not the bare literal: on Windows path.resolve treats a leading "/" as -// drive-relative and prepends the current drive, so production's own resolve(outDir) -// would otherwise write under a different key than this constant's raw string names. -const ABS_OUT = resolve("/tmp/aidd-flat-test"); -// FlatBuildStrategy embeds this into written JSON content ("/"-joined, forward-slash - see -// resolveClaudeRootAbsolute), never ABS_OUT's own native separators. -const ABS_OUT_IN_CONTENT = ABS_OUT.replace(/\\/g, "/"); -const PLUGIN = "aidd-test"; -// Avoid biome noTemplateCurlyInString: split literal for the placeholder. -const CLAUDE_ROOT_VAR = "$" + "{CLAUDE_PLUGIN_ROOT}"; - -const MINIMAL_MANIFEST_SCHEMA = { - type: "object", - required: ["name"], - properties: { name: { type: "string" } }, -}; - -const MINIMAL_MARKETPLACE_SCHEMA = { - type: "object", - required: ["name", "metadata", "owner", "plugins"], - properties: { - name: { type: "string" }, - metadata: { type: "object" }, - owner: { type: "object" }, - plugins: { type: "array" }, - }, -}; - -function makeValidator(fail = false): JsonSchemaValidator { - return { - validate(_schema: object, _data: unknown): void { - if (fail) throw new JsonSchemaValidationError(["schema validation failed"]); - }, - }; -} - -function makeAssetProvider(): AssetProvider { - return { - loadConfigAsset: (_toolId, fileName) => { - if (fileName === "opencode.json") { - return { - $schema: "https://opencode.ai/config.json", - instructions: [".opencode/rules/**/*.md"], - }; - } - throw new Error("not used"); - }, - loadDefaultMarketplace: () => { - throw new Error("not used"); - }, - loadSchema: (name) => { - if (name === "plugin-manifest") return MINIMAL_MANIFEST_SCHEMA; - if (name === "marketplace") return MINIMAL_MARKETPLACE_SCHEMA; - return {}; - }, - }; -} - -/** - * isDirectory probe for InMemoryFileAdapter: - * A path is a "directory" if it has no exact file entry but has child paths. - */ -function makeIsDirectory(fs: InMemoryFileAdapter): (path: string) => Promise { - // listUnder() normalizes path before comparing; a hand-rolled prefix scan here would - // compare a native-separator outDir against the adapter's "/"-only keys and never match - // on Windows, where production's resolve(outDir) is backslash-joined. - return async (path: string): Promise => { - if (fs.has(path)) return false; - return fs.listUnder(path).length > 0; - }; -} - -async function makeSeededFs(): Promise { - const memFs = new InMemoryFileAdapter(); - await seedFromDirectory(memFs, FIXTURE_DIR, { useAbsolutePaths: true }); - memFs.setFile(`${ABS_OUT}/.keep`, ""); - return memFs; -} - -function makeUseCase( - memFs: InMemoryFileAdapter, - force = false, - validator?: JsonSchemaValidator -): FrameworkBuildUseCase { - const v = validator ?? makeValidator(); - const ap = makeAssetProvider(); - const av = new AjvSchemaValidatorAdapter(); - const strategy = new FlatBuildStrategy( - memFs, - av, - ap, - buildCopilotFlatContract(), - force, - ABS_OUT, - makeIsDirectory(memFs) - ); - return new FrameworkBuildUseCase(memFs, v, ap, new CapturingLogger(), strategy); -} - -describe("FlatOutputStrategy integration", () => { - let memFs: InMemoryFileAdapter; - - beforeEach(async () => { - memFs = await makeSeededFs(); - }); - - describe("happy path", () => { - it("writes agent under .github/agents/-.agent.md (plugin-prefixed)", async () => { - const useCase = makeUseCase(memFs); - await useCase.execute({ sourceDir: FIXTURE_DIR, outDir: ABS_OUT, target: "copilot" }); - const agentPath = `${ABS_OUT}/.github/agents/${PLUGIN}-code-reviewer.agent.md`; - expect(memFs.has(agentPath)).toBe(true); - }); - - it("strips frontmatter to Copilot allowlist in agent file and uses plugin-prefixed name", async () => { - const useCase = makeUseCase(memFs); - await useCase.execute({ sourceDir: FIXTURE_DIR, outDir: ABS_OUT, target: "copilot" }); - const content = memFs.getFile(`${ABS_OUT}/.github/agents/${PLUGIN}-code-reviewer.agent.md`); - expect(content).toContain(`${PLUGIN}-code-reviewer`); - expect(content).toContain("description"); - }); - - it("writes skill files under .github/skills/-/ (plugin-prefixed)", async () => { - const useCase = makeUseCase(memFs); - await useCase.execute({ sourceDir: FIXTURE_DIR, outDir: ABS_OUT, target: "copilot" }); - const skillPath = `${ABS_OUT}/.github/skills/${PLUGIN}-commit/SKILL.md`; - expect(memFs.has(skillPath)).toBe(true); - }); - - it("rewrites @./ references in skill files", async () => { - const useCase = makeUseCase(memFs); - await useCase.execute({ sourceDir: FIXTURE_DIR, outDir: ABS_OUT, target: "copilot" }); - const content = memFs.getFile(`${ABS_OUT}/.github/skills/${PLUGIN}-hello.md`); - expect(content).toContain("[SKILL.md](./SKILL.md)"); - }); - - it("rewrites @CLAUDE_ROOT/skills/ in skill files to relative flat path", async () => { - const useCase = makeUseCase(memFs); - await useCase.execute({ sourceDir: FIXTURE_DIR, outDir: ABS_OUT, target: "copilot" }); - const content = memFs.getFile(`${ABS_OUT}/.github/skills/${PLUGIN}-hello.md`); - expect(content).not.toContain(`@${CLAUDE_ROOT_VAR}`); - }); - - it("writes per-plugin hooks file under .github/hooks/.hooks.json", async () => { - const useCase = makeUseCase(memFs); - await useCase.execute({ sourceDir: FIXTURE_DIR, outDir: ABS_OUT, target: "copilot" }); - const hooksPath = `${ABS_OUT}/.github/hooks/${PLUGIN}.hooks.json`; - expect(memFs.has(hooksPath)).toBe(true); - }); - - it("rewrites CLAUDE_ROOT/hooks/ in hooks JSON to per-plugin workspace-relative path", async () => { - const useCase = makeUseCase(memFs); - await useCase.execute({ sourceDir: FIXTURE_DIR, outDir: ABS_OUT, target: "copilot" }); - const content = memFs.getFile(`${ABS_OUT}/.github/hooks/${PLUGIN}.hooks.json`); - expect(content).not.toContain("CLAUDE_PLUGIN_ROOT"); - expect(content).toContain(`./.github/hooks/${PLUGIN}/check.sh`); - }); - - it("copies sibling hook scripts to .github/hooks// alongside the JSON", async () => { - const useCase = makeUseCase(memFs); - await useCase.execute({ sourceDir: FIXTURE_DIR, outDir: ABS_OUT, target: "copilot" }); - const scriptPath = `${ABS_OUT}/.github/hooks/${PLUGIN}/check.sh`; - expect(memFs.has(scriptPath)).toBe(true); - }); - - it("merges MCP servers into .vscode/mcp.json under servers key with plugin prefix", async () => { - const useCase = makeUseCase(memFs); - await useCase.execute({ sourceDir: FIXTURE_DIR, outDir: ABS_OUT, target: "copilot" }); - const mcpPath = `${ABS_OUT}/.vscode/mcp.json`; - expect(memFs.has(mcpPath)).toBe(true); - const raw = memFs.getFile(mcpPath) ?? ""; - const parsed = JSON.parse(raw) as { servers: Record }; - expect(parsed.servers).toHaveProperty(`${PLUGIN}-aidd-test-server`); - }); - - it("rewrites CLAUDE_ROOT in MCP to absolute path under absOut", async () => { - const useCase = makeUseCase(memFs); - await useCase.execute({ sourceDir: FIXTURE_DIR, outDir: ABS_OUT, target: "copilot" }); - const content = memFs.getFile(`${ABS_OUT}/.vscode/mcp.json`) ?? ""; - expect(content).not.toContain("CLAUDE_PLUGIN_ROOT"); - expect(content).toContain(ABS_OUT_IN_CONTENT); - }); - - it("does NOT write a marketplace.json", async () => { - const useCase = makeUseCase(memFs); - await useCase.execute({ sourceDir: FIXTURE_DIR, outDir: ABS_OUT, target: "copilot" }); - expect(memFs.has(`${ABS_OUT}/.plugin/marketplace.json`)).toBe(false); - expect(memFs.has(`${ABS_OUT}/.github/plugin/marketplace.json`)).toBe(false); - }); - }); - - describe("idempotency with --force", () => { - it("re-run with force produces byte-identical files", async () => { - const useCase1 = makeUseCase(memFs, false); - await useCase1.execute({ sourceDir: FIXTURE_DIR, outDir: ABS_OUT, target: "copilot" }); - const agentPath = `${ABS_OUT}/.github/agents/${PLUGIN}-code-reviewer.agent.md`; - const snapshot = memFs.getFile(agentPath); - - const useCase2 = makeUseCase(memFs, true); - await useCase2.execute({ sourceDir: FIXTURE_DIR, outDir: ABS_OUT, target: "copilot" }); - expect(memFs.getFile(agentPath)).toBe(snapshot); - }); - }); - - describe("collision detection without --force", () => { - it("halts with FlatTargetExistsError when agent file already exists", async () => { - const useCase1 = makeUseCase(memFs, false); - await useCase1.execute({ sourceDir: FIXTURE_DIR, outDir: ABS_OUT, target: "copilot" }); - - const useCase2 = makeUseCase(memFs, false); - await expect( - useCase2.execute({ sourceDir: FIXTURE_DIR, outDir: ABS_OUT, target: "copilot" }) - ).rejects.toBeInstanceOf(FlatTargetExistsError); - }); - }); - - describe("safety guards", () => { - it("throws OutDirNotDirectoryError when outDir does not exist", async () => { - const emptyFs = new InMemoryFileAdapter(); - await seedFromDirectory(emptyFs, FIXTURE_DIR, { useAbsolutePaths: true }); - const v = makeValidator(); - const ap = makeAssetProvider(); - const strategy = new FlatBuildStrategy( - emptyFs, - new AjvSchemaValidatorAdapter(), - ap, - buildCopilotFlatContract(), - false, - "/nonexistent", - makeIsDirectory(emptyFs) - ); - const useCase = new FrameworkBuildUseCase(emptyFs, v, ap, new CapturingLogger(), strategy); - await expect( - useCase.execute({ sourceDir: FIXTURE_DIR, outDir: "/nonexistent", target: "copilot" }) - ).rejects.toBeInstanceOf(OutDirNotDirectoryError); - }); - - it("throws OutDirNotDirectoryError when outDir is a file, not a directory", async () => { - const fileFs = new InMemoryFileAdapter(); - await seedFromDirectory(fileFs, FIXTURE_DIR, { useAbsolutePaths: true }); - fileFs.setFile(ABS_OUT, "I am a file, not a directory"); - const v2 = makeValidator(); - const ap2 = makeAssetProvider(); - const strategy = new FlatBuildStrategy( - fileFs, - new AjvSchemaValidatorAdapter(), - ap2, - buildCopilotFlatContract(), - false, - ABS_OUT, - makeIsDirectory(fileFs) - ); - const useCase = new FrameworkBuildUseCase(fileFs, v2, ap2, new CapturingLogger(), strategy); - await expect( - useCase.execute({ sourceDir: FIXTURE_DIR, outDir: ABS_OUT, target: "copilot" }) - ).rejects.toBeInstanceOf(OutDirNotDirectoryError); - }); - }); - - describe("invalid manifest", () => { - it("throws JsonSchemaValidationError for invalid plugin.json (orchestrator-side)", async () => { - const useCase = makeUseCase(memFs, false, makeValidator(true)); - await expect( - useCase.execute({ sourceDir: FIXTURE_DIR, outDir: ABS_OUT, target: "copilot" }) - ).rejects.toBeInstanceOf(JsonSchemaValidationError); - }); - }); - - describe("hooks path resolution for CLAUDE_ROOT/skills/", () => { - it("rewrites skills ref to ./.github/skills/- in hooks JSON (plugin-prefixed)", async () => { - const useCase = makeUseCase(memFs); - const hooksKey = `${FIXTURE_DIR}/plugins/${PLUGIN}/hooks/hooks.json`; - const skillsRef = `${CLAUDE_ROOT_VAR}/skills/commit/SKILL.md`; - memFs.setFile( - hooksKey, - JSON.stringify({ - hooks: { - PreToolUse: [{ hooks: [{ type: "command", command: skillsRef }] }], - }, - }) - ); - await useCase.execute({ sourceDir: FIXTURE_DIR, outDir: ABS_OUT, target: "copilot" }); - const content = memFs.getFile(`${ABS_OUT}/.github/hooks/${PLUGIN}.hooks.json`) ?? ""; - expect(content).toContain(`./.github/skills/${PLUGIN}-commit/SKILL.md`); - }); - }); - - describe("MCP path resolution for CLAUDE_ROOT", () => { - it("rewrites CLAUDE_ROOT/bin/server.js to absolute path under absOut", async () => { - const useCase = makeUseCase(memFs); - await useCase.execute({ sourceDir: FIXTURE_DIR, outDir: ABS_OUT, target: "copilot" }); - const content = memFs.getFile(`${ABS_OUT}/.vscode/mcp.json`) ?? ""; - expect(content).toContain(ABS_OUT_IN_CONTENT); - }); - }); - - describe("MCP key collision detection", () => { - it("throws FlatTargetExistsError when two writeMcp calls produce the same prefixed key", async () => { - const pluginSrc = `${FIXTURE_DIR}/plugins/${PLUGIN}`; - const strategy = new FlatBuildStrategy( - memFs, - new AjvSchemaValidatorAdapter(), - makeAssetProvider(), - buildCopilotFlatContract(), - false, - ABS_OUT, - makeIsDirectory(memFs) - ); - await strategy.writeMcp(PLUGIN, pluginSrc); - await expect(strategy.writeMcp(PLUGIN, pluginSrc)).rejects.toBeInstanceOf( - FlatTargetExistsError - ); - }); - }); - - describe("opencode.json config emission", () => { - function makeOpencodeUseCase(fs: InMemoryFileAdapter, force = false): FrameworkBuildUseCase { - const ap = makeAssetProvider(); - const strategy = new FlatBuildStrategy( - fs, - new AjvSchemaValidatorAdapter(), - ap, - buildOpencodeFlatContract(), - force, - ABS_OUT, - makeIsDirectory(fs) - ); - return new FrameworkBuildUseCase(fs, makeValidator(), ap, new CapturingLogger(), strategy); - } - - it("emits opencode.json with $schema + instructions and no mcp when no plugin ships MCP", async () => { - await memFs.deleteFile(`${FIXTURE_DIR}/plugins/${PLUGIN}/.mcp.json`); - await makeOpencodeUseCase(memFs).execute({ - sourceDir: FIXTURE_DIR, - outDir: ABS_OUT, - target: "opencode", - }); - const raw = memFs.getFile(`${ABS_OUT}/opencode.json`); - expect(raw, "opencode.json must be emitted even with zero MCP servers").toBeDefined(); - const config = JSON.parse(raw ?? "{}") as Record; - expect(config.$schema).toBe("https://opencode.ai/config.json"); - expect(config.instructions).toEqual([".opencode/rules/**/*.md"]); - expect(config).not.toHaveProperty("mcp"); - }); - - it("emits opencode.json with $schema + instructions + mcp when a plugin ships MCP", async () => { - await makeOpencodeUseCase(memFs).execute({ - sourceDir: FIXTURE_DIR, - outDir: ABS_OUT, - target: "opencode", - }); - const config = JSON.parse(memFs.getFile(`${ABS_OUT}/opencode.json`) ?? "{}") as { - $schema: string; - instructions: string[]; - mcp: Record; - }; - expect(config.$schema).toBe("https://opencode.ai/config.json"); - expect(config.instructions).toEqual([".opencode/rules/**/*.md"]); - expect(Object.keys(config.mcp).length).toBeGreaterThan(0); - }); - }); - - describe("AC #11: unsupported hooks warn-and-skip", () => { - it("warns and skips hooks for a hooks-bearing plugin when hooks is unsupported", async () => { - const captLogger = new CapturingLogger(); - // No shipped flat contract declares hooks unsupported any more (every tool's - // acceptsHooks is true) — this exercises writeHooks's own unsupported branch - // directly, on a contract built for that case rather than on any real tool's. - const base = buildOpencodeFlatContract(); - const strategy = new FlatBuildStrategy( - memFs, - new AjvSchemaValidatorAdapter(), - makeAssetProvider(), - { ...base, artifacts: { ...base.artifacts, hooks: { supported: false } } }, - false, - ABS_OUT, - makeIsDirectory(memFs), - captLogger - ); - const pluginSrc = `${FIXTURE_DIR}/plugins/${PLUGIN}`; - // plugin fixture has hooks — ensure hooks.json exists - memFs.setFile(`${FIXTURE_DIR}/plugins/${PLUGIN}/hooks/hooks.json`, '{"hooks":{}}'); - await strategy.writeHooks(PLUGIN, pluginSrc); - expect(captLogger.warnMessages.some((m) => m.includes("hooks"))).toBe(true); - // No hooks file emitted in the output - const hooksFiles = memFs - .listAll() - .filter((p) => p.startsWith(ABS_OUT) && p.includes("hooks")); - expect(hooksFiles).toHaveLength(0); - }); - }); -}); diff --git a/cli/tests/application/use-cases/helpers.ts b/cli/tests/application/use-cases/helpers.ts deleted file mode 100644 index 337ebf63a..000000000 --- a/cli/tests/application/use-cases/helpers.ts +++ /dev/null @@ -1,314 +0,0 @@ -import { mkdir, mkdtemp, rm } from "node:fs/promises"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; -import "../../../src/domain/tools/ai/claude.js"; -import "../../../src/domain/tools/ai/codex.js"; -import "../../../src/domain/tools/ai/copilot.js"; -import "../../../src/domain/tools/ai/cursor.js"; -import "../../../src/domain/tools/ai/opencode.js"; -import "../../../src/domain/tools/ide/vscode.js"; -import { CLIOutput } from "../../../src/application/output.js"; -import { InitUseCase } from "../../../src/application/use-cases/init-use-case.js"; -import { InstallIdeConfigUseCase } from "../../../src/application/use-cases/install/install-ide-config-use-case.js"; -import { InstallRuntimeConfigUseCase } from "../../../src/application/use-cases/install/install-runtime-config-use-case.js"; -import { GitignoreUseCase } from "../../../src/application/use-cases/shared/gitignore-use-case.js"; -import { PostInstallPipelineUseCase } from "../../../src/application/use-cases/shared/post-install-pipeline-use-case.js"; -import { Manifest } from "../../../src/domain/models/manifest.js"; -import type { Platform } from "../../../src/domain/ports/platform.js"; -import type { Prompter } from "../../../src/domain/ports/prompter.js"; -import type { VersionControl } from "../../../src/domain/ports/version-control.js"; -import type { VersionReader } from "../../../src/domain/ports/version-reader.js"; -import { isIdeToolId, type ToolId } from "../../../src/domain/tools/registry.js"; -import { CurrentVersionAdapter } from "../../../src/infrastructure/adapters/current-version-adapter.js"; -import { FileAdapter } from "../../../src/infrastructure/adapters/file-adapter.js"; -import { HasherAdapter } from "../../../src/infrastructure/adapters/hasher-adapter.js"; -import { ManifestRepositoryAdapter } from "../../../src/infrastructure/adapters/manifest-repository-adapter.js"; -import { PluginCatalogRepositoryAdapter } from "../../../src/infrastructure/adapters/plugin-catalog-repository-adapter.js"; -import { PluginDistributionReaderAdapter } from "../../../src/infrastructure/adapters/plugin-distribution-reader-adapter.js"; -import { PluginFetcherAdapter } from "../../../src/infrastructure/adapters/plugin-fetcher-adapter.js"; -import { SilentPrompterAdapter } from "../../../src/infrastructure/adapters/prompter-adapter.js"; -import { BundledAssetProviderAdapter } from "../../../src/infrastructure/assets/asset-loader.js"; - -export const linuxPlatform: Platform = { current: () => "linux" }; -export const win32Platform: Platform = { current: () => "win32" }; -export const noGit: VersionControl = { - installCommitMessageDelegate: async () => false, - removeCommitMessageDelegate: async () => false, - getRemoteUrl: async () => null, - listTrackedFiles: async () => [], - isRepository: async () => false, - hasHistoryFor: async () => false, - readCommitTrailerSetup: async () => ({ - delegate: "absent", - callSite: "no-hook-file", - hookHasOtherContent: false, - }), -}; - -export { SilentPrompterAdapter as OverwritePrompter }; - -export class KeepPrompter implements Prompter { - async resolveConflict( - _relativePath: string, - _reason: "deleted" | "modified" - ): Promise<"keep" | "overwrite"> { - return "keep"; - } - - async resolveConflictBulk( - _relativePath: string, - _reason: "deleted" | "modified" - ): Promise<"keep" | "overwrite" | "overwrite-all" | "skip-all"> { - return "keep"; - } - - async confirm(_message: string): Promise { - return true; - } - - async input(_message: string, defaultValue?: string): Promise { - return defaultValue ?? ""; - } - - async select( - _message: string, - choices: Array<{ name: string; value: T; disabled?: boolean }> - ): Promise { - const first = choices.find((c) => !c.disabled); - if (first === undefined) { - throw new Error("No enabled choices available"); - } - return first.value; - } - - async checkbox( - _message: string, - choices: Array<{ name: string; value: T; checked?: boolean; disabled?: boolean | string }> - ): Promise { - return choices.filter((c) => c.checked === true && !c.disabled).map((c) => c.value); - } -} - -abstract class QueuedSelectPrompter implements Prompter { - private readonly selectQueue: string[]; - private selectIdx = 0; - - constructor(selectQueue: string[]) { - this.selectQueue = selectQueue; - } - - abstract resolveConflict( - relativePath: string, - reason: "deleted" | "modified" - ): Promise<"keep" | "overwrite">; - - async resolveConflictBulk( - _relativePath: string, - _reason: "deleted" | "modified" - ): Promise<"keep" | "overwrite" | "overwrite-all" | "skip-all"> { - return "overwrite"; - } - - async confirm(_message: string): Promise { - return true; - } - - async input(_message: string, defaultValue?: string): Promise { - return defaultValue ?? ""; - } - - async select( - _message: string, - choices: Array<{ name: string; value: T; disabled?: boolean }> - ): Promise { - const response = - this.selectQueue[this.selectIdx] ?? this.selectQueue[this.selectQueue.length - 1]; - this.selectIdx++; - const match = choices.find((c) => !c.disabled && String(c.value) === response); - if (match === undefined) - throw new Error(`${this.constructor.name}: no match for "${response}" in choices`); - return match.value; - } - - async checkbox( - _message: string, - choices: Array<{ name: string; value: T; checked?: boolean; disabled?: boolean | string }> - ): Promise { - return choices.filter((c) => c.checked === true && !c.disabled).map((c) => c.value); - } -} - -export class SkipPrompter extends QueuedSelectPrompter { - constructor() { - super(["global", "skip all"]); - } - - async resolveConflict( - _relativePath: string, - _reason: "deleted" | "modified" - ): Promise<"keep" | "overwrite"> { - return "keep"; - } -} - -export class BackupPrompter extends QueuedSelectPrompter { - constructor() { - super(["global", "backup all"]); - } - - async resolveConflict( - _relativePath: string, - _reason: "deleted" | "modified" - ): Promise<"keep" | "overwrite"> { - return "overwrite"; - } -} - -export class RecordingPrompter implements Prompter { - readonly calls: Array<{ relativePath: string; reason: "deleted" | "modified" }> = []; - private readonly response: "keep" | "overwrite"; - - constructor(response: "keep" | "overwrite" = "overwrite") { - this.response = response; - } - - async resolveConflict( - relativePath: string, - reason: "deleted" | "modified" - ): Promise<"keep" | "overwrite"> { - this.calls.push({ relativePath, reason }); - return this.response; - } - - async resolveConflictBulk( - _relativePath: string, - _reason: "deleted" | "modified" - ): Promise<"keep" | "overwrite" | "overwrite-all" | "skip-all"> { - return this.response; - } - - async confirm(_message: string): Promise { - return true; - } - - async input(_message: string, defaultValue?: string): Promise { - return defaultValue ?? ""; - } - - async select( - _message: string, - choices: Array<{ name: string; value: T; disabled?: boolean }> - ): Promise { - const first = choices.find((c) => !c.disabled); - if (first === undefined) { - throw new Error("No enabled choices available"); - } - return first.value; - } - - async checkbox( - _message: string, - choices: Array<{ name: string; value: T; checked?: boolean; disabled?: boolean | string }> - ): Promise { - return choices.filter((c) => c.checked === true && !c.disabled).map((c) => c.value); - } -} - -export const FIXTURE_DIR = join(process.cwd(), "tests/fixtures/framework"); -export const FIXTURE_DIR_V2 = join(process.cwd(), "tests/fixtures/framework-v2"); - -export function buildDeps(projectRoot: string) { - const hasher = new HasherAdapter(); - const fs = new FileAdapter(hasher); - const manifestRepo = new ManifestRepositoryAdapter(projectRoot); - const logger = new CLIOutput(false); - const assetProvider = new BundledAssetProviderAdapter(); - const pluginFetcher = new PluginFetcherAdapter(fs); - const pluginDistributionReader = new PluginDistributionReaderAdapter(fs); - const pluginCatalogRepository = new PluginCatalogRepositoryAdapter(fs); - const gitignoreUseCase = new GitignoreUseCase(fs); - const postInstallPipelineUseCase = new PostInstallPipelineUseCase(manifestRepo, gitignoreUseCase); - const installRuntimeConfigUseCase = new InstallRuntimeConfigUseCase( - fs, - hasher, - logger, - assetProvider, - postInstallPipelineUseCase - ); - const installIdeConfigUseCase = new InstallIdeConfigUseCase( - fs, - hasher, - logger, - assetProvider, - postInstallPipelineUseCase - ); - const currentVersionProvider: VersionReader = new CurrentVersionAdapter(); - return { - hasher, - fs, - manifestRepo, - logger, - assetProvider, - pluginFetcher, - pluginDistributionReader, - pluginCatalogRepository, - installRuntimeConfigUseCase, - installIdeConfigUseCase, - currentVersionProvider, - }; -} - -export async function createTempProject(): Promise<{ tempDir: string; projectRoot: string }> { - const tempDir = await mkdtemp(join(tmpdir(), "aidd-test-")); - const projectRoot = join(tempDir, "project"); - await mkdir(projectRoot, { recursive: true }); - return { tempDir, projectRoot }; -} - -export async function cleanupTempProject(tempDir: string): Promise { - await rm(tempDir, { recursive: true, force: true }); -} - -export async function initProject( - deps: ReturnType, - projectRoot: string -): Promise { - const initUseCase = new InitUseCase(deps.fs, deps.manifestRepo); - await initUseCase.execute({ - projectRoot, - }); -} - -export async function installTool( - deps: ReturnType, - projectRoot: string, - toolId: ToolId -) { - const manifest = (await deps.manifestRepo.load()) ?? Manifest.create(); - const version = "test"; - if (isIdeToolId(toolId)) { - return deps.installIdeConfigUseCase.execute({ - toolId, - projectRoot, - manifest, - force: false, - version, - }); - } - return deps.installRuntimeConfigUseCase.execute({ - toolId, - projectRoot, - manifest, - force: false, - version, - }); -} - -export async function initAndInstall( - deps: ReturnType, - projectRoot: string, - toolId: ToolId -) { - await initProject(deps, projectRoot); - return installTool(deps, projectRoot, toolId); -} diff --git a/cli/tests/application/use-cases/interactive-menu-use-case.unit.test.ts b/cli/tests/application/use-cases/interactive-menu-use-case.unit.test.ts deleted file mode 100644 index 952469a68..000000000 --- a/cli/tests/application/use-cases/interactive-menu-use-case.unit.test.ts +++ /dev/null @@ -1,200 +0,0 @@ -import { describe, expect, it, vi } from "vitest"; -import { InteractiveMenuUseCase } from "../../../src/application/use-cases/menu-use-case.js"; -import type { Prompter } from "../../../src/domain/ports/prompter.js"; -import { buildUnitDeps, initProject } from "../../helpers/ports/build-unit-deps.js"; - -const PROJECT_ROOT = "/test-project"; - -type SelectChoice = { name: string; value: string }; - -function makeQueuedPrompter( - selectResponses: string[], - inputResponses: string[] = [] -): { prompter: Prompter; selectMock: ReturnType } { - let selectIdx = 0; - let inputIdx = 0; - const selectMock = vi.fn().mockImplementation((_msg: string, choices: SelectChoice[]) => { - const val = selectResponses[selectIdx++]; - const match = choices.find((c) => c.value === val); - if (!match) throw new Error(`No choice with value "${val}"`); - return Promise.resolve(match.value); - }); - const inputMock = vi.fn().mockImplementation(() => { - return Promise.resolve(inputResponses[inputIdx++] ?? ""); - }); - const prompter: Prompter = { - resolveConflict: vi.fn(), - resolveConflictBulk: vi.fn(), - confirm: vi.fn(), - input: inputMock, - select: selectMock, - checkbox: vi.fn(), - }; - return { prompter, selectMock }; -} - -describe("interactive menu", () => { - describe("project without AIDD installed", () => { - it("prompts to run setup when no manifest exists and user confirms", async () => { - const deps = await buildUnitDeps(PROJECT_ROOT); - const confirmMock = vi.fn().mockResolvedValue(true); - const prompter: Prompter = { - resolveConflict: vi.fn(), - resolveConflictBulk: vi.fn(), - confirm: confirmMock, - input: vi.fn(), - select: vi.fn(), - checkbox: vi.fn(), - }; - - const result = await new InteractiveMenuUseCase(deps.manifestRepo, prompter).execute(); - - expect(result.command).toEqual(["setup"]); - expect(confirmMock).toHaveBeenCalledWith("AIDD not initialized. Run setup now?", true); - }); - - it("exits when no manifest exists and user declines setup", async () => { - const deps = await buildUnitDeps(PROJECT_ROOT); - const prompter: Prompter = { - resolveConflict: vi.fn(), - resolveConflictBulk: vi.fn(), - confirm: vi.fn().mockResolvedValue(false), - input: vi.fn(), - select: vi.fn(), - checkbox: vi.fn(), - }; - - const result = await new InteractiveMenuUseCase(deps.manifestRepo, prompter).execute(); - - expect(result.command).toEqual(["exit"]); - }); - - it("does not show the full menu before installation", async () => { - const deps = await buildUnitDeps(PROJECT_ROOT); - const selectMock = vi.fn(); - const prompter: Prompter = { - resolveConflict: vi.fn(), - resolveConflictBulk: vi.fn(), - confirm: vi.fn().mockResolvedValue(false), - input: vi.fn(), - select: selectMock, - checkbox: vi.fn(), - }; - await new InteractiveMenuUseCase(deps.manifestRepo, prompter).execute(); - expect(selectMock).not.toHaveBeenCalled(); - }); - }); - - describe("project with AIDD installed", () => { - it("groups commands by usage area", async () => { - const deps = await buildUnitDeps(PROJECT_ROOT); - await initProject(deps, PROJECT_ROOT); - const { prompter, selectMock } = makeQueuedPrompter(["exit"]); - - await new InteractiveMenuUseCase(deps.manifestRepo, prompter).execute(); - - const values = (selectMock.mock.calls[0][1] as SelectChoice[]).map((c) => c.value); - expect(values).toContain("inspect"); - expect(values).toContain("manage-ai"); - expect(values).toContain("manage-ide"); - expect(values).toContain("manage-plugins"); - expect(values).toContain("marketplaces"); - expect(values).toContain("maintain"); - expect(values).toContain("system"); - expect(values).toContain("exit"); - }); - - it("each group has a description to guide the user", async () => { - const deps = await buildUnitDeps(PROJECT_ROOT); - await initProject(deps, PROJECT_ROOT); - const { prompter, selectMock } = makeQueuedPrompter(["exit"]); - - await new InteractiveMenuUseCase(deps.manifestRepo, prompter).execute(); - - const choices = selectMock.mock.calls[0][1] as Array<{ value: string; description?: string }>; - const groupsWithDescription = choices.filter((c) => c.value !== "exit" && c.description); - expect(groupsWithDescription.length).toBe(7); - }); - - it("status is reachable from the inspect group", async () => { - const deps = await buildUnitDeps(PROJECT_ROOT); - await initProject(deps, PROJECT_ROOT); - const { prompter } = makeQueuedPrompter(["inspect", "status"]); - const result = await new InteractiveMenuUseCase(deps.manifestRepo, prompter).execute(); - expect(result.command).toEqual(["status"]); - }); - - it("ai install is reachable from the manage-ai group", async () => { - const deps = await buildUnitDeps(PROJECT_ROOT); - await initProject(deps, PROJECT_ROOT); - const { prompter } = makeQueuedPrompter(["manage-ai", "ai-install"], ["claude"]); - const result = await new InteractiveMenuUseCase(deps.manifestRepo, prompter).execute(); - expect(result.command).toEqual(["ai", "install", "claude"]); - }); - - it("update-all is reachable from the maintain group", async () => { - const deps = await buildUnitDeps(PROJECT_ROOT); - await initProject(deps, PROJECT_ROOT); - const { prompter } = makeQueuedPrompter(["maintain", "update-all"]); - const result = await new InteractiveMenuUseCase(deps.manifestRepo, prompter).execute(); - expect(result.command).toEqual(["update"]); - }); - - it("self-update is reachable from the system group", async () => { - const deps = await buildUnitDeps(PROJECT_ROOT); - await initProject(deps, PROJECT_ROOT); - const { prompter } = makeQueuedPrompter(["system", "self-update"]); - const result = await new InteractiveMenuUseCase(deps.manifestRepo, prompter).execute(); - expect(result.command).toEqual(["self-update"]); - }); - - it("exit is available directly from a group submenu", async () => { - const deps = await buildUnitDeps(PROJECT_ROOT); - await initProject(deps, PROJECT_ROOT); - const { prompter, selectMock } = makeQueuedPrompter(["inspect", "exit"]); - const result = await new InteractiveMenuUseCase(deps.manifestRepo, prompter).execute(); - expect(result.command).toEqual(["exit"]); - expect(selectMock).toHaveBeenCalledTimes(2); - }); - - it("going back from a group returns to the main menu", async () => { - const deps = await buildUnitDeps(PROJECT_ROOT); - await initProject(deps, PROJECT_ROOT); - const { prompter, selectMock } = makeQueuedPrompter(["inspect", "back", "exit"]); - const result = await new InteractiveMenuUseCase(deps.manifestRepo, prompter).execute(); - expect(result.command).toEqual(["exit"]); - expect(selectMock).toHaveBeenCalledTimes(3); - }); - - it("internal commands adopt and init are never exposed", async () => { - const deps = await buildUnitDeps(PROJECT_ROOT); - await initProject(deps, PROJECT_ROOT); - const allValues: string[] = []; - const selectMock = vi.fn().mockImplementation((_msg: string, choices: SelectChoice[]) => { - allValues.push(...choices.map((c) => c.value)); - const first = choices.find((c) => c.value !== "exit" && c.value !== "back"); - return Promise.resolve(first?.value ?? "exit"); - }); - const prompter: Prompter = { - resolveConflict: vi.fn(), - resolveConflictBulk: vi.fn(), - confirm: vi.fn(), - input: vi.fn().mockResolvedValue(""), - select: selectMock, - checkbox: vi.fn(), - }; - await new InteractiveMenuUseCase(deps.manifestRepo, prompter).execute(); - expect(allValues).not.toContain("adopt"); - expect(allValues).not.toContain("init"); - }); - - it("always returns to root after a command (no breadcrumb saved)", async () => { - const deps = await buildUnitDeps(PROJECT_ROOT); - await initProject(deps, PROJECT_ROOT); - const { prompter } = makeQueuedPrompter(["inspect", "status"]); - const result = await new InteractiveMenuUseCase(deps.manifestRepo, prompter).execute(); - expect(result.command).toEqual(["status"]); - expect("returnTo" in result).toBe(false); - }); - }); -}); diff --git a/cli/tests/application/use-cases/list-installed-rules-use-case.unit.test.ts b/cli/tests/application/use-cases/list-installed-rules-use-case.unit.test.ts deleted file mode 100644 index 6ac1212ff..000000000 --- a/cli/tests/application/use-cases/list-installed-rules-use-case.unit.test.ts +++ /dev/null @@ -1,115 +0,0 @@ -import { join } from "node:path"; -import { describe, expect, it } from "vitest"; -// Side-effect imports: this use case asks the registry which tools have rules at all, so a -// tool that never registered is a tool it silently cannot see. -import "../../../src/domain/tools/ai/claude.js"; -import "../../../src/domain/tools/ai/codex.js"; -import "../../../src/domain/tools/ai/copilot.js"; -import "../../../src/domain/tools/ai/cursor.js"; -import "../../../src/domain/tools/ai/opencode.js"; -import { ListInstalledRulesUseCase } from "../../../src/application/use-cases/list-installed-rules-use-case.js"; -import type { FileReader } from "../../../src/domain/ports/file-reader.js"; - -const ROOT = "/project"; - -/** A reader answering from a map of paths to content. - * - * Every member of the port is implemented, and the four this use case never calls reject - * rather than return a placeholder: a stub answering `""` for a file nobody asked it about - * would let a use case start reading through the wrong member and still look green. A - * missing directory needs no branch — the real adapter answers an empty list for one it - * cannot read, and so does this. - */ -function readerOf(files: Readonly>): FileReader { - const unused = (member: string) => (): never => { - throw new Error(`this use case does not call ${member}`); - }; - return { - listFilesRecursive: async (dir: string) => - Object.keys(files).filter((path) => path.startsWith(dir.replaceAll("\\", "/"))), - readFile: async (path: string) => files[path.replaceAll("\\", "/")] ?? "", - listDirectory: unused("listDirectory"), - fileExists: unused("fileExists"), - readFileHash: unused("readFileHash"), - isExecutable: unused("isExecutable"), - }; -} - -const at = (relative: string) => join(ROOT, relative).replaceAll("\\", "/"); - -describe("ListInstalledRulesUseCase — every tool's installed rules, in one answer", () => { - it("finds a rule under each tool's own installed directory", async () => { - const useCase = new ListInstalledRulesUseCase( - readerOf({ - [at(".claude/rules/01-standards/1-naming.md")]: "---\ndescription: Names\n---\n", - [at(".cursor/rules/1-naming.mdc")]: "---\n---\n", - [at(".github/instructions/01-naming.instructions.md")]: "---\n---\n", - [at(".codex/rules/1-naming.md")]: "---\n---\n", - [at(".opencode/rules/1-naming.md")]: "---\n---\n", - }) - ); - - const { rules } = await useCase.execute({ projectRoot: ROOT }); - - expect(rules.map((rule) => rule.tool).sort()).toEqual([ - "claude", - "codex", - "copilot", - "cursor", - "opencode", - ]); - }); - - // The plugin script this replaced knew four directories and stated "Codex CLI: rules not - // supported, skipped". `plugin-content-translator.ts` installs a plugin's `rules/` into - // every tool whose capability accepts them, Codex included, so that answer was wrong and - // silently so: a Codex project asking what rules it had was told none. - it("answers for Codex, which the script it replaces skipped outright", async () => { - const useCase = new ListInstalledRulesUseCase( - readerOf({ [at(".codex/rules/1-naming.md")]: "---\ndescription: Names\n---\n" }) - ); - - const { rules } = await useCase.execute({ projectRoot: ROOT }); - - expect(rules).toEqual([ - { - tool: "codex", - path: ".codex/rules/1-naming.md", - name: "1-naming", - description: "Names", - }, - ]); - }); - - it("reports a path relative to the project, never the machine it ran on", async () => { - const useCase = new ListInstalledRulesUseCase( - readerOf({ [at(".claude/rules/deep/nested/1-naming.md")]: "---\n---\n" }) - ); - - const { rules } = await useCase.execute({ projectRoot: ROOT }); - - expect(rules[0]?.path).toBe(".claude/rules/deep/nested/1-naming.md"); - }); - - // A tool's rules directory holds what that tool installs there and nothing else says it - // is a rule. The extension is the only thing separating a Cursor rule from a stray file - // beside it, and it comes from the installer, never from a list written here. - it("passes over a file whose extension is not the one that tool installs", async () => { - const useCase = new ListInstalledRulesUseCase( - readerOf({ - [at(".cursor/rules/1-naming.mdc")]: "---\n---\n", - [at(".cursor/rules/README.md")]: "---\n---\n", - }) - ); - - const { rules } = await useCase.execute({ projectRoot: ROOT }); - - expect(rules.map((rule) => rule.path)).toEqual([".cursor/rules/1-naming.mdc"]); - }); - - it("answers an empty list, never an error, for a project holding no rule at all", async () => { - const useCase = new ListInstalledRulesUseCase(readerOf({})); - - await expect(useCase.execute({ projectRoot: ROOT })).resolves.toEqual({ rules: [] }); - }); -}); diff --git a/cli/tests/application/use-cases/marketplace/marketplace-check-use-case.unit.test.ts b/cli/tests/application/use-cases/marketplace/marketplace-check-use-case.unit.test.ts deleted file mode 100644 index 24f5aa4f5..000000000 --- a/cli/tests/application/use-cases/marketplace/marketplace-check-use-case.unit.test.ts +++ /dev/null @@ -1,143 +0,0 @@ -import { join } from "node:path"; -import { describe, expect, it } from "vitest"; -import "../../../../src/domain/tools/ai/claude.js"; -import { MarketplaceCheckUseCase } from "../../../../src/application/use-cases/marketplace/marketplace-check-use-case.js"; -import { FetchMarketplaceSourceUseCase } from "../../../../src/application/use-cases/shared/fetch-marketplace-source-use-case.js"; -import { ResolveMarketplaceUseCase } from "../../../../src/application/use-cases/shared/resolve-marketplace-use-case.js"; -import { Manifest } from "../../../../src/domain/models/manifest.js"; -import { Marketplace } from "../../../../src/domain/models/marketplace.js"; -import { Plugin } from "../../../../src/domain/models/plugin.js"; -import { PluginCatalogRepositoryAdapter } from "../../../../src/infrastructure/adapters/plugin-catalog-repository-adapter.js"; -import { DeterministicHasher } from "../../../helpers/ports/deterministic-hasher.js"; -import { FixturePluginFetcher } from "../../../helpers/ports/fixture-plugin-fetcher.js"; -import { InMemoryFileAdapter } from "../../../helpers/ports/in-memory-file-adapter.js"; -import { InMemoryManifestRepository } from "../../../helpers/ports/in-memory-manifest-repository.js"; -import { InMemoryMarketplaceRegistry } from "../../../helpers/ports/in-memory-marketplace-registry.js"; -import { seedFromDirectory } from "../../../helpers/ports/seed-from-directory.js"; - -const VALID_FIXTURE = join(process.cwd(), "tests/fixtures/framework/marketplace-sample"); -const PROJECT_ROOT = "/test-project"; - -async function buildUseCase() { - const hasher = new DeterministicHasher(); - const fs = new InMemoryFileAdapter({}, hasher); - await seedFromDirectory(fs, VALID_FIXTURE, { useAbsolutePaths: true }); - const registry = new InMemoryMarketplaceRegistry(); - const manifestRepo = new InMemoryManifestRepository(); - const fetchMarketplaceSource = new FetchMarketplaceSourceUseCase(new FixturePluginFetcher()); - const resolveMarketplace = new ResolveMarketplaceUseCase( - fetchMarketplaceSource, - new PluginCatalogRepositoryAdapter(fs) - ); - const useCase = new MarketplaceCheckUseCase(manifestRepo, registry, resolveMarketplace); - return { useCase, registry, manifestRepo }; -} - -describe("MarketplaceCheckUseCase", () => { - it("flags entries with no lastFetched as stale", async () => { - const { useCase, registry } = await buildUseCase(); - await registry.save( - PROJECT_ROOT, - Marketplace.create({ - name: "awesome", - source: { kind: "local", path: VALID_FIXTURE }, - scope: "project", - addedAt: "2026-04-29T10:00:00.000Z", - }) - ); - - const result = await useCase.execute({ projectRoot: PROJECT_ROOT }); - - expect(result.stale.map((m) => m.name)).toEqual(["awesome"]); - }); - - it("does not flag entries fetched within the window", async () => { - const { useCase, registry } = await buildUseCase(); - await registry.save( - PROJECT_ROOT, - Marketplace.create({ - name: "fresh", - source: { kind: "local", path: VALID_FIXTURE }, - scope: "project", - addedAt: "2026-04-29T10:00:00.000Z", - }) - ); - await registry.updateLastFetched(PROJECT_ROOT, "fresh", "project", new Date().toISOString()); - - const result = await useCase.execute({ projectRoot: PROJECT_ROOT }); - - expect(result.stale).toEqual([]); - }); - - it("reports upstream-removed plugins", async () => { - const { useCase, registry, manifestRepo } = await buildUseCase(); - const manifest = Manifest.create(); - manifest.addTool("claude", "1.0.0", []); - manifest.addPlugin( - "claude", - Plugin.fromJSON({ - name: "ghost-plugin", - source: { kind: "github", repo: "owner/ghost" }, - version: "1.0.0", - strict: false, - files: {}, - marketplace: "awesome", - }) - ); - await manifestRepo.save(manifest); - await registry.save( - PROJECT_ROOT, - Marketplace.create({ - name: "awesome", - source: { kind: "local", path: VALID_FIXTURE }, - scope: "project", - addedAt: "2026-04-29T10:00:00.000Z", - }) - ); - - const result = await useCase.execute({ projectRoot: PROJECT_ROOT }); - - expect(result.upstreamRemoved).toContainEqual({ - marketplace: "awesome", - plugin: "ghost-plugin", - toolId: "claude", - }); - }); - - it("neither skips nor reports upstream-removed when the catalog is missing (no error)", async () => { - const { useCase, registry } = await buildUseCase(); - await registry.save( - PROJECT_ROOT, - Marketplace.create({ - name: "empty", - source: { kind: "local", path: "/nonexistent-marketplace-dir" }, - scope: "project", - addedAt: "2026-04-29T10:00:00.000Z", - }) - ); - - const result = await useCase.execute({ projectRoot: PROJECT_ROOT }); - - expect(result.skipped).toEqual([]); - expect(result.upstreamRemoved).toEqual([]); - }); - - it("reports the marketplace as skipped when the catalog fetch throws", async () => { - const { useCase, registry } = await buildUseCase(); - await registry.save( - PROJECT_ROOT, - Marketplace.create({ - name: "unreachable", - source: { kind: "github", repo: "nonexistent/repo-12345" }, - scope: "project", - addedAt: "2026-04-29T10:00:00.000Z", - }) - ); - - const result = await useCase.execute({ projectRoot: PROJECT_ROOT }); - - expect(result.skipped).toHaveLength(1); - expect(result.skipped[0]?.marketplace).toBe("unreachable"); - expect(result.skipped[0]?.error).toBeDefined(); - }); -}); diff --git a/cli/tests/application/use-cases/marketplace/marketplace-remove-use-case.unit.test.ts b/cli/tests/application/use-cases/marketplace/marketplace-remove-use-case.unit.test.ts deleted file mode 100644 index f3adc544e..000000000 --- a/cli/tests/application/use-cases/marketplace/marketplace-remove-use-case.unit.test.ts +++ /dev/null @@ -1,95 +0,0 @@ -import { join } from "node:path"; -import { describe, expect, it } from "vitest"; -import "../../../../src/domain/tools/ai/claude.js"; -import { MarketplaceRemoveUseCase } from "../../../../src/application/use-cases/marketplace/marketplace-remove-use-case.js"; -import { MarketplaceNotFoundError } from "../../../../src/domain/errors.js"; -import { Manifest } from "../../../../src/domain/models/manifest.js"; -import { Marketplace } from "../../../../src/domain/models/marketplace.js"; -import { Plugin } from "../../../../src/domain/models/plugin.js"; -import { DeterministicHasher } from "../../../helpers/ports/deterministic-hasher.js"; -import { InMemoryFileAdapter } from "../../../helpers/ports/in-memory-file-adapter.js"; -import { InMemoryManifestRepository } from "../../../helpers/ports/in-memory-manifest-repository.js"; -import { InMemoryMarketplaceRegistry } from "../../../helpers/ports/in-memory-marketplace-registry.js"; -import { KeepPrompter } from "../../../helpers/ports/scripted-prompter.js"; - -const PROJECT_ROOT = "/test-project"; - -function buildUseCase() { - const hasher = new DeterministicHasher(); - const fs = new InMemoryFileAdapter({}, hasher); - const manifestRepo = new InMemoryManifestRepository(); - const registry = new InMemoryMarketplaceRegistry(); - const useCase = new MarketplaceRemoveUseCase(fs, manifestRepo, registry, new KeepPrompter()); - return { useCase, registry, manifestRepo, fs }; -} - -describe("MarketplaceRemoveUseCase", () => { - it("throws MarketplaceNotFoundError when entry does not exist", async () => { - const { useCase } = buildUseCase(); - await expect( - useCase.execute({ name: "missing", projectRoot: PROJECT_ROOT, autoConfirm: true }) - ).rejects.toThrow(MarketplaceNotFoundError); - }); - - it("removes registry entry when no orphans tracked", async () => { - const { useCase, registry } = buildUseCase(); - await registry.save( - PROJECT_ROOT, - Marketplace.create({ - name: "awesome", - source: { kind: "local", path: "/tmp/whatever" }, - scope: "project", - addedAt: "2026-04-29T10:00:00.000Z", - }) - ); - - const result = await useCase.execute({ - name: "awesome", - projectRoot: PROJECT_ROOT, - autoConfirm: true, - }); - - expect(result.removedPluginCount).toBe(0); - expect(await registry.list(PROJECT_ROOT)).toEqual([]); - }); - - it("removes orphan plugins and their files when autoConfirm is true", async () => { - const { useCase, registry, manifestRepo, fs } = buildUseCase(); - const manifest = Manifest.create(); - manifest.addTool("claude", "1.0.0", []); - const plugin = Plugin.fromJSON({ - name: "sample", - source: { kind: "github", repo: "owner/sample" }, - version: "1.0.0", - strict: false, - files: { ".claude/plugins/sample/CLAUDE.md": "0123456789abcdef0123456789abcdef" }, - marketplace: "awesome", - }); - manifest.addPlugin("claude", plugin); - await manifestRepo.save(manifest); - - const filePath = join(PROJECT_ROOT, ".claude/plugins/sample/CLAUDE.md"); - await fs.writeFile(filePath, "content"); - - await registry.save( - PROJECT_ROOT, - Marketplace.create({ - name: "awesome", - source: { kind: "github", repo: "owner/awesome" }, - scope: "project", - addedAt: "2026-04-29T10:00:00.000Z", - }) - ); - - const result = await useCase.execute({ - name: "awesome", - projectRoot: PROJECT_ROOT, - autoConfirm: true, - }); - - expect(result.removedPluginCount).toBe(1); - expect(fs.has(filePath)).toBe(false); - const reloaded = await manifestRepo.load(); - expect(reloaded?.getPlugins("claude")).toHaveLength(0); - }); -}); diff --git a/cli/tests/application/use-cases/marketplace/marketplace-sync-native-activation.integration.test.ts b/cli/tests/application/use-cases/marketplace/marketplace-sync-native-activation.integration.test.ts deleted file mode 100644 index f9db8fea2..000000000 --- a/cli/tests/application/use-cases/marketplace/marketplace-sync-native-activation.integration.test.ts +++ /dev/null @@ -1,287 +0,0 @@ -import "../../../../src/domain/tools/ai/claude.js"; -import { resolve } from "node:path"; -import { describe, expect, it } from "vitest"; -import { MarketplaceSyncSettingsUseCase } from "../../../../src/application/use-cases/marketplace/marketplace-sync-settings-use-case.js"; -import { Manifest } from "../../../../src/domain/models/manifest.js"; -import { Marketplace } from "../../../../src/domain/models/marketplace.js"; -import { Plugin } from "../../../../src/domain/models/plugin.js"; -import { buildHostRegistration } from "../../../../src/domain/models/telemetry-setup.js"; -import type { PluginCatalogRepository } from "../../../../src/domain/ports/plugin-catalog-repository.js"; -import { CapturingLogger } from "../../../helpers/ports/capturing-logger.js"; -import { DeterministicHasher } from "../../../helpers/ports/deterministic-hasher.js"; -import { fakeEnsureBuiltMarketplace } from "../../../helpers/ports/fake-ensure-built-marketplace.js"; -import { FakeNativePluginActivator } from "../../../helpers/ports/fake-native-plugin-activator.js"; -import { InMemoryFileAdapter } from "../../../helpers/ports/in-memory-file-adapter.js"; -import { InMemoryManifestRepository } from "../../../helpers/ports/in-memory-manifest-repository.js"; -import { InMemoryMarketplaceRegistry } from "../../../helpers/ports/in-memory-marketplace-registry.js"; - -/** - * The seam #703 is about, from the writing side. - * - * `aidd` declares a plugin in a project's own settings, and the host loads it only once - * that host's own CLI has registered it — `activateNativeTools` is what performs that - * second half. Nothing asserted it: `marketplace-sync-settings-use-case.ts` had no test - * file at all, so the one act that makes a declared plugin actually load was covered - * nowhere, on the branch that shipped it. - * - * The pairing that matters is the ref. This file proves the activation is driven with the - * same `@` string `TelemetryHostRegistrationSetup` looks up, so the - * two halves cannot drift into disagreeing about what to call one plugin — the failure the - * diagnostic exists to report would otherwise become a failure it invents. - */ -const PROJECT_ROOT = "/test-project"; -const MARKETPLACE = "aidd-framework"; -const PLUGIN = "aidd-telemetry"; -const REF = `${PLUGIN}@${MARKETPLACE}`; - -const NO_CATALOG: PluginCatalogRepository = { - load: async () => null, - loadForeign: async () => [], -}; - -function marketplace(): Marketplace { - return Marketplace.create({ - name: MARKETPLACE, - source: { kind: "github", repo: "ai-driven-dev/framework" }, - scope: "project", - addedAt: "2026-09-02T00:00:00Z", - }); -} - -function manifestWithPlugin(marketplace: string = MARKETPLACE): InMemoryManifestRepository { - const manifest = Manifest.create(); - manifest.addTool("claude", "test", []); - manifest.addPlugin( - "claude", - Plugin.fromMetadata( - PLUGIN, - "1.0.0", - { kind: "github", repo: "ai-driven-dev/framework" }, - true, - marketplace - ) - ); - return new InMemoryManifestRepository(manifest); -} - -function buildSync(activator: FakeNativePluginActivator, pluginMarketplace?: string) { - const registry = new InMemoryMarketplaceRegistry(); - const fs = new InMemoryFileAdapter(); - const manifestRepo = manifestWithPlugin(pluginMarketplace); - const hasher = new DeterministicHasher(); - return { - registry, - fs, - manifestRepo, - hasher, - useCase: new MarketplaceSyncSettingsUseCase( - fs, - manifestRepo, - registry, - NO_CATALOG, - hasher, - new CapturingLogger(), - new Map([["claude", activator]]), - fakeEnsureBuiltMarketplace() - ), - }; -} - -const SETTINGS_PATH = ".claude/settings.json"; - -/** `resolve`, exactly as `syncMarketplacesFile` does — not a `/`-joined literal. On Windows - * the production key is `C:\\test-project\\.claude\\settings.json`, and a hand-built POSIX - * path addresses a file the use case never wrote. */ -function settingsPathIn(projectRoot: string): string { - return resolve(projectRoot, SETTINGS_PATH); -} - -/** What the host's own CLI does that this code cannot see: `claude plugin marketplace add` - * and `claude plugin enable` write their result into the very file `syncTool` just hashed. - * The fake shells out to nothing, so it stands in for that write directly. */ -class ActivatorThatWritesSettings extends FakeNativePluginActivator { - constructor( - private readonly fs: InMemoryFileAdapter, - private readonly settingsAbsolutePath: string - ) { - super({ available: true }); - } - - private readonly writes: Promise[] = []; - - async settled(): Promise { - await Promise.all(this.writes); - } - - private async appendHostState(): Promise { - const before = await this.fs.readFile(this.settingsAbsolutePath).catch(() => "{}"); - const json = JSON.parse(before) as Record; - // Not a key this code writes: the point is content only the host could have put there. - json.installedPluginsBookkeeping = { [REF]: { installedAt: "2026-09-05T00:00:00Z" } }; - await this.fs.writeFile(this.settingsAbsolutePath, JSON.stringify(json, null, 2)); - } - - override addMarketplace(source: string): void { - super.addMarketplace(source); - this.writes.push(this.appendHostState()); - } - - override enablePlugin(pluginRef: string): void { - super.enablePlugin(pluginRef); - this.writes.push(this.appendHostState()); - } -} - -describe("syncing settings registers the plugin with the host's own CLI", () => { - it("drives the host CLI with the same ref the diagnostic looks up", async () => { - const activator = new FakeNativePluginActivator({ available: true }); - const { useCase, registry } = buildSync(activator); - await registry.save(PROJECT_ROOT, marketplace()); - - await useCase.execute({ projectRoot: PROJECT_ROOT }); - - expect(activator.enabledPlugins).toContain(REF); - // The other half of the pairing: the ref the comparison asks a registry about. If either - // side ever spells it differently, this line and the one above stop agreeing. - const asked = buildHostRegistration([ - { - tool: "claude", - plugins: [{ name: PLUGIN, marketplace: MARKETPLACE }], - reading: { location: "/registry", refs: new Map([[REF, true]]) }, - }, - ]); - expect(asked.entries[0]?.ref).toBe(activator.enabledPlugins[0]); - }); - - // The #703 state itself, from this side: the settings are written, the host CLI is absent, - // and nothing registers. The diagnostic is the only thing that can then tell a person. - it("registers nothing when the host CLI is not available, and does not fail the sync", async () => { - const activator = new FakeNativePluginActivator({ available: false }); - const { useCase, registry } = buildSync(activator); - await registry.save(PROJECT_ROOT, marketplace()); - - await useCase.execute({ projectRoot: PROJECT_ROOT }); - - expect(activator.enabledPlugins).toEqual([]); - }); - - /** - * The half of the disagreement the contract argues hardest for, and the reason the - * comparison starts from the manifest rather than from a settings file. - * - * `mergeEnabledPlugins` skips a plugin whose marketplace does not resolve — silently, - * with a bare `continue`. So this plugin reaches no settings file and no host CLI, while - * AIDD's own manifest says it is installed. A diagnostic reading settings against a - * registry would find both sides absent and call that agreement; reading the manifest - * against the registry is what makes it visible. - */ - it("registers nothing for a plugin whose marketplace does not resolve, and says nothing about it", async () => { - const activator = new FakeNativePluginActivator({ available: true }); - const { useCase, registry } = buildSync(activator, "a-marketplace-nobody-added"); - await registry.save(PROJECT_ROOT, marketplace()); - - await useCase.execute({ projectRoot: PROJECT_ROOT }); - - expect(activator.enabledPlugins).toEqual([]); - // And the manifest still carries it, which is the only place it can now be seen from. - const entry = buildHostRegistration([ - { - tool: "claude", - plugins: [{ name: PLUGIN, marketplace: "a-marketplace-nobody-added" }], - reading: { location: "/registry", refs: new Map() }, - }, - ]).entries[0]; - - expect(entry?.answer).toBe("not-registered"); - }); -}); - -/** - * `syncTool` writes `.claude/settings.json`, hashes what it wrote, and the manifest is saved. - * Only then does `activateNativeTools` run the host's own CLI — which writes into that same - * file, because Claude Code declares no separate `enabledPluginsSettingsPath`. - * - * So the tracked hash describes content that no longer exists the moment activation - * succeeds. Nothing re-hashes it. `status` and `doctor` report a file the user never touched - * as drifted, for as long as the manifest stands, and `restore` would undo the host's own - * registration to get back to a state AIDD only ever held for the length of one function. - * - * The one case in this file that is about what the activation leaves behind rather than what - * it was driven with. - */ -describe("what native activation leaves behind is not reported as the user's drift", () => { - it("tracks a hash that still matches the settings file after the host CLI has written to it", async () => { - const registry = new InMemoryMarketplaceRegistry(); - const fs = new InMemoryFileAdapter(); - const manifestRepo = manifestWithPlugin(); - const hasher = new DeterministicHasher(); - const settingsAbsolutePath = settingsPathIn(PROJECT_ROOT); - const activator = new ActivatorThatWritesSettings(fs, settingsAbsolutePath); - const useCase = new MarketplaceSyncSettingsUseCase( - fs, - manifestRepo, - registry, - NO_CATALOG, - hasher, - new CapturingLogger(), - new Map([["claude", activator]]), - fakeEnsureBuiltMarketplace() - ); - await registry.save(PROJECT_ROOT, marketplace()); - - await useCase.execute({ projectRoot: PROJECT_ROOT }); - // The port is synchronous and the host CLI's write is not, so let the writes the - // activator queued actually land before reading the file back. - await activator.settled(); - - const onDisk = await fs.readFile(settingsAbsolutePath); - const manifest = await manifestRepo.load(); - const tracked = manifest?.getToolFiles("claude") ?? []; - const entry = tracked.find((file) => file.relativePath === SETTINGS_PATH); - - expect(entry, "the settings file is tracked at all").toBeDefined(); - expect(entry?.hash).toEqual(hasher.hash(onDisk)); - }); - /** - * The other half, and the one that keeps the repair honest. A tool whose CLI is not on the - * PATH wrote nothing, so a settings file that differs from its tracked hash differs because - * a person changed it — which is exactly the drift `status` exists to report and `restore` - * exists to undo. Re-hashing every tool after activation would bless that as ours and - * silently make the change permanent. - */ - it("leaves a hash alone for a tool whose own CLI never ran", async () => { - const registry = new InMemoryMarketplaceRegistry(); - const fs = new InMemoryFileAdapter(); - const manifestRepo = manifestWithPlugin(); - const hasher = new DeterministicHasher(); - const settingsAbsolutePath = settingsPathIn(PROJECT_ROOT); - const useCase = new MarketplaceSyncSettingsUseCase( - fs, - manifestRepo, - registry, - NO_CATALOG, - hasher, - new CapturingLogger(), - // Not available: the binary is not on the PATH, so nothing of the host's is written. - new Map([["claude", new FakeNativePluginActivator({ available: false })]]), - fakeEnsureBuiltMarketplace() - ); - await registry.save(PROJECT_ROOT, marketplace()); - await useCase.execute({ projectRoot: PROJECT_ROOT }); - const hashAfterSync = (await manifestRepo.load()) - ?.getToolFiles("claude") - .find((file) => file.relativePath === SETTINGS_PATH)?.hash; - - // A person edits the file, then a second sync runs and changes nothing else. - const edited = `${await fs.readFile(settingsAbsolutePath)}\n`; - await fs.writeFile(settingsAbsolutePath, edited); - await useCase.execute({ projectRoot: PROJECT_ROOT }); - - const tracked = (await manifestRepo.load()) - ?.getToolFiles("claude") - .find((file) => file.relativePath === SETTINGS_PATH); - expect(hashAfterSync, "the settings file is tracked at all").toBeDefined(); - expect(tracked?.hash).toEqual(hashAfterSync); - expect(tracked?.hash).not.toEqual(hasher.hash(edited)); - }); -}); diff --git a/cli/tests/application/use-cases/plugin/plugin-add-hooks-trust-notice.integration.test.ts b/cli/tests/application/use-cases/plugin/plugin-add-hooks-trust-notice.integration.test.ts deleted file mode 100644 index c9aa2452c..000000000 --- a/cli/tests/application/use-cases/plugin/plugin-add-hooks-trust-notice.integration.test.ts +++ /dev/null @@ -1,63 +0,0 @@ -/** - * A hook a native tool actually delivers is not a skip - it is a delivered component with - * a precondition. Codex gates every hook behind a per-hook trust grant it can decline in - * silence (#699); this proves PluginAddUseCase names that at install time, on the info - * channel, and only for the tool that declares the gate. - */ -import "../../../../src/domain/tools/ai/codex.js"; -import "../../../../src/domain/tools/ai/claude.js"; -import { join } from "node:path"; -import { describe, expect, it } from "vitest"; -import { PluginAddUseCase } from "../../../../src/application/use-cases/plugin/plugin-add-use-case.js"; -import { codex } from "../../../../src/domain/tools/ai/codex.js"; -import { PluginDistributionReaderAdapter } from "../../../../src/infrastructure/adapters/plugin-distribution-reader-adapter.js"; -import { buildUnitDeps, initAndInstall } from "../../../helpers/ports/build-unit-deps.js"; -import { CapturingLogger } from "../../../helpers/ports/capturing-logger.js"; -import { fakeEnsureBuiltMarketplace } from "../../../helpers/ports/fake-ensure-built-marketplace.js"; -import { InMemoryMarketplaceRegistry } from "../../../helpers/ports/in-memory-marketplace-registry.js"; -import { seedFromDirectory } from "../../../helpers/ports/seed-from-directory.js"; - -const PLUGIN_FIXTURE = join(process.cwd(), "tests/fixtures/plugins/claude-format/sample-plugin"); -const PROJECT_ROOT = "/test-project"; - -async function installWithLogger(toolId: "codex" | "claude") { - const deps = await buildUnitDeps(PROJECT_ROOT); - await initAndInstall(deps, PROJECT_ROOT, toolId); - await seedFromDirectory(deps.fs, PLUGIN_FIXTURE, { useAbsolutePaths: true }); - const logger = new CapturingLogger(); - const useCase = new PluginAddUseCase( - deps.fs, - deps.manifestRepo, - deps.pluginFetcher, - new PluginDistributionReaderAdapter(deps.fs), - deps.hasher, - logger, - new InMemoryMarketplaceRegistry(), - fakeEnsureBuiltMarketplace() - ); - await useCase.execute({ - source: { kind: "local", path: PLUGIN_FIXTURE }, - toolIds: [toolId], - projectRoot: PROJECT_ROOT, - interactive: false, - }); - return logger; -} - -describe("PluginAddUseCase hook trust notice", () => { - it("names what Codex still requires, on the info channel, when the plugin delivers hooks", async () => { - const logger = await installWithLogger("codex"); - - expect(logger.infoMessages).toHaveLength(1); - expect(logger.infoMessages[0]).toBe( - `Plugin "sample-plugin" (codex): ${codex.capabilities.plugins.hooksTrustNotice}` - ); - expect(logger.warnMessages).toEqual([]); - }); - - it("says nothing for a tool with no trust gate on its hooks", async () => { - const logger = await installWithLogger("claude"); - - expect(logger.infoMessages).toEqual([]); - }); -}); diff --git a/cli/tests/application/use-cases/plugin/plugin-add-opencode-hooks-install.integration.test.ts b/cli/tests/application/use-cases/plugin/plugin-add-opencode-hooks-install.integration.test.ts deleted file mode 100644 index 9ab31dc78..000000000 --- a/cli/tests/application/use-cases/plugin/plugin-add-opencode-hooks-install.integration.test.ts +++ /dev/null @@ -1,68 +0,0 @@ -/** - * Phase 7 — OpenCode hooks install: installing a plugin with hooks/ against OpenCode - * delivers the module its loader scans for, instead of skipping the component. - * Renamed from plugin-add-opencode-hooks-skip.integration.test.ts (Phase 3), whose - * premise this phase reverses — see aidd_docs/tasks/2026_08/2026_08_22_telemetry-every-tool/ - * measurements.md, Phase 7. - */ - -import { join, posix } from "node:path"; -import { describe, expect, it } from "vitest"; -import { PluginAddUseCase } from "../../../../src/application/use-cases/plugin/plugin-add-use-case.js"; -import { PluginDistributionReaderAdapter } from "../../../../src/infrastructure/adapters/plugin-distribution-reader-adapter.js"; -import { buildUnitDeps, initAndInstall } from "../../../helpers/ports/build-unit-deps.js"; -import { CapturingLogger } from "../../../helpers/ports/capturing-logger.js"; -import { fakeEnsureBuiltMarketplace } from "../../../helpers/ports/fake-ensure-built-marketplace.js"; -import { InMemoryMarketplaceRegistry } from "../../../helpers/ports/in-memory-marketplace-registry.js"; -import { seedFromDirectory } from "../../../helpers/ports/seed-from-directory.js"; - -const PLUGIN_FIXTURE = join(process.cwd(), "tests/fixtures/plugins/claude-format/sample-plugin"); -const PROJECT_ROOT = "/test-project"; - -async function installSamplePlugin() { - const deps = await buildUnitDeps(PROJECT_ROOT); - await initAndInstall(deps, PROJECT_ROOT, "opencode"); - await seedFromDirectory(deps.fs, PLUGIN_FIXTURE, { useAbsolutePaths: true }); - const capturingLogger = new CapturingLogger(); - const registry = new InMemoryMarketplaceRegistry(); - const useCase = new PluginAddUseCase( - deps.fs, - deps.manifestRepo, - deps.pluginFetcher, - new PluginDistributionReaderAdapter(deps.fs), - deps.hasher, - capturingLogger, - registry, - fakeEnsureBuiltMarketplace() - ); - await useCase.execute({ - source: { kind: "local", path: PLUGIN_FIXTURE }, - toolIds: ["opencode"], - projectRoot: PROJECT_ROOT, - interactive: false, - }); - return { deps, capturingLogger }; -} - -describe("PluginAddUseCase OpenCode hooks install (Phase 7)", () => { - it("writes every hooks/ script but the manifest under .opencode/plugin/", async () => { - const { deps } = await installSamplePlugin(); - - // deps.fs is the in-memory adapter, whose listUnder() returns its own "/"-normalised - // keys regardless of host platform - a native `join` would answer with "\" on win32 - // and never match one of those keys. - const writtenPaths = deps.fs.listUnder(PROJECT_ROOT); - expect(writtenPaths).toContain( - posix.join(PROJECT_ROOT, ".opencode", "plugin", "update_memory.js") - ); - expect(writtenPaths).not.toContain( - posix.join(PROJECT_ROOT, ".opencode", "plugin", "hooks.json") - ); - }); - - it("emits no logger.warn — hooks are delivered, not skipped", async () => { - const { capturingLogger } = await installSamplePlugin(); - - expect(capturingLogger.warnMessages).toEqual([]); - }); -}); diff --git a/cli/tests/application/use-cases/plugin/plugin-add-skip-warn.integration.test.ts b/cli/tests/application/use-cases/plugin/plugin-add-skip-warn.integration.test.ts deleted file mode 100644 index c33ca8123..000000000 --- a/cli/tests/application/use-cases/plugin/plugin-add-skip-warn.integration.test.ts +++ /dev/null @@ -1,79 +0,0 @@ -/** - * Integration test for Phase 1: PluginAddUseCase emits logger.warn for each skip entry - * returned by the translation adapter. - * - * The live example this originally used — sample-plugin's hooks/ against OpenCode — - * stopped producing a skip once OpenCode's flat mode started accepting hooks (Phase 7, - * see the telemetry plan's measurements.md): every registered tool now runs what a - * plugin's hooks/ ships, so no live fixture currently exercises collectHooksSkips's - * non-empty branch. The first two tests below assert that absence directly rather than - * keep asserting a skip that no longer happens; the warn-format contract itself is still - * covered, tool-agnostically, by the last test in this file. - */ -import "../../../../src/domain/tools/ai/opencode.js"; -import { join } from "node:path"; -import { describe, expect, it } from "vitest"; -import { PluginAddUseCase } from "../../../../src/application/use-cases/plugin/plugin-add-use-case.js"; -import type { ReadonlySkipList } from "../../../../src/domain/models/plugin-translation-skip.js"; -import { PluginDistributionReaderAdapter } from "../../../../src/infrastructure/adapters/plugin-distribution-reader-adapter.js"; -import { buildUnitDeps, initAndInstall } from "../../../helpers/ports/build-unit-deps.js"; -import { CapturingLogger } from "../../../helpers/ports/capturing-logger.js"; -import { fakeEnsureBuiltMarketplace } from "../../../helpers/ports/fake-ensure-built-marketplace.js"; -import { InMemoryMarketplaceRegistry } from "../../../helpers/ports/in-memory-marketplace-registry.js"; -import { seedFromDirectory } from "../../../helpers/ports/seed-from-directory.js"; - -const PLUGIN_FIXTURE = join(process.cwd(), "tests/fixtures/plugins/claude-format/sample-plugin"); -const PROJECT_ROOT = "/test-project"; - -describe("PluginAddUseCase skip warnings", () => { - describe("when a plugin's hooks are now accepted (no skip entry)", () => { - it("emits no logger.warn — OpenCode delivers sample-plugin's hooks instead of skipping them", async () => { - const deps = await buildUnitDeps(PROJECT_ROOT); - await initAndInstall(deps, PROJECT_ROOT, "opencode"); - await seedFromDirectory(deps.fs, PLUGIN_FIXTURE, { useAbsolutePaths: true }); - const capturingLogger = new CapturingLogger(); - const registry = new InMemoryMarketplaceRegistry(); - const useCase = new PluginAddUseCase( - deps.fs, - deps.manifestRepo, - deps.pluginFetcher, - new PluginDistributionReaderAdapter(deps.fs), - deps.hasher, - capturingLogger, - registry, - fakeEnsureBuiltMarketplace() - ); - await useCase.execute({ - source: { kind: "local", path: PLUGIN_FIXTURE }, - toolIds: ["opencode"], - projectRoot: PROJECT_ROOT, - interactive: false, - }); - expect(capturingLogger.warnMessages).toEqual([]); - }); - }); - - describe("warn message format", () => { - it("formats skip warnings as Plugin : skipped for ", () => { - // Validate the format directly without going through the full use-case flow - const logger = new CapturingLogger(); - const skipped: ReadonlySkipList = [ - { - pluginName: "aidd-pm", - component: "hooks", - toolId: "opencode", - reason: "OpenCode plugin runtime is JS modules; declarative hooks.json is not supported.", - }, - ]; - for (const entry of skipped) { - logger.warn( - `Plugin "${entry.pluginName}": ${entry.component} skipped for ${entry.toolId} — ${entry.reason}` - ); - } - expect(logger.warnMessages).toHaveLength(1); - expect(logger.warnMessages[0]).toBe( - 'Plugin "aidd-pm": hooks skipped for opencode — OpenCode plugin runtime is JS modules; declarative hooks.json is not supported.' - ); - }); - }); -}); diff --git a/cli/tests/application/use-cases/plugin/plugin-create-use-case.integration.test.ts b/cli/tests/application/use-cases/plugin/plugin-create-use-case.integration.test.ts deleted file mode 100644 index 729973475..000000000 --- a/cli/tests/application/use-cases/plugin/plugin-create-use-case.integration.test.ts +++ /dev/null @@ -1,293 +0,0 @@ -import { join } from "node:path"; -import { describe, expect, it } from "vitest"; -import { PluginCreateUseCase } from "../../../../src/application/use-cases/plugin/plugin-create-use-case.js"; -import { - InvalidPluginNameError, - JsonSchemaValidationError, - MarketplaceEntryAlreadyExistsError, - PluginTargetExistsError, -} from "../../../../src/domain/errors.js"; -import type { AssetProvider } from "../../../../src/domain/ports/asset-provider.js"; -import type { JsonSchemaValidator } from "../../../../src/domain/ports/json-schema-validator.js"; -import { CapturingLogger } from "../../../helpers/ports/capturing-logger.js"; -import { InMemoryFileAdapter } from "../../../helpers/ports/in-memory-file-adapter.js"; -import { ScriptedPrompter } from "../../../helpers/ports/scripted-prompter.js"; - -const PROJECT_ROOT = "/project"; -const OUTPUT_DIR = "/project/output"; - -function makeMinimalManifestSchema(): object { - return { type: "object", properties: { name: { type: "string" } }, required: ["name"] }; -} - -function makeAssetProvider(schema = makeMinimalManifestSchema()): AssetProvider { - return { - loadConfigAsset: () => { - throw new Error("not used"); - }, - loadDefaultMarketplace: () => { - throw new Error("not used"); - }, - loadSchema: (name) => { - if (name === "plugin-manifest") return schema; - throw new Error("not used"); - }, - }; -} - -function makeValidator(): JsonSchemaValidator { - return { - validate(_schema: object, data: unknown): void { - const obj = data as Record; - if (typeof obj.name !== "string") - throw new JsonSchemaValidationError(["name must be string"]); - }, - }; -} - -function makeUseCase( - fs = new InMemoryFileAdapter(), - prompter = new ScriptedPrompter([]), - validator = makeValidator(), - assetProvider = makeAssetProvider(), - logger = new CapturingLogger() -): PluginCreateUseCase { - return new PluginCreateUseCase(fs, prompter, validator, assetProvider, logger); -} - -describe("PluginCreateUseCase", () => { - describe("name validation", () => { - it("throws InvalidPluginNameError for invalid name", async () => { - const uc = makeUseCase(); - await expect( - uc.execute({ - name: "My Plugin!", - kind: "full", - outputDir: OUTPUT_DIR, - force: false, - yes: false, - interactive: false, - projectRoot: PROJECT_ROOT, - }) - ).rejects.toThrow(InvalidPluginNameError); - }); - - it("throws InvalidPluginNameError for uppercase name", async () => { - const uc = makeUseCase(); - await expect( - uc.execute({ - name: "MyPlugin", - kind: "full", - outputDir: OUTPUT_DIR, - force: false, - yes: false, - interactive: false, - projectRoot: PROJECT_ROOT, - }) - ).rejects.toThrow(InvalidPluginNameError); - }); - }); - - describe("scaffold creation", () => { - it("writes scaffold files for kind full", async () => { - const fs = new InMemoryFileAdapter(); - const uc = makeUseCase(fs); - const result = await uc.execute({ - name: "my-plugin", - kind: "full", - outputDir: OUTPUT_DIR, - force: false, - yes: false, - interactive: false, - projectRoot: PROJECT_ROOT, - }); - expect(result.filesWritten).toBeGreaterThan(0); - expect(result.pluginDir).toBe(join(OUTPUT_DIR, "my-plugin")); - expect(result.marketplaceUpdated).toBe(false); - }); - - it("writes plugin.json manifest", async () => { - const fs = new InMemoryFileAdapter(); - const uc = makeUseCase(fs); - await uc.execute({ - name: "my-plugin", - kind: "skills", - outputDir: OUTPUT_DIR, - force: false, - yes: false, - interactive: false, - projectRoot: PROJECT_ROOT, - }); - const manifestPath = join(OUTPUT_DIR, "my-plugin", ".claude-plugin/plugin.json"); - const manifest = await fs.readFile(manifestPath); - expect(JSON.parse(manifest)).toMatchObject({ name: "my-plugin" }); - }); - - it("writes skills files for kind skills", async () => { - const fs = new InMemoryFileAdapter(); - const uc = makeUseCase(fs); - await uc.execute({ - name: "my-plugin", - kind: "skills", - outputDir: OUTPUT_DIR, - force: false, - yes: false, - interactive: false, - projectRoot: PROJECT_ROOT, - }); - const skillPath = join(OUTPUT_DIR, "my-plugin", "skills/00-example/SKILL.md"); - expect(await fs.fileExists(skillPath)).toBe(true); - }); - }); - - describe("force flag", () => { - it("throws PluginTargetExistsError when target exists and force is false", async () => { - const fs = new InMemoryFileAdapter(); - const pluginDir = join(OUTPUT_DIR, "my-plugin"); - await fs.writeFile(`${pluginDir}/existing.txt`, "content"); - const uc = makeUseCase(fs); - await expect( - uc.execute({ - name: "my-plugin", - kind: "full", - outputDir: OUTPUT_DIR, - force: false, - yes: false, - interactive: false, - projectRoot: PROJECT_ROOT, - }) - ).rejects.toThrow(PluginTargetExistsError); - }); - - it("overwrites when force is true", async () => { - const fs = new InMemoryFileAdapter(); - const pluginDir = join(OUTPUT_DIR, "my-plugin"); - await fs.writeFile(`${pluginDir}/existing.txt`, "old content"); - const uc = makeUseCase(fs); - const result = await uc.execute({ - name: "my-plugin", - kind: "full", - outputDir: OUTPUT_DIR, - force: true, - yes: false, - interactive: false, - projectRoot: PROJECT_ROOT, - }); - expect(result.filesWritten).toBeGreaterThan(0); - expect(await fs.fileExists(`${pluginDir}/existing.txt`)).toBe(false); - }); - }); - - describe("marketplace integration", () => { - it("does not update marketplace if file is absent", async () => { - const fs = new InMemoryFileAdapter(); - const uc = makeUseCase(fs); - const result = await uc.execute({ - name: "my-plugin", - kind: "full", - outputDir: OUTPUT_DIR, - force: false, - yes: false, - interactive: false, - projectRoot: PROJECT_ROOT, - }); - expect(result.marketplaceUpdated).toBe(false); - }); - - it("does not update marketplace in non-interactive yes mode", async () => { - const fs = new InMemoryFileAdapter(); - const marketplacePath = join(PROJECT_ROOT, ".claude-plugin/marketplace.json"); - await fs.writeFile(marketplacePath, JSON.stringify({ plugins: [] })); - const uc = makeUseCase(fs); - const result = await uc.execute({ - name: "my-plugin", - kind: "full", - outputDir: OUTPUT_DIR, - force: false, - yes: true, - interactive: true, - projectRoot: PROJECT_ROOT, - }); - expect(result.marketplaceUpdated).toBe(false); - }); - - it("appends to marketplace when interactive and confirmed", async () => { - const fs = new InMemoryFileAdapter(); - const marketplacePath = join(PROJECT_ROOT, ".claude-plugin/marketplace.json"); - await fs.writeFile(marketplacePath, JSON.stringify({ plugins: [] })); - const prompter = new ScriptedPrompter([{ type: "confirm", value: true }]); - const uc = makeUseCase(fs, prompter); - const result = await uc.execute({ - name: "my-plugin", - kind: "full", - outputDir: OUTPUT_DIR, - force: false, - yes: false, - interactive: true, - projectRoot: PROJECT_ROOT, - }); - expect(result.marketplaceUpdated).toBe(true); - const updated = JSON.parse(await fs.readFile(marketplacePath)) as { plugins: unknown[] }; - expect(updated.plugins).toHaveLength(1); - }); - - it("throws MarketplaceEntryAlreadyExistsError on duplicate name", async () => { - const fs = new InMemoryFileAdapter(); - const marketplacePath = join(PROJECT_ROOT, ".claude-plugin/marketplace.json"); - await fs.writeFile( - marketplacePath, - JSON.stringify({ - plugins: [ - { - name: "my-plugin", - version: "0.1.0", - source: ".", - description: "", - recommended: false, - strict: false, - }, - ], - }) - ); - const prompter = new ScriptedPrompter([{ type: "confirm", value: true }]); - const uc = makeUseCase(fs, prompter); - await expect( - uc.execute({ - name: "my-plugin", - kind: "full", - outputDir: OUTPUT_DIR, - force: false, - yes: false, - interactive: true, - projectRoot: PROJECT_ROOT, - }) - ).rejects.toThrow(MarketplaceEntryAlreadyExistsError); - }); - }); - - describe("schema validation", () => { - it("throws JsonSchemaValidationError when validator rejects manifest", async () => { - const rejectingValidator: JsonSchemaValidator = { - validate() { - throw new JsonSchemaValidationError(["name is required"]); - }, - }; - const uc = makeUseCase( - new InMemoryFileAdapter(), - new ScriptedPrompter([]), - rejectingValidator - ); - await expect( - uc.execute({ - name: "my-plugin", - kind: "full", - outputDir: OUTPUT_DIR, - force: false, - yes: false, - interactive: false, - projectRoot: PROJECT_ROOT, - }) - ).rejects.toThrow(JsonSchemaValidationError); - }); - }); -}); diff --git a/cli/tests/application/use-cases/plugin/plugin-list-use-case.unit.test.ts b/cli/tests/application/use-cases/plugin/plugin-list-use-case.unit.test.ts deleted file mode 100644 index f31a4874d..000000000 --- a/cli/tests/application/use-cases/plugin/plugin-list-use-case.unit.test.ts +++ /dev/null @@ -1,54 +0,0 @@ -import { describe, expect, it } from "vitest"; -import "../../../../src/domain/tools/ai/claude.js"; -import { PluginListUseCase } from "../../../../src/application/use-cases/plugin/plugin-list-use-case.js"; -import { Manifest } from "../../../../src/domain/models/manifest.js"; -import { Plugin } from "../../../../src/domain/models/plugin.js"; -import type { ManifestRepository } from "../../../../src/domain/ports/manifest-repository.js"; - -function makeManifestWithPlugin(): Manifest { - const manifest = Manifest.create(); - manifest.addTool("claude", "1.0.0", []); - const plugin = Plugin.fromJSON({ - name: "sample-plugin", - source: { kind: "local", path: "./sample" }, - version: "1.0.0", - strict: false, - files: {}, - }); - manifest.addPlugin("claude", plugin); - return manifest; -} - -function makeManifestRepository(manifest: Manifest): ManifestRepository { - return { - path: "/test-project/.aidd/manifest.json", - load: async () => manifest, - save: async () => {}, - delete: async () => {}, - }; -} - -describe("PluginListUseCase", () => { - describe("list plugins for installed tool", () => { - it("returns map with installed plugins for requested tool", async () => { - const manifest = makeManifestWithPlugin(); - const repo = makeManifestRepository(manifest); - const useCase = new PluginListUseCase(repo); - const result = await useCase.execute({ toolIds: ["claude"] }); - expect(result.has("claude")).toBe(true); - const plugins = result.get("claude") ?? []; - expect(plugins).toHaveLength(1); - expect(plugins[0].name).toBe("sample-plugin"); - expect(plugins[0].version).toBe("1.0.0"); - }); - - it("returns empty list for tool with no plugins", async () => { - const manifest = Manifest.create(); - manifest.addTool("claude", "1.0.0", []); - const repo = makeManifestRepository(manifest); - const useCase = new PluginListUseCase(repo); - const result = await useCase.execute({ toolIds: ["claude"] }); - expect(result.get("claude")).toHaveLength(0); - }); - }); -}); diff --git a/cli/tests/application/use-cases/plugin/plugin-pick-use-case.unit.test.ts b/cli/tests/application/use-cases/plugin/plugin-pick-use-case.unit.test.ts deleted file mode 100644 index e356a92a5..000000000 --- a/cli/tests/application/use-cases/plugin/plugin-pick-use-case.unit.test.ts +++ /dev/null @@ -1,187 +0,0 @@ -import { join } from "node:path"; -import { describe, expect, it } from "vitest"; -import { PluginAddUseCase } from "../../../../src/application/use-cases/plugin/plugin-add-use-case.js"; -import { PluginPickUseCase } from "../../../../src/application/use-cases/plugin/plugin-pick-use-case.js"; -import { FetchMarketplaceSourceUseCase } from "../../../../src/application/use-cases/shared/fetch-marketplace-source-use-case.js"; -import { ResolveMarketplaceUseCase } from "../../../../src/application/use-cases/shared/resolve-marketplace-use-case.js"; -import { - InteractiveOnlyError, - InvalidPluginManifestError, - NoMarketplacesRegisteredError, -} from "../../../../src/domain/errors.js"; -import { Marketplace } from "../../../../src/domain/models/marketplace.js"; -import type { Prompter } from "../../../../src/domain/ports/prompter.js"; -import { PluginCatalogRepositoryAdapter } from "../../../../src/infrastructure/adapters/plugin-catalog-repository-adapter.js"; -import { PluginDistributionReaderAdapter } from "../../../../src/infrastructure/adapters/plugin-distribution-reader-adapter.js"; -import { buildUnitDeps, initAndInstall } from "../../../helpers/ports/build-unit-deps.js"; -import { fakeEnsureBuiltMarketplace } from "../../../helpers/ports/fake-ensure-built-marketplace.js"; -import type { InMemoryFileAdapter } from "../../../helpers/ports/in-memory-file-adapter.js"; -import { InMemoryMarketplaceRegistry } from "../../../helpers/ports/in-memory-marketplace-registry.js"; -import { KeepPrompter } from "../../../helpers/ports/scripted-prompter.js"; -import { seedFromDirectory } from "../../../helpers/ports/seed-from-directory.js"; - -const PLUGIN_FIXTURE = join(process.cwd(), "tests/fixtures/plugins/claude-format/sample-plugin"); -const PROJECT_ROOT = "/test-project"; -const MKT_DIR = "/mkt-source"; -const MKT_DIR_2 = "/mkt-source-2"; - -function seedMarketplaceFile( - fs: InMemoryFileAdapter, - dir: string, - plugins: Array> -): void { - fs.writeFile(join(dir, ".claude-plugin/marketplace.json"), JSON.stringify({ plugins })); -} - -function registerMarketplace( - registry: InMemoryMarketplaceRegistry, - name: string, - dir: string -): Promise { - return registry.save( - PROJECT_ROOT, - Marketplace.create({ - name, - source: { kind: "local", path: dir }, - scope: "project", - addedAt: "2026-04-29T10:00:00.000Z", - }) - ); -} - -async function buildUseCase(prompter: Prompter = new KeepPrompter()) { - const deps = await buildUnitDeps(PROJECT_ROOT); - await initAndInstall(deps, PROJECT_ROOT, "claude"); - await seedFromDirectory(deps.fs, PLUGIN_FIXTURE, { useAbsolutePaths: true }); - const registry = new InMemoryMarketplaceRegistry(); - const pluginAdd = new PluginAddUseCase( - deps.fs, - deps.manifestRepo, - deps.pluginFetcher, - new PluginDistributionReaderAdapter(deps.fs), - deps.hasher, - deps.logger, - registry, - fakeEnsureBuiltMarketplace() - ); - const fetchMarketplaceSource = new FetchMarketplaceSourceUseCase(deps.pluginFetcher); - const resolveMarketplace = new ResolveMarketplaceUseCase( - fetchMarketplaceSource, - new PluginCatalogRepositoryAdapter(deps.fs) - ); - const useCase = new PluginPickUseCase(registry, resolveMarketplace, pluginAdd, prompter); - return { useCase, deps, registry }; -} - -describe("PluginPickUseCase", () => { - it("throws InteractiveOnlyError when not interactive", async () => { - const { useCase } = await buildUseCase(); - await expect( - useCase.execute({ toolIds: ["claude"], projectRoot: PROJECT_ROOT, interactive: false }) - ).rejects.toThrow(InteractiveOnlyError); - }); - - it("throws NoMarketplacesRegisteredError when registry is empty", async () => { - const { useCase } = await buildUseCase(); - await expect( - useCase.execute({ toolIds: ["claude"], projectRoot: PROJECT_ROOT, interactive: true }) - ).rejects.toThrow(NoMarketplacesRegisteredError); - }); - - it("installs the recommended plugins from the only registered marketplace", async () => { - const { useCase, deps, registry } = await buildUseCase(); - seedMarketplaceFile(deps.fs, MKT_DIR, [ - { - name: "sample-plugin", - source: { kind: "local", path: PLUGIN_FIXTURE }, - version: "1.0.0", - recommended: true, - }, - ]); - await registry.save( - PROJECT_ROOT, - Marketplace.create({ - name: "local", - source: { kind: "local", path: MKT_DIR }, - scope: "project", - addedAt: "2026-04-29T10:00:00.000Z", - }) - ); - - const result = await useCase.execute({ - toolIds: ["claude"], - projectRoot: PROJECT_ROOT, - interactive: true, - }); - - expect(result.marketplace.name).toBe("local"); - expect(result.installed).toEqual(["sample-plugin"]); - const manifest = await deps.manifestRepo.load(); - const plugins = manifest?.getPlugins("claude") ?? []; - const installed = plugins.find((p) => p.name === "sample-plugin"); - expect(installed?.marketplace).toBe("local"); - }); - - it("prompts to choose a marketplace when more than one is registered", async () => { - const { useCase, deps, registry } = await buildUseCase(); - seedMarketplaceFile(deps.fs, MKT_DIR, []); - seedMarketplaceFile(deps.fs, MKT_DIR_2, []); - await registerMarketplace(registry, "first", MKT_DIR); - await registerMarketplace(registry, "second", MKT_DIR_2); - - const result = await useCase.execute({ - toolIds: ["claude"], - projectRoot: PROJECT_ROOT, - interactive: true, - }); - - expect(result.marketplace.name).toBe("first"); - expect(result.installed).toEqual([]); - }); - - it("throws InvalidPluginManifestError when the marketplace catalog cannot be found", async () => { - const { useCase, registry } = await buildUseCase(); - await registerMarketplace(registry, "local", MKT_DIR); - - await expect( - useCase.execute({ toolIds: ["claude"], projectRoot: PROJECT_ROOT, interactive: true }) - ).rejects.toThrow(new InvalidPluginManifestError(`marketplace.json not found at "${MKT_DIR}"`)); - }); - - it("returns no installed plugins and skips the selection prompt when the catalog is empty", async () => { - const { useCase, deps, registry } = await buildUseCase(); - seedMarketplaceFile(deps.fs, MKT_DIR, []); - await registerMarketplace(registry, "local", MKT_DIR); - - const result = await useCase.execute({ - toolIds: ["claude"], - projectRoot: PROJECT_ROOT, - interactive: true, - }); - - expect(result.installed).toEqual([]); - }); - - it("installs an entry that carries a description and an explicit strict flag on the catalog", async () => { - const { useCase, deps, registry } = await buildUseCase(); - seedMarketplaceFile(deps.fs, MKT_DIR, [ - { - name: "sample-plugin", - source: { kind: "local", path: PLUGIN_FIXTURE }, - version: "1.0.0", - description: "A sample plugin used in tests", - recommended: true, - strict: true, - }, - ]); - await registerMarketplace(registry, "local", MKT_DIR); - - const result = await useCase.execute({ - toolIds: ["claude"], - projectRoot: PROJECT_ROOT, - interactive: true, - }); - - expect(result.installed).toEqual(["sample-plugin"]); - }); -}); diff --git a/cli/tests/application/use-cases/plugin/plugin-remove-native-activation.integration.test.ts b/cli/tests/application/use-cases/plugin/plugin-remove-native-activation.integration.test.ts deleted file mode 100644 index 44da20c4b..000000000 --- a/cli/tests/application/use-cases/plugin/plugin-remove-native-activation.integration.test.ts +++ /dev/null @@ -1,166 +0,0 @@ -/** - * Removal undoing native activation: `claude`, `codex`, and `copilot` only load a - * plugin once their own CLI has registered it in a user-global registry - * (`installed_plugins.json`, `config.toml`, `~/.copilot/config.json`). Install drives - * that CLI via `NativePluginActivator.enablePlugin` (see marketplace-sync-settings-use-case - * and deps.ts's `nativePluginActivators` map). Before this test, `PluginRemoveUseCase` - * had no reference to that map at all — it deleted local files and updated AIDD's own - * manifest, but the plugin stayed enabled in every host registry install wrote to. This - * file proves the removal counterpart: `uninstallPlugin` is called with the same - * `@` ref install used, and every unreachable-host case still - * completes the removal while naming what it could not clean up. - */ -import "../../../../src/domain/tools/ai/claude.js"; -import { describe, expect, it } from "vitest"; -import { PluginRemoveUseCase } from "../../../../src/application/use-cases/plugin/plugin-remove-use-case.js"; -import { ModeAMarketplaceTranslator } from "../../../../src/application/use-cases/plugin/translator/mode-a-marketplace-translator.js"; -import { Manifest } from "../../../../src/domain/models/manifest.js"; -import { PluginDistribution } from "../../../../src/domain/models/plugin-distribution.js"; -import { CapturingLogger } from "../../../helpers/ports/capturing-logger.js"; -import { FakeNativePluginActivator } from "../../../helpers/ports/fake-native-plugin-activator.js"; -import { InMemoryFileAdapter } from "../../../helpers/ports/in-memory-file-adapter.js"; -import { InMemoryManifestRepository } from "../../../helpers/ports/in-memory-manifest-repository.js"; - -const PROJECT_ROOT = "/test-project"; -const MARKETPLACE_NAME = "aidd-framework"; -const PLUGIN_NAME = "aidd-telemetry"; -const REF = `${PLUGIN_NAME}@${MARKETPLACE_NAME}`; - -function buildDist(): PluginDistribution { - return new PluginDistribution({ - manifest: { name: PLUGIN_NAME, version: "1.0.0" }, - format: "claude", - files: [{ relativePath: "commands/hello.md", content: "# Hello" }], - components: { - commands: [{ relativePath: "commands/hello.md", content: "# Hello" }], - agents: [], - rules: [], - skills: [], - hooks: [], - mcp: [], - }, - }); -} - -async function installViaModeA(manifest: Manifest): Promise { - await new ModeAMarketplaceTranslator().addPlugin( - buildDist(), - "claude", - { kind: "local", path: "/plugin-source" }, - PROJECT_ROOT, - manifest, - MARKETPLACE_NAME, - "docs" - ); -} - -function buildRemoveUseCase( - activator: FakeNativePluginActivator, - logger: CapturingLogger -): { removeUseCase: PluginRemoveUseCase; manifestRepo: InMemoryManifestRepository } { - const fs = new InMemoryFileAdapter(); - const manifestRepo = new InMemoryManifestRepository(); - const removeUseCase = new PluginRemoveUseCase( - fs, - manifestRepo, - logger, - new Map([["claude", activator]]) - ); - return { removeUseCase, manifestRepo }; -} - -describe("PluginRemoveUseCase undoes native activation", () => { - it("uninstalls via the host CLI using the same @ ref install used", async () => { - const activator = new FakeNativePluginActivator({ available: true }); - const logger = new CapturingLogger(); - const { removeUseCase, manifestRepo } = buildRemoveUseCase(activator, logger); - const manifest = Manifest.create(); - manifest.addTool("claude", "test", []); - await installViaModeA(manifest); - await manifestRepo.save(manifest); - - await removeUseCase.execute({ - pluginName: PLUGIN_NAME, - toolIds: ["claude"], - projectRoot: PROJECT_ROOT, - }); - - expect(activator.uninstalledPlugins).toEqual([REF]); - expect(logger.warnMessages).toEqual([]); - }); - - it("warns naming the host and leaves the removal complete when the CLI is not on PATH", async () => { - const activator = new FakeNativePluginActivator({ available: false }); - const logger = new CapturingLogger(); - const { removeUseCase, manifestRepo } = buildRemoveUseCase(activator, logger); - const manifest = Manifest.create(); - manifest.addTool("claude", "test", []); - await installViaModeA(manifest); - await manifestRepo.save(manifest); - - await removeUseCase.execute({ - pluginName: PLUGIN_NAME, - toolIds: ["claude"], - projectRoot: PROJECT_ROOT, - }); - - expect(activator.uninstalledPlugins).toEqual([]); - expect(logger.warnMessages).toHaveLength(1); - expect(logger.warnMessages[0]).toContain("claude"); - expect(logger.warnMessages[0]).toContain(REF); - const loaded = await manifestRepo.load(); - expect(loaded?.getPlugins("claude").some((p) => p.name === PLUGIN_NAME)).toBe(false); - }); - - it("warns naming the host and message when the host CLI reports the plugin already absent", async () => { - const activator = new FakeNativePluginActivator({ - available: true, - failOnUninstall: [REF], - }); - const logger = new CapturingLogger(); - const { removeUseCase, manifestRepo } = buildRemoveUseCase(activator, logger); - const manifest = Manifest.create(); - manifest.addTool("claude", "test", []); - await installViaModeA(manifest); - await manifestRepo.save(manifest); - - await expect( - removeUseCase.execute({ - pluginName: PLUGIN_NAME, - toolIds: ["claude"], - projectRoot: PROJECT_ROOT, - }) - ).resolves.not.toThrow(); - - expect(logger.warnMessages).toHaveLength(1); - expect(logger.warnMessages[0]).toContain("claude"); - expect(logger.warnMessages[0]).toContain(REF); - }); - - it("never calls the host CLI for a plugin installed without a recorded marketplace", async () => { - const activator = new FakeNativePluginActivator({ available: true }); - const logger = new CapturingLogger(); - const { removeUseCase, manifestRepo } = buildRemoveUseCase(activator, logger); - const manifest = Manifest.create(); - manifest.addTool("claude", "test", []); - await new ModeAMarketplaceTranslator().addPlugin( - buildDist(), - "claude", - { kind: "local", path: "/plugin-source" }, - PROJECT_ROOT, - manifest, - undefined, - "docs" - ); - await manifestRepo.save(manifest); - - await removeUseCase.execute({ - pluginName: PLUGIN_NAME, - toolIds: ["claude"], - projectRoot: PROJECT_ROOT, - }); - - expect(activator.uninstalledPlugins).toEqual([]); - expect(logger.warnMessages).toEqual([]); - }); -}); diff --git a/cli/tests/application/use-cases/plugin/plugin-remove-use-case.unit.test.ts b/cli/tests/application/use-cases/plugin/plugin-remove-use-case.unit.test.ts deleted file mode 100644 index 31b42d8ba..000000000 --- a/cli/tests/application/use-cases/plugin/plugin-remove-use-case.unit.test.ts +++ /dev/null @@ -1,82 +0,0 @@ -import { join } from "node:path"; -import { describe, expect, it } from "vitest"; -import { PluginAddUseCase } from "../../../../src/application/use-cases/plugin/plugin-add-use-case.js"; -import { PluginRemoveUseCase } from "../../../../src/application/use-cases/plugin/plugin-remove-use-case.js"; -import { PluginNotFoundError } from "../../../../src/domain/errors.js"; -import { PluginDistributionReaderAdapter } from "../../../../src/infrastructure/adapters/plugin-distribution-reader-adapter.js"; -import { buildUnitDeps, initAndInstall } from "../../../helpers/ports/build-unit-deps.js"; -import { fakeEnsureBuiltMarketplace } from "../../../helpers/ports/fake-ensure-built-marketplace.js"; -import { seedFromDirectory } from "../../../helpers/ports/seed-from-directory.js"; - -const PLUGIN_FIXTURE = join(process.cwd(), "tests/fixtures/plugins/claude-format/sample-plugin"); -const PROJECT_ROOT = "/test-project"; - -async function installPlugin(deps: Awaited>): Promise { - await seedFromDirectory(deps.fs, PLUGIN_FIXTURE, { useAbsolutePaths: true }); - const addUseCase = new PluginAddUseCase( - deps.fs, - deps.manifestRepo, - deps.pluginFetcher, - new PluginDistributionReaderAdapter(deps.fs), - deps.hasher, - deps.logger, - deps.marketplaceRegistry, - fakeEnsureBuiltMarketplace() - ); - await addUseCase.execute({ - source: { kind: "local", path: PLUGIN_FIXTURE }, - toolIds: ["claude"], - projectRoot: PROJECT_ROOT, - interactive: false, - }); -} - -describe("PluginRemoveUseCase", () => { - describe("remove installed plugin", () => { - it("deletes plugin files and updates manifest", async () => { - const deps = await buildUnitDeps(PROJECT_ROOT); - await initAndInstall(deps, PROJECT_ROOT, "claude"); - await installPlugin(deps); - - const removeUseCase = new PluginRemoveUseCase( - deps.fs, - deps.manifestRepo, - deps.logger, - deps.nativePluginActivators - ); - await removeUseCase.execute({ - pluginName: "sample-plugin", - toolIds: ["claude"], - projectRoot: PROJECT_ROOT, - }); - - expect( - deps.fs.has(join(PROJECT_ROOT, ".claude/plugins/sample-plugin/commands/greet.md")) - ).toBe(false); - const manifest = await deps.manifestRepo.load(); - const plugins = manifest?.getPlugins("claude") ?? []; - expect(plugins.some((p) => p.name === "sample-plugin")).toBe(false); - }); - }); - - describe("remove missing plugin", () => { - it("throws PluginNotFoundError", async () => { - const deps = await buildUnitDeps(PROJECT_ROOT); - await initAndInstall(deps, PROJECT_ROOT, "claude"); - - const removeUseCase = new PluginRemoveUseCase( - deps.fs, - deps.manifestRepo, - deps.logger, - deps.nativePluginActivators - ); - await expect( - removeUseCase.execute({ - pluginName: "nonexistent-plugin", - toolIds: ["claude"], - projectRoot: PROJECT_ROOT, - }) - ).rejects.toThrow(PluginNotFoundError); - }); - }); -}); diff --git a/cli/tests/application/use-cases/plugin/translator/built-tree-opencode-materialization.integration.test.ts b/cli/tests/application/use-cases/plugin/translator/built-tree-opencode-materialization.integration.test.ts deleted file mode 100644 index b0c6daf65..000000000 --- a/cli/tests/application/use-cases/plugin/translator/built-tree-opencode-materialization.integration.test.ts +++ /dev/null @@ -1,134 +0,0 @@ -import "../../../../../src/domain/tools/ai/opencode.js"; -import { describe, expect, it } from "vitest"; -import { BuiltTreeMaterializationTranslator } from "../../../../../src/application/use-cases/plugin/translator/built-tree-materialization-translator.js"; -import { Manifest } from "../../../../../src/domain/models/manifest.js"; -import { Marketplace } from "../../../../../src/domain/models/marketplace.js"; -import { PluginDistribution } from "../../../../../src/domain/models/plugin-distribution.js"; -import { DeterministicHasher } from "../../../../helpers/ports/deterministic-hasher.js"; -import { fakeEnsureBuiltMarketplace } from "../../../../helpers/ports/fake-ensure-built-marketplace.js"; -import { InMemoryFileAdapter } from "../../../../helpers/ports/in-memory-file-adapter.js"; -import { InMemoryMarketplaceRegistry } from "../../../../helpers/ports/in-memory-marketplace-registry.js"; - -const PROJECT_ROOT = "/proj"; -const BUILT = "/built/opencode"; - -function dist(): PluginDistribution { - return new PluginDistribution({ - manifest: { name: "aidd-vcs", version: "1.0.0" }, - format: "claude", - files: [], - components: { commands: [], agents: [], rules: [], skills: [], hooks: [], mcp: [] }, - }); -} - -function distWithHooks(): PluginDistribution { - return new PluginDistribution({ - manifest: { name: "aidd-vcs", version: "1.0.0" }, - format: "claude", - files: [], - components: { - commands: [], - agents: [], - rules: [], - skills: [], - mcp: [], - hooks: [ - { relativePath: "hooks/hooks.json", content: "{}" }, - { relativePath: "hooks/journal.cjs", content: "// journal" }, - { relativePath: "hooks/lib/host.cjs", content: "// host" }, - ], - }, - }); -} - -async function makeRegistry(): Promise { - const registry = new InMemoryMarketplaceRegistry(); - await registry.save( - PROJECT_ROOT, - Marketplace.create({ - name: "aidd-framework", - source: { kind: "local", path: "/src/framework" }, - scope: "project", - addedAt: "2026-01-01T00:00:00Z", - }) - ); - return registry; -} - -describe("BuiltTreeMaterializationTranslator — opencode (integration)", () => { - it("copies only this plugin's flat files into the project, byte-for-byte", async () => { - const fs = new InMemoryFileAdapter(); - const skill = "Load [assets/x.md](../assets/x.md)"; - // This plugin's skills nest under its own segment (aidd-vcs/...); agents stay - // hyphen-prefixed (aidd-vcs-helper.md). Another plugin's files must be ignored. - fs.setFile(`${BUILT}/.opencode/skills/aidd-vcs/01-commit/SKILL.md`, skill); - fs.setFile(`${BUILT}/.opencode/agents/aidd-vcs-helper.md`, "agent body"); - fs.setFile(`${BUILT}/.opencode/skills/aidd-dev/00-sdlc/SKILL.md`, "OTHER PLUGIN"); - fs.setFile(`${BUILT}/.build-version`, "5.0.0:1.0.0"); - - const manifest = Manifest.create(); - manifest.addTool("opencode", "test", []); - const translator = new BuiltTreeMaterializationTranslator( - fs, - new DeterministicHasher(), - () => "/home/u", - fakeEnsureBuiltMarketplace(), - await makeRegistry() - ); - - await translator.addPlugin( - dist(), - "opencode", - { kind: "local", path: "/plugin-source" }, - PROJECT_ROOT, - manifest, - "aidd-framework", - "docs" - ); - - expect(fs.getFile(`${PROJECT_ROOT}/.opencode/skills/aidd-vcs/01-commit/SKILL.md`)).toBe(skill); - expect(fs.getFile(`${PROJECT_ROOT}/.opencode/agents/aidd-vcs-helper.md`)).toBe("agent body"); - // Other plugin's files and the sentinel are NOT installed. - expect(fs.has(`${PROJECT_ROOT}/.opencode/skills/aidd-dev/00-sdlc/SKILL.md`)).toBe(false); - expect(fs.has(`${PROJECT_ROOT}/.build-version`)).toBe(false); - const installed = manifest.getPlugins("opencode").find((p) => p.name === "aidd-vcs"); - expect(installed?.files.size).toBe(2); - }); - - // finding #1: the built tree's flat hooks land in one shared, non-namespaced directory - // (.opencode/plugin/), not under a "-"-prefixed segment like skills/agents — - // so belongsToPlugin's naming-convention filter dropped every hook file here, even - // though the build itself now delivers them. This plugin's own hook filenames, read - // from its distribution, are what scope the copy instead. - it("copies this plugin's flat hooks by filename, not by naming convention", async () => { - const fs = new InMemoryFileAdapter(); - fs.setFile(`${BUILT}/.opencode/plugin/journal.cjs`, "// journal"); - fs.setFile(`${BUILT}/.opencode/plugin/lib/host.cjs`, "// host"); - fs.setFile(`${BUILT}/.opencode/plugin/other-plugin-hook.js`, "OTHER PLUGIN"); - - const manifest = Manifest.create(); - manifest.addTool("opencode", "test", []); - const translator = new BuiltTreeMaterializationTranslator( - fs, - new DeterministicHasher(), - () => "/home/u", - fakeEnsureBuiltMarketplace(), - await makeRegistry() - ); - - await translator.addPlugin( - distWithHooks(), - "opencode", - { kind: "local", path: "/plugin-source" }, - PROJECT_ROOT, - manifest, - "aidd-framework", - "docs" - ); - - expect(fs.getFile(`${PROJECT_ROOT}/.opencode/plugin/journal.cjs`)).toBe("// journal"); - expect(fs.getFile(`${PROJECT_ROOT}/.opencode/plugin/lib/host.cjs`)).toBe("// host"); - expect(fs.has(`${PROJECT_ROOT}/.opencode/plugin/hooks.json`)).toBe(false); - expect(fs.has(`${PROJECT_ROOT}/.opencode/plugin/other-plugin-hook.js`)).toBe(false); - }); -}); diff --git a/cli/tests/application/use-cases/plugin/translator/built-tree-vs-modeb-skills-agree.unit.test.ts b/cli/tests/application/use-cases/plugin/translator/built-tree-vs-modeb-skills-agree.unit.test.ts deleted file mode 100644 index 3a7254914..000000000 --- a/cli/tests/application/use-cases/plugin/translator/built-tree-vs-modeb-skills-agree.unit.test.ts +++ /dev/null @@ -1,110 +0,0 @@ -import { existsSync, readdirSync } from "node:fs"; -import { join, resolve } from "node:path"; -import { describe, expect, it } from "vitest"; -import { buildOpencodeFlatContract } from "../../../../../src/application/use-cases/framework/strategies/tool-contracts.js"; -import { FileHash } from "../../../../../src/domain/models/file.js"; -import { PluginContentTranslator } from "../../../../../src/domain/models/plugin-content-translator.js"; -import { - type PluginComponentFile, - PluginDistribution, -} from "../../../../../src/domain/models/plugin-distribution.js"; -import { opencode } from "../../../../../src/domain/tools/ai/opencode.js"; - -const PLUGINS_DIR = resolve(process.cwd(), "..", "plugins"); - -/** - * Two independent code paths compute an OpenCode-flat skill path for the same plugin - * content: - * - `aidd plugin install --tool opencode` (no marketplace registered) → - * ModeBFlatMaterializationTranslator → PluginContentTranslator.translateFlat - * - `aidd setup` (default marketplace registered, the common case) → - * BuiltTreeMaterializationTranslator → FlatBuildStrategy → buildOpencodeFlatContract - * - * They drifted once already (#defect): the built-tree route hyphen-prefixed every - * immediate child of skills/ — including non-skill children like a shared helper - * directory or a manifest file — breaking any relative `require()` that reaches a - * sibling by its original name. This test exercises both real production functions - * (not a restated formula of either) against a fixture whose non-skill children are - * exactly what makes that regression visible: a fixture with only one clean - * `hello/SKILL.md` folder would pass under either convention and prove nothing. - */ - -const PLUGIN_NAME = "aidd-telemetry"; -const stubHasher = { hash: (_content: string) => new FileHash("a".repeat(32)) }; - -function skillFile(relativePath: string, content = "// stub"): PluginComponentFile { - return { relativePath: `skills/${relativePath}`, content }; -} - -function makeTelemetryLikeDist(): PluginDistribution { - const skills = [ - skillFile("shared/attribution.cjs"), - skillFile("package.json", `{ "type": "commonjs" }`), - skillFile("01-cost/SKILL.md", `---\nname: 01-cost\ndescription: Cost skill\n---\n\nBody.\n`), - skillFile("01-cost/scripts/telemetry-report.cjs", `require("../../shared/attribution.cjs");`), - ]; - return new PluginDistribution({ - manifest: { name: PLUGIN_NAME, version: "1.0.0" }, - format: "claude", - files: skills, - components: { commands: [], agents: [], rules: [], skills, hooks: [], mcp: [] }, - }); -} - -function modeBSkillPaths(dist: PluginDistribution): string[] { - return new PluginContentTranslator(stubHasher) - .translate(dist, opencode, "") - .map((f) => f.relativePath) - .filter((p) => p.startsWith(".opencode/skills/")) - .sort(); -} - -function builtTreeSkillPaths(dist: PluginDistribution): string[] { - const skillsArtifact = buildOpencodeFlatContract().artifacts.skills; - if (!skillsArtifact.supported) throw new Error("opencode flat skills artifact unsupported"); - return dist.components.skills.map((f) => skillsArtifact.path(PLUGIN_NAME, f.relativePath)).sort(); -} - -describe("opencode flat skills — built-tree route agrees with mode-B install route", () => { - it("produce the identical set of relative output paths for a plugin with non-skill children", () => { - const dist = makeTelemetryLikeDist(); - - const modeB = modeBSkillPaths(dist); - const builtTree = builtTreeSkillPaths(dist); - - expect(builtTree).toEqual(modeB); - expect(modeB).toEqual([ - ".opencode/skills/aidd-telemetry/01-cost/SKILL.md", - ".opencode/skills/aidd-telemetry/01-cost/scripts/telemetry-report.cjs", - ".opencode/skills/aidd-telemetry/package.json", - ".opencode/skills/aidd-telemetry/shared/attribution.cjs", - ]); - }); -}); - -// The flat layout used to prefix every skill directory with its plugin's name, which made a -// collision between two plugins' identically-named skills structurally impossible. Nesting -// them under the plugin gives OpenCode a real directory to namespace by and shortens the -// skill's own `name` to its leaf - better to read, and correct as long as the leaves stay -// distinct. Nothing enforces that any more, so this does: the day someone adds a second -// `01-commit`, it is caught here rather than by whichever of the two OpenCode happens to -// resolve. -describe("skill names across plugins, now that the plugin prefix is a directory", () => { - it("no two plugins ship a skill with the same leaf name", () => { - const byLeaf = new Map(); - for (const plugin of readdirSync(PLUGINS_DIR, { withFileTypes: true })) { - if (!plugin.isDirectory()) continue; - const skillsDir = join(PLUGINS_DIR, plugin.name, "skills"); - if (!existsSync(skillsDir)) continue; - for (const skill of readdirSync(skillsDir, { withFileTypes: true })) { - if (!skill.isDirectory()) continue; - if (!existsSync(join(skillsDir, skill.name, "SKILL.md"))) continue; - byLeaf.set(skill.name, [...(byLeaf.get(skill.name) ?? []), plugin.name]); - } - } - - const shared = [...byLeaf.entries()].filter(([, plugins]) => plugins.length > 1); - expect(byLeaf.size).toBeGreaterThan(10); - expect(shared.map(([leaf, plugins]) => `${leaf}: ${plugins.join(", ")}`)).toEqual([]); - }); -}); diff --git a/cli/tests/application/use-cases/plugin/translator/install-plugin-claude-mode-a.integration.test.ts b/cli/tests/application/use-cases/plugin/translator/install-plugin-claude-mode-a.integration.test.ts deleted file mode 100644 index 58bbd39b6..000000000 --- a/cli/tests/application/use-cases/plugin/translator/install-plugin-claude-mode-a.integration.test.ts +++ /dev/null @@ -1,122 +0,0 @@ -import "../../../../../src/domain/tools/ai/claude.js"; -import { resolve } from "node:path"; -import { describe, expect, it } from "vitest"; -import { MarketplaceSyncSettingsUseCase } from "../../../../../src/application/use-cases/marketplace/marketplace-sync-settings-use-case.js"; -import { ModeAMarketplaceTranslator } from "../../../../../src/application/use-cases/plugin/translator/mode-a-marketplace-translator.js"; -import { Manifest } from "../../../../../src/domain/models/manifest.js"; -import { Marketplace } from "../../../../../src/domain/models/marketplace.js"; -import { PluginDistribution } from "../../../../../src/domain/models/plugin-distribution.js"; -import { PluginCatalogRepositoryAdapter } from "../../../../../src/infrastructure/adapters/plugin-catalog-repository-adapter.js"; -import { CapturingLogger } from "../../../../helpers/ports/capturing-logger.js"; -import { DeterministicHasher } from "../../../../helpers/ports/deterministic-hasher.js"; -import { fakeEnsureBuiltMarketplace } from "../../../../helpers/ports/fake-ensure-built-marketplace.js"; -import { InMemoryFileAdapter } from "../../../../helpers/ports/in-memory-file-adapter.js"; -import { InMemoryManifestRepository } from "../../../../helpers/ports/in-memory-manifest-repository.js"; -import { InMemoryMarketplaceRegistry } from "../../../../helpers/ports/in-memory-marketplace-registry.js"; - -const PROJECT_ROOT = "/test-project"; -const MARKETPLACE_NAME = "aidd-framework"; - -function buildDist(name = "aidd-context"): PluginDistribution { - return new PluginDistribution({ - manifest: { name, version: "1.0.0" }, - format: "claude", - files: [{ relativePath: "commands/hello.md", content: "# Hello" }], - components: { - commands: [{ relativePath: "commands/hello.md", content: "# Hello" }], - agents: [], - rules: [], - skills: [], - hooks: [], - mcp: [], - }, - }); -} - -describe("install claude plugin via Mode A (integration)", () => { - it("writes extraKnownMarketplaces in .claude/settings.json after sync", async () => { - const fs = new InMemoryFileAdapter(); - const hasher = new DeterministicHasher(); - const manifestRepo = new InMemoryManifestRepository(); - const registry = new InMemoryMarketplaceRegistry(); - const catalog = new PluginCatalogRepositoryAdapter(fs); - const manifest = Manifest.create(); - manifest.addTool("claude", "test", []); - - await new ModeAMarketplaceTranslator().addPlugin( - buildDist(), - "claude", - { kind: "local", path: "/plugin-source" }, - PROJECT_ROOT, - manifest, - MARKETPLACE_NAME, - "docs" - ); - await manifestRepo.save(manifest); - await registry.save( - PROJECT_ROOT, - Marketplace.create({ - name: MARKETPLACE_NAME, - source: { kind: "local", path: "/marketplace-source" }, - scope: "project", - addedAt: "2026-01-01T00:00:00Z", - }) - ); - - const useCase = new MarketplaceSyncSettingsUseCase( - fs, - manifestRepo, - registry, - catalog, - hasher, - new CapturingLogger(), - new Map(), - fakeEnsureBuiltMarketplace() - ); - const result = await useCase.execute({ projectRoot: PROJECT_ROOT }); - - expect(result.updatedTools).toContain("claude"); - const settingsPath = resolve(PROJECT_ROOT, ".claude/settings.json"); - const settings = JSON.parse(await fs.readFile(settingsPath)) as Record; - expect(settings.extraKnownMarketplaces).toBeDefined(); - // Settings reference the BUILT claude tree, not the raw source. - // marketplace-sync-settings-use-case.ts's resolveSourceForSettings re-resolves every - // local source against projectRoot before writing it out, even one already absolute - - // harmless when that path already carries a drive letter, as a real builtDir always - // does, but a genuinely drive-less absolute local path (e.g. a marketplaces.json - // committed on POSIX and read on Windows) would silently gain the current drive - // instead of failing loud. Named, not fixed here - the expectation below goes through - // the same call so this test does not mask that behavior as something else. - // (#707: this is what surfaced it - the fake ensureBuilt stand-in these tests share - // returns exactly that drive-less shape.) - expect((settings.extraKnownMarketplaces as Record)[MARKETPLACE_NAME]).toEqual({ - source: { - source: "directory", - path: resolve(PROJECT_ROOT, "/built/claude").replace(/\\/g, "/"), - }, - }); - expect(settings.enabledPlugins).toBeDefined(); - expect( - (settings.enabledPlugins as Record)[`aidd-context@${MARKETPLACE_NAME}`] - ).toBe(true); - }); - - it("does not materialize plugin files on disk for Mode A", async () => { - const fs = new InMemoryFileAdapter(); - const manifest = Manifest.create(); - manifest.addTool("claude", "test", []); - await new ModeAMarketplaceTranslator().addPlugin( - buildDist(), - "claude", - { kind: "local", path: "/plugin-source" }, - PROJECT_ROOT, - manifest, - MARKETPLACE_NAME, - "docs" - ); - const pluginFiles = fs.listAll().filter((p) => p.includes(".claude/plugins/")); - expect(pluginFiles).toEqual([]); - const installed = manifest.getPlugins("claude").find((p) => p.name === "aidd-context"); - expect(installed?.files.size).toBe(0); - }); -}); diff --git a/cli/tests/application/use-cases/plugin/translator/install-plugin-codex-mode-a.integration.test.ts b/cli/tests/application/use-cases/plugin/translator/install-plugin-codex-mode-a.integration.test.ts deleted file mode 100644 index 52c4fc4b1..000000000 --- a/cli/tests/application/use-cases/plugin/translator/install-plugin-codex-mode-a.integration.test.ts +++ /dev/null @@ -1,205 +0,0 @@ -// Codex enables plugins through its own CLI (`codex plugin add`), which writes the -// user-global `~/.codex/config.toml` and plugin cache — a project-local settings file is -// inert. This test asserts the sync drives the CodexActivator and writes NO `.codex/config.json`. -import "../../../../../src/domain/tools/ai/codex.js"; -import { resolve } from "node:path"; -import { describe, expect, it } from "vitest"; -import { MarketplaceSyncSettingsUseCase } from "../../../../../src/application/use-cases/marketplace/marketplace-sync-settings-use-case.js"; -import { ModeAMarketplaceTranslator } from "../../../../../src/application/use-cases/plugin/translator/mode-a-marketplace-translator.js"; -import { Manifest } from "../../../../../src/domain/models/manifest.js"; -import { Marketplace } from "../../../../../src/domain/models/marketplace.js"; -import { PluginDistribution } from "../../../../../src/domain/models/plugin-distribution.js"; -import type { PluginSource } from "../../../../../src/domain/models/plugin-source.js"; -import { PluginCatalogRepositoryAdapter } from "../../../../../src/infrastructure/adapters/plugin-catalog-repository-adapter.js"; -import { CapturingLogger } from "../../../../helpers/ports/capturing-logger.js"; -import { DeterministicHasher } from "../../../../helpers/ports/deterministic-hasher.js"; -import { fakeEnsureBuiltMarketplace } from "../../../../helpers/ports/fake-ensure-built-marketplace.js"; -import { FakeNativePluginActivator } from "../../../../helpers/ports/fake-native-plugin-activator.js"; -import { InMemoryFileAdapter } from "../../../../helpers/ports/in-memory-file-adapter.js"; -import { InMemoryManifestRepository } from "../../../../helpers/ports/in-memory-manifest-repository.js"; -import { InMemoryMarketplaceRegistry } from "../../../../helpers/ports/in-memory-marketplace-registry.js"; - -const PROJECT_ROOT = "/test-project"; -const MARKETPLACE_NAME = "aidd-framework"; - -function buildDist(name = "aidd-context"): PluginDistribution { - return new PluginDistribution({ - manifest: { name, version: "1.0.0" }, - format: "claude", - files: [{ relativePath: "commands/hello.md", content: "# Hello" }], - components: { - commands: [{ relativePath: "commands/hello.md", content: "# Hello" }], - agents: [], - rules: [], - skills: [], - hooks: [], - mcp: [], - }, - }); -} - -async function seedCodexPlugin( - manifestRepo: InMemoryManifestRepository, - registry: InMemoryMarketplaceRegistry, - source: PluginSource = { kind: "local", path: "/marketplace-source" } -): Promise { - const manifest = Manifest.create(); - manifest.addTool("codex", "test", []); - await new ModeAMarketplaceTranslator().addPlugin( - buildDist(), - "codex", - { kind: "local", path: "/plugin-source" }, - PROJECT_ROOT, - manifest, - MARKETPLACE_NAME, - "docs" - ); - await manifestRepo.save(manifest); - await registry.save( - PROJECT_ROOT, - Marketplace.create({ - name: MARKETPLACE_NAME, - source, - scope: "project", - addedAt: "2026-01-01T00:00:00Z", - }) - ); -} - -async function seedTwoCodexPlugins( - manifestRepo: InMemoryManifestRepository, - registry: InMemoryMarketplaceRegistry -): Promise { - const manifest = Manifest.create(); - manifest.addTool("codex", "test", []); - const translator = new ModeAMarketplaceTranslator(); - for (const name of ["aidd-context", "aidd-vcs"]) { - await translator.addPlugin( - buildDist(name), - "codex", - { kind: "local", path: "/plugin-source" }, - PROJECT_ROOT, - manifest, - MARKETPLACE_NAME, - "docs" - ); - } - await manifestRepo.save(manifest); - await registry.save( - PROJECT_ROOT, - Marketplace.create({ - name: MARKETPLACE_NAME, - source: { kind: "local", path: "/marketplace-source" }, - scope: "project", - addedAt: "2026-01-01T00:00:00Z", - }) - ); -} - -describe("install codex plugin via Mode A (integration)", () => { - it("drives the codex CLI and writes no project-local config.json", async () => { - const fs = new InMemoryFileAdapter(); - const hasher = new DeterministicHasher(); - const manifestRepo = new InMemoryManifestRepository(); - const registry = new InMemoryMarketplaceRegistry(); - const catalog = new PluginCatalogRepositoryAdapter(fs); - const activator = new FakeNativePluginActivator({ available: true }); - await seedCodexPlugin(manifestRepo, registry); - - const useCase = new MarketplaceSyncSettingsUseCase( - fs, - manifestRepo, - registry, - catalog, - hasher, - new CapturingLogger(), - new Map([["codex", activator]]), - fakeEnsureBuiltMarketplace() - ); - await useCase.execute({ projectRoot: PROJECT_ROOT }); - - // Registers the BUILT (transformed) tree, not the raw source. A fresh add - // succeeds outright — no pre-emptive remove on a clean install. - expect(activator.removedMarketplaces).toEqual([]); - expect(activator.addedMarketplaces).toEqual(["/built/codex"]); - expect(activator.upgradeCount).toBe(1); - expect(activator.enabledPlugins).toEqual([`aidd-context@${MARKETPLACE_NAME}`]); - expect(await fs.fileExists(resolve(PROJECT_ROOT, ".codex/config.json"))).toBe(false); - }); - - it("builds a github marketplace locally and registers the built tree", async () => { - const fs = new InMemoryFileAdapter(); - const manifestRepo = new InMemoryManifestRepository(); - const registry = new InMemoryMarketplaceRegistry(); - const activator = new FakeNativePluginActivator({ available: true }); - await seedCodexPlugin(manifestRepo, registry, { - kind: "github", - repo: "ai-driven-dev/framework", - }); - - const useCase = new MarketplaceSyncSettingsUseCase( - fs, - manifestRepo, - registry, - new PluginCatalogRepositoryAdapter(fs), - new DeterministicHasher(), - new CapturingLogger(), - new Map([["codex", activator]]), - fakeEnsureBuiltMarketplace() - ); - await useCase.execute({ projectRoot: PROJECT_ROOT }); - - expect(activator.addedMarketplaces).toEqual(["/built/codex"]); - expect(activator.enabledPlugins).toEqual([`aidd-context@${MARKETPLACE_NAME}`]); - }); - - it("enables the remaining plugins when one plugin fails (per-plugin best-effort)", async () => { - const fs = new InMemoryFileAdapter(); - const manifestRepo = new InMemoryManifestRepository(); - const registry = new InMemoryMarketplaceRegistry(); - const logger = new CapturingLogger(); - const activator = new FakeNativePluginActivator({ - available: true, - failOnPlugins: [`aidd-context@${MARKETPLACE_NAME}`], - }); - await seedTwoCodexPlugins(manifestRepo, registry); - - const useCase = new MarketplaceSyncSettingsUseCase( - fs, - manifestRepo, - registry, - new PluginCatalogRepositoryAdapter(fs), - new DeterministicHasher(), - logger, - new Map([["codex", activator]]), - fakeEnsureBuiltMarketplace() - ); - await useCase.execute({ projectRoot: PROJECT_ROOT }); - - expect(activator.enabledPlugins).toEqual([`aidd-vcs@${MARKETPLACE_NAME}`]); - expect(logger.warnMessages.some((m) => m.includes("aidd-context@aidd-framework"))).toBe(true); - }); - - it("skips activation when the codex CLI is unavailable", async () => { - const fs = new InMemoryFileAdapter(); - const manifestRepo = new InMemoryManifestRepository(); - const registry = new InMemoryMarketplaceRegistry(); - const activator = new FakeNativePluginActivator({ available: false }); - await seedCodexPlugin(manifestRepo, registry); - - const useCase = new MarketplaceSyncSettingsUseCase( - fs, - manifestRepo, - registry, - new PluginCatalogRepositoryAdapter(fs), - new DeterministicHasher(), - new CapturingLogger(), - new Map([["codex", activator]]), - fakeEnsureBuiltMarketplace() - ); - await useCase.execute({ projectRoot: PROJECT_ROOT }); - - expect(activator.addedMarketplaces).toEqual([]); - expect(activator.enabledPlugins).toEqual([]); - }); -}); diff --git a/cli/tests/application/use-cases/plugin/translator/install-plugin-copilot-mode-a.integration.test.ts b/cli/tests/application/use-cases/plugin/translator/install-plugin-copilot-mode-a.integration.test.ts deleted file mode 100644 index 66572a2dc..000000000 --- a/cli/tests/application/use-cases/plugin/translator/install-plugin-copilot-mode-a.integration.test.ts +++ /dev/null @@ -1,193 +0,0 @@ -import "../../../../../src/domain/tools/ai/copilot.js"; -import { resolve } from "node:path"; -import { describe, expect, it } from "vitest"; -import { MarketplaceSyncSettingsUseCase } from "../../../../../src/application/use-cases/marketplace/marketplace-sync-settings-use-case.js"; -import { ModeAMarketplaceTranslator } from "../../../../../src/application/use-cases/plugin/translator/mode-a-marketplace-translator.js"; -import { Manifest } from "../../../../../src/domain/models/manifest.js"; -import { Marketplace } from "../../../../../src/domain/models/marketplace.js"; -import { PluginDistribution } from "../../../../../src/domain/models/plugin-distribution.js"; -import { PluginCatalogRepositoryAdapter } from "../../../../../src/infrastructure/adapters/plugin-catalog-repository-adapter.js"; -import { CapturingLogger } from "../../../../helpers/ports/capturing-logger.js"; -import { DeterministicHasher } from "../../../../helpers/ports/deterministic-hasher.js"; -import { fakeEnsureBuiltMarketplace } from "../../../../helpers/ports/fake-ensure-built-marketplace.js"; -import { FakeNativePluginActivator } from "../../../../helpers/ports/fake-native-plugin-activator.js"; -import { InMemoryFileAdapter } from "../../../../helpers/ports/in-memory-file-adapter.js"; -import { InMemoryManifestRepository } from "../../../../helpers/ports/in-memory-manifest-repository.js"; -import { InMemoryMarketplaceRegistry } from "../../../../helpers/ports/in-memory-marketplace-registry.js"; - -const PROJECT_ROOT = "/test-project"; -const MARKETPLACE_NAME = "aidd-framework"; - -async function seedCopilotPlugin( - manifestRepo: InMemoryManifestRepository, - registry: InMemoryMarketplaceRegistry -): Promise { - const manifest = Manifest.create(); - manifest.addTool("copilot", "test", []); - await new ModeAMarketplaceTranslator().addPlugin( - buildDist(), - "copilot", - { kind: "github", repo: "ai-driven-dev/framework" }, - PROJECT_ROOT, - manifest, - MARKETPLACE_NAME, - "docs" - ); - await manifestRepo.save(manifest); - await registry.save( - PROJECT_ROOT, - Marketplace.create({ - name: MARKETPLACE_NAME, - source: { kind: "github", repo: "ai-driven-dev/framework" }, - scope: "project", - addedAt: "2026-01-01T00:00:00Z", - }) - ); -} - -function buildDist(name = "aidd-context"): PluginDistribution { - return new PluginDistribution({ - manifest: { name, version: "1.0.0" }, - format: "claude", - files: [{ relativePath: "commands/hello.md", content: "# Hello" }], - components: { - commands: [{ relativePath: "commands/hello.md", content: "# Hello" }], - agents: [], - rules: [], - skills: [], - hooks: [], - mcp: [], - }, - }); -} - -describe("install copilot plugin via Mode A (integration)", () => { - it("writes extraKnownMarketplaces in .github/copilot/settings.json after sync", async () => { - const fs = new InMemoryFileAdapter(); - const hasher = new DeterministicHasher(); - const manifestRepo = new InMemoryManifestRepository(); - const registry = new InMemoryMarketplaceRegistry(); - const catalog = new PluginCatalogRepositoryAdapter(fs); - await seedCopilotPlugin(manifestRepo, registry); - - const useCase = new MarketplaceSyncSettingsUseCase( - fs, - manifestRepo, - registry, - catalog, - hasher, - new CapturingLogger(), - new Map(), - fakeEnsureBuiltMarketplace() - ); - const result = await useCase.execute({ projectRoot: PROJECT_ROOT }); - - expect(result.updatedTools).toContain("copilot"); - const settingsPath = resolve(PROJECT_ROOT, ".github/copilot/settings.json"); - const settings = JSON.parse(await fs.readFile(settingsPath)) as Record; - expect(settings.extraKnownMarketplaces).toBeDefined(); - // Settings reference the BUILT copilot tree, not the raw github source. - // marketplace-sync-settings-use-case.ts's resolveSourceForSettings re-resolves every - // local source against projectRoot before writing it out, even one already absolute - - // harmless when that path already carries a drive letter, as a real builtDir always - // does, but a genuinely drive-less absolute local path (e.g. a marketplaces.json - // committed on POSIX and read on Windows) would silently gain the current drive - // instead of failing loud. Named, not fixed here - the expectation below goes through - // the same call so this test does not mask that behavior as something else. - // (#707: this is what surfaced it - the fake ensureBuilt stand-in these tests share - // returns exactly that drive-less shape.) - expect((settings.extraKnownMarketplaces as Record)[MARKETPLACE_NAME]).toEqual({ - source: { - source: "directory", - path: resolve(PROJECT_ROOT, "/built/copilot").replace(/\\/g, "/"), - }, - }); - expect(settings.enabledPlugins).toBeDefined(); - }); - - it("drives the copilot CLI activator and still writes the settings file", async () => { - const fs = new InMemoryFileAdapter(); - const manifestRepo = new InMemoryManifestRepository(); - const registry = new InMemoryMarketplaceRegistry(); - const activator = new FakeNativePluginActivator({ available: true }); - await seedCopilotPlugin(manifestRepo, registry); - - const useCase = new MarketplaceSyncSettingsUseCase( - fs, - manifestRepo, - registry, - new PluginCatalogRepositoryAdapter(fs), - new DeterministicHasher(), - new CapturingLogger(), - new Map([["copilot", activator]]), - fakeEnsureBuiltMarketplace() - ); - await useCase.execute({ projectRoot: PROJECT_ROOT }); - - // Registers the BUILT copilot tree (not the raw github source). A fresh add - // succeeds outright — no pre-emptive remove. - expect(activator.removedMarketplaces).toEqual([]); - expect(activator.addedMarketplaces).toEqual(["/built/copilot"]); - expect(activator.enabledPlugins).toEqual([`aidd-context@${MARKETPLACE_NAME}`]); - expect(await fs.fileExists(resolve(PROJECT_ROOT, ".github/copilot/settings.json"))).toBe(true); - }); - - it("removes then re-adds when the name is registered from a different source", async () => { - const fs = new InMemoryFileAdapter(); - const manifestRepo = new InMemoryManifestRepository(); - const registry = new InMemoryMarketplaceRegistry(); - const activator = new FakeNativePluginActivator({ available: true, conflictOnAdd: true }); - await seedCopilotPlugin(manifestRepo, registry); - - const useCase = new MarketplaceSyncSettingsUseCase( - fs, - manifestRepo, - registry, - new PluginCatalogRepositoryAdapter(fs), - new DeterministicHasher(), - new CapturingLogger(), - new Map([["copilot", activator]]), - fakeEnsureBuiltMarketplace() - ); - await useCase.execute({ projectRoot: PROJECT_ROOT }); - - // First add hits the different-source conflict → remove, then re-add succeeds. - expect(activator.removedMarketplaces).toEqual([MARKETPLACE_NAME]); - expect(activator.addedMarketplaces).toEqual(["/built/copilot"]); - expect(activator.enabledPlugins).toEqual([`aidd-context@${MARKETPLACE_NAME}`]); - }); - - it("traces the speculative remove at debug and surfaces the real error when add did not fail on a conflict", async () => { - const fs = new InMemoryFileAdapter(); - const manifestRepo = new InMemoryManifestRepository(); - const registry = new InMemoryMarketplaceRegistry(); - // add keeps failing and the name is absent (remove throws): not a recoverable conflict. - const activator = new FakeNativePluginActivator({ - available: true, - conflictOnAdd: true, - throwOnRemove: true, - }); - const logger = new CapturingLogger(); - await seedCopilotPlugin(manifestRepo, registry); - - const useCase = new MarketplaceSyncSettingsUseCase( - fs, - manifestRepo, - registry, - new PluginCatalogRepositoryAdapter(fs), - new DeterministicHasher(), - logger, - new Map([["copilot", activator]]), - fakeEnsureBuiltMarketplace() - ); - await useCase.execute({ projectRoot: PROJECT_ROOT }); - - // The speculative remove failure is a debug trace, never a scary warn... - expect(logger.warnMessages.some((m) => m.includes("unregister stale"))).toBe(false); - expect(logger.debugMessages.some((m) => m.includes("not unregistered before re-add"))).toBe( - true - ); - // ...and the real re-add failure is surfaced (best-effort warn), not swallowed. - expect(logger.warnMessages.some((m) => m.includes("register marketplace"))).toBe(true); - }); -}); diff --git a/cli/tests/application/use-cases/plugin/translator/install-plugin-cursor-marketplace-hooks.integration.test.ts b/cli/tests/application/use-cases/plugin/translator/install-plugin-cursor-marketplace-hooks.integration.test.ts deleted file mode 100644 index 278820747..000000000 --- a/cli/tests/application/use-cases/plugin/translator/install-plugin-cursor-marketplace-hooks.integration.test.ts +++ /dev/null @@ -1,214 +0,0 @@ -/** - * Phase 7, Task 2 — a marketplace-sourced Cursor install must deliver hooks to the - * same destination a local-source install does. Phase 6 routed the local-source route - * (ModeBFlatMaterializationTranslator) into the project's own .cursor/hooks.json; - * BuiltTreeMaterializationTranslator — the marketplace route, taken when - * `aidd plugin install --from ` resolves a registered marketplace — - * still copied the built tree's plugin-scoped hooks/hooks.json verbatim into - * ~/.cursor/plugins/local//hooks/hooks.json, the directory three probes showed - * Cursor never reads (see measurements.md, Phase 4). Both routes now delegate to the - * one ProjectHooksMaterializer, decided by cursor.ts's own hooksDestination declaration. - * - * The last test asserts the two translators agree on destination directly — the disagreement - * test the phase instruction asked for, not just "both happen to look right today". - */ -import "../../../../../src/domain/tools/ai/cursor.js"; -import { join } from "node:path"; -import { describe, expect, it } from "vitest"; -import { BuiltTreeMaterializationTranslator } from "../../../../../src/application/use-cases/plugin/translator/built-tree-materialization-translator.js"; -import { ModeBFlatMaterializationTranslator } from "../../../../../src/application/use-cases/plugin/translator/mode-b-flat-materialization-translator.js"; -import type { PluginTranslator } from "../../../../../src/application/use-cases/plugin/translator/plugin-translator.js"; -import { Manifest } from "../../../../../src/domain/models/manifest.js"; -import { Marketplace } from "../../../../../src/domain/models/marketplace.js"; -import { PluginDistribution } from "../../../../../src/domain/models/plugin-distribution.js"; -import type { AiToolId } from "../../../../../src/domain/models/tool-ids.js"; -import { getToolConfig, isAiTool } from "../../../../../src/domain/tools/registry.js"; -import { DeterministicHasher } from "../../../../helpers/ports/deterministic-hasher.js"; -import { fakeEnsureBuiltMarketplace } from "../../../../helpers/ports/fake-ensure-built-marketplace.js"; -import { InMemoryFileAdapter } from "../../../../helpers/ports/in-memory-file-adapter.js"; -import { InMemoryMarketplaceRegistry } from "../../../../helpers/ports/in-memory-marketplace-registry.js"; - -const PROJECT_ROOT = "/proj"; -const HOME = "/home/u"; -const BUILT = "/built/cursor"; -const PLUGIN_NAME = "sample-plugin"; - -// biome-ignore lint/suspicious/noTemplateCurlyInString: intentionally testing Claude hook placeholder substitution -const PLUGIN_ROOT_VAR = "${CLAUDE_PLUGIN_ROOT}"; - -const HOOKS_CONTENT = JSON.stringify({ - hooks: { - PostToolUse: [ - { hooks: [{ type: "command", command: `node ${PLUGIN_ROOT_VAR}/hooks/post.js` }] }, - ], - }, -}); - -function dist(): PluginDistribution { - return new PluginDistribution({ - manifest: { name: PLUGIN_NAME, version: "1.0.0" }, - format: "claude", - files: [{ relativePath: "hooks/hooks.json", content: HOOKS_CONTENT }], - components: { - commands: [], - agents: [], - rules: [], - skills: [], - hooks: [ - { relativePath: "hooks/hooks.json", content: HOOKS_CONTENT }, - { relativePath: "hooks/post.js", content: "module.exports = () => {};" }, - ], - mcp: [], - }, - }); -} - -async function makeRegistry(): Promise { - const registry = new InMemoryMarketplaceRegistry(); - await registry.save( - PROJECT_ROOT, - Marketplace.create({ - name: "aidd-framework", - source: { kind: "local", path: "/src/framework" }, - scope: "project", - addedAt: "2026-01-01T00:00:00Z", - }) - ); - return registry; -} - -describe("BuiltTreeMaterializationTranslator — cursor marketplace hooks (Phase 7)", () => { - it("merges hooks into the project's .cursor/hooks.json, not the plugin-scoped built tree", async () => { - const fs = new InMemoryFileAdapter(); - fs.setFile(`${BUILT}/plugins/${PLUGIN_NAME}/hooks/hooks.json`, HOOKS_CONTENT); - fs.setFile(`${BUILT}/plugins/${PLUGIN_NAME}/hooks/post.js`, "module.exports = () => {};"); - const manifest = Manifest.create(); - manifest.addTool("cursor", "test", []); - const translator = new BuiltTreeMaterializationTranslator( - fs, - new DeterministicHasher(), - () => HOME, - fakeEnsureBuiltMarketplace(), - await makeRegistry() - ); - - const { skipped } = await translator.addPlugin( - dist(), - "cursor", - { kind: "local", path: "/plugin-source" }, - PROJECT_ROOT, - manifest, - "aidd-framework", - "docs" - ); - - const hooksPath = join(PROJECT_ROOT, ".cursor", "hooks.json"); - expect(fs.has(hooksPath)).toBe(true); - const parsed = JSON.parse(fs.getFile(hooksPath) ?? "{}") as { hooks: Record }; - expect(parsed.hooks).toHaveProperty("postToolUse"); - expect(skipped).toEqual([]); - }); - - it("writes no hooks/ path under the plugin-scoped built-tree destination", async () => { - const fs = new InMemoryFileAdapter(); - fs.setFile(`${BUILT}/plugins/${PLUGIN_NAME}/hooks/hooks.json`, HOOKS_CONTENT); - fs.setFile(`${BUILT}/plugins/${PLUGIN_NAME}/hooks/post.js`, "module.exports = () => {};"); - const manifest = Manifest.create(); - manifest.addTool("cursor", "test", []); - const translator = new BuiltTreeMaterializationTranslator( - fs, - new DeterministicHasher(), - () => HOME, - fakeEnsureBuiltMarketplace(), - await makeRegistry() - ); - - await translator.addPlugin( - dist(), - "cursor", - { kind: "local", path: "/plugin-source" }, - PROJECT_ROOT, - manifest, - "aidd-framework", - "docs" - ); - - const base = `${HOME}/.cursor/plugins/local/${PLUGIN_NAME}`; - const written = fs.listUnder(base); - expect(written.some((p) => p.includes("hooks"))).toBe(false); - }); -}); - -describe("Cursor's two install routes agree on hooks destination (Phase 7, Task 2)", () => { - async function installViaLocal(): Promise { - const fs = new InMemoryFileAdapter(); - const manifest = Manifest.create(); - manifest.addTool("cursor", "test", []); - const translator: PluginTranslator = new ModeBFlatMaterializationTranslator( - fs, - new DeterministicHasher(), - () => HOME - ); - await translator.addPlugin( - dist(), - "cursor", - { kind: "local", path: "/plugin-source" }, - PROJECT_ROOT, - manifest, - undefined, - "docs" - ); - return fs; - } - - async function installViaMarketplace(): Promise { - const fs = new InMemoryFileAdapter(); - fs.setFile(`${BUILT}/plugins/${PLUGIN_NAME}/hooks/hooks.json`, HOOKS_CONTENT); - fs.setFile(`${BUILT}/plugins/${PLUGIN_NAME}/hooks/post.js`, "module.exports = () => {};"); - const manifest = Manifest.create(); - manifest.addTool("cursor", "test", []); - const translator: PluginTranslator = new BuiltTreeMaterializationTranslator( - fs, - new DeterministicHasher(), - () => HOME, - fakeEnsureBuiltMarketplace(), - await makeRegistry() - ); - await translator.addPlugin( - dist(), - "cursor", - { kind: "local", path: "/plugin-source" }, - PROJECT_ROOT, - manifest, - "aidd-framework", - "docs" - ); - return fs; - } - - // The destination this test pins to is read from cursor.ts's own declaration, not - // hard-coded — so it fails if either translator drifts from what the tool itself names, - // not merely if the two translators drift from each other (both regressing to plugin - // scope together would still pass a route-vs-route-only comparison). - function declaredHooksDestination(toolId: AiToolId): "plugin" | "project" { - const toolConfig = getToolConfig(toolId); - if (!isAiTool(toolConfig)) throw new Error(`${toolId} is not an AI tool`); - const caps = toolConfig.capabilities as Record; - return (caps.plugins as { hooksDestination: "plugin" | "project" }).hooksDestination; - } - - it("both routes write to the destination cursor.ts declares — a plugin.hooksDestination change breaks this", async () => { - expect(declaredHooksDestination("cursor")).toBe("project"); - - const viaLocal = await installViaLocal(); - const viaMarketplace = await installViaMarketplace(); - - const projectHooksPath = join(PROJECT_ROOT, ".cursor", "hooks.json"); - expect(viaLocal.has(projectHooksPath)).toBe(true); - expect(viaMarketplace.has(projectHooksPath)).toBe(true); - - const pluginScopedBase = `${HOME}/.cursor/plugins/local/${PLUGIN_NAME}`; - expect(viaLocal.listUnder(pluginScopedBase).some((p) => p.includes("hooks"))).toBe(false); - expect(viaMarketplace.listUnder(pluginScopedBase).some((p) => p.includes("hooks"))).toBe(false); - }); -}); diff --git a/cli/tests/application/use-cases/plugin/translator/remove-plugin-cursor-hooks-mcp.integration.test.ts b/cli/tests/application/use-cases/plugin/translator/remove-plugin-cursor-hooks-mcp.integration.test.ts deleted file mode 100644 index 159a2db9b..000000000 --- a/cli/tests/application/use-cases/plugin/translator/remove-plugin-cursor-hooks-mcp.integration.test.ts +++ /dev/null @@ -1,173 +0,0 @@ -/** - * Phase 6 — Cursor remove: mcp.json is tracked in Plugin.files, so the existing - * deletePluginFiles mechanism removes it on uninstall exactly as before. - * - * hooks.json is not among those keys: hooksDestination:"project" (see - * install-plugin-cursor-hooks-mcp.integration.test.ts) merges hook entries into the - * project's own .cursor/hooks.json, a file `plugin remove`'s baseDir-relative - * deletePluginFiles has no way to find. Phase 7 closes that gap directly: - * PluginRemoveUseCase.removeProjectHooks unmerges this plugin's entries out of - * .cursor/hooks.json and deletes its .cursor/hooks// scripts, leaving every - * other plugin's contribution untouched — proven below by installing and removing for - * real, not by reading mergeCursorProjectHooksJson. - * - * The mcp.json deletion is tested indirectly: we verify that its Plugin.files key - * matches the written absolute path (so join(resolvedBase, key) == absolutePath). - * PluginRemoveUseCase.deletePluginFiles iterates these keys, so if it's correct - * the file will be removed. - */ -import "../../../../../src/domain/tools/ai/cursor.js"; -import { join } from "node:path"; -import { describe, expect, it } from "vitest"; -import { CLIOutput } from "../../../../../src/application/output.js"; -import { PluginRemoveUseCase } from "../../../../../src/application/use-cases/plugin/plugin-remove-use-case.js"; -import { ModeBFlatMaterializationTranslator } from "../../../../../src/application/use-cases/plugin/translator/mode-b-flat-materialization-translator.js"; -import { Manifest } from "../../../../../src/domain/models/manifest.js"; -import { PluginDistribution } from "../../../../../src/domain/models/plugin-distribution.js"; -import { DeterministicHasher } from "../../../../helpers/ports/deterministic-hasher.js"; -import { InMemoryFileAdapter } from "../../../../helpers/ports/in-memory-file-adapter.js"; -import { InMemoryManifestRepository } from "../../../../helpers/ports/in-memory-manifest-repository.js"; - -const STUB_HOME = "/tmp/test-home"; -const PROJECT_ROOT = "/test-project"; -const PLUGIN_NAME = "aidd-context"; -const OTHER_PLUGIN_NAME = "aidd-context-two"; -const RESOLVED_BASE = join(STUB_HOME, ".cursor", "plugins", "local"); -const HOOKS_PATH = join(PROJECT_ROOT, ".cursor", "hooks.json"); -const SCRIPT_PATH = join(PROJECT_ROOT, ".cursor", "hooks", PLUGIN_NAME, "pre.js"); -const OTHER_SCRIPT_PATH = join(PROJECT_ROOT, ".cursor", "hooks", OTHER_PLUGIN_NAME, "pre.js"); - -// biome-ignore lint/suspicious/noTemplateCurlyInString: intentionally testing Claude hook placeholder substitution -const PLUGIN_ROOT_VAR = "${CLAUDE_PLUGIN_ROOT}"; - -const HOOKS_CONTENT = JSON.stringify({ - hooks: { - PreToolUse: [ - { - hooks: [{ type: "command", command: `node ${PLUGIN_ROOT_VAR}/hooks/pre.js` }], - }, - ], - }, -}); - -const MCP_CONTENT = JSON.stringify({ - mcpServers: { - "local-tool": { command: "node", args: ["./mcp-server.js"] }, - }, -}); - -function buildDist(name: string): PluginDistribution { - return new PluginDistribution({ - manifest: { name, version: "1.0.0" }, - format: "claude", - files: [ - { relativePath: "hooks/hooks.json", content: HOOKS_CONTENT }, - { relativePath: "hooks/pre.js", content: "module.exports = () => {};" }, - { relativePath: ".mcp.json", content: MCP_CONTENT }, - ], - components: { - commands: [], - agents: [], - rules: [], - skills: [], - hooks: [ - { relativePath: "hooks/hooks.json", content: HOOKS_CONTENT }, - { relativePath: "hooks/pre.js", content: "module.exports = () => {};" }, - ], - mcp: [{ relativePath: ".mcp.json", content: MCP_CONTENT }], - }, - }); -} - -async function installPlugin(fs: InMemoryFileAdapter, manifest: Manifest, name: string) { - const adapter = new ModeBFlatMaterializationTranslator( - fs, - new DeterministicHasher(), - () => STUB_HOME - ); - await adapter.addPlugin( - buildDist(name), - "cursor", - { kind: "local", path: "/plugin-source" }, - PROJECT_ROOT, - manifest, - undefined, - "docs" - ); -} - -describe("Cursor plugin.files tracking enables uninstall of mcp.json; hooks.json is out-of-band (Phase 6)", () => { - it("Plugin.files keys join to the exact written absolute paths (uninstall can find the files)", async () => { - const fs = new InMemoryFileAdapter(); - const manifest = Manifest.create(); - manifest.addTool("cursor", "test", []); - await installPlugin(fs, manifest, PLUGIN_NAME); - - const plugins = manifest.getPlugins("cursor"); - const installed = plugins.find((p) => p.name === PLUGIN_NAME); - expect(installed).toBeDefined(); - const keys = [...(installed?.files.keys() ?? [])]; - expect(keys.some((k) => k.endsWith("hooks.json"))).toBe(false); - expect(keys.some((k) => k.endsWith("mcp.json"))).toBe(true); - // Every tracked key, when joined with resolvedBase, must match a written file - for (const key of keys) { - const absPath = join(RESOLVED_BASE, key); - expect(fs.has(absPath)).toBe(true); - } - // hooks.json was still written - just not tracked in Plugin.files, and not here - expect(fs.has(HOOKS_PATH)).toBe(true); - }); -}); - -describe("plugin remove unmerges Cursor project hooks (Phase 7, Task 3)", () => { - it("removes what an install merged and copied, leaving every other plugin's entries untouched", async () => { - const fs = new InMemoryFileAdapter(); - const manifest = Manifest.create(); - manifest.addTool("cursor", "test", []); - await installPlugin(fs, manifest, PLUGIN_NAME); - await installPlugin(fs, manifest, OTHER_PLUGIN_NAME); - const manifestRepo = new InMemoryManifestRepository(manifest); - await manifestRepo.save(manifest); - expect(fs.has(SCRIPT_PATH)).toBe(true); - expect(fs.has(OTHER_SCRIPT_PATH)).toBe(true); - - const removeUseCase = new PluginRemoveUseCase( - fs, - manifestRepo, - new CLIOutput(false), - new Map() - ); - await removeUseCase.execute({ - pluginName: PLUGIN_NAME, - toolIds: ["cursor"], - projectRoot: PROJECT_ROOT, - }); - - const parsed = JSON.parse(await fs.readFile(HOOKS_PATH)) as { - hooks: Record>; - }; - const commands = parsed.hooks.preToolUse.map((e) => e.command); - expect(commands.some((c) => c.includes(`/${PLUGIN_NAME}/`))).toBe(false); - expect(commands.some((c) => c.includes(`/${OTHER_PLUGIN_NAME}/`))).toBe(true); - expect(fs.has(SCRIPT_PATH)).toBe(false); - expect(fs.has(OTHER_SCRIPT_PATH)).toBe(true); - }); - - it("installing the same plugin twice leaves one copy in .cursor/hooks.json", async () => { - // Mirrors `aidd plugin install --replace` (PluginAddUseCase.dropExistingPlugin): - // the manifest entry is dropped before re-adding, but the .cursor/hooks.json this - // plugin already merged into is untouched by that drop — the exact scenario - // mergeCursorProjectHooksJson's own dedup exists for. - const fs = new InMemoryFileAdapter(); - const manifest = Manifest.create(); - manifest.addTool("cursor", "test", []); - await installPlugin(fs, manifest, PLUGIN_NAME); - manifest.removePlugin("cursor", PLUGIN_NAME); - await installPlugin(fs, manifest, PLUGIN_NAME); - - const parsed = JSON.parse(await fs.readFile(HOOKS_PATH)) as { - hooks: Record>; - }; - expect(parsed.hooks.preToolUse).toHaveLength(1); - }); -}); diff --git a/cli/tests/application/use-cases/require-auth-use-case.unit.test.ts b/cli/tests/application/use-cases/require-auth-use-case.unit.test.ts deleted file mode 100644 index 917492eb1..000000000 --- a/cli/tests/application/use-cases/require-auth-use-case.unit.test.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { NotAuthenticatedError } from "../../../src/application/errors.js"; -import { RequireAuthUseCase } from "../../../src/application/use-cases/auth/require-auth-use-case.js"; -import type { TokenProvider } from "../../../src/domain/ports/token-provider.js"; - -function makeTokenProvider(token: string | null): TokenProvider { - return { resolve: async () => token }; -} - -describe("RequireAuthUseCase", () => { - it("resolves without throwing when token is present", async () => { - const useCase = new RequireAuthUseCase(makeTokenProvider("ghp_abc123")); - await expect(useCase.execute()).resolves.not.toThrow(); - }); - - it("throws NotAuthenticatedError when token is null", async () => { - const useCase = new RequireAuthUseCase(makeTokenProvider(null)); - await expect(useCase.execute()).rejects.toThrow(NotAuthenticatedError); - }); -}); diff --git a/cli/tests/application/use-cases/restore-all-use-case.unit.test.ts b/cli/tests/application/use-cases/restore-all-use-case.unit.test.ts deleted file mode 100644 index eab2b5dd1..000000000 --- a/cli/tests/application/use-cases/restore-all-use-case.unit.test.ts +++ /dev/null @@ -1,310 +0,0 @@ -import { join } from "node:path"; -import { describe, expect, it } from "vitest"; -import { RestoreAllUseCase } from "../../../src/application/use-cases/global/restore-all-use-case.js"; -import { PluginAddUseCase } from "../../../src/application/use-cases/plugin/plugin-add-use-case.js"; -import { RestoreUseCase } from "../../../src/application/use-cases/restore/restore-use-case.js"; -import { DetectPluginDriftUseCase } from "../../../src/application/use-cases/shared/detect-plugin-drift-use-case.js"; -import { StatusUseCase } from "../../../src/application/use-cases/status-use-case.js"; -import { PluginDistributionReaderAdapter } from "../../../src/infrastructure/adapters/plugin-distribution-reader-adapter.js"; -import { buildUnitDeps, initAndInstall, installTool } from "../../helpers/ports/build-unit-deps.js"; -import { fakeEnsureBuiltMarketplace } from "../../helpers/ports/fake-ensure-built-marketplace.js"; -import { FakePlatform } from "../../helpers/ports/fake-platform.js"; -import { OverwritePrompter, ScriptedPrompter } from "../../helpers/ports/scripted-prompter.js"; -import { seedFromDirectory } from "../../helpers/ports/seed-from-directory.js"; - -const PROJECT_ROOT = "/test-project"; -const PLUGIN_FIXTURE = join(process.cwd(), "tests/fixtures/plugins/claude-format/sample-plugin"); - -type Deps = Awaited>; - -function builtDeps(deps: Deps) { - return { - ensureBuilt: fakeEnsureBuiltMarketplace(), - marketplaceRegistry: deps.marketplaceRegistry, - homedir: () => "/home/test", - }; -} - -async function installPlugin( - deps: Deps, - toolId: "claude" | "cursor", - pluginReader: PluginDistributionReaderAdapter -): Promise { - await new PluginAddUseCase( - deps.fs, - deps.manifestRepo, - deps.pluginFetcher, - pluginReader, - deps.hasher, - deps.logger, - deps.marketplaceRegistry, - fakeEnsureBuiltMarketplace() - ).execute({ - source: { kind: "local", path: PLUGIN_FIXTURE }, - toolIds: [toolId], - projectRoot: PROJECT_ROOT, - interactive: false, - }); -} - -function makeRestoreAllUseCase( - deps: Deps, - pluginReader: PluginDistributionReaderAdapter, - prompter: OverwritePrompter | ScriptedPrompter = new OverwritePrompter(), - withBuiltDeps = false -): RestoreAllUseCase { - const statusUseCase = new StatusUseCase( - deps.fs, - deps.manifestRepo, - deps.hasher, - new DetectPluginDriftUseCase(deps.fs) - ); - const restoreUseCase = new RestoreUseCase( - deps.fs, - deps.manifestRepo, - deps.hasher, - deps.logger, - new FakePlatform("linux"), - prompter, - deps.pluginFetcher, - pluginReader, - deps.assetProvider, - withBuiltDeps ? builtDeps(deps) : undefined - ); - return new RestoreAllUseCase(deps.manifestRepo, prompter, statusUseCase, restoreUseCase); -} - -function countingReader(fs: Deps["fs"]): { - reader: PluginDistributionReaderAdapter; - count: () => number; -} { - const reader = new PluginDistributionReaderAdapter(fs); - let calls = 0; - const original = reader.read.bind(reader); - reader.read = async (...args: Parameters) => { - calls++; - return original(...args); - }; - return { reader, count: () => calls }; -} - -describe("RestoreAllUseCase — plugin materialization", () => { - it("restores a corrupted plugin file with exactly one materialization call (translate-mode: claude)", async () => { - const deps = await buildUnitDeps(PROJECT_ROOT); - await initAndInstall(deps, PROJECT_ROOT, "claude"); - await seedFromDirectory(deps.fs, PLUGIN_FIXTURE, { useAbsolutePaths: true }); - await installPlugin(deps, "claude", new PluginDistributionReaderAdapter(deps.fs)); - - const pluginFile = join(PROJECT_ROOT, ".claude/plugins/sample-plugin/commands/greet.md"); - await deps.fs.writeFile(pluginFile, "CORRUPTED CONTENT"); - - // Counting reader wired only from here — installPlugin's own read() must not count. - const { reader, count } = countingReader(deps.fs); - const useCase = makeRestoreAllUseCase(deps, reader); - await useCase.execute(PROJECT_ROOT, false, false); - - expect(deps.fs.getFile(pluginFile)).not.toBe("CORRUPTED CONTENT"); - expect(deps.fs.getFile(pluginFile)).toContain("Greet from sample-plugin."); - expect(count()).toBe(1); - }); - - it("restores a corrupted plugin file with exactly one materialization call (cursor — installScope:user tool)", async () => { - // A local-source install never reaches restoreViaBuiltTree — that path requires - // plugin.marketplace to be set (see apply-plugin-files-use-case.ts) — so this exercises - // restoreViaTranslate, same as claude, but for a differently configured tool - // (installScope:"user", pluginsDir:""). Confirms single-pass materialization is not - // claude-specific; the true built-tree write path needs a marketplace-sourced install. - const deps = await buildUnitDeps(PROJECT_ROOT); - await initAndInstall(deps, PROJECT_ROOT, "cursor"); - await seedFromDirectory(deps.fs, PLUGIN_FIXTURE, { useAbsolutePaths: true }); - await installPlugin(deps, "cursor", new PluginDistributionReaderAdapter(deps.fs)); - - const manifestAfterInstall = await deps.manifestRepo.load(); - const plugin = manifestAfterInstall - ?.getPlugins("cursor") - .find((p) => p.name === "sample-plugin"); - const trackedRelativePath = [...(plugin?.files.keys() ?? [])][0]; - expect(trackedRelativePath).toBeDefined(); - // plugin.files keys are relativePath (see restoreViaTranslate); actual fs storage is - // keyed by the absolute path the file was written to. - const pluginFile = join(PROJECT_ROOT, trackedRelativePath as string); - await deps.fs.writeFile(pluginFile, "CORRUPTED CONTENT"); - - // Counting reader wired only from here — installPlugin's own read() must not count. - const { reader, count } = countingReader(deps.fs); - const useCase = makeRestoreAllUseCase(deps, reader, new OverwritePrompter(), true); - await useCase.execute(PROJECT_ROOT, false, false); - - expect(deps.fs.getFile(pluginFile)).not.toBe("CORRUPTED CONTENT"); - expect(count()).toBe(1); - }); - - it("result.pluginNamesRestored lists the restored plugin exactly once", async () => { - const deps = await buildUnitDeps(PROJECT_ROOT); - await initAndInstall(deps, PROJECT_ROOT, "claude"); - await seedFromDirectory(deps.fs, PLUGIN_FIXTURE, { useAbsolutePaths: true }); - const { reader } = countingReader(deps.fs); - await installPlugin(deps, "claude", reader); - - const pluginFile = join(PROJECT_ROOT, ".claude/plugins/sample-plugin/commands/greet.md"); - await deps.fs.writeFile(pluginFile, "CORRUPTED CONTENT"); - - const result = await makeRestoreAllUseCase(deps, reader).execute(PROJECT_ROOT, false, false); - - expect(result.pluginNamesRestored).toEqual(["sample-plugin"]); - expect(result.errors).toHaveLength(0); - }); - - it("a plugin already up to date is not listed as restored and produces no error", async () => { - const deps = await buildUnitDeps(PROJECT_ROOT); - await initAndInstall(deps, PROJECT_ROOT, "claude"); - await seedFromDirectory(deps.fs, PLUGIN_FIXTURE, { useAbsolutePaths: true }); - const { reader } = countingReader(deps.fs); - await installPlugin(deps, "claude", reader); - - // Nothing corrupted — plugin files are already at their installed state. - const manifestBefore = await deps.manifestRepo.load(); - const pluginBefore = manifestBefore - ?.getPlugins("claude") - .find((p) => p.name === "sample-plugin"); - - const result = await makeRestoreAllUseCase(deps, reader).execute(PROJECT_ROOT, false, false); - - expect(result.pluginNamesRestored).toEqual([]); - expect(result.errors).toHaveLength(0); - const manifestAfter = await deps.manifestRepo.load(); - const pluginAfter = manifestAfter?.getPlugins("claude").find((p) => p.name === "sample-plugin"); - expect(pluginAfter?.files).toEqual(pluginBefore?.files); - }); - - it("interactive restore with an explicit file selection also skips unselected plugin files (translate-mode)", async () => { - // The interactive picker never offers plugin drift (promptForFiles does not read - // StatusUseCase's pluginDrift), so once the user picks any specific regular file, - // ctx.fileFilter is active and no plugin path can match it. Same behaviour as - // ai.ts/ide.ts's `restore `. - const deps = await buildUnitDeps(PROJECT_ROOT); - await initAndInstall(deps, PROJECT_ROOT, "claude"); - await installTool(deps, PROJECT_ROOT, "vscode"); - await seedFromDirectory(deps.fs, PLUGIN_FIXTURE, { useAbsolutePaths: true }); - const { reader } = countingReader(deps.fs); - await installPlugin(deps, "claude", reader); - - const pluginFile = join(PROJECT_ROOT, ".claude/plugins/sample-plugin/commands/greet.md"); - await deps.fs.writeFile(pluginFile, "CORRUPTED CONTENT"); - // keybindings.json is a plain tracked file (unlike settings.json, which is merge-type - // and reports composite "path > key" drift entries, not a plain selectable path). - const vscodeKeybindingsPath = join(PROJECT_ROOT, ".vscode/keybindings.json"); - await deps.fs.writeFile(vscodeKeybindingsPath, "CORRUPTED KEYBINDINGS"); - - // User selects only the regular vscode file from the drifted-files checkbox - // (StatusUseCase reports relativePath, not the absolute path). Whether - // Whether keybindings.json is itself repaired is not asserted: RestoreAllUseCase never - // supplies frameworkPath to RestoreUseCase, so CONFIG_REFS-driven content cannot - // regenerate through this path at all. What is asserted is narrower: any explicit - // selection turns fileFilter on, and once on it excludes every plugin path, since a - // plugin file is never offered as a choice. - const prompter = new ScriptedPrompter([ - ScriptedPrompter.answer.checkbox([".vscode/keybindings.json"]), - ]); - const useCase = makeRestoreAllUseCase(deps, reader, prompter); - await useCase.execute(PROJECT_ROOT, true, false); - - expect(deps.fs.getFile(pluginFile)).toBe("CORRUPTED CONTENT"); - }); - - it("unscoped restore still restores every installed AI tool's plugins (no regression)", async () => { - const deps = await buildUnitDeps(PROJECT_ROOT); - await initAndInstall(deps, PROJECT_ROOT, "claude"); - await installTool(deps, PROJECT_ROOT, "codex"); - await seedFromDirectory(deps.fs, PLUGIN_FIXTURE, { useAbsolutePaths: true }); - const { reader } = countingReader(deps.fs); - await installPlugin(deps, "claude", reader); - await new PluginAddUseCase( - deps.fs, - deps.manifestRepo, - deps.pluginFetcher, - reader, - deps.hasher, - deps.logger, - deps.marketplaceRegistry, - fakeEnsureBuiltMarketplace() - ).execute({ - source: { kind: "local", path: PLUGIN_FIXTURE }, - toolIds: ["codex"], - projectRoot: PROJECT_ROOT, - interactive: false, - }); - - const claudePluginFile = join(PROJECT_ROOT, ".claude/plugins/sample-plugin/commands/greet.md"); - const codexPluginFile = join(PROJECT_ROOT, ".codex/plugins/sample-plugin/commands/greet.md"); - await deps.fs.writeFile(claudePluginFile, "CORRUPTED CLAUDE"); - await deps.fs.writeFile(codexPluginFile, "CORRUPTED CODEX"); - - await makeRestoreAllUseCase(deps, reader).execute(PROJECT_ROOT, false, false); - - expect(deps.fs.getFile(claudePluginFile)).not.toBe("CORRUPTED CLAUDE"); - expect(deps.fs.getFile(codexPluginFile)).not.toBe("CORRUPTED CODEX"); - }); -}); - -describe("RestoreAllUseCase — consent to overwrite", () => { - type RestoreOptions = Parameters[0]; - - /** Records what RestoreAllUseCase asks the restore to do, without doing it. */ - function recordAsks(restoreUseCase: RestoreUseCase): RestoreOptions[] { - const seen: RestoreOptions[] = []; - restoreUseCase.execute = async (options: RestoreOptions) => { - seen.push(options); - return { - tools: [], - totalRestored: 0, - totalKept: 0, - totalPluginFilesRestored: 0, - restoredPluginNames: [], - unrestorable: [], - }; - }; - return seen; - } - - async function askedWith(interactive: boolean, force: boolean): Promise { - const deps = await buildUnitDeps(PROJECT_ROOT); - await initAndInstall(deps, PROJECT_ROOT, "claude"); - const prompter = new OverwritePrompter(); - const statusUseCase = new StatusUseCase( - deps.fs, - deps.manifestRepo, - deps.hasher, - new DetectPluginDriftUseCase(deps.fs) - ); - const restoreUseCase = new RestoreUseCase( - deps.fs, - deps.manifestRepo, - deps.hasher, - deps.logger, - new FakePlatform("linux"), - prompter - ); - const seen = recordAsks(restoreUseCase); - await new RestoreAllUseCase(deps.manifestRepo, prompter, statusUseCase, restoreUseCase).execute( - PROJECT_ROOT, - interactive, - force - ); - const asked = seen[0]; - expect(asked).toBeDefined(); - return asked as RestoreOptions; - } - - it("carries --force through to the restore it delegates to", async () => { - expect((await askedWith(false, true)).force).toBe(true); - }); - - it("does not overwrite without consent when neither --force nor a TTY is there", async () => { - expect((await askedWith(false, false)).force).toBe(false); - }); - - it("treats the interactive file selection as the consent, so nothing is asked twice", async () => { - expect((await askedWith(true, false)).force).toBe(true); - }); -}); diff --git a/cli/tests/application/use-cases/setup-auth-guard.unit.test.ts b/cli/tests/application/use-cases/setup-auth-guard.unit.test.ts deleted file mode 100644 index 00079bf08..000000000 --- a/cli/tests/application/use-cases/setup-auth-guard.unit.test.ts +++ /dev/null @@ -1,168 +0,0 @@ -import { describe, expect, it, vi } from "vitest"; -import type { MarketplaceRefresh } from "../../../src/application/use-cases/marketplace/marketplace-refresh-use-case.js"; -import type { MarketplaceRegisterFramework } from "../../../src/application/use-cases/marketplace/marketplace-register-framework-use-case.js"; -import type { MarketplaceSyncSettings } from "../../../src/application/use-cases/marketplace/marketplace-sync-settings-use-case.js"; -import type { PluginInstallFromMarketplace } from "../../../src/application/use-cases/plugin/plugin-install-from-marketplace-use-case.js"; -import type { PluginPick } from "../../../src/application/use-cases/plugin/plugin-pick-use-case.js"; -import { SetupMarketplaceSourceUseCase } from "../../../src/application/use-cases/setup/setup-marketplace-source-use-case.js"; -import { SetupPluginsPromptUseCase } from "../../../src/application/use-cases/setup/setup-plugins-prompt-use-case.js"; -import { SetupToolsUseCase } from "../../../src/application/use-cases/setup/setup-tools-use-case.js"; -import { SetupUseCase } from "../../../src/application/use-cases/setup-use-case.js"; -import type { ResolveMarketplace } from "../../../src/application/use-cases/shared/resolve-marketplace-use-case.js"; -import { CatalogFetchAuthError } from "../../../src/domain/errors.js"; -import { FRAMEWORK_MARKETPLACE_NAME, Marketplace } from "../../../src/domain/models/marketplace.js"; -import { MarketplaceSourceMode } from "../../../src/domain/models/marketplace-source-mode.js"; -import { SetupFlow } from "../../../src/domain/models/setup-flow.js"; -import type { LatestReleaseResolver } from "../../../src/domain/ports/latest-release-resolver.js"; -import type { TokenProvider } from "../../../src/domain/ports/token-provider.js"; -import { buildUnitDeps } from "../../helpers/ports/build-unit-deps.js"; -import { InMemoryMarketplaceRegistry } from "../../helpers/ports/in-memory-marketplace-registry.js"; -import { OverwritePrompter } from "../../helpers/ports/scripted-prompter.js"; - -function makeReleaseResolver(isPublic: boolean): LatestReleaseResolver { - return { - resolveLatest: vi.fn().mockResolvedValue(null), - listRootReleases: vi.fn().mockResolvedValue([]), - isRepoPublic: vi.fn().mockResolvedValue(isPublic), - }; -} - -// Real values, not empty objects: a no-op double still has to answer with what its -// contract promises, so a caller that starts reading the answer breaks here first. -const FRAMEWORK_MARKETPLACE = Marketplace.create({ - name: FRAMEWORK_MARKETPLACE_NAME, - source: { kind: "local", path: "/framework" }, - scope: "project", - addedAt: "2026-08-20T00:00:00.000Z", -}); - -function makeNoOpPluginPick(): PluginPick { - return { - execute: vi.fn().mockResolvedValue({ marketplace: FRAMEWORK_MARKETPLACE, installed: [] }), - }; -} - -function makeNoOpPluginInstallFromMarketplace(): PluginInstallFromMarketplace { - return { - execute: vi.fn().mockResolvedValue({ - marketplace: FRAMEWORK_MARKETPLACE, - entry: { - name: "aidd-context", - source: { kind: "local", path: "/framework/plugins/aidd-context" }, - recommended: false, - strict: false, - }, - }), - }; -} - -function makeNoOpResolveMarketplace(): ResolveMarketplace { - return { - execute: vi - .fn() - .mockResolvedValue({ marketplace: FRAMEWORK_MARKETPLACE, localPath: "", catalog: null }), - }; -} - -function makeNoOpRegisterFramework(): MarketplaceRegisterFramework { - return { execute: vi.fn().mockResolvedValue({ registered: false }) }; -} - -function makeNoOpRefresh(): MarketplaceRefresh { - return { execute: vi.fn().mockResolvedValue({ results: [], failedCount: 0 }) }; -} - -function makeNoOpSyncSettings(): MarketplaceSyncSettings { - return { execute: vi.fn().mockResolvedValue({ updatedTools: [] }) }; -} - -function makeTokenProvider(token: string | null): TokenProvider { - return { resolve: vi.fn().mockResolvedValue(token) }; -} - -const PROJECT_ROOT = "/test-project"; - -async function buildSetupUseCase(tokenProvider: TokenProvider, isRepoPublic = false) { - const deps = await buildUnitDeps(PROJECT_ROOT); - const prompter = new OverwritePrompter(); - const setupMarketplaceSourceUseCase = new SetupMarketplaceSourceUseCase( - prompter, - makeReleaseResolver(true) - ); - const setupToolsUseCase = new SetupToolsUseCase( - deps.manifestRepo, - deps.installRuntimeConfigUseCase, - deps.installIdeConfigUseCase - ); - const setupPluginsPromptUseCase = new SetupPluginsPromptUseCase( - makeNoOpPluginPick(), - makeNoOpPluginInstallFromMarketplace(), - new InMemoryMarketplaceRegistry(), - makeNoOpResolveMarketplace() - ); - return new SetupUseCase( - deps.fs, - deps.manifestRepo, - setupMarketplaceSourceUseCase, - makeNoOpRegisterFramework(), - makeNoOpRefresh(), - makeNoOpSyncSettings(), - setupToolsUseCase, - setupPluginsPromptUseCase, - deps.currentVersionProvider, - tokenProvider, - undefined, - undefined, - makeReleaseResolver(isRepoPublic) - ); -} - -describe("SetupUseCase — auth guard for remote source", () => { - it("throws CatalogFetchAuthError when source is remote, no token, and repo is private", async () => { - const useCase = await buildSetupUseCase(makeTokenProvider(null), false); - - const flow = new SetupFlow({ - projectRoot: PROJECT_ROOT, - source: MarketplaceSourceMode.remote(), - interactive: false, - }); - - await expect(useCase.execute(flow)).rejects.toThrow(CatalogFetchAuthError); - }); - - it("proceeds when source is remote, no token, but repo is public", async () => { - const useCase = await buildSetupUseCase(makeTokenProvider(null), true); - - const flow = new SetupFlow({ - projectRoot: PROJECT_ROOT, - source: MarketplaceSourceMode.remote(), - interactive: false, - }); - - await expect(useCase.execute(flow)).resolves.toBeDefined(); - }); - - it("proceeds without error when source is remote and a token is present", async () => { - const useCase = await buildSetupUseCase(makeTokenProvider("ghp_valid-token")); - - const flow = new SetupFlow({ - projectRoot: PROJECT_ROOT, - source: MarketplaceSourceMode.remote(), - interactive: false, - }); - - await expect(useCase.execute(flow)).resolves.toBeDefined(); - }); - - it("proceeds without error when source is local (no token required)", async () => { - const useCase = await buildSetupUseCase(makeTokenProvider(null)); - - const flow = new SetupFlow({ - projectRoot: PROJECT_ROOT, - source: MarketplaceSourceMode.local("/some/path"), - interactive: false, - }); - - await expect(useCase.execute(flow)).resolves.toBeDefined(); - }); -}); diff --git a/cli/tests/application/use-cases/setup-use-case.unit.test.ts b/cli/tests/application/use-cases/setup-use-case.unit.test.ts deleted file mode 100644 index 18c063c21..000000000 --- a/cli/tests/application/use-cases/setup-use-case.unit.test.ts +++ /dev/null @@ -1,361 +0,0 @@ -import { join } from "node:path"; -import { describe, expect, it, vi } from "vitest"; -import type { MarketplaceRefresh } from "../../../src/application/use-cases/marketplace/marketplace-refresh-use-case.js"; -import type { MarketplaceRegisterFramework } from "../../../src/application/use-cases/marketplace/marketplace-register-framework-use-case.js"; -import type { MarketplaceSyncSettings } from "../../../src/application/use-cases/marketplace/marketplace-sync-settings-use-case.js"; -import type { PluginInstallFromMarketplace } from "../../../src/application/use-cases/plugin/plugin-install-from-marketplace-use-case.js"; -import type { PluginPick } from "../../../src/application/use-cases/plugin/plugin-pick-use-case.js"; -import { SetupMarketplaceSourceUseCase } from "../../../src/application/use-cases/setup/setup-marketplace-source-use-case.js"; -import { SetupPluginsPromptUseCase } from "../../../src/application/use-cases/setup/setup-plugins-prompt-use-case.js"; -import { SetupToolsPromptUseCase } from "../../../src/application/use-cases/setup/setup-tools-prompt-use-case.js"; -import { SetupToolsUseCase } from "../../../src/application/use-cases/setup/setup-tools-use-case.js"; -import { SetupUseCase } from "../../../src/application/use-cases/setup-use-case.js"; -import type { ResolveMarketplace } from "../../../src/application/use-cases/shared/resolve-marketplace-use-case.js"; -import { FRAMEWORK_MARKETPLACE_NAME, Marketplace } from "../../../src/domain/models/marketplace.js"; -import { MarketplaceSourceMode } from "../../../src/domain/models/marketplace-source-mode.js"; -import type { PluginCatalogEntry } from "../../../src/domain/models/plugin-catalog.js"; -import { SetupFlow } from "../../../src/domain/models/setup-flow.js"; -import type { LatestReleaseResolver } from "../../../src/domain/ports/latest-release-resolver.js"; -import { AI_TOOL_IDS, IDE_TOOL_IDS, type ToolId } from "../../../src/domain/tools/registry.js"; -import { buildUnitDeps, initAndInstall, initProject } from "../../helpers/ports/build-unit-deps.js"; -import { InMemoryMarketplaceRegistry } from "../../helpers/ports/in-memory-marketplace-registry.js"; -import { OverwritePrompter, ScriptedPrompter } from "../../helpers/ports/scripted-prompter.js"; - -function makeNoOpLatestResolver(): LatestReleaseResolver { - return { - resolveLatest: vi.fn().mockResolvedValue(null), - listRootReleases: vi.fn().mockResolvedValue([]), - isRepoPublic: vi.fn().mockResolvedValue(true), - }; -} - -type RegisterFrameworkMock = MarketplaceRegisterFramework & { execute: ReturnType }; -type RefreshMock = MarketplaceRefresh & { execute: ReturnType }; - -function makeNoOpMarketplaceRegisterFramework(): RegisterFrameworkMock { - const execute = vi.fn().mockResolvedValue({ registered: false }); - return { execute }; -} - -function makeNoOpMarketplaceRefresh(): RefreshMock { - const execute = vi.fn().mockResolvedValue({ results: [], failedCount: 0 }); - return { execute }; -} - -function makeNoOpMarketplaceSyncSettings(): MarketplaceSyncSettings { - return { execute: vi.fn().mockResolvedValue({ updatedTools: [] }) }; -} - -function makeNoOpPluginPick(): PluginPick { - return { - execute: vi.fn().mockResolvedValue({ marketplace: FRAMEWORK_MARKETPLACE, installed: [] }), - }; -} - -function makeNoOpPluginInstallFromMarketplace(): PluginInstallFromMarketplace { - return { - execute: vi - .fn() - .mockResolvedValue({ marketplace: FRAMEWORK_MARKETPLACE, entry: CATALOG_ENTRY }), - }; -} - -function makeNoOpResolveMarketplace(): ResolveMarketplace { - return { - execute: vi - .fn() - .mockResolvedValue({ marketplace: FRAMEWORK_MARKETPLACE, localPath: "", catalog: null }), - }; -} - -const PROJECT_ROOT = "/test-project"; - -// Real values, not empty objects: a no-op double still has to answer with what its -// contract promises, so a caller that starts reading the answer breaks here first. -const FRAMEWORK_MARKETPLACE = Marketplace.create({ - name: FRAMEWORK_MARKETPLACE_NAME, - source: { kind: "local", path: "/framework" }, - scope: "project", - addedAt: "2026-08-20T00:00:00.000Z", -}); - -const CATALOG_ENTRY: PluginCatalogEntry = { - name: "aidd-context", - source: { kind: "local", path: "/framework/plugins/aidd-context" }, - recommended: false, - strict: false, -}; - -async function buildUseCase(setupToolsPromptUseCase?: SetupToolsPromptUseCase) { - const deps = await buildUnitDeps(PROJECT_ROOT); - const prompter = new OverwritePrompter(); - const setupMarketplaceSourceUseCase = new SetupMarketplaceSourceUseCase( - prompter, - makeNoOpLatestResolver() - ); - const setupToolsUseCase = new SetupToolsUseCase( - deps.manifestRepo, - deps.installRuntimeConfigUseCase, - deps.installIdeConfigUseCase - ); - const setupPluginsPromptUseCase = new SetupPluginsPromptUseCase( - makeNoOpPluginPick(), - makeNoOpPluginInstallFromMarketplace(), - new InMemoryMarketplaceRegistry(), - makeNoOpResolveMarketplace() - ); - const marketplaceRegisterFramework = makeNoOpMarketplaceRegisterFramework(); - const marketplaceRefresh = makeNoOpMarketplaceRefresh(); - const useCase = new SetupUseCase( - deps.fs, - deps.manifestRepo, - setupMarketplaceSourceUseCase, - marketplaceRegisterFramework, - marketplaceRefresh, - makeNoOpMarketplaceSyncSettings(), - setupToolsUseCase, - setupPluginsPromptUseCase, - deps.currentVersionProvider, - undefined, - setupToolsPromptUseCase - ); - return { useCase, deps, marketplaceRegisterFramework, marketplaceRefresh }; -} - -function remoteFlow(opts: Partial<{ aiTools: ToolId[]; ideTools: ToolId[] }> = {}): SetupFlow { - return new SetupFlow({ - projectRoot: PROJECT_ROOT, - source: MarketplaceSourceMode.remote(), - aiTools: opts.aiTools ?? [], - ideTools: opts.ideTools ?? [], - pluginMode: "none", - interactive: false, - }); -} - -describe("setup without TTY", () => { - it("fresh project with all tools flag initializes and installs all tools", async () => { - const { useCase } = await buildUseCase(); - const result = await useCase.execute( - remoteFlow({ aiTools: [...AI_TOOL_IDS], ideTools: [...IDE_TOOL_IDS] }) - ); - - expect(result.kind).toBe("initialized"); - if (result.kind === "initialized") { - expect(result.install.results.length).toBeGreaterThan(0); - } - }); - - it("fresh project without tool flags initializes docs only and installs no tools", async () => { - const { useCase } = await buildUseCase(); - const result = await useCase.execute(remoteFlow()); - - expect(result.kind).toBe("initialized"); - if (result.kind === "initialized") { - expect(result.install.results).toHaveLength(0); - } - }); - - it("aidd_docs exists without tool signals routes to init and installs tools", async () => { - const { useCase, deps } = await buildUseCase(); - deps.fs.writeFile(join(PROJECT_ROOT, "aidd_docs/.keep"), ""); - - const result = await useCase.execute( - remoteFlow({ aiTools: [...AI_TOOL_IDS], ideTools: [...IDE_TOOL_IDS] }) - ); - - expect(result.kind).toBe("initialized"); - if (result.kind === "initialized") { - expect(result.install.results.length).toBeGreaterThan(0); - } - }); - - it("manifest exists — returns up-to-date even with tool flags (tools still installed)", async () => { - const { useCase, deps } = await buildUseCase(); - await initProject(deps, PROJECT_ROOT); - - const result = await useCase.execute( - remoteFlow({ aiTools: [...AI_TOOL_IDS], ideTools: [...IDE_TOOL_IDS] }) - ); - - expect(result.kind).toBe("up-to-date"); - if (result.kind === "up-to-date") { - expect(result.install.results.length).toBeGreaterThan(0); - } - }); - - it("manifest exists without tool flags returns up-to-date with empty install", async () => { - const { useCase, deps } = await buildUseCase(); - await initProject(deps, PROJECT_ROOT); - - const result = await useCase.execute(remoteFlow()); - - expect(result.kind).toBe("up-to-date"); - if (result.kind === "up-to-date") { - expect(result.install.results).toHaveLength(0); - } - }); - - it("project already up to date — exits without error", async () => { - const { useCase, deps } = await buildUseCase(); - await initAndInstall(deps, PROJECT_ROOT, "claude"); - - const result = await useCase.execute(remoteFlow()); - - expect(result.kind).toBe("up-to-date"); - }); - - describe("default marketplace opt-out (#197)", () => { - it("registers framework marketplace by default", async () => { - const { useCase, marketplaceRegisterFramework, marketplaceRefresh } = await buildUseCase(); - await useCase.execute(remoteFlow({ aiTools: ["claude" as ToolId] })); - expect(marketplaceRegisterFramework.execute).toHaveBeenCalledOnce(); - expect(marketplaceRefresh.execute).toHaveBeenCalledOnce(); - }); - - it("skips framework register + refresh when registerDefaultMarketplace=false", async () => { - const { useCase, marketplaceRegisterFramework, marketplaceRefresh } = await buildUseCase(); - await useCase.execute( - new SetupFlow({ - projectRoot: PROJECT_ROOT, - source: MarketplaceSourceMode.remote(), - aiTools: ["claude" as ToolId], - ideTools: [], - pluginMode: "none", - interactive: false, - registerDefaultMarketplace: false, - }) - ); - expect(marketplaceRegisterFramework.execute).not.toHaveBeenCalled(); - expect(marketplaceRefresh.execute).not.toHaveBeenCalled(); - }); - - it("still installs tools when default marketplace is opted out", async () => { - const { useCase } = await buildUseCase(); - const result = await useCase.execute( - new SetupFlow({ - projectRoot: PROJECT_ROOT, - source: MarketplaceSourceMode.remote(), - aiTools: ["claude" as ToolId], - ideTools: [], - pluginMode: "none", - interactive: false, - registerDefaultMarketplace: false, - }) - ); - expect(result.kind).toBe("initialized"); - if (result.kind === "initialized") { - const claudeResult = result.install.results.find((r) => r.toolId === "claude"); - expect(claudeResult).toBeDefined(); - } - }); - }); - - describe("issue #141 — post-uninstall regression", () => { - it("succeeds when aidd_docs/ and .aidd/ exist but no manifest and no tool dirs", async () => { - const { useCase, deps } = await buildUseCase(); - deps.fs.writeFile(join(PROJECT_ROOT, "aidd_docs/.keep"), ""); - deps.fs.writeFile(join(PROJECT_ROOT, ".aidd/.keep"), ""); - - const result = await useCase.execute(remoteFlow({ aiTools: ["claude" as ToolId] })); - - expect(result.kind).toBe("initialized"); - }); - - it("installs selected tools when only aidd_docs/ survives uninstall", async () => { - const { useCase, deps } = await buildUseCase(); - deps.fs.writeFile(join(PROJECT_ROOT, "aidd_docs/.keep"), ""); - deps.fs.writeFile(join(PROJECT_ROOT, ".aidd/.keep"), ""); - - const result = await useCase.execute(remoteFlow({ aiTools: ["opencode" as ToolId] })); - - expect(result.kind).toBe("initialized"); - if (result.kind === "initialized") { - const opencodeTool = result.install.results.find((r) => r.toolId === "opencode"); - expect(opencodeTool).toBeDefined(); - expect(opencodeTool?.skipped).toBe(false); - } - }); - - it("does not fail when only aidd_docs/ exists (no manifest)", async () => { - const { useCase, deps } = await buildUseCase(); - deps.fs.writeFile(join(PROJECT_ROOT, "aidd_docs/.keep"), ""); - deps.fs.writeFile(join(PROJECT_ROOT, ".aidd/.keep"), ""); - - const result = await useCase.execute(remoteFlow()); - - expect(result.kind).toBe("initialized"); - }); - - it("preserves user files in aidd_docs/ across setup", async () => { - const { useCase, deps } = await buildUseCase(); - deps.fs.writeFile(join(PROJECT_ROOT, "aidd_docs/.keep"), ""); - deps.fs.writeFile(join(PROJECT_ROOT, ".aidd/.keep"), ""); - deps.fs.writeFile(join(PROJECT_ROOT, "aidd_docs/README.md"), "my custom readme"); - - await useCase.execute(remoteFlow({ aiTools: ["claude" as ToolId] })); - - const content = deps.fs.getFile(join(PROJECT_ROOT, "aidd_docs/README.md")) ?? ""; - expect(content).toBe("my custom readme"); - }); - }); -}); - -describe("setup interactive tool selection", () => { - function interactiveFlow( - opts: Partial<{ aiTools: ToolId[]; ideTools: ToolId[] }> = {} - ): SetupFlow { - return new SetupFlow({ - projectRoot: PROJECT_ROOT, - source: MarketplaceSourceMode.remote(), - aiTools: opts.aiTools ?? [], - ideTools: opts.ideTools ?? [], - pluginMode: "none", - interactive: true, - }); - } - - it("interactive + empty tools → prompts and installs user-selected tools", async () => { - const prompter = new ScriptedPrompter([ - ScriptedPrompter.answer.checkbox(["claude"]), - ScriptedPrompter.answer.checkbox([]), - ]); - const setupToolsPromptUseCase = new SetupToolsPromptUseCase(prompter); - const { useCase } = await buildUseCase(setupToolsPromptUseCase); - - const result = await useCase.execute(interactiveFlow()); - - expect(result.kind).toBe("initialized"); - if (result.kind === "initialized") { - const installed = result.install.results.map((r) => r.toolId); - expect(installed).toContain("claude"); - } - }); - - it("interactive + tools provided via flow → no extra prompt, installs given tools", async () => { - const prompter = new ScriptedPrompter([]); // no tool prompts expected - const setupToolsPromptUseCase = new SetupToolsPromptUseCase(prompter); - const { useCase } = await buildUseCase(setupToolsPromptUseCase); - - const result = await useCase.execute(interactiveFlow({ aiTools: ["cursor" as ToolId] })); - - expect(result.kind).toBe("initialized"); - if (result.kind === "initialized") { - const installed = result.install.results.map((r) => r.toolId); - expect(installed).toContain("cursor"); - } - }); - - it("non-interactive + empty tools → no prompt, installs nothing", async () => { - const prompter = new ScriptedPrompter([]); // no prompts expected - const setupToolsPromptUseCase = new SetupToolsPromptUseCase(prompter); - const { useCase } = await buildUseCase(setupToolsPromptUseCase); - - const result = await useCase.execute(remoteFlow()); - - expect(result.kind).toBe("initialized"); - if (result.kind === "initialized") { - expect(result.install.results).toHaveLength(0); - } - }); -}); diff --git a/cli/tests/application/use-cases/shared/ensure-built-marketplace-use-case.integration.test.ts b/cli/tests/application/use-cases/shared/ensure-built-marketplace-use-case.integration.test.ts deleted file mode 100644 index 563473b5d..000000000 --- a/cli/tests/application/use-cases/shared/ensure-built-marketplace-use-case.integration.test.ts +++ /dev/null @@ -1,348 +0,0 @@ -import { tmpdir } from "node:os"; -import { join, resolve, sep } from "node:path"; -import { beforeEach, describe, expect, it } from "vitest"; -import { FrameworkBuildUseCase } from "../../../../src/application/use-cases/framework/framework-build-use-case.js"; -import { FlatBuildStrategy } from "../../../../src/application/use-cases/framework/strategies/flat-build-strategy.js"; -import { buildCopilotFlatContract } from "../../../../src/application/use-cases/framework/strategies/tool-contracts.js"; -import { - EnsureBuiltMarketplaceUseCase, - type FrameworkBuildFor, -} from "../../../../src/application/use-cases/shared/ensure-built-marketplace-use-case.js"; -import { FetchMarketplaceSourceUseCase } from "../../../../src/application/use-cases/shared/fetch-marketplace-source-use-case.js"; -import { ResolveMarketplaceUseCase } from "../../../../src/application/use-cases/shared/resolve-marketplace-use-case.js"; -import { Marketplace } from "../../../../src/domain/models/marketplace.js"; -import { BUILT_CACHE_SUBDIR, builtMarketplaceDir } from "../../../../src/domain/models/paths.js"; -import type { AssetProvider } from "../../../../src/domain/ports/asset-provider.js"; -import type { JsonSchemaValidator } from "../../../../src/domain/ports/json-schema-validator.js"; -import type { VersionReader } from "../../../../src/domain/ports/version-reader.js"; -import { CapturingLogger } from "../../../helpers/ports/capturing-logger.js"; -import { InMemoryFileAdapter } from "../../../helpers/ports/in-memory-file-adapter.js"; -import { seedFromDirectory } from "../../../helpers/ports/seed-from-directory.js"; - -const PROJECT = "/proj"; -const FIXTURE_DIR = resolve(process.cwd(), "tests/fixtures/framework"); -const PLUGIN = "aidd-test"; - -const MINIMAL_MANIFEST_SCHEMA = { - type: "object", - required: ["name"], - properties: { name: { type: "string" } }, -}; - -function noopValidator(): JsonSchemaValidator { - return { validate: () => undefined }; -} - -function stubAssetProvider(): AssetProvider { - return { - loadConfigAsset: () => { - throw new Error("not used"); - }, - loadDefaultMarketplace: () => { - throw new Error("not used"); - }, - loadSchema: (name) => (name === "plugin-manifest" ? MINIMAL_MANIFEST_SCHEMA : {}), - }; -} - -function makeIsDirectory(memFs: InMemoryFileAdapter): (path: string) => Promise { - return async (path: string): Promise => { - if (memFs.has(path)) return false; - // memFs stores every key "/"-normalised (in-memory-file-adapter.ts's own `norm`) - a - // native, backslash-separated `path` on Windows would never prefix-match one of those - // keys without the same normalisation here. - const normalized = path.replaceAll("\\", "/"); - const prefix = normalized.endsWith("/") ? normalized : `${normalized}/`; - return memFs.listAll().some((k) => k.startsWith(prefix)); - }; -} - -function makeMarketplace(): Marketplace { - return Marketplace.create({ - name: "aidd-framework", - source: { kind: "local", path: "/src/framework" }, - scope: "project", - addedAt: "2026-06-29T00:00:00.000Z", - }); -} - -function fakeResolve(localPath: string, version: string | undefined): ResolveMarketplaceUseCase { - return new ResolveMarketplaceUseCase( - new FetchMarketplaceSourceUseCase({ fetch: async () => localPath }), - { - load: async () => (version === undefined ? null : { version, plugins: [] }), - loadForeign: async () => [], - } - ); -} - -function fakeVersion(value: string): VersionReader { - return { get: () => value }; -} - -describe("builtMarketplaceDir", () => { - it("places the per-target tree under .aidd/cache/built//", () => { - expect(builtMarketplaceDir("/p", "aidd", "codex")).toBe( - // The layout stays spelled out segment by segment, not taken from the same - // constant the implementation uses - that would only assert the code agrees with - // itself. join() so the claim is about the layout, not the separator (#707). - join("/p", ".aidd", "cache", "built", "aidd", "codex") - ); - }); -}); - -describe("EnsureBuiltMarketplaceUseCase", () => { - let fs: InMemoryFileAdapter; - let builds: number; - let buildFor: FrameworkBuildFor; - - beforeEach(() => { - fs = new InMemoryFileAdapter(); - builds = 0; - buildFor = (_target, _mode, outDir) => ({ - execute: async () => { - builds += 1; - await fs.writeFile(join(outDir, "plugins/aidd-vcs/SKILL.md"), "built content"); - return { outDir, plugins: [], totalFiles: 1 }; - }, - }); - }); - - it("rebuilds and writes a sentinel when none exists", async () => { - const uc = new EnsureBuiltMarketplaceUseCase( - fs, - fakeResolve("/src/framework", "1.0.0"), - buildFor, - fakeVersion("5.0.0") - ); - const r = await uc.execute({ - projectRoot: PROJECT, - marketplace: makeMarketplace(), - target: "codex", - mode: "marketplace", - }); - expect(r.rebuilt).toBe(true); - expect(builds).toBe(1); - expect(fs.getFile(join(r.builtDir, ".build-version"))).toBe("5.0.0:1.0.0"); - }); - - it("does not rebuild when the sentinel matches (cliVer:catalogVer)", async () => { - const builtDir = resolve(builtMarketplaceDir(PROJECT, "aidd-framework", "codex")); - fs.setFile(join(builtDir, ".build-version"), "5.0.0:1.0.0"); - const uc = new EnsureBuiltMarketplaceUseCase( - fs, - fakeResolve("/src/framework", "1.0.0"), - buildFor, - fakeVersion("5.0.0") - ); - const r = await uc.execute({ - projectRoot: PROJECT, - marketplace: makeMarketplace(), - target: "codex", - mode: "marketplace", - }); - expect(r.rebuilt).toBe(false); - expect(builds).toBe(0); - }); - - it("rebuilds when the CLI version changed even if catalog version is the same", async () => { - const builtDir = resolve(builtMarketplaceDir(PROJECT, "aidd-framework", "codex")); - fs.setFile(join(builtDir, ".build-version"), "4.0.0:1.0.0"); - const uc = new EnsureBuiltMarketplaceUseCase( - fs, - fakeResolve("/src/framework", "1.0.0"), - buildFor, - fakeVersion("5.0.0") - ); - const r = await uc.execute({ - projectRoot: PROJECT, - marketplace: makeMarketplace(), - target: "codex", - mode: "marketplace", - }); - expect(r.rebuilt).toBe(true); - expect(builds).toBe(1); - }); - - it("always rebuilds when catalog version is undefined", async () => { - const builtDir = resolve(builtMarketplaceDir(PROJECT, "aidd-framework", "codex")); - fs.setFile(join(builtDir, ".build-version"), "5.0.0:unversioned"); - const uc = new EnsureBuiltMarketplaceUseCase( - fs, - fakeResolve("/src/framework", undefined), - buildFor, - fakeVersion("5.0.0") - ); - const r = await uc.execute({ - projectRoot: PROJECT, - marketplace: makeMarketplace(), - target: "codex", - mode: "marketplace", - }); - expect(r.rebuilt).toBe(true); - expect(builds).toBe(1); - }); - - it("builds via a temp dir and copies into the cache when the cache nests under the source (dogfood)", async () => { - // Source == project root, so builtDir (.aidd/cache/built/...) nests under source → guardPaths would throw. - const uc = new EnsureBuiltMarketplaceUseCase( - fs, - fakeResolve(PROJECT, "1.0.0"), - buildFor, - fakeVersion("5.0.0") - ); - const r = await uc.execute({ - projectRoot: PROJECT, - marketplace: makeMarketplace(), - target: "codex", - mode: "marketplace", - }); - expect(r.builtDir).toBe(resolve(builtMarketplaceDir(PROJECT, "aidd-framework", "codex"))); - expect(fs.getFile(join(r.builtDir, "plugins/aidd-vcs/SKILL.md"))).toBe("built content"); - // temp dir cleaned up - expect(fs.listUnder(tmpdir()).length).toBe(0); - }); - - it("memoizes within a run: a second call for the same target/version does not rebuild", async () => { - const uc = new EnsureBuiltMarketplaceUseCase( - fs, - fakeResolve("/src/framework", "1.0.0"), - buildFor, - fakeVersion("5.0.0") - ); - const opts = { - projectRoot: PROJECT, - marketplace: makeMarketplace(), - target: "codex" as const, - mode: "marketplace" as const, - }; - await uc.execute(opts); - await uc.execute(opts); - expect(builds).toBe(1); - }); -}); - -// outDir here is always builtMarketplaceDir() — an aidd-owned disposable cache, never a -// user directory — so a collision just means "a previous build is still there" and must be -// overwritten. Uses a real FlatBuildStrategy rather than the fake buildFor stub above, so it -// fails if force is flipped to false or outDir stops being cache-only. -describe("force behavior at the cache-rebuild path", () => { - it("overwrites a colliding file already present in the build cache instead of throwing FlatTargetExistsError", async () => { - const memFs = new InMemoryFileAdapter(); - await seedFromDirectory(memFs, FIXTURE_DIR, { useAbsolutePaths: true }); - - // EnsureBuiltMarketplaceUseCase.execute() resolves builtDir before handing it to - // buildFor(), so FlatBuildStrategy's write target (absOut) is always drive-qualified on - // Windows - mirror that resolve() here so this test's drive-less PROJECT seeds the same - // path the real write lands on. - const builtDir = resolve(builtMarketplaceDir(PROJECT, "aidd-framework", "copilot")); - const agentPath = `${builtDir}/.github/agents/${PLUGIN}-code-reviewer.agent.md`; - memFs.setFile(agentPath, "stale cache content from a previous build"); - - const realBuildFor: FrameworkBuildFor = (_target, _mode, outDir) => { - const validator = noopValidator(); - const assetProvider = stubAssetProvider(); - const strategy = new FlatBuildStrategy( - memFs, - validator, - assetProvider, - buildCopilotFlatContract(), - true, // force:true — mirrors deps.ts wiring for every *:flat target - outDir, - makeIsDirectory(memFs), - new CapturingLogger() - ); - return new FrameworkBuildUseCase( - memFs, - validator, - assetProvider, - new CapturingLogger(), - strategy - ); - }; - - const uc = new EnsureBuiltMarketplaceUseCase( - memFs, - fakeResolve(FIXTURE_DIR, "1.0.0"), - realBuildFor, - fakeVersion("5.0.0") - ); - - const result = await uc.execute({ - projectRoot: PROJECT, - marketplace: makeMarketplace(), - target: "copilot", - mode: "flat", - }); - - expect(result.rebuilt).toBe(true); - expect(memFs.getFile(agentPath)).not.toBe("stale cache content from a previous build"); - }); -}); - -// The "force behavior" suite above proves the collision-bypass fires. It does not prove -// the bypass only ever fires against an aidd-owned directory — that guarantee lives in -// which outDir runBuild() is called with, both for a direct build (build()) and a build -// routed through a temp dir first (buildViaTemp(), used when the cache nests under the -// source). This pins that outDir, whichever path is taken, never leaves the build cache -// or the OS temp dir — so a future change that points either call site at a live user -// directory (e.g. a tool's real config dir) fails here, not in someone's project. -describe("outDir invariant for the cache-rebuild build path", () => { - it("only ever builds into the aidd build cache or the OS temp dir, never a user directory", async () => { - const memFs = new InMemoryFileAdapter(); - const capturedOutDirs: string[] = []; - const capturingBuildFor: FrameworkBuildFor = (_target, _mode, outDir) => { - capturedOutDirs.push(outDir); - return { - execute: async () => { - await memFs.writeFile(join(outDir, "plugins/aidd-vcs/SKILL.md"), "built content"); - return { outDir, plugins: [], totalFiles: 1 }; - }, - }; - }; - - // Direct path: source lives outside the cache tree → build() writes straight to builtDir. - const direct = new EnsureBuiltMarketplaceUseCase( - memFs, - fakeResolve("/src/framework", "1.0.0"), - capturingBuildFor, - fakeVersion("5.0.0") - ); - await direct.execute({ - projectRoot: PROJECT, - marketplace: makeMarketplace(), - target: "codex", - mode: "marketplace", - }); - - // Dogfood path: source is the project root, so builtDir nests under it → buildViaTemp() - // routes the same call through a temp dir instead (see the "builds via a temp dir" test above). - const dogfood = new EnsureBuiltMarketplaceUseCase( - memFs, - fakeResolve(PROJECT, "1.0.0"), - capturingBuildFor, - fakeVersion("5.0.0") - ); - await dogfood.execute({ - projectRoot: PROJECT, - marketplace: makeMarketplace(), - target: "cursor", - mode: "marketplace", - }); - - expect(capturedOutDirs).toHaveLength(2); - // resolve(): execute() always resolves builtDir before this capture sees it (#707). - const cacheRoot = resolve(join(PROJECT, BUILT_CACHE_SUBDIR)); - const tmpRoot = tmpdir(); - for (const outDir of capturedOutDirs) { - const underCache = outDir === cacheRoot || outDir.startsWith(`${cacheRoot}${sep}`); - const underTmp = outDir === tmpRoot || outDir.startsWith(`${tmpRoot}${sep}`); - expect(underCache || underTmp).toBe(true); - } - // The dogfood call specifically must have gone through the temp dir, not the cache - // (#707): nested() now compares "/"-normalized paths, and both sourceDir and builtDir - // are resolve()'d before it sees them, so a drive-less-vs-drive-qualified or - // "\"-vs-"/" mismatch can no longer hide real nesting on Windows. - expect(capturedOutDirs[1]?.startsWith(`${tmpRoot}${sep}`)).toBe(true); - }); -}); diff --git a/cli/tests/application/use-cases/shared/post-install-pipeline-use-case.unit.test.ts b/cli/tests/application/use-cases/shared/post-install-pipeline-use-case.unit.test.ts deleted file mode 100644 index dc1fe8c52..000000000 --- a/cli/tests/application/use-cases/shared/post-install-pipeline-use-case.unit.test.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { join } from "node:path"; -import { describe, expect, it } from "vitest"; -import { PostInstallPipelineUseCase } from "../../../../src/application/use-cases/shared/post-install-pipeline-use-case.js"; -import { buildUnitDeps, initAndInstall } from "../../../helpers/ports/build-unit-deps.js"; - -const PROJECT_ROOT = "/test-project"; - -describe("post-install pipeline", () => { - it("saves manifest and updates gitignore after file write", async () => { - const deps = await buildUnitDeps(PROJECT_ROOT); - await initAndInstall(deps, PROJECT_ROOT, "claude"); - - const manifest = await deps.manifestRepo.load(); - if (manifest === null) throw new Error("manifest not found"); - - await new PostInstallPipelineUseCase(deps.manifestRepo, deps.gitignoreUseCase).execute({ - projectRoot: PROJECT_ROOT, - manifest, - }); - - // manifest saved - const reloaded = await deps.manifestRepo.load(); - expect(reloaded).not.toBeNull(); - - // gitignore updated - const gitignorePath = join(PROJECT_ROOT, ".gitignore"); - expect(deps.fs.has(gitignorePath)).toBe(true); - const gitignoreContent = deps.fs.getFile(gitignorePath) ?? ""; - expect(gitignoreContent).toContain(".aidd/cache/"); - }); - - it("ignores the run journal, and nothing wider", async () => { - const deps = await buildUnitDeps(PROJECT_ROOT); - await initAndInstall(deps, PROJECT_ROOT, "claude"); - const manifest = await deps.manifestRepo.load(); - if (manifest === null) throw new Error("manifest not found"); - - await new PostInstallPipelineUseCase(deps.manifestRepo, deps.gitignoreUseCase).execute({ - projectRoot: PROJECT_ROOT, - manifest, - }); - - const gitignoreContent = deps.fs.getFile(join(PROJECT_ROOT, ".gitignore")) ?? ""; - expect(gitignoreContent).toContain("aidd_docs/runs/"); - expect(gitignoreContent).not.toContain("aidd_docs/*"); - expect(gitignoreContent).not.toMatch(/^aidd_docs\/$/mu); - }); -}); diff --git a/cli/tests/application/use-cases/status-plugin-user-scope.unit.test.ts b/cli/tests/application/use-cases/status-plugin-user-scope.unit.test.ts deleted file mode 100644 index c63656a51..000000000 --- a/cli/tests/application/use-cases/status-plugin-user-scope.unit.test.ts +++ /dev/null @@ -1,124 +0,0 @@ -import "../../../src/domain/tools/ai/cursor.js"; -import { join } from "node:path"; -import { describe, expect, it } from "vitest"; -import { DetectPluginDriftUseCase } from "../../../src/application/use-cases/shared/detect-plugin-drift-use-case.js"; -import { StatusUseCase } from "../../../src/application/use-cases/status-use-case.js"; -import { FileHash } from "../../../src/domain/models/file.js"; -import { Manifest } from "../../../src/domain/models/manifest.js"; -import { Plugin } from "../../../src/domain/models/plugin.js"; -import type { FileReader } from "../../../src/domain/ports/file-reader.js"; -import type { Hasher } from "../../../src/domain/ports/hasher.js"; -import type { ManifestRepository } from "../../../src/domain/ports/manifest-repository.js"; - -const EXPECTED_HASH = "abc123abc123abc123abc123abc123ab"; -const DRIFTED_HASH = "def456def456def456def456def456de"; - -// Cursor Mode B: file key is base-relative (no absolute prefix, relative to user plugins dir) -const PLUGIN_KEY = "aidd-context/commands/hello.md"; - -function makeManifest(pluginFileHash: string): Manifest { - const manifest = Manifest.create(); - manifest.addTool("cursor", "1.0.0", []); - manifest.addPlugin( - "cursor", - Plugin.fromJSON({ - name: "aidd-context", - source: { kind: "local", path: "/some/path" }, - version: "1.0.0", - strict: false, - files: { [PLUGIN_KEY]: pluginFileHash }, - }) - ); - return manifest; -} - -function makeFs(fileExists: boolean, diskHash: string): FileReader { - return { - fileExists: async () => fileExists, - isExecutable: async () => false, - readFileHash: async () => new FileHash(diskHash), - readFile: async () => "", - listDirectory: async () => [], - listFilesRecursive: async () => [], - }; -} - -function makeManifestRepo(manifest: Manifest): ManifestRepository { - return { - path: "/proj/.aidd/manifest.json", - load: async () => manifest, - save: async () => {}, - delete: async () => {}, - }; -} - -const noopHasher: Hasher = { - hash: () => new FileHash("00000000000000000000000000000000"), -}; - -describe("StatusUseCase — cursor plugin drift (user-scope)", () => { - describe("when cursor plugin file has drifted (base-relative key)", () => { - it("resolves absolute path from homedir via resolvePluginsBaseDir before checking disk", async () => { - const manifest = makeManifest(EXPECTED_HASH); - const checkedPaths: string[] = []; - const fs: FileReader = { - fileExists: async (p: string) => { - checkedPaths.push(p); - return true; - }, - isExecutable: async () => false, - readFileHash: async () => new FileHash(DRIFTED_HASH), - readFile: async () => "", - listDirectory: async () => [], - listFilesRecursive: async () => [], - }; - - const useCase = new StatusUseCase( - fs, - makeManifestRepo(manifest), - noopHasher, - new DetectPluginDriftUseCase(fs) - ); - await useCase.execute({ projectRoot: "/proj" }); - - // All checked paths must be absolute (resolved from user home, not from projectRoot) - expect(checkedPaths.some((p) => p.includes(join(".cursor", "plugins", "local")))).toBe(true); - expect(checkedPaths.every((p) => !p.includes(join("/proj", PLUGIN_KEY)))).toBe(true); - }); - - it("returns plugin drift entry with the relative key", async () => { - const manifest = makeManifest(EXPECTED_HASH); - const fs = makeFs(true, DRIFTED_HASH); - const useCase = new StatusUseCase( - fs, - makeManifestRepo(manifest), - noopHasher, - new DetectPluginDriftUseCase(fs) - ); - - const report = await useCase.execute({ projectRoot: "/proj" }); - - expect(report.pluginDrift).toHaveLength(1); - expect(report.pluginDrift[0].toolId).toBe("cursor"); - expect(report.pluginDrift[0].pluginName).toBe("aidd-context"); - expect(report.pluginDrift[0].driftedFiles).toContain(PLUGIN_KEY); - }); - }); - - describe("when cursor plugin file is in sync (base-relative key)", () => { - it("returns empty pluginDrift", async () => { - const manifest = makeManifest(EXPECTED_HASH); - const fs = makeFs(true, EXPECTED_HASH); - const useCase = new StatusUseCase( - fs, - makeManifestRepo(manifest), - noopHasher, - new DetectPluginDriftUseCase(fs) - ); - - const report = await useCase.execute({ projectRoot: "/proj" }); - - expect(report.pluginDrift).toHaveLength(0); - }); - }); -}); diff --git a/cli/tests/application/use-cases/status-use-case.unit.test.ts b/cli/tests/application/use-cases/status-use-case.unit.test.ts deleted file mode 100644 index 5ae579d12..000000000 --- a/cli/tests/application/use-cases/status-use-case.unit.test.ts +++ /dev/null @@ -1,54 +0,0 @@ -import { describe, expect, it } from "vitest"; -import "../../../src/domain/tools/ai/claude.js"; -import "../../../src/domain/tools/ai/codex.js"; -import "../../../src/domain/tools/ai/copilot.js"; -import "../../../src/domain/tools/ai/cursor.js"; -import "../../../src/domain/tools/ai/opencode.js"; -import "../../../src/domain/tools/ide/vscode.js"; -import { InitUseCase } from "../../../src/application/use-cases/init-use-case.js"; -import { DetectPluginDriftUseCase } from "../../../src/application/use-cases/shared/detect-plugin-drift-use-case.js"; -import { StatusUseCase } from "../../../src/application/use-cases/status-use-case.js"; -import { compareSemver } from "../../../src/domain/models/semver.js"; -import { buildUnitDeps } from "../../helpers/ports/build-unit-deps.js"; - -const PROJECT_ROOT = "/test-project"; - -describe("status", () => { - it("reports no drift when no tools are installed", async () => { - const deps = await buildUnitDeps(PROJECT_ROOT); - await new InitUseCase(deps.fs, deps.manifestRepo).execute({ projectRoot: PROJECT_ROOT }); - - const useCase = new StatusUseCase( - deps.fs, - deps.manifestRepo, - deps.hasher, - new DetectPluginDriftUseCase(deps.fs) - ); - const report = await useCase.execute({ projectRoot: PROJECT_ROOT }); - - expect(report.tools).toHaveLength(0); - expect(report.inSync).toBe(true); - }); - - describe("compareSemver()", () => { - it("orders lower major version as smaller", () => { - expect(compareSemver("1.0.0", "2.0.0")).toBe(-1); - }); - - it("orders lower minor version as smaller", () => { - expect(compareSemver("3.1.0", "3.2.0")).toBe(-1); - }); - - it("orders higher patch version as greater", () => { - expect(compareSemver("3.1.1", "3.1.0")).toBe(1); - }); - - it("treats identical versions as equal", () => { - expect(compareSemver("3.1.0", "3.1.0")).toBe(0); - }); - - it("handles v-prefix", () => { - expect(compareSemver("3.0.0", "v3.1.0")).toBe(-1); - }); - }); -}); diff --git a/cli/tests/application/use-cases/telemetry/report-cost-use-case.unit.test.ts b/cli/tests/application/use-cases/telemetry/report-cost-use-case.unit.test.ts deleted file mode 100644 index e648c18a8..000000000 --- a/cli/tests/application/use-cases/telemetry/report-cost-use-case.unit.test.ts +++ /dev/null @@ -1,956 +0,0 @@ -import { readFileSync } from "node:fs"; -import { fileURLToPath } from "node:url"; -import { beforeEach, describe, expect, it } from "vitest"; -import "../../../../src/domain/tools/ai/claude.js"; -import "../../../../src/domain/tools/ai/codex.js"; -import "../../../../src/domain/tools/ai/copilot.js"; -import "../../../../src/domain/tools/ai/cursor.js"; -import "../../../../src/domain/tools/ai/opencode.js"; -import { ReadLocalCostUseCase } from "../../../../src/application/use-cases/telemetry/read-local-cost-use-case.js"; -import { ReportCostUseCase } from "../../../../src/application/use-cases/telemetry/report-cost-use-case.js"; -import { UnreadableIdentityFileError } from "../../../../src/domain/errors.js"; -import { toMicroUsd } from "../../../../src/domain/models/cost-report.js"; -import { taskFolderPathFromIdentity } from "../../../../src/domain/models/task-backlog-link.js"; -import type { TelemetrySinkRecord } from "../../../../src/domain/models/telemetry-sink-record.js"; -import { AI_TOOL_IDS } from "../../../../src/domain/models/tool-ids.js"; -import type { RunJournal } from "../../../../src/domain/ports/run-journal-reader.js"; -import type { LocalCostCandidateRecord } from "../../../../src/domain/ports/session-cost-reader.js"; -import { NULL_PERSON_IDENTITY_READER } from "../../../helpers/ports/in-memory-person-identity-reader.js"; -import { InMemoryPersonIdentityStore } from "../../../helpers/ports/in-memory-person-identity-store.js"; -import { InMemoryRunJournalReader } from "../../../helpers/ports/in-memory-run-journal-reader.js"; -import { InMemoryTaskBacklogReader } from "../../../helpers/ports/in-memory-task-backlog-reader.js"; -import { InMemoryTelemetrySink } from "../../../helpers/ports/in-memory-telemetry-sink.js"; -import { StubTelemetryEvidenceReader } from "../../../helpers/ports/stub-telemetry-evidence-reader.js"; - -const PERIOD = { fromDay: "2026-08-17", toDay: "2026-08-21" } as const; -// `execute()` now also asks whether the project switch is on - every test in this file is -// about what the sink and the journal hold, not about that switch, so it always answers -// "on" here and passes a fixed root and an empty env just to satisfy the shape. -const BASE_OPTIONS = { projectRoot: "/project", env: {} } as const; -const STORED_ON = new Date("2026-08-21T09:00:00Z"); -const TASK = "2026_08/2026_08_21_cost-reporter"; - -function record(overrides: Partial): TelemetrySinkRecord { - return { - sink_schema_version: 2, - kind: "request", - provenance: "local-read", - tool: "claude", - vendor_id: "s-1", - vendor_field: "sessionId", - step_attribution: "unattributed", - event_timestamp: "2026-08-18T10:00:00.000Z", - ...overrides, - }; -} - -describe("ReportCostUseCase", () => { - let sink: InMemoryTelemetrySink; - let journals: InMemoryRunJournalReader; - let identity: InMemoryPersonIdentityStore; - let evidence: StubTelemetryEvidenceReader; - let taskBacklog: InMemoryTaskBacklogReader; - let useCase: ReportCostUseCase; - - beforeEach(() => { - sink = new InMemoryTelemetrySink(); - journals = new InMemoryRunJournalReader(); - identity = new InMemoryPersonIdentityStore(); - evidence = new StubTelemetryEvidenceReader(); - taskBacklog = new InMemoryTaskBacklogReader(); - useCase = new ReportCostUseCase(sink, journals, identity, evidence, taskBacklog); - }); - - async function store(...records: readonly TelemetrySinkRecord[]): Promise { - for (const stored of records) await sink.appendRecord(stored, STORED_ON); - } - - it("reports a period from what the sink holds, whatever session it belongs to", async () => { - await store( - record({ vendor_id: "s-1", cost_usd: 0.1 }), - record({ vendor_id: "s-2", cost_usd: 0.2 }) - ); - - const built = await useCase.execute({ ...BASE_OPTIONS, period: PERIOD }); - - expect(built.sessions).toBe(2); - expect(built.totals.costMicroUsd).toBe(toMicroUsd(0.3)); - expect([built.fromDay, built.toDay]).toEqual(["2026-08-17", "2026-08-21"]); - }); - - it("leaves out work that happened before the period, however recently it was stored", async () => { - // Both lines are appended on the same day; only their own moments differ. - await store( - record({ vendor_id: "july", cost_usd: 9, event_timestamp: "2026-07-29T15:12:27.889Z" }), - record({ vendor_id: "august", cost_usd: 1 }) - ); - - const built = await useCase.execute({ ...BASE_OPTIONS, period: PERIOD }); - - expect(built.totals.costMicroUsd).toBe(toMicroUsd(1)); - expect(built.sessions).toBe(1); - }); - - it("restricts to the sessions that wrote into the task asked for", async () => { - journals.set("s-task", { - boundaries: [], - session: { - type: "session_start", - at: "2026-08-18T09:00:00Z", - run_id: "01ARZ3NDEKTSV4RRFFQ69G5FAV", - tool: "claude-code", - vendor_id: "s-task", - }, - filesWritten: [ - { - type: "file_written", - at: "2026-08-18T09:30:00Z", - path: `aidd_docs/tasks/${TASK}/plan.md`, - }, - ], - taskDeclarations: [], - }); - await store( - record({ vendor_id: "s-task", cost_usd: 1 }), - record({ vendor_id: "s-elsewhere", cost_usd: 8 }) - ); - - const built = await useCase.execute({ ...BASE_OPTIONS, period: PERIOD, task: TASK }); - - expect(built.task).toBe(TASK); - expect(built.totals.costMicroUsd).toBe(toMicroUsd(1)); - }); - - // The written-file route only fires inside the span the journal itself witnessed, and that - // span reaches the report from the journal's own lines - nowhere else. A record inside it - // that no declaration covers is named after the one task folder the session wrote into; a - // record from before the journal was ever open is not, however many files that session - // went on to write. - it("names a record inside the journal's span after the only task folder that session wrote into", async () => { - journals.set("s-inferred", { - boundaries: [], - session: { - type: "session_start", - at: "2026-08-18T09:00:00Z", - run_id: "01ARZ3NDEKTSV4RRFFQ69G5FAV", - tool: "claude-code", - vendor_id: "s-inferred", - }, - filesWritten: [ - { - type: "file_written", - at: "2026-08-18T09:30:00Z", - path: `aidd_docs/tasks/${TASK}/plan.md`, - }, - ], - taskDeclarations: [], - }); - await store( - record({ - vendor_id: "s-inferred", - cost_usd: 1, - event_timestamp: "2026-08-18T09:15:00Z", - }), - record({ - vendor_id: "s-inferred", - cost_usd: 2, - event_timestamp: "2026-08-17T09:15:00Z", - }) - ); - - const built = await useCase.execute({ ...BASE_OPTIONS, period: PERIOD }); - - const inferred = built.byTasks.find((row) => row.attribution === "inferred"); - expect(inferred?.task).toBe(TASK); - expect(inferred?.totals.costMicroUsd).toBe(toMicroUsd(1)); - expect(built.byTasks.some((row) => row.reason !== undefined)).toBe(true); - }); - - it("gives every declared tool a row, with the reason an unreadable one cannot be read", async () => { - await store(record({ cost_usd: 1 })); - - const built = await useCase.execute({ ...BASE_OPTIONS, period: PERIOD }); - - expect(built.byTools.map((row) => row.tool)).toEqual([...AI_TOOL_IDS]); - const cursor = built.byTools.find((row) => row.tool === "cursor"); - expect(cursor?.coverage).toBe("not-covered"); - expect(cursor?.reason).toBeTruthy(); - }); - - it("reports what the read could not place or could not parse", async () => { - await store( - record({ cost_usd: 1 }), - record({ vendor_id: "no-moment", event_timestamp: undefined }) - ); - - const built = await useCase.execute({ ...BASE_OPTIONS, period: PERIOD }); - - expect(built.undatedRecords).toBe(1); - expect(built.totals.requests).toBe(1); - }); - - it("answers an empty period with an empty report and no error", async () => { - const built = await useCase.execute({ ...BASE_OPTIONS, period: PERIOD }); - - expect(built.sessions).toBe(0); - expect(built.totals).toEqual({ requests: 0 }); - expect(built.byTools.every((row) => row.totals.requests === 0)).toBe(true); - }); - - it("reports a period whose sessions have no journal at all", async () => { - await store(record({ cost_usd: 1 })); - - expect((await useCase.execute({ ...BASE_OPTIONS, period: PERIOD })).totals.requests).toBe(1); - }); - - it("resolves byPeople against the identity this store holds", async () => { - identity = new InMemoryPersonIdentityStore({ - personId: "person-a", - origin: "adopted", - alsoMe: ["machine-1"], - }); - useCase = new ReportCostUseCase( - sink, - journals, - identity, - evidence, - new InMemoryTaskBacklogReader() - ); - await store(record({ vendor_id: "s-1", cost_usd: 1, person_id: "machine-1" })); - - const built = await useCase.execute({ ...BASE_OPTIONS, period: PERIOD }); - - const mapped = built.byPeople.find((row) => row.resolution === "mapped"); - expect(mapped?.person).toBe("person-a"); - }); - - it("survives an identity that cannot be read, reporting every figure with the caveat set", async () => { - identity.throwOnRead = new UnreadableIdentityFileError(identity.filePath, "EISDIR"); - await store(record({ vendor_id: "s-1", cost_usd: 1, person_id: "machine-1" })); - - const built = await useCase.execute({ ...BASE_OPTIONS, period: PERIOD }); - - expect(built.totals.requests).toBe(1); - expect(built.identityUnusableCause).toBe("unreadable"); - expect(built.byPeople.every((row) => row.resolution !== "mapped")).toBe(true); - }); - - it("reports no identity declared as its own cause, distinct from unreadable", async () => { - await store(record({ vendor_id: "s-1", cost_usd: 1, person_id: "machine-1" })); - - const built = await useCase.execute({ ...BASE_OPTIONS, period: PERIOD }); - - expect(built.identityUnusableCause).toBe("absent"); - }); - - it("reports whether the project switch is on, from the evidence reader alone", async () => { - evidence.enabled = false; - - const built = await useCase.execute({ ...BASE_OPTIONS, period: PERIOD }); - - expect(built.measurementEnabled).toBe(false); - }); - - it("reports the switch as on when the evidence reader says so, even with nothing measured", async () => { - evidence.enabled = true; - - const built = await useCase.execute({ ...BASE_OPTIONS, period: PERIOD }); - - expect(built.measurementEnabled).toBe(true); - expect(built.totals.requests).toBe(0); - }); - - it("re-throws an error it does not recognise rather than mislabelling it as a named cause", async () => { - identity.throwOnRead = new Error("some other failure entirely"); - - await expect(useCase.execute({ ...BASE_OPTIONS, period: PERIOD })).rejects.toThrow( - "some other failure entirely" - ); - }); - - // A task reached only by the written-file route still has a folder, and that folder can - // declare a backlog item. Resolving declarations from declared intervals alone would send - // every inferred record to the "this task declares no backlog item" row - a claim about - // the task, produced by a lookup that never happened. - // A journal moment is a second: `nowIso()` in the writing hook strips the milliseconds - // (`plugins/aidd-telemetry/hooks/lib/record.cjs`). A record carries them. Comparing the - // two as instants refuses a record that landed in the very second the journal last wrote, - // which is a rounding artefact of the source, not a fact about the work - measured, it - // cost one record of 1073 on a real session. - it("counts a record inside the last second its journal wrote as witnessed", async () => { - journals.set("s-same-second", { - boundaries: [], - session: { - type: "session_start", - at: "2026-08-18T09:00:00Z", - run_id: "01ARZ3NDEKTSV4RRFFQ69G5FB0", - tool: "claude-code", - vendor_id: "s-same-second", - }, - filesWritten: [ - { - type: "file_written", - at: "2026-08-18T09:30:00Z", - path: `aidd_docs/tasks/${TASK}/plan.md`, - }, - ], - taskDeclarations: [], - }); - await store( - record({ - vendor_id: "s-same-second", - cost_usd: 5, - event_timestamp: "2026-08-18T09:30:00.351Z", - }) - ); - - const built = await useCase.execute({ ...BASE_OPTIONS, period: PERIOD }); - - expect(built.byTasks.find((row) => row.attribution === "inferred")?.task).toBe(TASK); - }); - - it("resolves the backlog declaration of a task no interval ever declared", async () => { - journals.set("s-written-only", { - boundaries: [], - session: { - type: "session_start", - at: "2026-08-18T09:00:00Z", - run_id: "01ARZ3NDEKTSV4RRFFQ69G5FAX", - tool: "claude-code", - vendor_id: "s-written-only", - }, - filesWritten: [ - { - type: "file_written", - at: "2026-08-18T09:40:00Z", - path: `aidd_docs/tasks/${TASK}/plan.md`, - }, - ], - taskDeclarations: [], - }); - taskBacklog.set(taskFolderPathFromIdentity(TASK), { - kind: "declared", - link: { backlog: "acme/widgets#742", writtenAt: "2026-08-18T09:00:00Z", writtenBy: "x" }, - }); - await store( - record({ - vendor_id: "s-written-only", - cost_usd: 3, - event_timestamp: "2026-08-18T09:20:00Z", - }) - ); - - const built = await useCase.execute({ ...BASE_OPTIONS, period: PERIOD }); - - expect(built.byBacklog.map((row) => row.backlog)).toContain("acme/widgets#742"); - }); - - it("resolves the declaration through TaskBacklogReader, keyed on the folder the task identity resolves to", async () => { - // Pins the wiring `distinctTaskIdentities` -> `taskFolderPathFromIdentity` -> - // `TaskBacklogReader.read` actually performs: the double is set on the exact folder - // path a real adapter would be asked to read, never on the bare task identity string. - journals.set("s-task", { - boundaries: [], - session: { - type: "session_start", - at: "2026-08-18T09:00:00Z", - run_id: "01ARZ3NDEKTSV4RRFFQ69G5FAW", - tool: "claude-code", - vendor_id: "s-task", - }, - // A witnessed moment after the record's own timestamp - without one, the declared - // interval's own end collapses to its start (buildTaskIntervals's own documented - // behaviour), and the record below would fall outside it. - filesWritten: [ - { - type: "file_written", - at: "2026-08-18T09:40:00Z", - path: `aidd_docs/tasks/${TASK}/plan.md`, - }, - ], - taskDeclarations: [ - { - type: "task_declared", - at: "2026-08-18T09:00:00Z", - path: `aidd_docs/tasks/${TASK}/spec.md`, - }, - ], - }); - taskBacklog.set(taskFolderPathFromIdentity(TASK), { - kind: "declared", - link: { - backlog: "acme/widgets#661", - writtenAt: "2026-08-18T08:00:00Z", - writtenBy: "aidd-pm:04-spec", - }, - }); - await store( - record({ vendor_id: "s-task", cost_usd: 4, event_timestamp: "2026-08-18T09:30:00Z" }) - ); - - const built = await useCase.execute({ ...BASE_OPTIONS, period: PERIOD }); - - const named = built.byBacklog.find((row) => row.backlog === "acme/widgets#661"); - expect(named?.totals.requests).toBe(1); - expect(named?.totals.costMicroUsd).toBe(toMicroUsd(4)); - }); - - it("names no tool, by string literal", () => { - const source = readFileSync( - fileURLToPath( - new URL( - "../../../../src/application/use-cases/telemetry/report-cost-use-case.ts", - import.meta.url - ) - ), - "utf8" - ); - - for (const toolId of AI_TOOL_IDS) { - expect(source).not.toContain(`"${toolId}"`); - expect(source).not.toContain(`'${toolId}'`); - } - }); -}); - -/** - * Catching the sink up, so a report is the only command a person runs. - * - * `report` reads the sink, and until now nothing filled the sink but `aidd telemetry read`. - * A person who forgot that step was told, truthfully, that the period held nothing — the one - * answer indistinguishable from a period where nothing was spent. - */ -describe("a report that catches the sink up first", () => { - const SESSION = "s-catch-up"; - const AT = "2026-08-18T10:00:00.000Z"; - - let sink: InMemoryTelemetrySink; - let journals: InMemoryRunJournalReader; - let evidence: StubTelemetryEvidenceReader; - - /** A journal for one session, dated inside the period unless told otherwise. */ - function journalAt(at: string): RunJournal { - return { - boundaries: [], - filesWritten: [], - taskDeclarations: [], - session: { - type: "session_start", - at, - run_id: "01ARZ3NDEKTSV4RRFFQ69G5FAV", - tool: "claude-code", - vendor_id: SESSION, - }, - }; - } - - /** The real local read, over in-memory ports and one stub reader — not a double for it. - * What is being asserted is that `report` reaches the read at all, which a stand-in for - * the read could be made to show whether or not it were true. */ - function localRead(records: readonly LocalCostCandidateRecord[]): ReadLocalCostUseCase { - return new ReadLocalCostUseCase( - sink, - new Map([["claude", { read: async () => ({ records, sessionFound: true }) }]]), - journals, - NULL_PERSON_IDENTITY_READER, - evidence - ); - } - - function reportWith(read?: ReadLocalCostUseCase): ReportCostUseCase { - return new ReportCostUseCase( - sink, - journals, - new InMemoryPersonIdentityStore(), - evidence, - new InMemoryTaskBacklogReader(), - read - ); - } - - const CANDIDATE: LocalCostCandidateRecord = { - kind: "request", - vendor_id: SESSION, - vendor_field: "sessionId", - turn_id: "t-1", - event_timestamp: AT, - input_tokens: 100, - output_tokens: 10, - }; - - beforeEach(() => { - sink = new InMemoryTelemetrySink(); - journals = new InMemoryRunJournalReader(); - evidence = new StubTelemetryEvidenceReader(); - }); - - it("reports a journalled session nobody ran a read for", async () => { - journals.set(SESSION, journalAt(AT)); - - const built = await reportWith(localRead([CANDIDATE])).execute({ - ...BASE_OPTIONS, - period: PERIOD, - }); - - expect(built.totals.requests).toBe(1); - expect(built.totals.inputTokens).toBe(100); - }); - - it("reports only what the sink holds when no read was wired, rather than guessing", async () => { - journals.set(SESSION, journalAt(AT)); - - const built = await reportWith().execute({ ...BASE_OPTIONS, period: PERIOD }); - - expect(built.totals.requests).toBe(0); - }); - - // Was asserted the other way round — `expect(reads).toBe(0)`, "leaves a session already - // stored alone" — and that assertion is why the defect survived: it locked an - // optimisation that buys speed with correctness. Keyed on whether a session appears at - // all, one stored record froze a session that was still running. Measured on a live - // session: the sink held 285 records while the transcript had 541, and `report` added - // none of them. A plausible wrong figure, which is the one thing this layer refuses - // everywhere else. - // - // Re-reading is safe because the reader already dedupes per `turn_id` - // (`read-local-cost-use-case.ts`), so the session-level gate was a second filter at the - // wrong granularity. What bounds the cost is the period, not this. - // A judgement is derived, never trusted from disk. `step_attribution` is written into the - // record at read time, so a record stored before a rule was corrected keeps the answer - // that rule gave — measured on a live sink, which reported 91% `unattributed` while a - // fresh read of the same session reported 0%. `tool-stated` stays trusted: the tool naming - // a skill on the counters line is an observation, not a judgement. - // The other half of the same rule, and it had no test until a mutation went unnoticed: - // overwriting `tool-stated` too broke nothing. An observation outranks a derivation — the - // tool named that skill on the line carrying the counters, and no interval can improve on - // it. Asserted with the journal naming a *different* skill, so trusting the stored one is - // the only way to pass. - // A journal can disappear while its records stay: `aidd_docs/runs/` lives in the project - // and is git-ignored, so a clean checkout has the figures and none of the boundaries. - // Deriving there would answer `unattributed` for a session that once resolved a step, - // trading a stale reading for no reading — the one direction this whole change refuses. - it("keeps a stored step for a session the period's journals say nothing about", async () => { - await sink.appendRecord( - record({ - vendor_id: "s-no-journal", - event_timestamp: "2026-08-18T10:00:00.000Z", - step_attribution: "journal-interval", - step: "aidd-dev:05-review", - }), - STORED_ON - ); - - const built = await reportWith().execute({ ...BASE_OPTIONS, period: PERIOD }); - - expect(built.bySteps).toContainEqual( - expect.objectContaining({ attribution: "journal-interval", step: "aidd-dev:05-review" }) - ); - }); - - it("leaves a tool-stated step alone, even where the journal's interval names another", async () => { - const journal = journalAt("2026-08-18T09:00:00Z"); - journals.set(SESSION, { - ...journal, - boundaries: [ - { type: "step_start", at: "2026-08-18T09:30:00Z", skill: "aidd-dev:02-implement" }, - ], - }); - await sink.appendRecord( - record({ - vendor_id: SESSION, - event_timestamp: "2026-08-18T10:00:00.000Z", - step_attribution: "tool-stated", - step: "aidd-vcs:01-commit", - }), - STORED_ON - ); - - const built = await reportWith().execute({ ...BASE_OPTIONS, period: PERIOD }); - - expect(built.bySteps).toContainEqual( - expect.objectContaining({ attribution: "tool-stated", step: "aidd-vcs:01-commit" }) - ); - }); - - // The exact join, and the reason the whole prompt chain exists. The record's moment falls - // *outside* every interval, so an interval reading answers `unattributed` — only matching - // the prompt both sides name can attribute it. That is what survives two tasks advancing - // at once: two prompts stay two prompts however their moments overlap. - // Three steps really do open under one prompt: measured on a live session, where - // `aidd-orchestrator:01-sdlc`, `aidd-pm:04-spec` and `aidd-dev:01-plan` all carried - // `839ab4a8-…`. The prompt names the step its work began in; taking the last opener would - // answer "plan" for the reasoning that produced the spec — a different claim, and a wrong - // one. - it("names the step a shared prompt opened first, never the last to reuse it", async () => { - const journal = journalAt("2026-08-18T09:00:00Z"); - journals.set(SESSION, { - ...journal, - boundaries: [ - { - type: "step_start", - at: "2026-08-18T11:00:00Z", - skill: "aidd-pm:04-spec", - turn_id: "p-abc", - }, - { - type: "step_start", - at: "2026-08-18T11:30:00Z", - skill: "aidd-dev:01-plan", - turn_id: "p-abc", - }, - ], - }); - await sink.appendRecord( - record({ - vendor_id: SESSION, - event_timestamp: "2026-08-18T10:00:00.000Z", - prompt_id: "p-abc", - }), - STORED_ON - ); - - const built = await reportWith().execute({ ...BASE_OPTIONS, period: PERIOD }); - - expect(built.bySteps).toContainEqual( - expect.objectContaining({ attribution: "prompt-matched", step: "aidd-pm:04-spec" }) - ); - }); - - it("attributes on the prompt both sides name, where no interval covers the moment", async () => { - const journal = journalAt("2026-08-18T09:00:00Z"); - journals.set(SESSION, { - ...journal, - boundaries: [ - { - type: "step_start", - at: "2026-08-18T11:00:00Z", - skill: "aidd-dev:02-implement", - turn_id: "p-abc", - }, - ], - }); - await sink.appendRecord( - record({ - vendor_id: SESSION, - // Before the step ever opened: no interval can reach it. - event_timestamp: "2026-08-18T10:00:00.000Z", - prompt_id: "p-abc", - }), - STORED_ON - ); - - const built = await reportWith().execute({ ...BASE_OPTIONS, period: PERIOD }); - - expect(built.bySteps).toContainEqual( - expect.objectContaining({ attribution: "prompt-matched", step: "aidd-dev:02-implement" }) - ); - }); - - /** - * A session whose journal never opened the step, because the hook was not installed when - * it ran. The record still carries what its own transcript said: the skill a `Skill` call - * invoked inside that prompt. Same fact, same identifier, read from the other side. - * - * Measured on the real sink: 28 such prompts across 22 days, 318 records named this way - * and by nothing else. - */ - it("attributes on the skill the record's own prompt invoked, where no journal saw it", async () => { - journals.set(SESSION, journalAt("2026-08-18T09:00:00Z")); - await sink.appendRecord( - record({ - vendor_id: SESSION, - event_timestamp: "2026-08-18T10:00:00.000Z", - prompt_id: "p-abc", - prompt_skill: "aidd-dev:01-plan", - }), - STORED_ON - ); - - const built = await reportWith().execute({ ...BASE_OPTIONS, period: PERIOD }); - - expect(built.bySteps).toContainEqual( - expect.objectContaining({ attribution: "prompt-matched", step: "aidd-dev:01-plan" }) - ); - }); - - // The journal is the stronger of the two: it was written by a hook the host itself fired, - // while the transcript is read back afterwards. They can only disagree if one of them is - // wrong, and the reading with a witness wins. - it("keeps the journal's own answer when both sides name a skill for the same prompt", async () => { - const journal = journalAt("2026-08-18T09:00:00Z"); - journals.set(SESSION, { - ...journal, - boundaries: [ - { - type: "step_start", - at: "2026-08-18T11:00:00Z", - skill: "aidd-pm:04-spec", - turn_id: "p-abc", - }, - ], - }); - await sink.appendRecord( - record({ - vendor_id: SESSION, - event_timestamp: "2026-08-18T10:00:00.000Z", - prompt_id: "p-abc", - prompt_skill: "aidd-dev:01-plan", - }), - STORED_ON - ); - - const built = await reportWith().execute({ ...BASE_OPTIONS, period: PERIOD }); - - expect(built.bySteps).toContainEqual( - expect.objectContaining({ attribution: "prompt-matched", step: "aidd-pm:04-spec" }) - ); - }); - - it("derives a stored record's step from the journal rather than trusting the stored one", async () => { - const at = "2026-08-18T10:00:00.000Z"; - const journal = journalAt("2026-08-18T09:00:00Z"); - journals.set(SESSION, { - ...journal, - // The pause after the record is what the step is capped at: a step runs past a pause - // but never past the last moment its own journal witnessed. - boundaries: [ - { type: "step_start", at: "2026-08-18T09:30:00Z", skill: "aidd-dev:02-implement" }, - { type: "turn_end", at: "2026-08-18T10:30:00Z" }, - ], - }); - await sink.appendRecord( - record({ vendor_id: SESSION, event_timestamp: at, step_attribution: "unattributed" }), - STORED_ON - ); - - const built = await reportWith().execute({ ...BASE_OPTIONS, period: PERIOD }); - - expect(built.bySteps).toContainEqual( - expect.objectContaining({ attribution: "journal-interval", step: "aidd-dev:02-implement" }) - ); - }); - - // The limit of re-reading, found by the reference-week e2e going from 7 requests to 10. - // A re-read is matched against what is stored on `turn_id`, and `groupByTurnId` indexes - // nothing without one — so re-reading a session whose records carry none appends them a - // second time. Rare while only unseen sessions were read; systematic once every session - // in the period is. Claude Code writes a `requestId` on every line (0 of 810 records - // without one on a live sink), but a host that does not must not be silently doubled. - it("leaves a session alone when its stored records carry no turn id to match on", async () => { - journals.set(SESSION, journalAt(AT)); - await sink.appendRecord(record({ vendor_id: SESSION, event_timestamp: AT }), STORED_ON); - let reads = 0; - const counting = new ReadLocalCostUseCase( - sink, - new Map([ - [ - "claude", - { - read: async () => { - reads += 1; - return { records: [CANDIDATE], sessionFound: true }; - }, - }, - ], - ]), - journals, - NULL_PERSON_IDENTITY_READER, - evidence - ); - - await reportWith(counting).execute({ ...BASE_OPTIONS, period: PERIOD }); - - expect(reads).toBe(0); - }); - - it("reads a stored session again, so a live session's later turns land", async () => { - journals.set(SESSION, journalAt(AT)); - // A `turn_id` is what makes a re-read reconcilable rather than duplicating: the test - // below holds the other half of that rule. - await sink.appendRecord( - record({ vendor_id: SESSION, event_timestamp: AT, turn_id: "req_1" }), - STORED_ON - ); - let reads = 0; - const counting = new ReadLocalCostUseCase( - sink, - new Map([ - [ - "claude", - { - read: async () => { - reads += 1; - return { records: [CANDIDATE], sessionFound: true }; - }, - }, - ], - ]), - journals, - NULL_PERSON_IDENTITY_READER, - evidence - ); - - await reportWith(counting).execute({ ...BASE_OPTIONS, period: PERIOD }); - - expect(reads).toBe(1); - }); - - it("reaches a session journalled on the last day of the period, which runs to midnight", async () => { - // The bound is the first instant after `toDay`, not `toDay` at 00:00. Getting that - // wrong excluded a whole day: a report over the single day work happened on answered - // "nothing in this period", and `--days N` sets `toDay` to today, so the default report - // never caught up anything journalled today. - journals.set(SESSION, journalAt(`${PERIOD.toDay}T23:59:59.999Z`)); - - const built = await reportWith( - localRead([{ ...CANDIDATE, event_timestamp: `${PERIOD.toDay}T23:59:59.999Z` }]) - ).execute({ ...BASE_OPTIONS, period: PERIOD }); - - expect(built.totals.requests).toBe(1); - }); - - it("reaches a session journalled at the very first instant of the period", async () => { - journals.set(SESSION, journalAt(`${PERIOD.fromDay}T00:00:00.000Z`)); - - const built = await reportWith( - localRead([{ ...CANDIDATE, event_timestamp: `${PERIOD.fromDay}T00:00:00.000Z` }]) - ).execute({ ...BASE_OPTIONS, period: PERIOD }); - - expect(built.totals.requests).toBe(1); - }); - - it("never reaches for a session journalled the instant the period ends", async () => { - // Midnight opening the day *after* `toDay` is the first moment outside, not the last - // one inside — the other half of the boundary, and the half a `>` instead of a `>=` - // would silently widen. - const dayAfter = new Date(Date.parse(`${PERIOD.toDay}T00:00:00Z`) + 86_400_000); - journals.set(SESSION, journalAt(dayAfter.toISOString())); - - const built = await reportWith(localRead([CANDIDATE])).execute({ - ...BASE_OPTIONS, - period: PERIOD, - }); - - expect(built.totals.requests).toBe(0); - }); - - it("never reaches for a session whose own moment falls outside the period asked about", async () => { - // Otherwise the cost of catching up would grow with the age of the repository rather - // than with the length of the period, and a one-week report on a two-year project would - // re-read every session it ever journalled. - journals.set(SESSION, journalAt("2020-01-01T00:00:00.000Z")); - - const built = await reportWith(localRead([CANDIDATE])).execute({ - ...BASE_OPTIONS, - period: PERIOD, - }); - - expect(built.totals.requests).toBe(0); - }); - - it("deletes no stored day file, since a question is not housekeeping", async () => { - // `read` prunes past its retention window, which is right for the command a person runs - // to do housekeeping. Behind a report it meant a command that had never destroyed - // anything started deleting measurement as a side effect of being asked a question. - journals.set(SESSION, journalAt(AT)); - for (let day = 1; day <= 120; day += 1) { - const stamp = new Date(Date.UTC(2025, 0, day)); - await sink.appendRecord(record({ vendor_id: `old-${day}` }), stamp); - } - const before = await sink.listDayFiles(); - - await reportWith(localRead([CANDIDATE])).execute({ ...BASE_OPTIONS, period: PERIOD }); - - // Asserted as "none of these is gone", not as an unchanged count: the catch-up stores - // what it reads, so it adds a day file of its own. What must hold is that it removed - // nothing. - const after = new Set(await sink.listDayFiles()); - expect(before.filter((file) => !after.has(file))).toEqual([]); - }); - - it("says what a reader could not answer, rather than reporting the silence as no spend", async () => { - // A period where every reader threw would otherwise print exactly what a period with no - // spend prints. The read surface showed those failures; behind a report nobody sees its - // output any more, so the report has to carry them itself. - journals.set(SESSION, journalAt(AT)); - const warnings: string[] = []; - const throwing = new ReadLocalCostUseCase( - sink, - new Map([ - [ - "claude", - { - read: async () => { - throw new Error("the transcript directory is unreadable"); - }, - }, - ], - ]), - journals, - NULL_PERSON_IDENTITY_READER, - evidence - ); - const report = new ReportCostUseCase( - sink, - journals, - new InMemoryPersonIdentityStore(), - evidence, - new InMemoryTaskBacklogReader(), - throwing, - { debug: () => {}, info: () => {}, warn: (m: string) => warnings.push(m) } - ); - - const built = await report.execute({ ...BASE_OPTIONS, period: PERIOD }); - - expect(built.totals.requests).toBe(0); - expect(warnings.join("\n")).toContain("the transcript directory is unreadable"); - }); - - it("skips a journal whose own moment cannot be read at all, rather than treating it as now", async () => { - journals.set(SESSION, journalAt("not a moment")); - - const built = await reportWith(localRead([CANDIDATE])).execute({ - ...BASE_OPTIONS, - period: PERIOD, - }); - - expect(built.totals.requests).toBe(0); - }); - - it("opens no tool's files at all when the project switch is off", async () => { - // Asserted on whether the reader was reached, not on the total: a refused catch-up and - // an absent one both report zero, so a total cannot tell them apart. What has to hold - // here is that a report never opens a person's session files against their refusal. - journals.set(SESSION, journalAt(AT)); - let reads = 0; - const counting = new ReadLocalCostUseCase( - sink, - new Map([ - [ - "claude", - { - read: async () => { - reads += 1; - return { records: [CANDIDATE], sessionFound: true }; - }, - }, - ], - ]), - journals, - NULL_PERSON_IDENTITY_READER, - evidence - ); - - evidence.enabled = true; - await reportWith(counting).execute({ ...BASE_OPTIONS, period: PERIOD }); - const readWhenOn = reads; - - sink = new InMemoryTelemetrySink(); - reads = 0; - evidence.enabled = false; - await reportWith(counting).execute({ ...BASE_OPTIONS, period: PERIOD }); - - expect(readWhenOn).toBeGreaterThan(0); - expect(reads).toBe(0); - }); -}); diff --git a/cli/tests/application/use-cases/telemetry/telemetry-on-use-case.unit.test.ts b/cli/tests/application/use-cases/telemetry/telemetry-on-use-case.unit.test.ts deleted file mode 100644 index abfd1ee52..000000000 --- a/cli/tests/application/use-cases/telemetry/telemetry-on-use-case.unit.test.ts +++ /dev/null @@ -1,163 +0,0 @@ -import { join } from "node:path"; -import { describe, expect, it } from "vitest"; -import { TelemetryProjectScopeRequiresYesError } from "../../../../src/application/errors.js"; -import { GitignoreUseCase } from "../../../../src/application/use-cases/shared/gitignore-use-case.js"; -import { TelemetryOnUseCase } from "../../../../src/application/use-cases/telemetry/telemetry-on-use-case.js"; -import { - SESSION_TRAILER_DELEGATE_FILE, - SESSION_TRAILER_TOKEN, - sessionTrailerDelegateScript, -} from "../../../../src/domain/formats/commit-session-trailer.js"; -import type { VersionControl } from "../../../../src/domain/ports/version-control.js"; -import { CapturingLogger } from "../../../helpers/ports/capturing-logger.js"; -import { DeterministicHasher } from "../../../helpers/ports/deterministic-hasher.js"; -import { InMemoryFileAdapter } from "../../../helpers/ports/in-memory-file-adapter.js"; -import { noGit } from "../helpers.js"; - -const PROJECT_ROOT = "/repo"; -const SWITCH_PATH = join(PROJECT_ROOT, ".aidd", "config.json"); -const LOCAL_SETTINGS_PATH = join(PROJECT_ROOT, ".claude", "settings.local.json"); - -function buildUseCase(seed: Record = {}) { - const hasher = new DeterministicHasher(); - const fs = new InMemoryFileAdapter(seed, hasher); - const logger = new CapturingLogger(); - const useCase = new TelemetryOnUseCase(fs, logger, new GitignoreUseCase(fs), noGit); - return { fs, logger, useCase }; -} - -describe("TelemetryOnUseCase — the switch alone", () => { - it("succeeds with no endpoint anywhere, and writes no tool's settings file", async () => { - const { fs, useCase } = buildUseCase(); - const result = await useCase.execute({ projectRoot: PROJECT_ROOT, confirmed: true }); - - expect(result.switchChanged).toBe(true); - const written = JSON.parse(fs.getFile(SWITCH_PATH) ?? "null"); - expect(written.telemetry).toEqual({ enabled: true }); - expect(fs.has(LOCAL_SETTINGS_PATH)).toBe(false); - }); - - it("prints the resolved switch path before writing anything", async () => { - const { logger, useCase } = buildUseCase(); - await useCase.execute({ projectRoot: PROJECT_ROOT, confirmed: true }); - expect(logger.infoMessages[0]).toBe(`AIDD telemetry switch -> ${SWITCH_PATH}`); - }); - - it("preserves an endpoint already recorded in the switch file — `on` has no opinion on it", async () => { - const seed = { - [SWITCH_PATH]: JSON.stringify({ - telemetry: { enabled: false, endpoint: "https://otel.example.com" }, - }), - }; - const { fs, useCase } = buildUseCase(seed); - await useCase.execute({ projectRoot: PROJECT_ROOT, confirmed: true }); - const written = JSON.parse(fs.getFile(SWITCH_PATH) as string); - expect(written.telemetry).toEqual({ enabled: true, endpoint: "https://otel.example.com" }); - }); - - it("enabling twice reports the switch unchanged the second time", async () => { - const { useCase } = buildUseCase(); - const first = await useCase.execute({ projectRoot: PROJECT_ROOT, confirmed: true }); - const second = await useCase.execute({ projectRoot: PROJECT_ROOT, confirmed: true }); - expect(first.switchChanged).toBe(true); - expect(second.switchChanged).toBe(false); - }); -}); - -describe("TelemetryOnUseCase — the same consent `endpoint --scope project` already demands", () => { - it("without --yes, refuses and writes nothing, naming the consequence", async () => { - const { fs, useCase } = buildUseCase(); - await expect(useCase.execute({ projectRoot: PROJECT_ROOT, confirmed: false })).rejects.toThrow( - TelemetryProjectScopeRequiresYesError - ); - await expect(useCase.execute({ projectRoot: PROJECT_ROOT, confirmed: false })).rejects.toThrow( - /everyone who clones/ - ); - expect(fs.has(SWITCH_PATH)).toBe(false); - }); - - it("fires even when the switch is already on — the same unconditional guard `endpoint` uses", async () => { - const { fs, useCase } = buildUseCase(); - await useCase.execute({ projectRoot: PROJECT_ROOT, confirmed: true }); - expect(fs.has(SWITCH_PATH)).toBe(true); - - await expect(useCase.execute({ projectRoot: PROJECT_ROOT, confirmed: false })).rejects.toThrow( - TelemetryProjectScopeRequiresYesError - ); - }); - - it("with --yes, writes the switch", async () => { - const { fs, useCase } = buildUseCase(); - const result = await useCase.execute({ projectRoot: PROJECT_ROOT, confirmed: true }); - expect(result.switchChanged).toBe(true); - expect(fs.has(SWITCH_PATH)).toBe(true); - }); -}); - -describe("TelemetryOnUseCase — making commits joinable to the session that made them", () => { - /** Records what the use case asked git to do, so the unit tier can hold the decision - * (install, then say so) apart from the mechanics of writing a hook, which the adapter's - * own integration suite proves against real repositories. */ - function recordingGit(installed: boolean) { - const calls: { delegateFile: string; script: string }[] = []; - const git: VersionControl = { - ...noGit, - installCommitMessageDelegate: async (_root, delegateFile, script) => { - calls.push({ delegateFile, script }); - return installed; - }, - }; - return { calls, git }; - } - - function useCaseWith(git: VersionControl) { - const fs = new InMemoryFileAdapter({}, new DeterministicHasher()); - const logger = new CapturingLogger(); - return { - fs, - logger, - useCase: new TelemetryOnUseCase(fs, logger, new GitignoreUseCase(fs), git), - }; - } - - it("installs the delegate the domain declares, never a script written out a second time", async () => { - const { calls, git } = recordingGit(true); - const { useCase } = useCaseWith(git); - - await useCase.execute({ projectRoot: PROJECT_ROOT, confirmed: true }); - - expect(calls).toHaveLength(1); - expect(calls[0]?.delegateFile).toBe(SESSION_TRAILER_DELEGATE_FILE); - expect(calls[0]?.script).toBe(sessionTrailerDelegateScript()); - }); - - it("says what it will write into commit messages, and how to undo it", async () => { - const { git } = recordingGit(true); - const { logger, useCase } = useCaseWith(git); - - await useCase.execute({ projectRoot: PROJECT_ROOT, confirmed: true }); - - const said = logger.allMessages.join("\n"); - expect(said).toContain(SESSION_TRAILER_TOKEN); - expect(said).toContain("aidd telemetry off"); - }); - - it("says nothing when it was already installed - a no-op is not news", async () => { - const { git } = recordingGit(false); - const { logger, useCase } = useCaseWith(git); - - await useCase.execute({ projectRoot: PROJECT_ROOT, confirmed: true }); - - expect(logger.allMessages.join("\n")).not.toContain(SESSION_TRAILER_TOKEN); - }); - - it("installs on every successful on, so a project turned on before this is caught up", async () => { - const { calls, git } = recordingGit(false); - const { useCase } = useCaseWith(git); - - await useCase.execute({ projectRoot: PROJECT_ROOT, confirmed: true }); - await useCase.execute({ projectRoot: PROJECT_ROOT, confirmed: true }); - - expect(calls).toHaveLength(2); - }); -}); diff --git a/cli/tests/application/use-cases/telemetry/tool-attribution.unit.test.ts b/cli/tests/application/use-cases/telemetry/tool-attribution.unit.test.ts deleted file mode 100644 index 945b5ed2f..000000000 --- a/cli/tests/application/use-cases/telemetry/tool-attribution.unit.test.ts +++ /dev/null @@ -1,95 +0,0 @@ -import { readFileSync } from "node:fs"; -import { fileURLToPath } from "node:url"; -import { describe, expect, it } from "vitest"; -// Side-effect imports: the use-case resolves each tool's declaration from the registry, -// so every AI tool must be registered for these tests to see Claude Code's and Codex's. -import "../../../../src/domain/tools/ai/claude.js"; -import "../../../../src/domain/tools/ai/codex.js"; -import "../../../../src/domain/tools/ai/copilot.js"; -import "../../../../src/domain/tools/ai/cursor.js"; -import "../../../../src/domain/tools/ai/opencode.js"; -import { ReadLocalCostUseCase } from "../../../../src/application/use-cases/telemetry/read-local-cost-use-case.js"; -import { mapClaudeCodeTranscriptToSinkRecords } from "../../../../src/domain/formats/claude-code-transcript.js"; -import type { TelemetrySinkRecord } from "../../../../src/domain/models/telemetry-sink-record.js"; -import { AI_TOOL_IDS } from "../../../../src/domain/models/tool-ids.js"; -import type { SessionCostReader } from "../../../../src/domain/ports/session-cost-reader.js"; -import { NULL_PERSON_IDENTITY_READER } from "../../../helpers/ports/in-memory-person-identity-reader.js"; -import { NULL_RUN_JOURNAL_READER } from "../../../helpers/ports/in-memory-run-journal-reader.js"; -import { InMemoryTelemetrySink } from "../../../helpers/ports/in-memory-telemetry-sink.js"; -import { StubTelemetryEvidenceReader } from "../../../helpers/ports/stub-telemetry-evidence-reader.js"; - -const TRANSCRIPT_SESSION_ID = "22222222-2222-4222-8222-222222222222"; -const PROJECT_ROOT = "/repo"; - -function loadCapturedTranscript(): string { - const url = new URL( - `../../../fixtures/local-cost/.claude/projects/fake-project/${TRANSCRIPT_SESSION_ID}.jsonl`, - import.meta.url - ); - return readFileSync(fileURLToPath(url), "utf8"); -} - -function readSourceFile(relativePathFromSrc: string): string { - const url = new URL(`../../../../src/${relativePathFromSrc}`, import.meta.url); - return readFileSync(fileURLToPath(url), "utf8"); -} - -/** Exercises the real local-read path (`ReadLocalCostUseCase`) against the captured Claude - * Code transcript fixture already used by `claude-code-transcript.unit.test.ts`. The - * transcript is parsed by the real pure mapper; only the file-walking adapter is stubbed - * out, keeping this a unit test while still proving the use-case's own stamping. */ -async function readCapturedTranscript(): Promise<{ - readonly sink: InMemoryTelemetrySink; - readonly records: readonly TelemetrySinkRecord[]; -}> { - const candidates = mapClaudeCodeTranscriptToSinkRecords(loadCapturedTranscript()); - const stubReader: SessionCostReader = { - read: async () => ({ records: candidates, sessionFound: true }), - }; - const sink = new InMemoryTelemetrySink(); - const useCase = new ReadLocalCostUseCase( - sink, - new Map([["claude", stubReader]]), - NULL_RUN_JOURNAL_READER, - NULL_PERSON_IDENTITY_READER, - new StubTelemetryEvidenceReader() - ); - await useCase.execute({ - projectRoot: PROJECT_ROOT, - env: {}, - sessionId: TRANSCRIPT_SESSION_ID, - }); - return { sink, records: [...sink.files.values()].flat() }; -} - -describe("every stored record names its tool", () => { - it("names a tool on every record produced from a captured transcript", async () => { - const { records } = await readCapturedTranscript(); - expect(records.length).toBeGreaterThan(0); - expect(records.every((record) => record.tool !== undefined)).toBe(true); - }); - - it("names only a declared tool identifier, never a free string", async () => { - const { records } = await readCapturedTranscript(); - for (const record of records) { - expect(AI_TOOL_IDS).toContain(record.tool); - } - }); - - it("names the tool consistently, and the vendor field it read the identity from", async () => { - const { records } = await readCapturedTranscript(); - expect(records.every((record) => record.tool === "claude")).toBe(true); - expect(records[0]?.vendor_field).toBe("sessionId"); - }); - - // Derived from AI_TOOL_IDS, never hand-listed: hardcoding the tool names here would - // defeat the very criterion it proves — that adding a tool is a declaration the use-case - // never has to be told about by name. - it("contains no tool name, by string literal, in the local-read use-case", () => { - const source = readSourceFile("application/use-cases/telemetry/read-local-cost-use-case.ts"); - for (const toolId of AI_TOOL_IDS) { - expect(source).not.toContain(`"${toolId}"`); - expect(source).not.toContain(`'${toolId}'`); - } - }); -}); diff --git a/cli/tests/application/use-cases/uninstall-plugin.unit.test.ts b/cli/tests/application/use-cases/uninstall-plugin.unit.test.ts deleted file mode 100644 index 54415de9e..000000000 --- a/cli/tests/application/use-cases/uninstall-plugin.unit.test.ts +++ /dev/null @@ -1,68 +0,0 @@ -import { join } from "node:path"; -import { describe, expect, it } from "vitest"; -import "../../../src/domain/tools/ai/claude.js"; -import { PluginAddUseCase } from "../../../src/application/use-cases/plugin/plugin-add-use-case.js"; -import { UninstallUseCase } from "../../../src/application/use-cases/uninstall/uninstall-use-case.js"; -import { PluginNotFoundError } from "../../../src/domain/errors.js"; -import { PluginDistributionReaderAdapter } from "../../../src/infrastructure/adapters/plugin-distribution-reader-adapter.js"; -import { buildUnitDeps, initAndInstall } from "../../helpers/ports/build-unit-deps.js"; -import { fakeEnsureBuiltMarketplace } from "../../helpers/ports/fake-ensure-built-marketplace.js"; - -const PLUGIN_FIXTURE = join(process.cwd(), "tests/fixtures/plugins/claude-format/sample-plugin"); -const PROJECT_ROOT = "/test-project"; - -describe("UninstallUseCase — plugin scope", () => { - it("removes plugin files and unregisters from manifest when --plugin given", async () => { - const deps = await buildUnitDeps(PROJECT_ROOT); - // Seed plugin fixture content so PluginDistributionReaderAdapter can read it - const { seedFromDirectory } = await import("../../helpers/ports/seed-from-directory.js"); - await seedFromDirectory(deps.fs, PLUGIN_FIXTURE, { useAbsolutePaths: true }); - - await initAndInstall(deps, PROJECT_ROOT, "claude"); - - const reader = new PluginDistributionReaderAdapter(deps.fs); - await new PluginAddUseCase( - deps.fs, - deps.manifestRepo, - deps.pluginFetcher, - reader, - deps.hasher, - deps.logger, - deps.marketplaceRegistry, - fakeEnsureBuiltMarketplace() - ).execute({ - source: { kind: "local", path: PLUGIN_FIXTURE }, - toolIds: ["claude"], - projectRoot: PROJECT_ROOT, - interactive: false, - }); - - const pluginFile = join(PROJECT_ROOT, ".claude/plugins/sample-plugin/commands/greet.md"); - expect(deps.fs.has(pluginFile)).toBe(true); - - await new UninstallUseCase(deps.fs, deps.manifestRepo, deps.logger).execute({ - toolIds: [], - projectRoot: PROJECT_ROOT, - mcpFilter: [], - pluginName: "sample-plugin", - }); - - expect(deps.fs.has(pluginFile)).toBe(false); - const manifest = await deps.manifestRepo.load(); - expect(manifest?.getPlugins("claude").find((p) => p.name === "sample-plugin")).toBeUndefined(); - }); - - it("throws PluginNotFoundError when the plugin is not installed on any tool", async () => { - const deps = await buildUnitDeps(PROJECT_ROOT); - await initAndInstall(deps, PROJECT_ROOT, "claude"); - - await expect( - new UninstallUseCase(deps.fs, deps.manifestRepo, deps.logger).execute({ - toolIds: [], - projectRoot: PROJECT_ROOT, - mcpFilter: [], - pluginName: "nonexistent", - }) - ).rejects.toThrow(PluginNotFoundError); - }); -}); diff --git a/cli/tests/architecture/automation-calls-real-scripts.arch.test.ts b/cli/tests/architecture/automation-calls-real-scripts.arch.test.ts new file mode 100644 index 000000000..2287fc4cd --- /dev/null +++ b/cli/tests/architecture/automation-calls-real-scripts.arch.test.ts @@ -0,0 +1,215 @@ +/** + * CI and the git hooks live outside this package, so a script renamed here goes on being + * called there while every local check passes. Only calls that run against this package + * count: a `cd cli && pnpm x` line, or a call in a `run:` block that `cd`s here first — + * `cd` on one line still governs a `pnpm` call two lines later, in the same shell. + */ +import { readdirSync, readFileSync } from "node:fs"; +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; +import { CLI_ROOT } from "./helpers.js"; + +const REPO_ROOT = join(CLI_ROOT, ".."); +const WORKFLOWS = join(REPO_ROOT, ".github", "workflows"); + +/** pnpm's built-in verbs, which are never package scripts. */ +const PNPM_BUILTINS = new Set([ + "install", + "exec", + "run", + "dlx", + "add", + "remove", + "why", + "pack", + "publish", + "store", + "workspace", + "list", + "outdated", + "update", + "config", + "link", + "audit", + "-r", + "--filter", +]); + +function manifestScripts(): Set { + const manifest = JSON.parse(readFileSync(join(CLI_ROOT, "package.json"), "utf8")) as { + scripts: Record; + }; + return new Set(Object.keys(manifest.scripts)); +} + +function automationFiles(): string[] { + const files = [join(REPO_ROOT, "lefthook.yml")]; + for (const entry of readdirSync(WORKFLOWS)) { + if (entry.endsWith(".yml") || entry.endsWith(".yaml")) files.push(join(WORKFLOWS, entry)); + } + return files; +} + +/** Leading spaces only: YAML forbids a tab for indentation. */ +function indentOf(line: string): number { + return /^(\s*)/.exec(line)?.[1]?.length ?? 0; +} + +/** + * A `run: value` is a body of one line; `run: |` opens a block scalar whose body is every + * line indented further than the `run:` key, ending at the first line back at or above that + * indentation rather than at the next blank line. + */ +function runBodies(text: string): string[][] { + const lines = text.split("\n"); + const bodies: string[][] = []; + let i = 0; + while (i < lines.length) { + const line = lines[i] as string; + const match = /^(\s*)(?:-\s+)?run:\s*(.*)$/.exec(line); + if (!match) { + i++; + continue; + } + const keyIndent = indentOf(line); + const rest = (match[2] as string).trim(); + if (/^[|>][+-]?$/.test(rest)) { + const body: string[] = []; + i++; + while (i < lines.length) { + const bodyLine = lines[i] as string; + if (bodyLine.trim() !== "" && indentOf(bodyLine) <= keyIndent) break; + body.push(bodyLine); + i++; + } + bodies.push(body); + } else { + bodies.push([rest]); + i++; + } + } + return bodies; +} + +/** + * Tracked line by line the way the shell runs the block: a `cd` governs every later `pnpm` + * call in the same body, and the state does not survive into the next `run:`, which is its + * own shell process. + */ +function pnpmCallsAgainst(dir: string, body: readonly string[]): string[] { + let cwd: string | null = null; + const calls: string[] = []; + for (const rawLine of body) { + for (const segment of rawLine.split("&&")) { + const trimmed = segment.trim(); + const cd = /^cd\s+(\S+)/.exec(trimmed); + if (cd) { + cwd = cd[1] as string; + continue; + } + const pnpm = /^pnpm\s+([a-z][\w:.-]*)/.exec(trimmed); + if (pnpm && cwd === dir) calls.push(pnpm[1] as string); + } + } + return calls; +} + +function undeclaredScriptCalls(text: string, declared: ReadonlySet): string[] { + const missing: string[] = []; + for (const body of runBodies(text)) { + for (const script of pnpmCallsAgainst("cli", body)) { + if (!PNPM_BUILTINS.has(script) && !declared.has(script)) missing.push(script); + } + } + return missing; +} + +function foreignIncludes(tsconfig: string): string[] { + const config = JSON.parse(tsconfig.replace(/\/\/[^\n]*/g, "")) as { include?: string[] }; + return (config.include ?? []).filter((pattern) => pattern.startsWith("../")); +} + +describe("this package's program stops at this package", () => { + it("compiles nothing outside cli/, so a CI job needs no sibling's dependencies", () => { + expect( + foreignIncludes(readFileSync(join(CLI_ROOT, "tsconfig.json"), "utf8")), + "tsconfig reaches outside the package — every job running tsc must then install that sibling's dependencies, and dropping one breaks CI while every local check stays green" + ).toEqual([]); + }); +}); + +describe("the automation calls scripts this package still has", () => { + it("every pnpm script CI and the hooks run against cli/ exists in its manifest", () => { + const scripts = manifestScripts(); + const missing = automationFiles() + .flatMap((file) => + undeclaredScriptCalls(readFileSync(file, "utf8"), scripts).map( + (script) => `${file.replace(`${REPO_ROOT}/`, "")}: pnpm ${script}` + ) + ) + .sort(); + + expect(missing, "an automation file runs a script cli/package.json no longer declares").toEqual( + [] + ); + }); + + it("finds a call in a workflow and ignores pnpm's own verbs", () => { + const found = undeclaredScriptCalls( + readFileSync(join(WORKFLOWS, "cli-ci.yml"), "utf8"), + new Set() + ); + + expect(found, "the real workflow calls this package's scripts").toContain("knip"); + expect(found, "`pnpm install` is not a script").not.toContain("install"); + }); +}); + +describe("the guard itself", () => { + it("names a script the manifest lost and stays silent on one it still declares", () => { + const workflow = [" - run: |", " cd cli", " pnpm gone", ""].join("\n"); + + expect(undeclaredScriptCalls(workflow, new Set(["knip"]))).toEqual(["gone"]); + expect(undeclaredScriptCalls(workflow, new Set(["gone"]))).toEqual([]); + }); + + it("reads an include reaching outside the package, and clears one that stays inside", () => { + expect(foreignIncludes('{ "include": ["../kanban/src/**/*.ts"] }')).toEqual([ + "../kanban/src/**/*.ts", + ]); + expect(foreignIncludes('{ "include": ["src/**/*.ts"] } // a trailing note')).toEqual([]); + }); + + it("follows cd line by line inside a run: | block, past where the old regex stopped seeing", () => { + const block = [ + " run: |", + " cd cli", + " pnpm build", + " pnpm pack --pack-destination ./dist", + " npm install -g ./dist/ai-driven-dev-cli-*.tgz --force", + ].join("\n"); + + expect(runBodies(block)).toEqual([ + [ + " cd cli", + " pnpm build", + " pnpm pack --pack-destination ./dist", + " npm install -g ./dist/ai-driven-dev-cli-*.tgz --force", + ], + ]); + expect(pnpmCallsAgainst("cli", runBodies(block)[0] as string[])).toContain("build"); + }); + + it("does not let cd cross into a later, unrelated run: body", () => { + const twoSteps = [ + " - run: |", + " cd kanban", + " - run: |", + " pnpm bogus-script", + ].join("\n"); + + for (const body of runBodies(twoSteps)) { + expect(pnpmCallsAgainst("cli", body)).toEqual([]); + } + }); +}); diff --git a/cli/tests/architecture/biome-context-parity.arch.test.ts b/cli/tests/architecture/biome-context-parity.arch.test.ts new file mode 100644 index 000000000..b9007491c --- /dev/null +++ b/cli/tests/architecture/biome-context-parity.arch.test.ts @@ -0,0 +1,195 @@ +/** + * Measured: biome replaces a rule's whole `options` with the LAST override matching a file + * rather than merging pattern arrays, so a broader override silently discards a narrower + * one's restriction. Every override checked here is scoped to one context's one layer. + */ +import { describe, expect, it } from "vitest"; +import { + ALLOWED, + baselineLayers, + contextNames, + matchesGlob, + read, + sourceFiles, +} from "./helpers.js"; + +type Layer = "domain" | "application" | "infrastructure"; +const LAYERS: readonly Layer[] = ["domain", "application", "infrastructure"]; + +interface BiomeOverride { + readonly includes?: readonly string[]; + readonly linter?: { + readonly rules?: { + readonly style?: { + readonly noRestrictedImports?: { + readonly options?: { + readonly patterns?: readonly { readonly group?: readonly string[] }[]; + }; + }; + }; + }; + }; +} + +function biomeOverrides(): BiomeOverride[] { + const config = JSON.parse(read("biome.json")) as { overrides?: BiomeOverride[] }; + return config.overrides ?? []; +} + +function restrictedImportGroups(override: BiomeOverride): string[] | undefined { + const patterns = override.linter?.rules?.style?.noRestrictedImports?.options?.patterns; + if (!patterns) return undefined; + return patterns.flatMap((entry) => entry.group ?? []); +} + +/** `"**\/foo/**"` reads as the context or layer named `foo`; another shape is not a graph + * edge, and is left as-is for the allowlist below to recognise. */ +function tokenOf(pattern: string): string { + const match = /^\*\*\/([^/]+)\/\*\*$/.exec(pattern); + return match ? (match[1] as string) : pattern; +} + +/** + * Patterns naming something other than a graph edge, so a deliberate narrower restriction is + * not read as drift: `distribution` reads a marketplace, never framework's installation + * record, which is stricter than the edge itself. + */ +const NON_GRAPH_EXTRA: Readonly> = { + "distribution/domain": ["**/manifest.js"], + "distribution/application": ["**/manifest.js"], +}; + +/** The layer-intrinsic direction: what any file at this layer must not import, whatever its + * context. */ +function genericLayerTargets(layer: Layer): ReadonlySet { + if (layer === "domain") { + return new Set(["application", "infrastructure", "presentation", "runtime"]); + } + if (layer === "application") return new Set(["infrastructure"]); + return new Set(); +} + +/** + * The layer-intrinsic direction, union every context and presentation/runtime this context may + * not reach, minus what `ALLOWED` admits and minus the debt `BASELINE` carries at this layer. + */ +function expectedForbidden( + context: string, + layer: Layer, + contexts: readonly string[], + baselineLayerMap: ReadonlyMap> +): Set { + const forbidden = new Set(genericLayerTargets(layer)); + const others = contexts.filter((name) => name !== context); + for (const target of [...others, "presentation", "runtime"]) { + const edge = `${context}->${target}`; + if (ALLOWED.has(edge)) continue; + if (baselineLayerMap.get(edge)?.has(layer)) continue; + forbidden.add(target); + } + return forbidden; +} + +function contextLayerIncludes(context: string, layer: Layer): string { + return `src/contexts/${context}/${layer}/**/*.ts`; +} + +function filesMatchedTwice( + files: readonly string[], + includes: readonly (readonly string[])[] +): string[] { + return files.filter( + (file) => includes.filter((globs) => globs.some((glob) => matchesGlob(glob, file))).length > 1 + ); +} + +describe("cli/biome.json forbids exactly the edges context-graph.arch.test.ts forbids", () => { + const overrides = biomeOverrides().filter((override) => restrictedImportGroups(override)); + const contexts = contextNames(); + const baselineLayerMap = baselineLayers(sourceFiles()); + + it("finds context/layer overrides to check, so this rule cannot pass by selecting nothing", () => { + expect( + overrides.length, + "no noRestrictedImports override found — the scope of this rule is stale" + ).toBeGreaterThan(10); + expect(contexts.length, "no context found under src/contexts/").toBeGreaterThan(3); + }); + + it("at most one noRestrictedImports override matches any source file", () => { + const matchedByMoreThanOne = filesMatchedTwice( + sourceFiles(), + overrides.map((override) => override.includes ?? []) + ); + + expect( + matchedByMoreThanOne, + "biome replaces a rule's options with the last matching override rather than merging " + + "them — a file matched by two noRestrictedImports overrides silently loses the first" + ).toEqual([]); + }); + + it.each(contexts.flatMap((context) => LAYERS.map((layer) => ({ context, layer }))))( + "$context/$layer forbids exactly what context-graph.arch.test.ts forbids", + ({ context, layer }) => { + const includesGlob = contextLayerIncludes(context, layer); + const override = overrides.find((candidate) => candidate.includes?.includes(includesGlob)); + expect(override, `no override at ${includesGlob}`).toBeDefined(); + + const groups = restrictedImportGroups(override as BiomeOverride) ?? []; + const extra = new Set(NON_GRAPH_EXTRA[`${context}/${layer}`] ?? []); + const actual = new Set( + groups.filter((pattern) => !extra.has(pattern)).map((pattern) => tokenOf(pattern)) + ); + const expected = expectedForbidden(context, layer, contexts, baselineLayerMap); + + expect([...actual].sort()).toEqual([...expected].sort()); + } + ); + + it("kernel forbids every context layer, presentation and runtime, unconditionally", () => { + const override = overrides.find((candidate) => + candidate.includes?.includes("src/kernel/**/*.ts") + ); + expect(override, "no override at src/kernel/**/*.ts").toBeDefined(); + + const groups = (restrictedImportGroups(override as BiomeOverride) ?? []).map(tokenOf); + expect(new Set(groups)).toEqual( + new Set(["domain", "application", "infrastructure", "presentation", "runtime"]) + ); + }); +}); + +describe("the guard itself", () => { + const twoContexts = ["framework", "tools"]; + + it("names a file two overrides both match, and clears one only a single override reaches", () => { + const files = ["src/contexts/tools/domain/registry.ts", "src/kernel/tool.ts"]; + const includes = [["src/contexts/**/*.ts"], ["src/contexts/tools/domain/**/*.ts"]]; + + expect(filesMatchedTwice(files, includes)).toEqual(["src/contexts/tools/domain/registry.ts"]); + expect(filesMatchedTwice(files, [includes[0] as string[]])).toEqual([]); + }); + + it("forbids a context the graph does not admit, and drops one it does", () => { + const noBaseline = new Map>(); + + expect( + expectedForbidden("tools", "domain", twoContexts, noBaseline).has("framework"), + "tools->framework is no allowed edge" + ).toBe(true); + expect( + expectedForbidden("framework", "domain", twoContexts, noBaseline).has("tools"), + "framework->tools is one" + ).toBe(false); + }); + + it("drops a target the baseline already carries at that layer, and keeps it at another", () => { + const debt = new Map([["tools->framework", new Set(["domain"])]]); + + expect(expectedForbidden("tools", "domain", twoContexts, debt).has("framework")).toBe(false); + expect(expectedForbidden("tools", "application", twoContexts, debt).has("framework")).toBe( + true + ); + }); +}); diff --git a/cli/tests/architecture/biome-guards-bite.arch.test.ts b/cli/tests/architecture/biome-guards-bite.arch.test.ts new file mode 100644 index 000000000..25fb04dbe --- /dev/null +++ b/cli/tests/architecture/biome-guards-bite.arch.test.ts @@ -0,0 +1,135 @@ +/** + * Three rules live in biome rather than here: `process.exit` below the command edge through the + * GritQL plugin, `export default` under src/ and tests/, and the `process` global in a context's + * domain and application. A rule biome silently stops applying is invisible, so each is planted. + */ +import { spawnSync } from "node:child_process"; +import { cpSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { CLI_ROOT, read } from "./helpers.js"; + +const BIOME = join(CLI_ROOT, "node_modules", ".bin", "biome"); +const PLUGIN = "biome-plugins/no-process-exit.grit"; + +/** The diagnostics biome prints for one planted tree, as `:` lines. */ +function biomeFindings(projectDir: string): string[] { + const result = spawnSync(BIOME, ["lint", "--reporter=github", "."], { + cwd: projectDir, + encoding: "utf8", + }); + const lines = `${result.stdout}\n${result.stderr}`.split("\n"); + return lines + .map((line) => /^\s*::(?:error|warning) title=(\S+),file=(\S+?),/.exec(line)) + .filter((match): match is RegExpExecArray => match !== null) + .map((match) => `${match[2]}:${match[1]}`) + .sort(); +} + +describe("biome carries the exit and default-export rules", () => { + it("scopes the process.exit plugin to the layers that throw, and to nothing else", () => { + const config = JSON.parse(read("biome.json")) as { + overrides?: { includes?: string[]; plugins?: string[] }[]; + }; + const carrier = (config.overrides ?? []).find((o) => o.plugins?.includes(`./${PLUGIN}`)); + expect(carrier?.includes, `no override applies ${PLUGIN}`).toEqual([ + "src/kernel/**", + "src/contexts/**", + "src/runtime/**", + ]); + }); + + it("bans the process global across every context's domain and application", () => { + const config = JSON.parse(read("biome.json")) as { + overrides?: { + includes?: string[]; + linter?: { rules?: { style?: { noRestrictedGlobals?: { options?: unknown } } } }; + }[]; + }; + const carrier = (config.overrides ?? []).find( + (o) => o.linter?.rules?.style?.noRestrictedGlobals !== undefined + ); + + expect( + carrier?.includes, + "no override denies the process global to a context's domain and application" + ).toEqual(["src/contexts/*/domain/**/*.ts", "src/contexts/*/application/**/*.ts"]); + }); +}); + +describe("the guard itself", () => { + let planted: string; + + beforeAll(() => { + planted = mkdtempSync(join(tmpdir(), "aidd-biome-bite-")); + cpSync(join(CLI_ROOT, "biome.json"), join(planted, "biome.json")); + cpSync(join(CLI_ROOT, "biome-plugins"), join(planted, "biome-plugins"), { recursive: true }); + for (const dir of [ + "src/kernel", + "src/presentation", + "tests/kernel", + "src/contexts/framework/domain", + "src/contexts/framework/application", + "src/contexts/framework/infrastructure", + ]) { + mkdirSync(join(planted, dir), { recursive: true }); + } + writeFileSync( + join(planted, "src/kernel/exit.ts"), + "export const stop = (): never => process.exit(1);\n" + ); + writeFileSync( + join(planted, "src/presentation/exit.ts"), + "export const stop = (): never => process.exit(1);\n" + ); + writeFileSync( + join(planted, "src/kernel/default.ts"), + "const value = 1;\nexport default value;\n" + ); + writeFileSync( + join(planted, "tests/kernel/default.ts"), + "const value = 1;\nexport default value;\n" + ); + writeFileSync(join(planted, "src/kernel/clean.ts"), "export const one = 1;\n"); + const readsEnv = "export const home = (): string | undefined => process.env.HOME;\n"; + for (const file of [ + "src/contexts/framework/domain/env.ts", + "src/contexts/framework/application/env.ts", + "src/contexts/framework/infrastructure/env.ts", + ]) { + writeFileSync(join(planted, file), readsEnv); + } + }); + + afterAll(() => { + rmSync(planted, { recursive: true, force: true }); + }); + + it("flags process.exit under kernel/, contexts/ and runtime/, and leaves the command edge alone", () => { + const findings = biomeFindings(planted); + expect(findings).toContain("src/kernel/exit.ts:plugin"); + expect(findings.filter((f) => f.startsWith("src/presentation/"))).toEqual([]); + }); + + it("flags the process global in a context's domain and application, and not in its infrastructure", () => { + const findings = biomeFindings(planted); + + expect(findings).toContain( + "src/contexts/framework/domain/env.ts:lint/style/noRestrictedGlobals" + ); + expect(findings).toContain( + "src/contexts/framework/application/env.ts:lint/style/noRestrictedGlobals" + ); + expect(findings.filter((f) => f.startsWith("src/contexts/framework/infrastructure/"))).toEqual( + [] + ); + }); + + it("flags a default export under src/ and tests/ alike, and a clean module not at all", () => { + const findings = biomeFindings(planted); + expect(findings).toContain("src/kernel/default.ts:lint/style/noDefaultExport"); + expect(findings).toContain("tests/kernel/default.ts:lint/style/noDefaultExport"); + expect(findings.filter((f) => f.startsWith("src/kernel/clean.ts"))).toEqual([]); + }); +}); diff --git a/cli/tests/architecture/catches-that-swallow.arch.test.ts b/cli/tests/architecture/catches-that-swallow.arch.test.ts new file mode 100644 index 000000000..6ea935361 --- /dev/null +++ b/cli/tests/architecture/catches-that-swallow.arch.test.ts @@ -0,0 +1,133 @@ +/** + * An empty catch turns an unreadable file into an absent one. The handful that remain are + * best-effort cleanups, each named here with its reason, and the list only shrinks. + */ +import { readdirSync, statSync } from "node:fs"; +import { join, relative } from "node:path"; +import { describe, expect, it } from "vitest"; +import { CLI_ROOT, read, sourceFiles } from "./helpers.js"; + +/** File → how many empty catches it keeps, and why each is a deliberate best effort. */ +const BASELINE: Readonly> = { + "src/contexts/framework/infrastructure/manifest-repository-adapter.ts": { + count: 2, + reason: "deleting a manifest, then its empty directory, must not fail an uninstall midway", + }, + "src/contexts/telemetry/infrastructure/telemetry-sink-adapter.ts": { + count: 2, + reason: "icacls and chmod are best effort on someone else's directory or a modeless filesystem", + }, + "src/contexts/tools/domain/profiles/opencode/opencode-hooks-bridge.ts": { + count: 2, + reason: + "the bridge text shipped into OpenCode: a hook that fails must never surface as a thrown error inside the host", + }, + "tests/contexts/tools/domain/profiles/opencode/opencode-hooks-bridge.unit.test.ts": { + count: 2, + reason: "asserts that same bridge text byte for byte", + }, + "src/runtime/filesystem/file-adapter.ts": { + count: 1, + reason: "a refusal to delete something present must not fail an uninstall midway", + }, + "tests/e2e/framework-build.e2e.test.ts": { + count: 1, + reason: "hashing a tree skips directories and vanished entries", + }, + "tests/golden/framework-build-golden.e2e.test.ts": { + count: 1, + reason: "hashing a tree skips directories and vanished entries", + }, + "tests/helpers/ports/in-memory-file-adapter.ts": { + count: 1, + reason: "a merge over invalid JSON overwrites, as the real adapter does", + }, + "tests/helpers/ports/seed-from-directory.ts": { + count: 1, + reason: "seeding a fixture skips an unreadable entry", + }, +}; + +/** The probes below plant the very shape this guard reports. */ +const SELF = "tests/architecture/catches-that-swallow.arch.test.ts"; + +const CATCH_HEAD = /\bcatch\s*(?:\([^)]*\))?\s*\{/g; + +/** Lines whose catch block holds nothing but whitespace and comments. */ +function swallowedCatches(text: string): number[] { + const lines: number[] = []; + for (const match of text.matchAll(CATCH_HEAD)) { + const bodyStart = match.index + match[0].length; + const body = text.slice(bodyStart); + const closing = body.indexOf("}"); + if (closing === -1) continue; + const inside = body + .slice(0, closing) + .replace(/\/\*[\s\S]*?\*\//g, "") + .replace(/\/\/[^\n]*/g, "") + .trim(); + if (inside === "") lines.push(text.slice(0, match.index).split("\n").length); + } + return lines; +} + +function testFiles(): string[] { + const out: string[] = []; + const walk = (dir: string): void => { + for (const entry of readdirSync(dir)) { + const full = join(dir, entry); + if (statSync(full).isDirectory()) { + if (entry !== "fixtures" && entry !== "snapshots") walk(full); + } else if (entry.endsWith(".ts")) out.push(relative(CLI_ROOT, full)); + } + }; + walk(join(CLI_ROOT, "tests")); + return out.sort(); +} + +describe("a catch never swallows", () => { + it("every empty catch under src/ and tests/ is a listed best effort, and none is listed twice over", () => { + const found = new Map(); + for (const file of [...sourceFiles(), ...testFiles()]) { + if (file === SELF) continue; + const lines = swallowedCatches(read(file)); + if (lines.length > 0) found.set(file, lines.length); + } + + const added = [...found] + .filter(([file, count]) => count > (BASELINE[file]?.count ?? 0)) + .map( + ([file, count]) => + `${file}: ${count} empty catch(es), ${BASELINE[file]?.count ?? 0} allowed` + ); + const fixed = Object.entries(BASELINE) + .filter(([file, { count }]) => (found.get(file) ?? 0) < count) + .map(([file]) => file); + + expect(added, "convert the error into a typed one or let it travel").toEqual([]); + expect(fixed, "fixed — lower or remove these in BASELINE").toEqual([]); + }); + + it("every baseline entry says why", () => { + for (const [file, { reason }] of Object.entries(BASELINE)) { + expect(reason.length, `${file} is allowed with no reason given`).toBeGreaterThan(30); + } + }); +}); + +describe("the guard itself", () => { + it("reports a catch holding only whitespace or a comment, and clears one that does something", () => { + const planted = [ + "try { a(); } catch {}", + "try { b(); } catch (error) {\n // a reason\n}", + "try { c(); } catch (e) { /* later */ }", + "try { d(); } catch (error) { throw new TypedError(error); }", + "try { e(); } catch { return null; }", + ].join("\n"); + expect(swallowedCatches(planted)).toEqual([1, 2, 5]); + }); + + it("reads a bare block as nothing to report", () => { + expect(swallowedCatches("const x = {};\nfunction catchAll() {}")).toEqual([]); + }); +}); diff --git a/cli/tests/architecture/codebase-map.arch.test.ts b/cli/tests/architecture/codebase-map.arch.test.ts new file mode 100644 index 000000000..0949431cb --- /dev/null +++ b/cli/tests/architecture/codebase-map.arch.test.ts @@ -0,0 +1,103 @@ +/** + * `codebase-map.md` is the single place that says where things live, and a map maintained by + * hand drifts both ways. The comparison is over full paths reconstructed from the tree block's + * indentation — comparing names alone reads `application/` as present wherever it exists — and + * it runs in both directions: an invented directory sends a reader to create a file there. + */ +import { describe, expect, it } from "vitest"; +import { read, sourceFiles } from "./helpers.js"; + +const MAP = "aidd_docs/memory/codebase-map.md"; + +/** The fenced block whose first line is `src/`. */ +function sourceTreeBlock(text: string): string { + for (const match of text.matchAll(/```[a-z]*\n([\s\S]*?)```/g)) { + const body = match[1] as string; + if (body.trimStart().startsWith("src/")) return body; + } + throw new Error(`${MAP} has no fenced block drawing the source tree`); +} + +/** One tree entry: four prefix characters per level, then a branch marker and a name. */ +const TREE_ENTRY = /^([│\s]*)(?:├──|└──)\s+([A-Za-z0-9_.-]+)(\/?)/; +const LEVEL_WIDTH = 4; + +function drawnDirectoriesInText(block: string): Set { + const drawn = new Set(); + const stack: string[] = []; + for (const line of block.split("\n")) { + const entry = TREE_ENTRY.exec(line); + if (entry === null) continue; + const depth = (entry[1] as string).length / LEVEL_WIDTH; + stack.length = depth; + stack[depth] = entry[2] as string; + if (entry[3] === "/") drawn.add(`src/${stack.slice(0, depth + 1).join("/")}`); + } + return drawn; +} + +function drawnDirectories(): Set { + return drawnDirectoriesInText(sourceTreeBlock(read(MAP))); +} + +function realDirectories(): Set { + const real = new Set(); + for (const file of sourceFiles()) { + const segments = file.split("/").slice(0, -1); + for (let i = 2; i <= segments.length; i += 1) real.add(segments.slice(0, i).join("/")); + } + return real; +} + +function disagreements( + real: ReadonlySet, + drawn: ReadonlySet +): { undocumented: string[]; invented: string[] } { + return { + undocumented: [...real].filter((dir) => !drawn.has(dir)).sort(), + invented: [...drawn].filter((dir) => !real.has(dir)).sort(), + }; +} + +describe("the codebase map matches the tree", () => { + it("draws every directory that holds source, and no directory that does not exist", () => { + const { undocumented, invented } = disagreements(realDirectories(), drawnDirectories()); + + expect(undocumented, `${MAP} is silent about these directories`).toEqual([]); + expect(invented, `${MAP} draws these directories and they do not exist`).toEqual([]); + }); +}); + +describe("the guard itself", () => { + it("reads a path from the indentation, so the same name under two parents is two paths", () => { + const block = [ + "src/", + "├── kernel/ # shared vocabulary", + "└── contexts/ # bounded contexts", + " ├── tools/ # what the project targets", + " │ └── domain/ # its rules", + " └── ghost/ # invented", + ].join("\n"); + + expect(drawnDirectoriesInText(block)).toEqual( + new Set([ + "src/kernel", + "src/contexts", + "src/contexts/tools", + "src/contexts/tools/domain", + "src/contexts/ghost", + ]) + ); + }); + + it("names a real directory the map omits, an invented one it draws, nothing when they agree", () => { + const real = new Set(["src/kernel", "src/contexts/tools/domain"]); + const drawn = new Set(["src/kernel", "src/contexts/tools/application"]); + + expect(disagreements(real, drawn)).toEqual({ + undocumented: ["src/contexts/tools/domain"], + invented: ["src/contexts/tools/application"], + }); + expect(disagreements(real, real)).toEqual({ undocumented: [], invented: [] }); + }); +}); diff --git a/cli/tests/architecture/comments.arch.test.ts b/cli/tests/architecture/comments.arch.test.ts new file mode 100644 index 000000000..0886da2be --- /dev/null +++ b/cli/tests/architecture/comments.arch.test.ts @@ -0,0 +1,102 @@ +/** + * A comment exists only when it says something the code cannot, and never names a ticket, + * a commit, a date or a URL: the fact belongs in the comment, where it was decided in git + * and `aidd_docs/`. The volume of comments under `src/` and `tests/` may only shrink. + */ +import { readdirSync, statSync } from "node:fs"; +import { join, relative } from "node:path"; +import { describe, expect, it } from "vitest"; +import { CLI_ROOT, read, sourceFiles } from "./helpers.js"; + +const COMMENT_LINE = /^\s*(\/\/|\/\*|\*)/; +const DIRECTIVE = /biome-ignore|@ts-expect-error|eslint-disable/; + +/** What a comment may not carry: an issue or PR number, a date, a commit hash, a link. */ +const EXTERNAL_REFERENCE: readonly { readonly name: string; readonly pattern: RegExp }[] = [ + { name: "issue or pull request number", pattern: /(^|[^\w&])#\d{2,}\b/ }, + { name: "date", pattern: /\b20\d\d-\d\d-\d\d\b/ }, + { name: "commit hash", pattern: /\b[0-9a-f]{7,40}\b/ }, + { name: "url", pattern: /https?:\/\// }, + { name: "pull request", pattern: /\bpull request\b|\bPR\s*#?\d/i }, +]; + +/** Comment lines under `src/` and `tests/` may only decrease; a raise needs its reason here: tests/ 2960 to 2984, four guard files and their probes; 2984 to 2987, the reason the display folder is baselined over the size limit. */ +const MAX_COMMENT_LINES = { src: 4474, tests: 2987 }; + +function testFiles(): string[] { + const out: string[] = []; + const walk = (dir: string): void => { + for (const entry of readdirSync(dir)) { + const full = join(dir, entry); + if (statSync(full).isDirectory()) { + if (entry === "fixtures" || entry === "snapshots") continue; + walk(full); + } else if (entry.endsWith(".ts")) out.push(relative(CLI_ROOT, full)); + } + }; + walk(join(CLI_ROOT, "tests")); + return out.sort(); +} + +function commentLinesIn(source: string): { line: number; text: string }[] { + return source + .split("\n") + .map((text, index) => ({ line: index + 1, text })) + .filter(({ text }) => COMMENT_LINE.test(text) && !DIRECTIVE.test(text)); +} + +function commentLines(file: string): { line: number; text: string }[] { + return commentLinesIn(read(file)); +} + +function externalReferenceIn(text: string): string | null { + return EXTERNAL_REFERENCE.find(({ pattern }) => pattern.test(text))?.name ?? null; +} + +describe("comments", () => { + it("name no ticket, commit, date or link — the fact stays, where it was decided goes", () => { + const offenders: string[] = []; + for (const file of [...sourceFiles(), ...testFiles()]) { + for (const { line, text } of commentLines(file)) { + const hit = externalReferenceIn(text); + if (hit) offenders.push(`${file}:${line} (${hit}): ${text.trim()}`); + } + } + expect(offenders, "comments carrying an external reference").toEqual([]); + }); + + it("do not grow: the comment volume under src/ and tests/ only ratchets down", () => { + const count = (files: string[]) => + files.reduce((total, file) => total + commentLines(file).length, 0); + const src = count(sourceFiles()); + const tests = count(testFiles()); + expect(src, `comment lines under src/ (baseline ${MAX_COMMENT_LINES.src})`).toBeLessThanOrEqual( + MAX_COMMENT_LINES.src + ); + expect( + tests, + `comment lines under tests/ (baseline ${MAX_COMMENT_LINES.tests})` + ).toBeLessThanOrEqual(MAX_COMMENT_LINES.tests); + }); +}); + +describe("the guard itself", () => { + it("names the kind of reference a comment carries, and stays silent on one carrying none", () => { + expect(externalReferenceIn(" // closes #4242")).toBe("issue or pull request number"); + expect(externalReferenceIn(" // measured on 2024-01-31")).toBe("date"); + expect(externalReferenceIn(" // see https://example.test/x")).toBe("url"); + expect(externalReferenceIn(" // the constraint, and what it costs")).toBeNull(); + }); + + it("counts a comment line, skips code, and skips a line whose only job is a directive", () => { + const source = [ + " // a note", + " /* a block opens", + " * and continues", + " const x = 1;", + " // biome-ignore lint/style/noVar: reason", + ].join("\n"); + + expect(commentLinesIn(source).map(({ line }) => line)).toEqual([1, 2, 3]); + }); +}); diff --git a/cli/tests/architecture/context-boundary.arch.test.ts b/cli/tests/architecture/context-boundary.arch.test.ts new file mode 100644 index 000000000..3fa5fce17 --- /dev/null +++ b/cli/tests/architecture/context-boundary.arch.test.ts @@ -0,0 +1,253 @@ +/** + * A codebase that forbids barrels cannot hold a context's boundary with a re-export file, so + * it holds it here: an import from outside a context may only target a module listed below. + * The list is the fence, not a description of what happens to be used. + */ +import { describe, expect, it } from "vitest"; +import { expectRatchet, importersByFile, sourceFiles } from "./helpers.js"; + +/** Each context's declared public surface, one entry per context. */ +const PUBLIC_MODULES: Readonly> = { + tools: [ + // the tool contract and lookup surface + "src/contexts/tools/domain/contracts.ts", + "src/contexts/tools/domain/registry.ts", + "src/contexts/tools/domain/build-contract.ts", + // co-owned configuration (settings.json, .mcp.json et al.) + "src/contexts/tools/domain/capabilities/mcp-capability.ts", + "src/contexts/tools/domain/mcp-exclusion.ts", + "src/contexts/tools/domain/capabilities/settings-capability.ts", + "src/contexts/tools/domain/capabilities/hooks-capability.ts", + "src/contexts/tools/domain/capabilities/config-refs.ts", + "src/contexts/tools/domain/formats/opencode-mcp-merge.ts", + // ports a caller wires a concrete adapter into, or whose type it must accept + "src/contexts/tools/domain/ports/file-merger.ts", + "src/contexts/tools/domain/ports/native-plugin-activator.ts", + "src/contexts/tools/domain/ports/schema-validator.ts", + "src/contexts/tools/domain/ports/host-plugin-registry-reader.ts", + // What a host's registry says about an installed plugin: telemetry's diagnostic and + // `doctor` both need the comparison. + "src/contexts/tools/domain/host-plugin-registration.ts", + "src/contexts/tools/domain/ports/host-marketplace-registry-reader.ts", + // Whether a name a host's registry holds points at a different source: the sync-time + // guard and `doctor`'s conflict check share the comparison. + "src/contexts/tools/domain/marketplace-source-conflict.ts", + // what a tool declares about plugins: this context has no application layer, since + // installing is framework work + "src/contexts/tools/domain/capabilities/plugins-capability.ts", + "src/contexts/tools/domain/marketplace-settings.ts", + "src/contexts/tools/domain/plugin-translation-mode.ts", + "src/contexts/tools/domain/hooks-format.ts", + "src/contexts/tools/domain/models/plugin-install-notice.ts", + // The shape of the file a tool's hooks land in, read by the installer that merges them + // and the diagnostic that answers whether the tool will run them. + "src/contexts/tools/domain/formats/flat-hooks-merge.ts", + "src/contexts/tools/domain/formats/cursor-hooks-project-merge.ts", + // The variable each tool expands to an installed plugin's directory: a tool declares it, + // translate substitutes it, the diagnostic looks for it in what was installed. + "src/contexts/tools/domain/formats/plugin-root-token.ts", + ], + // Every entry is reached by `presentation` or by the composition root, and none is an + // adapter: what telemetry needs elsewhere it declares as its own port instead, so + // measurement reaches into no context and no context reaches into it. + telemetry: [ + // the six use cases the `telemetry` command drives + "src/contexts/telemetry/application/telemetry-on-use-case.ts", + "src/contexts/telemetry/application/telemetry-off-use-case.ts", + "src/contexts/telemetry/application/read-local-cost-use-case.ts", + "src/contexts/telemetry/application/report-cost-use-case.ts", + "src/contexts/telemetry/application/diagnose-telemetry-use-case.ts", + "src/contexts/telemetry/application/forget-telemetry-use-case.ts", + "src/contexts/telemetry/application/person-identity-use-case.ts", + // the shapes a rendered answer is made of + "src/contexts/telemetry/domain/cost-report.ts", + "src/contexts/telemetry/domain/cost-report-envelope.ts", + // how a person id was resolved, printed beside each row + "src/contexts/telemetry/domain/person-resolution.ts", + "src/contexts/telemetry/domain/report-period.ts", + "src/contexts/telemetry/domain/telemetry-removal.ts", + "src/contexts/telemetry/domain/telemetry-claim.ts", + "src/contexts/telemetry/domain/telemetry-setup.ts", + "src/contexts/telemetry/domain/telemetry-export-leftover.ts", + "src/contexts/telemetry/domain/flow-attribution.ts", + "src/contexts/telemetry/domain/step-attribution.ts", + "src/contexts/telemetry/domain/task-attribution.ts", + // the trailer a commit carries, written by the git adapter that installs the hook + "src/contexts/telemetry/domain/formats/commit-session-trailer.ts", + // ports a caller wires a concrete adapter into + "src/contexts/telemetry/domain/ports/telemetry-sink.ts", + "src/contexts/telemetry/domain/ports/version-control.ts", + ], + translate: [ + // the canonical shapes framework produces and translate consumes + "src/contexts/translate/domain/canon.ts", + "src/contexts/translate/domain/plugin-distribution.ts", + "src/contexts/translate/domain/plugin-format.ts", + "src/contexts/translate/domain/plugin-translation-skip.ts", + "src/contexts/translate/domain/build-target.ts", + // the translator itself — what this context is for + "src/contexts/translate/domain/content-translator.ts", + // the build use case — `framework build`, one source to N targets + "src/contexts/translate/application/translate-source.ts", + // A per-consumer allowance is not expressible here: the lookup is keyed by the imported + // file's own context, so a `tools` file is only ever checked against `tools`. + ], + // Measured with the composition root excluded, and not one entry is an adapter: the + // adapters are wired from `runtime/wiring/` alone, so they stay internal. + distribution: [ + // what a marketplace is, and where it can be read from + "src/contexts/distribution/domain/marketplace.ts", + "src/contexts/distribution/domain/marketplace-source-mode.ts", + "src/contexts/distribution/domain/catalog.ts", + // the ports its callers hold, so they can be given an implementation + "src/contexts/distribution/domain/ports/marketplace-registry.ts", + "src/contexts/distribution/domain/ports/marketplace-trust-store.ts", + "src/contexts/distribution/domain/ports/plugin-catalog-repository.ts", + "src/contexts/distribution/domain/ports/plugin-fetcher.ts", + // the three operations other contexts genuinely ask for + "src/contexts/distribution/application/resolve-marketplace-use-case.ts", + "src/contexts/distribution/application/marketplace-refresh-use-case.ts", + "src/contexts/distribution/application/marketplace-register-framework-use-case.ts", + ], + // The largest context: while it had no entry the mechanism below skipped every framework + // file, so its interior was reached unchecked. Measured, composition root excluded. + framework: [ + // the rule inventory `framework rules` prints, one row per installed rule + "src/contexts/framework/domain/installed-rule.ts", + // the installation record, which is what this context owns + "src/contexts/framework/domain/manifest.ts", + "src/contexts/framework/domain/ports/manifest-repository.ts", + "src/contexts/framework/domain/install-scope.ts", + "src/contexts/framework/domain/project-context.ts", + // the flows a command drives end to end + "src/contexts/framework/application/setup-use-case.ts", + "src/contexts/framework/application/setup/setup-tools-use-case.ts", + "src/contexts/framework/application/plugin/plugin-add-use-case.ts", + "src/contexts/framework/application/plugin/plugin-install-from-marketplace-use-case.ts", + // what a display or a prompt reads to render a decision it does not make + "src/contexts/framework/domain/doctor.ts", + "src/contexts/framework/domain/setup-flow.ts", + "src/contexts/framework/domain/tool-recommendations.ts", + // the one operation another context genuinely asks for: `distribution` removes a + // marketplace and this context forgets the plugins that came from it + "src/contexts/framework/application/flows/marketplace-remove-use-case.ts", + ], +}; + +function contextsOnDisk(files: readonly string[]): string[] { + const names = new Set(); + for (const file of files) { + const match = /^src\/contexts\/([^/]+)\//.exec(file); + if (match) names.add(match[1] as string); + } + return [...names].sort(); +} + +function contextOf(file: string): string | null { + const match = /^src\/contexts\/([^/]+)\//.exec(file); + return match ? match[1] : null; +} + +/** + * The composition root wires every context by construction — a profile registers itself + * through a side-effect import, an adapter must be named to be instantiated — so the whole + * `runtime/wiring/` directory is exempt rather than a caller this rule tries to catch. + */ +function isCompositionRoot(file: string): boolean { + return file.startsWith("src/runtime/wiring/"); +} + +function reachesIntoInterior( + files: readonly string[], + importers: ReadonlyMap>, + publicModules: Readonly> +): string[] { + const violations: string[] = []; + for (const file of files) { + const owner = contextOf(file); + if (owner === null || !(owner in publicModules)) continue; + if (publicModules[owner].includes(file)) continue; + for (const importer of importers.get(file) ?? []) { + if (isCompositionRoot(importer)) continue; + if (contextOf(importer) === owner) continue; + violations.push(`${importer} -> ${file}`); + } + } + return violations.sort(); +} + +/** + * Reaches into a context's interior today; the list may only shrink. Each entry is an + * `install-*-use-case.ts` reaching past `tools`' declared contract for the capability class + * itself, and they resolve together when `install/` moves into `contexts/tools/application/`. + */ +const BASELINE = [ + "src/contexts/framework/application/install/content/install-agents-use-case.ts -> src/contexts/tools/domain/capabilities/agents-capability.ts", + "src/contexts/framework/application/install/content/install-commands-use-case.ts -> src/contexts/tools/domain/capabilities/commands-capability.ts", + "src/contexts/framework/application/install/content/install-content-section-use-case.ts -> src/contexts/tools/domain/formats/command.ts", + "src/contexts/framework/application/install/content/install-rules-use-case.ts -> src/contexts/tools/domain/capabilities/rules-capability.ts", + "src/contexts/framework/application/install/content/install-skills-use-case.ts -> src/contexts/tools/domain/capabilities/skills-capability.ts", +]; + +describe("nothing imports a context's interior", () => { + it("every cross-context import targets a declared public module", () => { + const violations = reachesIntoInterior(sourceFiles(), importersByFile(), PUBLIC_MODULES); + + const { added, fixed } = expectRatchet(violations, BASELINE); + expect(added, "new import reaches a context's undeclared interior").toEqual([]); + expect(fixed, "fixed — remove these from BASELINE").toEqual([]); + }); + + it("declares a public surface for every context on disk", () => { + const declared = Object.keys(PUBLIC_MODULES).sort(); + + expect( + contextsOnDisk(sourceFiles()), + "a context with no entry above is skipped entirely by the rule, not held by it" + ).toEqual(declared); + }); +}); + +describe("the guard itself", () => { + it("skips a context silently when it has no declaration, which is why the check above exists", () => { + const files = ["src/contexts/ghost/domain/inner.ts"]; + const importers = new Map([ + ["src/contexts/ghost/domain/inner.ts", new Set(["src/presentation/commands/x.ts"])], + ]); + + expect( + reachesIntoInterior(files, importers, {}), + "undeclared means unchecked — the failure mode this rule had for framework" + ).toEqual([]); + expect( + reachesIntoInterior(files, importers, { ghost: [] }), + "declared with an empty surface means every reach is a violation" + ).toEqual(["src/presentation/commands/x.ts -> src/contexts/ghost/domain/inner.ts"]); + }); + + it("flags a reach into a context's interior and clears one that targets its public surface", () => { + const files = ["src/contexts/acme/domain/internal.ts", "src/contexts/acme/domain/public.ts"]; + const importers = new Map([ + ["src/contexts/acme/domain/internal.ts", new Set(["src/application/outsider.ts"])], + ["src/contexts/acme/domain/public.ts", new Set(["src/application/outsider.ts"])], + ]); + const publicModules = { acme: ["src/contexts/acme/domain/public.ts"] }; + + expect(reachesIntoInterior(files, importers, publicModules)).toEqual([ + "src/application/outsider.ts -> src/contexts/acme/domain/internal.ts", + ]); + }); + + it("lets a context import its own interior freely, and exempts the composition root", () => { + const files = ["src/contexts/acme/domain/internal.ts"]; + const importers = new Map([ + [ + "src/contexts/acme/domain/internal.ts", + new Set(["src/contexts/acme/application/sibling.ts", "src/runtime/wiring/framework.ts"]), + ], + ]); + const publicModules = { acme: [] }; + + expect(reachesIntoInterior(files, importers, publicModules)).toEqual([]); + }); +}); diff --git a/cli/tests/architecture/context-graph.arch.test.ts b/cli/tests/architecture/context-graph.arch.test.ts new file mode 100644 index 000000000..de204e312 --- /dev/null +++ b/cli/tests/architecture/context-graph.arch.test.ts @@ -0,0 +1,77 @@ +/** + * The context chain as a graph rather than a paragraph. A per-file biome override cannot see + * it: an override matches the text of a specifier, not the path it resolves to, and answers + * one file at a time. `presentation` and `runtime` may depend on anything below them, so a + * context reaching back into either is an edge recorded here. Data lives in `helpers.ts`. + */ +import { describe, expect, it } from "vitest"; +import { + ALLOWED, + BASELINE, + contextOf, + edgesBetweenContexts, + expectRatchet, + sourceFiles, + weighedEdges, +} from "./helpers.js"; + +function forbiddenEdges(edges: readonly string[], allowed: ReadonlySet): string[] { + return edges.filter((edge) => !allowed.has(edge)); +} + +function edgeBetween(importer: string, target: string): string { + return `${contextOf(importer)}->${contextOf(target)}`; +} + +describe("the context graph has only the edges the plan allows", () => { + it("no context reaches another the chain does not permit", () => { + const violations = forbiddenEdges(edgesBetweenContexts(sourceFiles()), ALLOWED); + + const { added, fixed } = expectRatchet( + violations, + BASELINE.map((entry) => entry.edge) + ); + expect(added, "an edge the chain forbids — see arborescence.md invariant 2").toEqual([]); + expect(fixed, "fixed — remove these from BASELINE").toEqual([]); + }); + + it("holds each admitted edge to the weight its reason was written around", () => { + const weighed = weighedEdges(sourceFiles()); + const recorded = BASELINE.map( + ({ edge, imports, files }) => `${edge}: ${imports} imports across ${files} files` + ); + const actual = BASELINE.map(({ edge }) => { + const weight = weighed.get(edge) ?? { imports: 0, files: 0 }; + return `${edge}: ${weight.imports} imports across ${weight.files} files`; + }); + + expect( + actual, + "an admitted edge absorbed imports — a baselined edge is not a licence to grow" + ).toEqual(recorded); + }); +}); + +describe("the guard itself", () => { + it("names the edge it is given, and stays silent on one it allows", () => { + expect(contextOf("src/contexts/tools/domain/registry.ts")).toBe("tools"); + expect(contextOf("src/kernel/tool.ts")).toBe("kernel"); + expect(contextOf("src/somewhere-that-is-no-layer/thing.ts")).toBe("outside"); + expect(ALLOWED.has("translate->tools")).toBe(true); + expect(ALLOWED.has("tools->translate")).toBe(false); + }); + + it("reports a planted crossing the chain forbids and clears one it permits", () => { + const forbidden = edgeBetween( + "src/contexts/tools/domain/registry.ts", + "src/contexts/translate/domain/canon.ts" + ); + const permitted = edgeBetween( + "src/contexts/translate/application/translate-source.ts", + "src/contexts/tools/domain/registry.ts" + ); + + expect(forbiddenEdges([forbidden, permitted], ALLOWED)).toEqual(["tools->translate"]); + expect(forbiddenEdges([permitted], ALLOWED)).toEqual([]); + }); +}); diff --git a/cli/tests/architecture/context-self-reentry.arch.test.ts b/cli/tests/architecture/context-self-reentry.arch.test.ts new file mode 100644 index 000000000..51a249124 --- /dev/null +++ b/cli/tests/architecture/context-self-reentry.arch.test.ts @@ -0,0 +1,88 @@ +/** + * A file whose specifier climbs above `src/contexts//` and then spells `` back out lands + * on a module it could reach directly — the scar a mechanical file move leaves. Not a boundary + * violation: a real cross-context edge climbs out and never comes back. + */ +import { describe, expect, it } from "vitest"; +import { expectRatchet, INTERNAL_IMPORT, read, sourceFiles } from "./helpers.js"; + +/** `src/contexts//` is always three path segments. */ +const CONTEXT_ROOT_DEPTH = 3; + +/** + * Whether a specifier climbs out of `src/contexts//` and spells `context` back out on + * the way down. One that only climbs within the context never reaches this depth. + */ +function climbsOutAndReenters(specifier: string, context: string, fileDirDepth: number): boolean { + if (!specifier.startsWith("..")) return false; + const segments = specifier.split("/"); + let up = 0; + for (const segment of segments) { + if (segment === "..") up += 1; + else break; + } + const upsNeededToExit = fileDirDepth - CONTEXT_ROOT_DEPTH + 1; + if (up < upsNeededToExit) return false; + const remaining = segments.slice(up); + return remaining[0] === context || (remaining[0] === "contexts" && remaining[1] === context); +} + +function selfReentryViolations(files: readonly string[]): string[] { + const violations: string[] = []; + for (const file of files) { + const match = /^src\/contexts\/([^/]+)\//.exec(file); + if (!match) continue; + const context = match[1] as string; + const fileDirDepth = file.slice(0, file.lastIndexOf("/")).split("/").length; + for (const importMatch of read(file).matchAll(INTERNAL_IMPORT)) { + const specifier = importMatch[1] as string; + if (climbsOutAndReenters(specifier, context, fileDirDepth)) { + violations.push(`${file} -> ${specifier}`); + } + } + } + return violations.sort(); +} + +/** Empty on purpose: a file that fails this test is fixed, never listed. */ +const BASELINE: string[] = []; + +describe("a context reaches its own interior directly, never by climbing out and back in", () => { + it("no file re-enters its own context under its own name", () => { + const violations = selfReentryViolations(sourceFiles()); + + const { added, fixed } = expectRatchet(violations, BASELINE); + expect(added, "a new import climbs out of its own context and back in").toEqual([]); + expect(fixed, "fixed — remove these from BASELINE").toEqual([]); + }); +}); + +describe("the guard itself", () => { + it("flags a specifier that climbs out of its context root and spells the context back out", () => { + // From `contexts/acme/application/`, three `..` clear application, acme and contexts. + expect(climbsOutAndReenters("../../../contexts/acme/domain/y.js", "acme", 4)).toBe(true); + expect(climbsOutAndReenters("../../../acme/domain/y.js", "acme", 4)).toBe(true); + }); + + it("clears a specifier that stays inside the context, however far it climbs", () => { + // One `..` from `application/plugin/` reaches `application/`, still inside the context. + expect( + climbsOutAndReenters("../framework/translator/plugin-translator.js", "framework", 5) + ).toBe(false); + expect(climbsOutAndReenters("../domain/ports/version-control.js", "telemetry", 4)).toBe(false); + }); + + it("ignores a specifier that does not climb at all", () => { + expect(climbsOutAndReenters("./sibling.js", "acme", 4)).toBe(false); + }); + + it("does not flag a real cross-context edge, which climbs out and never comes back", () => { + expect( + climbsOutAndReenters( + "../../../contexts/tools/domain/formats/cursor-hooks-project-merge.js", + "telemetry", + 4 + ) + ).toBe(false); + }); +}); diff --git a/cli/tests/architecture/docs-do-not-lie.arch.test.ts b/cli/tests/architecture/docs-do-not-lie.arch.test.ts new file mode 100644 index 000000000..e619e0856 --- /dev/null +++ b/cli/tests/architecture/docs-do-not-lie.arch.test.ts @@ -0,0 +1,87 @@ +/** + * A reader cannot tell a promised command from a declared one; this test can. Naming a command + * in order to say it is gone is not a lie, so a citation is accepted when its line marks it + * removed or is a migration row — which keeps the check honest without a name allowlist. + */ +import { describe, expect, it } from "vitest"; +import { + declaredCommands, + pluginReadmes, + read, + readFromRepoRoot, + unresolvedCommandMentions, +} from "./helpers.js"; + +/** Documents that present the CLI's surface to a reader — the memory bank included, since an + * agent reads it first in every session. */ +const DOCS = [ + "ARCHITECTURE.md", + "README.md", + "aidd_docs/memory/codebase-map.md", + "aidd_docs/memory/project-brief.md", + "aidd_docs/GUIDELINES.md", +]; + +/** The line itself says the command is gone. */ +const MARKED_GONE = /\b(removed|legacy|no longer|deprecated|replaced by)\b|there is no/i; + +/** A table row naming two commands maps an old one to its replacement. */ +function isMigrationRow(line: string): boolean { + return line.trimStart().startsWith("|") && [...line.matchAll(/\baidd\s+[a-z]/g)].length >= 2; +} + +/** + * `MARKED_GONE` is checked against a whole paragraph unwrapped to one line: markdown wraps + * prose, so a citation and the word marking it gone can land on different physical lines. A + * migration row is still read one line at a time, a table having no blank line to unwrap at. + */ +function textClaimingCommandsWork(text: string): string { + const paragraphs = text.split(/\n\s*\n/); + const kept: string[] = []; + for (const paragraph of paragraphs) { + if (MARKED_GONE.test(paragraph.replace(/\n/g, " "))) continue; + kept.push( + paragraph + .split("\n") + .filter((line) => !isMigrationRow(line)) + .join("\n") + ); + } + return kept.join("\n\n"); +} + +function undeclaredCommands(text: string, declared: ReadonlySet): string[] { + return [...new Set(unresolvedCommandMentions(textClaimingCommandsWork(text), declared))].sort(); +} + +describe("documented commands exist", () => { + const declared = declaredCommands(); + + it.each(DOCS)("%s presents no command the CLI does not declare", (doc) => { + const missing = undeclaredCommands(read(doc), declared); + expect(missing, `${doc} presents commands that do not exist`).toEqual([]); + }); + + it.each(pluginReadmes())("%s presents no command the CLI does not declare", (doc) => { + const missing = undeclaredCommands(readFromRepoRoot(doc), declared); + expect(missing, `${doc} presents commands that do not exist`).toEqual([]); + }); +}); + +describe("the guard itself", () => { + it("flags an undeclared command, a bad pair, and clears one marked gone, migrated, or registered", () => { + const knownCommands = new Set(["init", "plugin", "plugin install"]); + + expect(undeclaredCommands("Run `aidd bogus-command` to do it.", knownCommands)).toEqual([ + "bogus-command", + ]); + // `plugin` exists; `plugin bogus` does not, and reading only the first word clears it. + expect(undeclaredCommands("Run `aidd plugin bogus` to do it.", knownCommands)).toEqual([ + "plugin bogus", + ]); + expect(undeclaredCommands("`aidd bogus-command` was removed.", knownCommands)).toEqual([]); + expect(undeclaredCommands("| `aidd old-name` | `aidd new-name` |", knownCommands)).toEqual([]); + expect(undeclaredCommands("Run `aidd init` to start.", knownCommands)).toEqual([]); + expect(undeclaredCommands("Run `aidd plugin install` to add one.", knownCommands)).toEqual([]); + }); +}); diff --git a/cli/tests/architecture/earned-sharing.arch.test.ts b/cli/tests/architecture/earned-sharing.arch.test.ts new file mode 100644 index 000000000..733038c0c --- /dev/null +++ b/cli/tests/architecture/earned-sharing.arch.test.ts @@ -0,0 +1,98 @@ +/** + * A module is shared only when it has callers in at least two functional areas. One caller + * means the code belongs to that caller: move it down, do not promote it. + */ +import { describe, expect, it } from "vitest"; +import { expectRatchet, importersByFile, sourceFiles } from "./helpers.js"; + +/** Files that fail the rule today. This list may only shrink. */ +const BASELINE: string[] = []; + +/** The functional area a file belongs to. Two callers in one area are still one area. */ +function areaOf(file: string): string { + // The composition root constructs every use case by definition: counting it as an area + // would let any module satisfy the rule by being wired rather than needed twice. + if (file.startsWith("src/runtime/wiring/")) return "composition-root"; + // A context's application layer is where the areas live. + const contextArea = /^src\/contexts\/[^/]+\/application\/([^/]+)\//.exec(file); + if (contextArea) return `use-case:${contextArea[1]}`; + const contextRoot = /^src\/contexts\/([^/]+)\/application\/[^/]+\.ts$/.exec(file); + if (contextRoot) return `use-case:${contextRoot[1]}-root`; + const contextInner = /^src\/contexts\/([^/]+)\/(domain|infrastructure)\//.exec(file); + if (contextInner) return `${contextInner[2]}:${contextInner[1]}`; + if (file.startsWith("src/presentation/commands/")) return "commands"; + if (file.startsWith("src/presentation/prompts/")) return "prompts"; + if (file.startsWith("src/presentation/")) return "presentation"; + if (file.startsWith("src/kernel/")) return "kernel"; + if (file.startsWith("src/runtime/")) return "runtime"; + return "other"; +} + +const NON_AREAS = new Set(["use-case:shared", "composition-root"]); + +/** + * Only a file sitting directly inside a `shared/` directory is offered to callers; one nested + * further under a shared module is that module's own private step. + */ +function underSharedDirectory(file: string): boolean { + return /\/shared\/[^/]+$/.test(file); +} + +function unearned(files: readonly string[], importers: Map>): string[] { + return files.filter(underSharedDirectory).filter((file) => { + const areas = new Set( + [...(importers.get(file) ?? [])].map(areaOf).filter((area) => !NON_AREAS.has(area)) + ); + return areas.size < 2; + }); +} + +describe("shared modules are earned", () => { + it("every shared module has callers in at least two areas", () => { + const files = sourceFiles(); + // A rule that selects nothing passes forever, and this one selects a single directory. + expect( + files.filter(underSharedDirectory).length, + "no shared module found — the scope of this rule is stale" + ).toBeGreaterThan(0); + const violations = unearned(files, importersByFile()); + + const { added, fixed } = expectRatchet(violations, BASELINE); + expect(added, "new shared module with fewer than two calling areas").toEqual([]); + expect(fixed, "fixed — remove these from BASELINE").toEqual([]); + }); +}); + +describe("the guard itself", () => { + it("flags a shared module called from one area and clears one called from two", () => { + const lonely = "src/contexts/framework/application/shared/lonely.ts"; + const earned = "src/contexts/framework/application/shared/earned.ts"; + const importers = new Map([ + [lonely, new Set(["src/contexts/framework/application/doctor/a.ts"])], + [ + earned, + new Set([ + "src/contexts/framework/application/doctor/a.ts", + "src/presentation/commands/doctor.ts", + ]), + ], + ]); + + expect(unearned([lonely, earned], importers)).toEqual([lonely]); + }); + + it("names an area for every place a caller lives, so two callers are not both 'other'", () => { + expect(areaOf("src/contexts/framework/application/doctor/a.ts")).toBe("use-case:doctor"); + expect(areaOf("src/contexts/framework/application/setup-use-case.ts")).toBe( + "use-case:framework-root" + ); + expect(areaOf("src/contexts/tools/domain/registry.ts")).toBe("domain:tools"); + expect(areaOf("src/contexts/tools/infrastructure/a.ts")).toBe("infrastructure:tools"); + expect(areaOf("src/presentation/commands/doctor.ts")).toBe("commands"); + expect(areaOf("src/presentation/display/a.ts")).toBe("presentation"); + expect(areaOf("src/kernel/tool.ts")).toBe("kernel"); + expect(areaOf("src/runtime/wiring/tools.ts"), "wired, not needed twice").toBe( + "composition-root" + ); + }); +}); diff --git a/cli/tests/architecture/errors-that-are-thrown.arch.test.ts b/cli/tests/architecture/errors-that-are-thrown.arch.test.ts new file mode 100644 index 000000000..2f2609e93 --- /dev/null +++ b/cli/tests/architecture/errors-that-are-thrown.arch.test.ts @@ -0,0 +1,68 @@ +/** + * One catalog for the whole codebase is easy to read and easy to rot: a class outlives the + * code that threw it and nothing complains. knip cannot see it — an error imported by its own + * test reads as used, and a test asserting `new SomeError().name` is not a caller. + */ +import { describe, expect, it } from "vitest"; +import { expectRatchet, read, sourceFiles } from "./helpers.js"; + +const CATALOG = "src/kernel/errors.ts"; + +function declaredErrorNames(source: string): string[] { + return [...source.matchAll(/^export class (\w+) extends/gm)].map((match) => match[1] as string); +} + +function thrownNames(source: string): string[] { + return [...source.matchAll(/throw new (\w+)/g)].map((match) => match[1] as string); +} + +function thrownErrors(files: readonly string[]): Set { + const thrown = new Set(); + for (const file of files) { + for (const name of thrownNames(read(file))) thrown.add(name); + } + return thrown; +} + +function orphanErrors(declared: readonly string[], thrown: ReadonlySet): string[] { + return declared.filter((name) => !thrown.has(name)).sort(); +} + +/** Empty and staying so: an error with no thrower is a missing code path or a leftover. */ +const BASELINE: string[] = []; + +describe("the error catalog carries no class nothing throws", () => { + it("every declared error is thrown by some production file", () => { + // The catalog is excluded: a throw inside it would let a class outlive every real + // thrower as long as it mentions itself once. + const thrown = thrownErrors(sourceFiles().filter((file) => file !== CATALOG)); + const orphans = orphanErrors(declaredErrorNames(read(CATALOG)), thrown); + + const { added, fixed } = expectRatchet(orphans, BASELINE); + expect( + added, + "declared but never thrown — either a code path is missing, or the class outlived it" + ).toEqual([]); + expect(fixed, "fixed — remove these from BASELINE").toEqual([]); + }); +}); + +describe("the guard itself", () => { + it("reads a class as thrown only where the throw is, not where the name is mentioned", () => { + expect(thrownNames('expect(error.name).toBe("GhostError");')).toEqual([]); + expect(thrownNames("throw new GhostError();")).toEqual(["GhostError"]); + expect(declaredErrorNames("export class GhostError extends Error {}")).toEqual(["GhostError"]); + expect(declaredErrorNames("const GhostError = 1;"), "only a declaration counts").toEqual([]); + }); + + it("names a declared error nothing throws, and clears one something does", () => { + const catalog = [ + "export class GhostError extends AiddError {}", + "export class LiveError extends AiddError {}", + ].join("\n"); + const declared = declaredErrorNames(catalog); + + expect(orphanErrors(declared, new Set(["LiveError"]))).toEqual(["GhostError"]); + expect(orphanErrors(declared, new Set(["GhostError", "LiveError"]))).toEqual([]); + }); +}); diff --git a/cli/tests/architecture/errors-that-instruct.arch.test.ts b/cli/tests/architecture/errors-that-instruct.arch.test.ts new file mode 100644 index 000000000..93d841f5a --- /dev/null +++ b/cli/tests/architecture/errors-that-instruct.arch.test.ts @@ -0,0 +1,74 @@ +/** + * A message that *describes* is prose; one that *instructs* is a contract, and its cost when + * wrong is a person typing a command that does not exist. Only the instructing half is + * checked, over the surface a person reads: `presentation/` and each `application/` layer. + */ +import { describe, expect, it } from "vitest"; +import { + declaredCommands, + expectRatchet, + read, + sourceFiles, + unresolvedCommandMentions, +} from "./helpers.js"; + +/** + * A crude regex, not a parser: it cannot tell a comment from code, so a backticked example in + * a doc comment is scanned like a printed message. What the quote requirement buys is dropping + * unquoted prose ("the aidd config directory"), which never claimed to be runnable. + */ +function stringLiterals(source: string): string[] { + const literals: string[] = []; + for (const match of source.matchAll(/"(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*'|`(?:[^`\\]|\\.)*`/g)) { + literals.push(match[0].slice(1, -1)); + } + return literals; +} + +/** A use case, or a file that speaks to the user directly under `presentation/`. */ +function instructsAUser(file: string): boolean { + return file.startsWith("src/presentation/") || /\/application\//.test(file); +} + +/** Empty, and staying so: a message naming a command the CLI does not declare is a message to + * fix, not one to record here. */ +const BASELINE: string[] = []; + +describe("a message that instructs names a command that exists", () => { + it("every command a message tells the user to run is declared", () => { + const declared = declaredCommands(); + const offenders: string[] = []; + for (const file of sourceFiles().filter(instructsAUser)) { + for (const literal of stringLiterals(read(file))) { + for (const command of unresolvedCommandMentions(literal, declared)) { + offenders.push(`${file}: aidd ${command}`); + } + } + } + + const { added, fixed } = expectRatchet(offenders.sort(), BASELINE); + expect(added, "a message sends the user at a command the CLI does not declare").toEqual([]); + expect(fixed, "fixed — remove these from BASELINE").toEqual([]); + }); +}); + +describe("the guard itself", () => { + it("flags an instruction the CLI cannot honour, and passes one it can", () => { + const declared = new Set(["marketplace", "marketplace add", "plugin", "setup"]); + expect(unresolvedCommandMentions("Use `aidd marketplace add ` first.", declared)).toEqual( + [] + ); + expect(unresolvedCommandMentions("Run `aidd setup` again.", declared)).toEqual([]); + // `plugin` exists; `plugin marketplace` does not, and checking only the first word + // would clear it. + expect(unresolvedCommandMentions("Run `aidd plugin marketplace add`.", declared)).toEqual([ + "plugin marketplace", + ]); + }); + + it("reads only quoted text, so a bare mention outside any string is not a claim", () => { + expect(stringLiterals('const x = "aidd bogus-command";')).toEqual(["aidd bogus-command"]); + expect(stringLiterals("the aidd config directory")).toEqual([]); + expect(stringLiterals("// `aidd bogus-command` used to exist")).toEqual(["aidd bogus-command"]); + }); +}); diff --git a/cli/tests/architecture/folder-size.arch.test.ts b/cli/tests/architecture/folder-size.arch.test.ts new file mode 100644 index 000000000..f06a6343b --- /dev/null +++ b/cli/tests/architecture/folder-size.arch.test.ts @@ -0,0 +1,105 @@ +/** + * Past ten direct `.ts` files a folder stops being a place and becomes a pile: files stop + * finding their neighbours and duplication creeps in unnoticed. + */ +import { describe, expect, it } from "vitest"; +import { expectRatchet, sourceFiles } from "./helpers.js"; + +const MAX_FILES_PER_FOLDER = 10; + +/** + * Directories over the limit, each with the count it carries and the reason it is still here. + * The list may only shrink, and an entry leaves when the defect behind it is fixed, not when + * files are shuffled. The test asserts the count, so a reason nobody measured fails here. + */ +const BASELINE: readonly { readonly path: string; readonly count: number }[] = [ + // Twelve files carry the command surface, plus three helpers no other folder imports. That + // is the flattest mapping from the CLI's surface to its source; moving the helpers out + // would leave twelve, still over the limit and clearer about nothing. + { path: "src/presentation/commands", count: 15 }, + // One display module per command, mirroring `presentation/commands/`: that mirror is what + // lets a command's rendering be asserted without running the binary. Fewer files would mean + // one module rendering several commands, which is what put the printing in the actions. + { path: "src/presentation/display", count: 19 }, + // Eleven separate vocabularies with no pair among them, the file helpers already grouped + // under `reading/`. Reaching ten means a folder holding one file: a grouping invented to + // satisfy a count is worse than the count. + { path: "src/kernel", count: 11 }, + // Telemetry's own vocabulary — what a record is, how a report is shaped, whose a figure is + // — is one subject: splitting it by shape files `cost-report.ts` away from the envelope it + // fills. + { path: "src/contexts/telemetry/domain", count: 20 }, + // Measurement reads that many things it does not own: a sink, a journal, an identity, a + // host registry, hook trust and the rest. One port per question; collapsing two answers two. + { path: "src/contexts/telemetry/domain/ports", count: 11 }, + // `marketplace-source-conflict.ts` is read by both the sync-time guard and `doctor`, and no + // grouping here fits it: the marketplace files beside it are each a tool-build concern it is + // not, and moving one out to make room is the shuffle this rule refuses. + { path: "src/contexts/tools/domain", count: 11 }, + + // `marketplace-source-drift.ts` decides a version/migration drift, a fact about aidd's own + // migration: that concern belongs to `framework`, not `tools`. + { path: "src/contexts/framework/domain", count: 11 }, + + // Four native-cache and host-CLI helpers are shared by `clean` at project and machine scope + // both, which is what `earned-sharing.arch.test.ts` means by earned; a `clean/`-only home + // would pass that rule by sitting outside the directory it judges. Nothing else here groups + // with them. + { path: "src/contexts/framework/application/shared", count: 13 }, +]; + +/** A subfolder counts toward itself, not toward its parent. */ +function countsByDirectory(files: readonly string[]): Map { + const counts = new Map(); + for (const file of files) { + const dir = file.slice(0, file.lastIndexOf("/")); + counts.set(dir, (counts.get(dir) ?? 0) + 1); + } + return counts; +} + +function foldersOverLimit(files: readonly string[], limit: number): string[] { + return [...countsByDirectory(files)] + .filter(([, count]) => count > limit) + .map(([dir]) => dir) + .sort(); +} + +describe("folders stay small enough to hold in mind", () => { + it("no directory carries more than ten direct source files", () => { + const violations = foldersOverLimit(sourceFiles(), MAX_FILES_PER_FOLDER); + + const { added, fixed } = expectRatchet( + violations, + BASELINE.map((entry) => entry.path) + ); + expect(added, "new folder past the size limit — split it").toEqual([]); + expect(fixed, "fixed — remove these from BASELINE").toEqual([]); + }); + + it("holds each baseline entry to the count its reason was written around", () => { + const measured = countsByDirectory(sourceFiles()); + const recorded = BASELINE.map(({ path, count }) => `${path}: ${count}`); + const actual = BASELINE.map(({ path }) => `${path}: ${measured.get(path) ?? 0}`); + + expect( + actual, + "a baseline count drifted from the tree — fix the number and its reason" + ).toEqual(recorded); + }); +}); + +describe("the guard itself", () => { + it("fails the ratchet by name when a folder is pushed past the limit", () => { + const files = [ + ...Array.from({ length: 11 }, (_, i) => `src/pile/f${i}.ts`), + ...Array.from({ length: 10 }, (_, i) => `src/tidy/f${i}.ts`), + ]; + + const violations = foldersOverLimit(files, MAX_FILES_PER_FOLDER); + expect(violations, "eleven files is over, ten is not").toEqual(["src/pile"]); + + const { added } = expectRatchet(violations, []); + expect(added, "the ratchet names the offender, not just the detector").toEqual(["src/pile"]); + }); +}); diff --git a/cli/tests/architecture/helpers.ts b/cli/tests/architecture/helpers.ts new file mode 100644 index 000000000..de74b7c03 --- /dev/null +++ b/cli/tests/architecture/helpers.ts @@ -0,0 +1,293 @@ +/** + * These tests read source as text and never import the code under test, so they stay fast + * enough for a pre-commit hook and cannot be broken by runtime wiring. + */ +import { existsSync, readdirSync, readFileSync, statSync } from "node:fs"; +import { dirname, join, normalize, relative, resolve } from "node:path"; + +export const CLI_ROOT = resolve(import.meta.dirname, "..", ".."); +export const SRC = join(CLI_ROOT, "src"); + +export const REPO_ROOT = resolve(CLI_ROOT, ".."); + +export function sourceFiles(): string[] { + const out: string[] = []; + const walk = (dir: string): void => { + for (const entry of readdirSync(dir)) { + const full = join(dir, entry); + if (statSync(full).isDirectory()) walk(full); + else if (entry.endsWith(".ts")) out.push(relative(CLI_ROOT, full)); + } + }; + walk(SRC); + return out.sort(); +} + +export function read(relativePath: string): string { + return readFileSync(join(CLI_ROOT, relativePath), "utf8"); +} + +export function readFromRepoRoot(relativePath: string): string { + return readFileSync(join(REPO_ROOT, relativePath), "utf8"); +} + +export function pluginReadmes(): string[] { + const pluginsDir = join(REPO_ROOT, "plugins"); + const found: string[] = []; + for (const entry of readdirSync(pluginsDir, { withFileTypes: true })) { + if (!entry.isDirectory()) continue; + const readme = join(pluginsDir, entry.name, "README.md"); + if (existsSync(readme)) found.push(join("plugins", entry.name, "README.md")); + } + if (found.length === 0) { + throw new Error("no plugin README found — the scope of this rule is stale"); + } + return found.sort(); +} + +/** + * Every way one file names another: `from "./x.js"`, a bare side-effect `import "./x.js"`, + * and the `import("./x.js")` type expression. A second extractor anywhere in this directory + * would disagree with this one and hide a real dependency. The `@/` alias resolves to `src/` + * and is handled although nothing uses it today, so using it takes no file out of sight. + */ +export const INTERNAL_IMPORT = /(?:from|import)\s*\(?\s*["'](\.[^"']+|@\/[^"']+)["']/g; + +function resolveImportTarget(file: string, specifier: string): string { + return ( + specifier.startsWith("@/") + ? `src/${specifier.slice(2)}` + : normalize(join(dirname(file), specifier)) + ).replace(/\.js$/, ".ts"); +} + +/** Side-effect imports (`import "./x.js"`) count: that is how tools register themselves. */ +export function importersByFile(): Map> { + const files = sourceFiles(); + const known = new Set(files); + const importers = new Map>(); + for (const file of files) { + const text = read(file); + for (const match of text.matchAll(INTERNAL_IMPORT)) { + const target = resolveImportTarget(file, match[1] as string); + if (!known.has(target)) continue; + const set = importers.get(target) ?? new Set(); + set.add(file); + importers.set(target, set); + } + } + return importers; +} + +/** The baseline may only shrink: a new violation fails, and so does removing one without + * taking it out of the baseline. */ +export function expectRatchet( + current: readonly string[], + baseline: readonly string[] +): { added: string[]; fixed: string[] } { + const base = new Set(baseline); + const now = new Set(current); + return { + added: current.filter((entry) => !base.has(entry)).sort(), + fixed: baseline.filter((entry) => !now.has(entry)).sort(), + }; +} + +/** + * The subset of glob syntax this suite declares: a literal prefix, `/**\/` for any number of + * directories *including none* — `src/kernel/**\/*.ts` must match `src/kernel/errors.ts` too, + * or a scope silently covers only its subdirectories — and `*` within one segment. + */ +export function matchesGlob(glob: string, path: string): boolean { + const pattern = glob + .split("/**/") + .map((segment) => segment.split("*").map(escapeGlobLiteral).join("[^/]*")) + .join("/(?:.*/)?"); + return new RegExp(`^${pattern}$`).test(path); +} + +function escapeGlobLiteral(literal: string): string { + return literal.replace(/[.+?^${}()|[\]\\]/g, "\\$&"); +} + +// The context graph: single source of truth for which context may import which. It lives here +// so `biome-context-parity.arch.test.ts` compares biome's own per-context `noRestrictedImports` +// overrides against this same data rather than a hand-copied list that only looks like it agrees. + +export function contextNames(): string[] { + return readdirSync(join(SRC, "contexts"), { withFileTypes: true }) + .filter((entry) => entry.isDirectory()) + .map((entry) => entry.name) + .sort(); +} + +/** Exactly these edges between contexts, plus every context to the kernel. `framework → tools` + * is among them because framework installs for a tool and must name it. */ +export const ALLOWED = new Set([ + "framework->translate", + "framework->tools", + "framework->distribution", + "translate->tools", + // Measurement asks a tool what it declares; a tool declares nothing about measurement in + // return, since the vocabulary both speak sits in `kernel/measurement.ts`. + "telemetry->tools", +]); + +/** + * Edges the chain forbids and the tree still has. The list may only shrink, and each entry + * carries its measured weight: an edge alone could absorb any number of imports in silence. + */ +export const BASELINE: readonly { + readonly edge: string; + readonly imports: number; + readonly files: number; +}[] = [ + // `marketplace add --overwrite` removes before it adds, and removing deletes installed + // plugin files — framework work the calling side, not distribution, should orchestrate. + { edge: "distribution->framework", imports: 1, files: 1 }, + // The http client, the git token injection, the user-config directory and `atomicWriteFile` + // are concrete: a real dependency on runtime, resolved by inverting them into ports. + { edge: "distribution->runtime", imports: 6, files: 3 }, + // Three framework orchestrators still name the prompt classes they are handed, type-only. + // Inverting them into a port is a design change, so the edge is measured rather than moved. + { edge: "framework->presentation", imports: 4, files: 3 }, + // Token provider, platform and latest-release resolver: interfaces a context may depend on, + // sitting in the wrong place — a port two contexts need belongs in the kernel. Nothing + // concrete crosses here. + { edge: "framework->runtime", imports: 8, files: 7 }, +]; + +export function contextOf(file: string): string { + const inContext = /^src\/contexts\/([^/]+)\//.exec(file); + if (inContext) return inContext[1] as string; + if (file.startsWith("src/kernel/")) return "kernel"; + if (file.startsWith("src/presentation/")) return "presentation"; + if (file.startsWith("src/runtime/")) return "runtime"; + return "outside"; +} + +/** A layer a context may not depend on: the arrows run towards the kernel, never back. */ +export const BELOW_NOTHING = new Set(["presentation", "runtime"]); + +export function isContext(name: string): boolean { + return !BELOW_NOTHING.has(name) && name !== "kernel" && name !== "outside"; +} + +interface Crossing { + readonly file: string; + readonly from: string; + readonly to: string; + /** `domain`, `application` or `infrastructure` — null when `file` is not itself under a + * context's own layer split (kernel, presentation, runtime). */ + readonly layer: "domain" | "application" | "infrastructure" | null; +} + +const LAYER_OF_FILE = /^src\/contexts\/[^/]+\/(domain|application|infrastructure)\//; + +/** The one walk every edge-shaped rule in this directory builds on. */ +function crossings(files: readonly string[]): Crossing[] { + const out: Crossing[] = []; + for (const file of files) { + const from = contextOf(file); + const source = read(file); + const layerMatch = LAYER_OF_FILE.exec(file); + for (const match of source.matchAll(INTERNAL_IMPORT)) { + const target = resolveImportTarget(file, match[1] as string); + const to = contextOf(target); + if (from === to || to === "kernel" || from === "outside" || to === "outside") continue; + // presentation and runtime may reach down; only the reverse is an edge worth naming. + if (BELOW_NOTHING.has(from)) continue; + out.push({ file, from, to, layer: (layerMatch?.[1] as Crossing["layer"]) ?? null }); + } + } + return out; +} + +export function weighedEdges( + files: readonly string[] +): Map { + const found = new Map }>(); + for (const crossing of crossings(files)) { + const edge = `${crossing.from}->${crossing.to}`; + const weight = found.get(edge) ?? { imports: 0, files: new Set() }; + weight.imports += 1; + weight.files.add(crossing.file); + found.set(edge, weight); + } + return new Map( + [...found].map(([edge, weight]) => [ + edge, + { imports: weight.imports, files: weight.files.size }, + ]) + ); +} + +export function edgesBetweenContexts(files: readonly string[]): string[] { + return [...weighedEdges(files).keys()].sort(); +} + +/** For each baselined edge, the layers of its `from` context that carry it — derived from the + * same walk, so a debt file moving layer shows up without anyone updating a list. */ +export function baselineLayers(files: readonly string[]): Map> { + const baselineEdges = new Set(BASELINE.map((entry) => entry.edge)); + const layers = new Map>(); + for (const crossing of crossings(files)) { + const edge = `${crossing.from}->${crossing.to}`; + if (!baselineEdges.has(edge) || crossing.layer === null) continue; + const set = layers.get(edge) ?? new Set(); + set.add(crossing.layer); + layers.set(edge, set); + } + return layers; +} + +/** + * Every invocation the CLI declares: a top-level verb, and each `noun verb` pair. The pair + * matters — `aidd plugin marketplace add` passes any check reading only the first word. + */ +export function declaredCommands(): Set { + const declared = new Set(); + for (const file of sourceFiles().filter((f) => f.startsWith("src/presentation/commands/"))) { + const source = read(file); + // `program.command("noun")` names a parent; every other `.command("verb")` in that file + // is one of its subcommands. + const parent = /program\s*\n?\s*\.?command\("([a-z][a-z-]*)"/.exec(source)?.[1]; + for (const match of source.matchAll(/\.command\("([a-z][a-z-]*)/g)) { + declared.add(match[1] as string); + if (parent !== undefined && match[1] !== parent) declared.add(`${parent} ${match[1]}`); + } + } + // An empty set would clear every document at once: nothing can be undeclared when nothing + // is declared. + if (declared.size === 0) throw new Error("no command found — the scope of this rule is stale"); + return declared; +} + +/** A placeholder, or a word a path continues right past: `aidd sync rules/naming.md` names a + * file, and the `/` right after it is the tell no placeholder syntax gives. */ +function isArgumentLike(word: string, trailing: string): boolean { + return word.startsWith("<") || word.startsWith("[") || trailing.startsWith("/"); +} + +const INSTRUCTED_COMMAND = /\baidd ([a-z][a-z-]*)(?: ([a-z][a-z-]*)([^\s`]*))?/g; + +/** `aidd ` or `aidd ` as it appears inside text, resolved against a + * pair-aware declared set. */ +export function unresolvedCommandMentions(text: string, declared: ReadonlySet): string[] { + const missing: string[] = []; + for (const match of text.matchAll(INSTRUCTED_COMMAND)) { + const [, first, second, trailing] = match; + // A bare verb needs only itself declared — `aidd setup --ai` reads as one, a flag being + // no word this pattern captures. A pair needs the pair. + if (second === undefined) { + if (!declared.has(first as string)) missing.push(first as string); + continue; + } + if (declared.has(`${first} ${second}`)) continue; + // A declared verb followed by something else is that verb plus an argument: + // `aidd marketplace add` is a pair, `aidd update --force` is not. + if (declared.has(first as string) && isArgumentLike(second, trailing ?? "")) continue; + missing.push(`${first} ${second}`); + } + return missing; +} diff --git a/cli/tests/architecture/import-rules-bite.arch.test.ts b/cli/tests/architecture/import-rules-bite.arch.test.ts new file mode 100644 index 000000000..e110e2942 --- /dev/null +++ b/cli/tests/architecture/import-rules-bite.arch.test.ts @@ -0,0 +1,79 @@ +/** + * A biome `noRestrictedImports` pattern is a guard only while the directory it names exists; + * one naming a deleted path reads as a boundary and forbids nothing. This cannot prove a rule + * forbids the right thing, only that it can still forbid anything at all. + */ +import { describe, expect, it } from "vitest"; +import { read, sourceFiles } from "./helpers.js"; + +interface RestrictedPattern { + readonly override: string; + readonly pattern: string; +} + +function restrictedPatterns(): RestrictedPattern[] { + const config = JSON.parse(read("biome.json")) as { + overrides?: readonly { + includes?: readonly string[]; + linter?: { + rules?: { + style?: { + noRestrictedImports?: { + options?: { patterns?: readonly { group?: readonly string[] }[] }; + }; + }; + }; + }; + }[]; + }; + const out: RestrictedPattern[] = []; + for (const override of config.overrides ?? []) { + const scope = (override.includes ?? []).join(", "); + const groups = override.linter?.rules?.style?.noRestrictedImports?.options?.patterns ?? []; + for (const { group } of groups) { + for (const pattern of group ?? []) out.push({ override: scope, pattern }); + } + } + return out; +} + +/** + * The literal part of a glob: what biome must find in an import specifier for it to match. + * `**` + `/application/**` yields `application/`, `../../domain/ports/**` yields `domain/ports/`. + */ +function literalCore(pattern: string): string { + const core = pattern + .replace(/^(\.\.\/)+/, "") + .replace(/^\*\*\//, "") + .replace(/\/\*\*$/, "/"); + return core.startsWith("/") ? core.slice(1) : core; +} + +function matchesSomething(core: string, paths: readonly string[]): boolean { + const needle = core.endsWith("/") ? core : `${core.replace(/\.js$/, ".ts")}`; + return paths.some((path) => `${path}/`.includes(`/${needle}`)); +} + +describe("import rules still bite", () => { + it("no restricted-import pattern names a path the refactor deleted", () => { + const paths = sourceFiles(); + const dead = restrictedPatterns() + .filter(({ pattern }) => !matchesSomething(literalCore(pattern), paths)) + .map(({ override, pattern }) => `${override}: ${pattern}`); + + expect( + dead, + "pattern matches nothing under src/ — the rule it belongs to forbids nothing" + ).toEqual([]); + }); +}); + +describe("the guard itself", () => { + it("checks a real rule and flags a deleted one", () => { + const paths = ["src/contexts/translate/domain/canon.ts", "src/runtime/wiring/translate.ts"]; + expect(matchesSomething(literalCore("**/runtime/**"), paths)).toBe(true); + expect(matchesSomething(literalCore("**/application/use-cases/**"), paths)).toBe(false); + expect(literalCore("../../../domain/ports/**")).toBe("domain/ports/"); + expect(literalCore("**/manifest.js")).toBe("manifest.js"); + }); +}); diff --git a/cli/tests/architecture/mutation-covers-source.arch.test.ts b/cli/tests/architecture/mutation-covers-source.arch.test.ts new file mode 100644 index 000000000..d2d1df59b --- /dev/null +++ b/cli/tests/architecture/mutation-covers-source.arch.test.ts @@ -0,0 +1,230 @@ +/** + * A file escaping mutation is silent: the score does not drop, because the mutants that would + * have died were never generated. `mutation-scopes.json` declares the globs, the floor each + * scope must hold, and what is left out, so a directory belonging to neither fails by name. + */ +import { existsSync } from "node:fs"; +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; +import { HARNESS, scopesToRun } from "../../scripts/mutation-scopes-to-run.mjs"; +import { + breakVerdict, + pruneIncremental, + scoreOf, + strykerArgs, +} from "../../scripts/run-mutation.mjs"; +import { matchesGlob, REPO_ROOT, read, sourceFiles } from "./helpers.js"; + +interface Scope { + readonly mutate: string | readonly string[]; + readonly break: number; +} + +interface ScopeDeclaration { + readonly scopes: Readonly>; + readonly excluded: Readonly>; +} + +function declaration(): ScopeDeclaration { + return JSON.parse(read("mutation-scopes.json")) as ScopeDeclaration; +} + +function scopeMatches(mutate: Scope["mutate"], path: string): boolean { + const globs = [mutate].flat(); + return ( + globs.some((glob) => !glob.startsWith("!") && matchesGlob(glob, path)) && + !globs.some((glob) => glob.startsWith("!") && matchesGlob(glob.slice(1), path)) + ); +} + +function isCovered(path: string, { scopes, excluded }: ScopeDeclaration): boolean { + return ( + Object.values(scopes).some((scope) => scopeMatches(scope.mutate, path)) || + Object.keys(excluded).some((glob) => matchesGlob(glob, path)) + ); +} + +describe("mutation covers every source file", () => { + it("no file under src/ falls outside both the scopes and the exclusions", () => { + const declared = declaration(); + const uncovered = sourceFiles().filter((file) => !isCovered(file, declared)); + + expect( + uncovered, + "neither mutated nor excluded — add it to a scope in mutation-scopes.json, or exclude it with the reason" + ).toEqual([]); + }); + + it("every exclusion carries a reason, and every scope matches something", () => { + const { scopes, excluded } = declaration(); + const files = sourceFiles(); + + for (const [glob, reason] of Object.entries(excluded)) { + expect(reason.length, `${glob} is excluded with no reason given`).toBeGreaterThan(40); + expect( + files.some((file) => matchesGlob(glob, file)), + `${glob} excludes nothing — the directory it names is gone` + ).toBe(true); + } + for (const [name, { mutate }] of Object.entries(scopes)) { + expect( + files.some((file) => scopeMatches(mutate, file)), + `scope "${name}" (${mutate}) matches no file — it would score an empty set` + ).toBe(true); + } + }); + + it("no source file is mutated by two scopes, so a mutant is measured once", () => { + const { scopes } = declaration(); + const twice = sourceFiles().filter( + (file) => Object.values(scopes).filter((scope) => scopeMatches(scope.mutate, file)).length > 1 + ); + expect(twice, "narrow one scope with a ! glob").toEqual([]); + }); + + it("every scope declares the floor its score must hold", () => { + for (const [name, scope] of Object.entries(declaration().scopes)) { + expect( + Number.isInteger(scope.break) && scope.break > 0 && scope.break <= 100, + `scope "${name}" declares no break floor — a run cannot fail` + ).toBe(true); + } + }); + + it("package.json runs every scope, and nothing it does not", () => { + const { scripts } = JSON.parse(read("package.json")) as { scripts: Record }; + const scripted = Object.keys(scripts) + .filter((name) => name.startsWith("test:mutation:")) + .map((name) => name.slice("test:mutation:".length)); + + expect(scripted.sort()).toEqual(Object.keys(declaration().scopes).sort()); + }); + + it("no other file lists what mutation covers", () => { + const stryker = JSON.parse(read("stryker.conf.json")) as Record; + expect("mutate" in stryker, "stryker.conf.json declares its own mutate again").toBe(false); + }); +}); + +describe("the guard itself", () => { + const scopes = { + kernel: { mutate: "src/kernel/**/*.ts", break: 70 }, + tools: { mutate: "src/contexts/tools/**/*.ts", break: 60 }, + }; + + it("matches a path inside a glob and rejects one outside it", () => { + expect(matchesGlob("src/kernel/**/*.ts", "src/kernel/ports/logger.ts")).toBe(true); + expect(matchesGlob("src/kernel/**/*.ts", "src/kernel/tool.ts")).toBe(true); + expect(matchesGlob("src/kernel/**/*.ts", "src/contexts/tools/domain/registry.ts")).toBe(false); + expect(matchesGlob("src/cli.ts", "src/cli.ts")).toBe(true); + expect(matchesGlob("src/cli.ts", "src/clints.ts")).toBe(false); + }); + + it("lets a ! glob carve a file out of a scope, and joins a list for stryker", () => { + const split = { + tools: { + mutate: ["src/contexts/tools/**/*.ts", "!src/contexts/tools/domain/profiles/**/*.ts"], + break: 1, + }, + profiles: { mutate: "src/contexts/tools/domain/profiles/**/*.ts", break: 1 }, + }; + expect(scopeMatches(split.tools.mutate, "src/contexts/tools/domain/registry.ts")).toBe(true); + expect( + scopeMatches(split.tools.mutate, "src/contexts/tools/domain/profiles/claude/profile.ts") + ).toBe(false); + expect( + scopeMatches(split.profiles.mutate, "src/contexts/tools/domain/profiles/claude/profile.ts") + ).toBe(true); + expect(strykerArgs("tools", split)).toContain( + "src/contexts/tools/**/*.ts,!src/contexts/tools/domain/profiles/**/*.ts" + ); + expect( + scopesToRun(["cli/src/contexts/tools/domain/profiles/claude/profile.ts"], split).sort() + ).toEqual(["profiles", "tools"]); + }); + + it("reports a file no scope and no exclusion names, and clears one a scope covers", () => { + const declared = { scopes, excluded: { "src/cli.ts": "the entry point" } }; + expect(isCovered("src/kernel/paths.ts", declared)).toBe(true); + expect(isCovered("src/cli.ts", declared)).toBe(true); + expect(isCovered("src/contexts/framework/domain/manifest.ts", declared)).toBe(false); + }); + + it("gives each scope its own glob and incremental file, and refuses a scope nobody declared", () => { + expect(strykerArgs("kernel", scopes)).toEqual([ + "run", + "--mutate", + "src/kernel/**/*.ts", + "--incremental", + "--incrementalFile", + "reports/mutation/kernel/incremental.json", + ]); + expect(strykerArgs("tools", scopes, { force: true })).toContain("--force"); + expect(() => strykerArgs("nowhere", scopes)).toThrow('Unknown scope "nowhere"'); + }); + + it("scores detected over detected plus undetected, leaves ignored and errored aside, and an empty report as zero", () => { + const report = { + files: { + "a.ts": { + mutants: [ + { status: "Killed" }, + { status: "Timeout" }, + { status: "Survived" }, + { status: "NoCoverage" }, + { status: "Ignored" }, + { status: "RuntimeError" }, + { status: "CompileError" }, + ], + }, + }, + }; + expect(scoreOf(report)).toBe(50); + expect(scoreOf({ files: {} })).toBe(0); + }); + + it("runs the scopes a change touches, through source or mirrored tests, and all of them for the harness or a helper", () => { + expect(scopesToRun(["cli/src/kernel/paths.ts"], scopes)).toEqual(["kernel"]); + expect( + scopesToRun(["cli/tests/contexts/tools/domain/registry-conformance.unit.test.ts"], scopes) + ).toEqual(["tools"]); + expect(scopesToRun(["README.md"], scopes)).toEqual([]); + expect(scopesToRun(["cli/tests/helpers/repository-root.ts"], scopes)).toEqual([ + "kernel", + "tools", + ]); + expect(scopesToRun([HARNESS[0]], scopes)).toEqual(["kernel", "tools"]); + expect(scopesToRun([], scopes, { all: true })).toEqual(["kernel", "tools"]); + }); + + it("names every harness file the tree holds, so a renamed one cannot silently stop counting", () => { + for (const file of HARNESS) { + expect( + existsSync(join(REPO_ROOT, file)), + `${file} is named in HARNESS but is not there` + ).toBe(true); + } + }); + + it("carries only a kill forward, so a test written later reaches what survived, went uncovered or is static", () => { + const pruned = pruneIncremental({ + files: { + "a.ts": { + mutants: [ + { status: "Killed" }, + { status: "Timeout" }, + { status: "Killed", static: true }, + { status: "Survived" }, + { status: "NoCoverage" }, + ], + }, + }, + }); + expect(pruned.files?.["a.ts"]?.mutants).toEqual([{ status: "Killed" }, { status: "Timeout" }]); + }); + + it("fails a score under the floor and passes one on it", () => { + expect(breakVerdict(69.9, scopes.kernel)).toMatch(/below the 70/); + expect(breakVerdict(70, scopes.kernel)).toBeNull(); + }); +}); diff --git a/cli/tests/architecture/no-re-export.arch.test.ts b/cli/tests/architecture/no-re-export.arch.test.ts new file mode 100644 index 000000000..df6a1d4a3 --- /dev/null +++ b/cli/tests/architecture/no-re-export.arch.test.ts @@ -0,0 +1,40 @@ +/** + * Biome sees neither form: `noBarrelFile` only sees a file that does nothing but re-export and + * `noReExportAll` only `export *`. A module re-exporting a name it does not own becomes a + * second source of truth for it, which is what makes a hub. + */ +import { describe, expect, it } from "vitest"; +import { expectRatchet, read, sourceFiles } from "./helpers.js"; + +/** `export … from "…"` — a re-export written in one statement. */ +const INLINE_RE_EXPORT = /^export\s+(?:type\s+)?(?:\*|\{[^}]*\})\s*(?:as\s+\w+\s*)?from\s+["']/m; + +/** `export { X };` or `export type { X };` — re-exporting a name imported above. */ +const BARE_RE_EXPORT = /^export\s+(?:type\s+)?\{[^}]*\};$/m; + +/** Files re-exporting a symbol they do not define. This list may only shrink. */ +const BASELINE: string[] = []; + +function reExports(source: string): boolean { + return INLINE_RE_EXPORT.test(source) || BARE_RE_EXPORT.test(source); +} + +describe("no module re-exports another module's symbol", () => { + it("every symbol is imported from the module that defines it", () => { + const violations = sourceFiles().filter((file) => reExports(read(file))); + + const { added, fixed } = expectRatchet(violations, BASELINE); + expect(added, "re-export — import the symbol from its source instead").toEqual([]); + expect(fixed, "fixed — remove these from BASELINE").toEqual([]); + }); +}); + +describe("the guard itself", () => { + it("flags both re-export forms and clears a plain import", () => { + expect(reExports('export { thing } from "./thing.js";')).toBe(true); + expect(reExports('import { thing } from "./thing.js";\nexport { thing };')).toBe(true); + expect( + reExports('import { thing } from "./thing.js";\nexport function use() { return thing; }') + ).toBe(false); + }); +}); diff --git a/cli/tests/architecture/no-shared-binary.arch.test.ts b/cli/tests/architecture/no-shared-binary.arch.test.ts new file mode 100644 index 000000000..caae270cc --- /dev/null +++ b/cli/tests/architecture/no-shared-binary.arch.test.ts @@ -0,0 +1,52 @@ +/** + * The build cleans its output directory, so a second concurrent run rewrites the binary a + * first run's golden suites are reading mid-capture. `tests/e2e/global-setup.ts` builds a + * private binary per run instead. The scope is `tests/` alone: `scripts/` reads the shipped + * binary on purpose, and builds before it reads. + */ +import { readdirSync, readFileSync, statSync } from "node:fs"; +import { join, relative, resolve } from "node:path"; +import { describe, expect, it } from "vitest"; + +const CLI_ROOT = resolve(import.meta.dirname, "..", ".."); +const TESTS_ROOT = join(CLI_ROOT, "tests"); + +/** `resolve(process.cwd(), "dist...")` or `join(process.cwd(), "dist...")` — the bug. */ +const CWD_INTO_DIST = /(?:resolve|join)\(\s*process\.cwd\(\)\s*,\s*["'`]dist(?:\/|["'`])/; + +function testFiles(): string[] { + const out: string[] = []; + const walk = (dir: string): void => { + for (const entry of readdirSync(dir)) { + const full = join(dir, entry); + if (statSync(full).isDirectory()) walk(full); + else if (entry.endsWith(".ts")) out.push(full); + } + }; + walk(TESTS_ROOT); + return out; +} + +describe("no test resolves a path into the shared dist/ build output", () => { + it("every file under tests/ reads the e2e run's own binary, not dist/cli.js", () => { + const violations = testFiles() + .filter((file) => CWD_INTO_DIST.test(readFileSync(file, "utf8"))) + .map((file) => relative(CLI_ROOT, file)); + + expect( + violations, + "resolves into the shared dist/ — read cliPath() from tests/e2e/helpers.ts instead" + ).toEqual([]); + }); +}); + +describe("the guard itself", () => { + it("flags process.cwd() resolved into dist/, not an unrelated temp dist dir", () => { + // Built from two pieces so this file's own text never carries the literal the rule + // above forbids, which would trip it on itself. + const violation = `resolve(process.cwd(), "di${""}st/cli.js")`; + expect(CWD_INTO_DIST.test(violation)).toBe(true); + expect(CWD_INTO_DIST.test('join(tempDir, "dist")')).toBe(false); + expect(CWD_INTO_DIST.test('expect(content).toContain("dist/")')).toBe(false); + }); +}); diff --git a/cli/tests/architecture/orchestrator-deps.arch.test.ts b/cli/tests/architecture/orchestrator-deps.arch.test.ts new file mode 100644 index 000000000..b58ffe356 --- /dev/null +++ b/cli/tests/architecture/orchestrator-deps.arch.test.ts @@ -0,0 +1,195 @@ +/** + * A constructor listing many collaborators is the signal that an orchestration reaches + * inside the areas it crosses instead of asking their entry points. + */ +import { describe, expect, it } from "vitest"; +import { expectRatchet, read, sourceFiles } from "./helpers.js"; + +/** Above this, an orchestrator is reaching inside the areas it crosses. */ +const MAX_INJECTED_USE_CASES = 4; + +/** + * Orchestrators over the limit today, each with the count its reason was written around. + * The list may only shrink, and an entry naming only the file would let one grow in silence. + */ +const BASELINE: readonly { readonly path: string; readonly injected: number }[] = [ + // Six checks, one per thing that can drift, reported at once: the fan-out is the feature. + // It resolves by giving each check a result type, not by removing a check. + { path: "src/contexts/framework/application/doctor/doctor-use-case.ts", injected: 7 }, + // `setup` brings a project from nothing to correct, so it names each stage: registration, + // tool install, plugin prompt, settings sync, version, plus the machine-scope handoff. + { path: "src/contexts/framework/application/setup-use-case.ts", injected: 11 }, + // Five section generators reached inline through a switch on section name, plus the config + // generator. It resolves by registering a generator per section type, not by dropping one. + { + path: "src/contexts/framework/application/restore/generate-tool-distribution-use-case.ts", + injected: 9, + }, + { + path: "src/contexts/framework/application/flows/marketplace-sync-settings-use-case.ts", + injected: 12, + }, + { path: "src/contexts/framework/application/restore/restore-use-case.ts", injected: 12 }, + // Carries `hostPluginRegistries`, the host's own registry reader per `AiToolId`, so the + // scope asked for when uninstalling a ref is the one the host actually registered it at. + { path: "src/contexts/framework/application/clean-use-case.ts", injected: 11 }, + // The machine-scope counterpart of `clean-use-case.ts`, same shape and same reason. + { + path: "src/contexts/framework/application/clean/clean-user-scope-use-case.ts", + injected: 10, + }, + { path: "src/contexts/telemetry/application/diagnose-telemetry-use-case.ts", injected: 10 }, + { + path: "src/contexts/framework/application/restore/restore-tool-files-use-case.ts", + injected: 9, + }, + { + path: "src/contexts/framework/application/shared/setup-marketplace-registration-use-case.ts", + injected: 10, + }, + { path: "src/contexts/framework/application/plugin/plugin-add-use-case.ts", injected: 8 }, + { path: "src/contexts/translate/application/strategies/flat-build-strategy.ts", injected: 8 }, + { + path: "src/contexts/framework/application/doctor/doctor-registration-use-case.ts", + injected: 7, + }, + { path: "src/contexts/telemetry/application/read-local-cost-use-case.ts", injected: 7 }, + { path: "src/contexts/telemetry/application/report-cost-use-case.ts", injected: 7 }, + { path: "src/contexts/framework/application/install/install-ide-tool-use-case.ts", injected: 6 }, + { path: "src/contexts/framework/application/plugin/plugin-install-use-case.ts", injected: 7 }, + { path: "src/contexts/framework/application/plugin/plugin-update-use-case.ts", injected: 6 }, + { + path: "src/contexts/framework/application/restore/restore-all-plugins-use-case.ts", + injected: 6, + }, + { path: "src/contexts/distribution/application/marketplace-add-use-case.ts", injected: 5 }, + { path: "src/contexts/distribution/application/marketplace-refresh-use-case.ts", injected: 5 }, + { + path: "src/contexts/framework/application/framework/translator/built-tree-materialization-translator.ts", + injected: 5, + }, + { path: "src/contexts/framework/application/global/update-one-tool-use-case.ts", injected: 5 }, + { path: "src/contexts/framework/application/install/install-ai-tool-use-case.ts", injected: 5 }, + { + path: "src/contexts/framework/application/install/install-ide-config-use-case.ts", + injected: 5, + }, + { + path: "src/contexts/framework/application/install/install-runtime-config-use-case.ts", + injected: 5, + }, + { + path: "src/contexts/framework/application/plugin/plugin-install-from-marketplace-use-case.ts", + injected: 5, + }, + { path: "src/contexts/framework/application/shared/apply-plugin-files-use-case.ts", injected: 5 }, + { + path: "src/contexts/framework/application/shared/ensure-built-marketplace-use-case.ts", + injected: 5, + }, + { path: "src/contexts/telemetry/application/telemetry-on-use-case.ts", injected: 5 }, + { + path: "src/contexts/translate/application/strategies/marketplace-build-strategy.ts", + injected: 5, + }, + { path: "src/contexts/translate/application/translate-source.ts", injected: 5 }, + // Carries `clean`'s own shared-source guard as well, so `plugin remove` in one project + // cannot disable a plugin another project on the same machine still needs. + { path: "src/contexts/framework/application/plugin/plugin-remove-use-case.ts", injected: 7 }, +]; + +/** + * Every constructor parameter counts, required or optional and whatever its type is named, + * plus each `new XUseCase(...)` a method reaches for, deduped by class name within each. + */ +function injectedUseCaseCount(source: string): number { + const signature = /constructor\((.*?)\)\s*\{/s.exec(source); + const paramCount = signature + ? [...signature[1].matchAll(/private readonly \w+\??:\s*[\w<>[\]| ]+/g)].length + : 0; + const inlineNames = new Set([...source.matchAll(/new (\w+UseCase)\(/g)].map((m) => m[1])); + return paramCount + inlineNames.size; +} + +function isUseCase(file: string): boolean { + return /^src\/contexts\/[^/]+\/application\//.test(file); +} + +function overLimit(source: string): boolean { + return injectedUseCaseCount(source) > MAX_INJECTED_USE_CASES; +} + +describe("orchestrators depend on entry points, not on parts", () => { + it(`no use case injects more than ${MAX_INJECTED_USE_CASES} other use cases`, () => { + const candidates = sourceFiles().filter(isUseCase); + // A rule that selects nothing passes forever: this filter named a tree the files left. + expect(candidates.length, "the rule selects no file — its scope is stale").toBeGreaterThan(20); + const violations = candidates.filter((file) => overLimit(read(file))); + + const { added, fixed } = expectRatchet( + violations, + BASELINE.map((entry) => entry.path) + ); + expect(added, "orchestrator reaching inside the areas it crosses").toEqual([]); + expect(fixed, "fixed — remove these from BASELINE").toEqual([]); + }); + + it("holds each admitted orchestrator to the count its reason was written around", () => { + const recorded = BASELINE.map(({ path, injected }) => `${path}: ${injected}`); + const actual = BASELINE.map(({ path }) => `${path}: ${injectedUseCaseCount(read(path))}`); + + expect( + actual, + "an admitted orchestrator took on another collaborator — fix the count and its reason" + ).toEqual(recorded); + }); +}); + +describe("the guard itself", () => { + it("flags a constructor past the limit and clears one sitting at the limit", () => { + const over = `constructor( + ${Array.from({ length: MAX_INJECTED_USE_CASES + 1 }, (_, i) => `private readonly a${i}: FooUseCase,`).join("\n")} + ) {}`; + const atLimit = `constructor( + ${Array.from({ length: MAX_INJECTED_USE_CASES }, (_, i) => `private readonly a${i}: FooUseCase,`).join("\n")} + ) {}`; + + expect(overLimit(over)).toBe(true); + expect(overLimit(atLimit)).toBe(false); + }); + + it("counts an optional constructor dependency, not just a required one", () => { + const atLimit = `constructor( + ${Array.from({ length: MAX_INJECTED_USE_CASES }, (_, i) => `private readonly a${i}: FooUseCase,`).join("\n")} + ) {}`; + const withOptionalOverLimit = `constructor( + ${Array.from({ length: MAX_INJECTED_USE_CASES }, (_, i) => `private readonly a${i}: FooUseCase,`).join("\n")} + private readonly extra?: FooUseCase + ) {}`; + + expect(overLimit(atLimit)).toBe(false); + expect(overLimit(withOptionalOverLimit)).toBe(true); + }); + + it("counts a use case instantiated inline in a method body, not just an injected one", () => { + const atLimit = `constructor( + ${Array.from({ length: MAX_INJECTED_USE_CASES }, (_, i) => `private readonly a${i}: FooUseCase,`).join("\n")} + ) {} + method(): void { new BarUseCase(this.a0).execute(); }`; + + expect(overLimit(atLimit)).toBe(true); + }); + + it("counts a plain port too, not only a collaborator named or imported as a use case", () => { + const atLimit = `constructor( + ${Array.from({ length: MAX_INJECTED_USE_CASES }, (_, i) => `private readonly a${i}: FooUseCase,`).join("\n")} + ) {}`; + const withPlainPortOverLimit = `constructor( + ${Array.from({ length: MAX_INJECTED_USE_CASES }, (_, i) => `private readonly a${i}: FooUseCase,`).join("\n")} + private readonly extra?: ManifestRepository + ) {}`; + + expect(overLimit(atLimit)).toBe(false); + expect(overLimit(withPlainPortOverLimit)).toBe(true); + }); +}); diff --git a/cli/tests/architecture/ports-are-called.arch.test.ts b/cli/tests/architecture/ports-are-called.arch.test.ts new file mode 100644 index 000000000..c05f42afe --- /dev/null +++ b/cli/tests/architecture/ports-are-called.arch.test.ts @@ -0,0 +1,120 @@ +/** + * The shape of dead code knip cannot see: a port declares a method, an adapter implements it, + * both reference the name, and nobody checks a *caller* exists. Deliberately coarse — it asks + * whether `.someMethod(` appears anywhere outside the port's own file, so it proves only that + * nothing spells the name as a call, never that a real path reaches it. + */ +import { readdirSync, readFileSync, statSync } from "node:fs"; +import { join, relative, sep } from "node:path"; +import { describe, expect, it } from "vitest"; +import { CLI_ROOT, expectRatchet, SRC, sourceFiles } from "./helpers.js"; + +/** "ports" must be its own path segment: a substring check also matches `supports/` and + * `reports/`. */ +function portFiles(): string[] { + const out: string[] = []; + const walk = (dir: string): void => { + for (const entry of readdirSync(dir)) { + const full = join(dir, entry); + if (statSync(full).isDirectory()) walk(full); + else if (entry.endsWith(".ts") && /(^|\/)ports\//.test(full.split(sep).join("/"))) { + out.push(relative(CLI_ROOT, full)); + } + } + }; + walk(SRC); + return out.sort(); +} + +const INTERFACE_BLOCK = /^export interface (\w+) \{([\s\S]*?)\n\}/gm; +const METHOD_SIGNATURE = /^ {2}(\w+)\(/gm; + +function declaredMethods(file: string, source: string): string[] { + const declared: string[] = []; + for (const block of source.matchAll(INTERFACE_BLOCK)) { + for (const method of (block[2] as string).matchAll(METHOD_SIGNATURE)) { + declared.push(`${file} :: ${block[1]}.${method[1]}`); + } + } + return declared; +} + +/** Empty, and it stays empty: an uncalled port method is a missing caller or a dead + * declaration, and both are closed by fixing the code rather than recording it. */ +const BASELINE: readonly string[] = []; + +function uncalledMethods(bodies: ReadonlyMap, ports: readonly string[]): string[] { + const uncalled: string[] = []; + for (const port of ports) { + for (const declared of declaredMethods(port, bodies.get(port) ?? "")) { + const method = declared.slice(declared.lastIndexOf(".") + 1); + const called = [...bodies].some( + ([file, body]) => file !== port && new RegExp(`\\.${method}\\s*\\(`).test(body) + ); + if (!called) uncalled.push(declared); + } + } + return uncalled.sort(); +} + +describe("a port declares nothing nobody calls", () => { + it("every method a port declares is spelled as a call somewhere in src", () => { + const bodies = new Map( + sourceFiles().map((file) => [file, readFileSync(join(CLI_ROOT, file), "utf8")]) + ); + + const uncalled = uncalledMethods(bodies, portFiles()); + + const { added, fixed } = expectRatchet(uncalled, BASELINE); + expect( + added, + "a port declares a method nothing calls — an adapter implementing it is not a caller" + ).toEqual([]); + expect(fixed, "fixed — remove these from BASELINE").toEqual([]); + }); + + it("finds the ports of this codebase, so the rule cannot pass by selecting nothing", () => { + expect( + portFiles().length, + "no port file found — the scope of this rule is stale" + ).toBeGreaterThan(10); + }); +}); + +describe("the guard itself", () => { + it("names the port method nothing spells as a call, and clears the one a caller spells", () => { + const port = "src/kernel/ports/thing.ts"; + const bodies = new Map([ + [ + port, + ["export interface Thing {", " doIt(x: number): void;", " gone(): void;", "}"].join("\n"), + ], + ["src/runtime/caller.ts", "thing.doIt(1);"], + ]); + + expect(uncalledMethods(bodies, [port])).toEqual([`${port} :: Thing.gone`]); + }); + + it("does not read an adapter implementing a method as a caller of it", () => { + const port = "src/kernel/ports/thing.ts"; + const bodies = new Map([ + [port, ["export interface Thing {", " doIt(x: number): void;", "}"].join("\n")], + ["src/runtime/thing-adapter.ts", "class ThingAdapter { doIt(x: number): void {} }"], + ]); + + expect(uncalledMethods(bodies, [port])).toEqual([`${port} :: Thing.doIt`]); + }); + + it("reads a method off an interface block and ignores a property", () => { + const source = [ + "export interface Thing {", + " readonly name: string;", + " doIt(x: number): void;", + "}", + ].join("\n"); + + expect(declaredMethods("src/kernel/ports/thing.ts", source)).toEqual([ + "src/kernel/ports/thing.ts :: Thing.doIt", + ]); + }); +}); diff --git a/cli/tests/architecture/referenced-paths.arch.test.ts b/cli/tests/architecture/referenced-paths.arch.test.ts new file mode 100644 index 000000000..056446a03 --- /dev/null +++ b/cli/tests/architecture/referenced-paths.arch.test.ts @@ -0,0 +1,123 @@ +/** + * An instructing document is read by an agent about to change this codebase, so a path that + * moved sends the next change to a directory that is no longer there. Only prose is checked: + * a fenced block is where these documents show invented examples. + */ +import { readdirSync, readFileSync, statSync } from "node:fs"; +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; +import { CLI_ROOT, expectRatchet } from "./helpers.js"; + +const FENCED_BLOCK = /```[\s\S]*?```/g; + +/** + * A citation is rooted at the package (`src/…`, `tests/…`) or written relative to `src/`, + * naming a top-level area directly (`kernel/…`, `contexts/…`). A bare `application/` or + * `domain/` prefix is deliberately not matched: `application/json` is a media type and + * `domain/ports` names a fragment, so a wider regex reports more noise than drift. + */ +const CITED_PATH = + /\b(?:src|tests)\/[A-Za-z0-9_./-]+|\b(?:kernel|contexts|presentation|runtime)\/[A-Za-z0-9_./-]+/g; + +/** `src/`-relative citations resolve under `src/`; rooted ones resolve as written. */ +function resolveCitation(cited: string): string { + return cited.startsWith("src/") || cited.startsWith("tests/") ? cited : `src/${cited}`; +} + +/** Paths cited in prose that no longer exist. This list may only shrink. */ +const BASELINE: string[] = []; + +/** + * Where a cited path is an instruction rather than a record. `aidd_docs/tasks/` is absent on + * purpose: a finished plan describing the tree as it was is a record, and holding it true + * would forbid ever moving a file. + */ +const INSTRUCTING_SOURCES: readonly string[] = [ + ".claude/rules", + ".claude/skills", + "aidd_docs/memory", + "aidd_docs/GUIDELINES.md", + "ARCHITECTURE.md", + "README.md", + "vitest.config.ts", + "vitest.workspace.ts", + "knip.json", + "tsup.config.ts", + "stryker.conf.json", +]; + +function instructingFiles(): string[] { + const out: string[] = []; + const walk = (dir: string): void => { + for (const entry of readdirSync(dir)) { + const full = join(dir, entry); + if (statSync(full).isDirectory()) walk(full); + else out.push(full); + } + }; + for (const source of INSTRUCTING_SOURCES) { + const full = join(CLI_ROOT, source); + if (statSync(full).isDirectory()) walk(full); + else out.push(full); + } + return out; +} + +function statable(relativePath: string): boolean { + try { + statSync(join(CLI_ROOT, relativePath)); + return true; + } catch { + return false; + } +} + +/** A `.js` specifier is how ESM output names a `.ts` file, so a document citing one names a + * file that exists. */ +function exists(relativePath: string): boolean { + if (statable(relativePath)) return true; + return relativePath.endsWith(".js") && statable(relativePath.replace(/\.js$/, ".ts")); +} + +/** Every path a document instructs the reader to open, fenced examples excluded. */ +function citedInProse(text: string): string[] { + const prose = text.replace(FENCED_BLOCK, ""); + return ( + [...new Set(prose.match(CITED_PATH) ?? [])] + // Trailing punctuation belongs to the sentence, not the path: `src/kernel/.` and + // `src/kernel/` both name the directory. + .map((cited) => cited.replace(/[./]+$/, "")) + .filter((cited) => cited.includes("/")) + ); +} + +describe("every document that instructs names paths that exist", () => { + it("every path an instructing document names is still there", () => { + const dead = new Set(); + for (const file of instructingFiles()) { + for (const cited of citedInProse(readFileSync(file, "utf8"))) { + if (!exists(resolveCitation(cited))) dead.add(cited); + } + } + + const { added, fixed } = expectRatchet([...dead].sort(), BASELINE); + expect(added, "an instructing document names a path that no longer exists").toEqual([]); + expect(fixed, "fixed — remove these from BASELINE").toEqual([]); + }); +}); + +describe("the guard itself", () => { + it("reads instructions and ignores illustrations", () => { + const text = "Open `src/cli.ts`.\n\n```ts\n// src/domain/models/invented.ts\n```\n"; + expect(citedInProse(text)).toEqual(["src/cli.ts"]); + }); + + it("catches a dead path written the way the skills write it, without the src/ prefix", () => { + const cited = citedInProse("The primitives live in `kernel/gone.ts`."); + + expect(cited, "a bare top-level area is a citation too").toEqual(["kernel/gone.ts"]); + expect(resolveCitation("kernel/gone.ts")).toBe("src/kernel/gone.ts"); + expect(exists(resolveCitation("kernel/gone.ts")), "and it is checked, not skipped").toBe(false); + expect(exists(resolveCitation("kernel/errors.ts")), "a live one still passes").toBe(true); + }); +}); diff --git a/cli/tests/architecture/tests-reach-the-repository-through-one-helper.arch.test.ts b/cli/tests/architecture/tests-reach-the-repository-through-one-helper.arch.test.ts new file mode 100644 index 000000000..085f9d54c --- /dev/null +++ b/cli/tests/architecture/tests-reach-the-repository-through-one-helper.arch.test.ts @@ -0,0 +1,73 @@ +/** + * A test that climbs above cli/ by counting `../` or by `process.cwd()` reads the wrong tree + * the day the package is copied, which a mutation run does. `tests/helpers/repository-root.ts` + * is the one place that knows where the repository is. + */ +import { readdirSync, statSync } from "node:fs"; +import { join, relative } from "node:path"; +import { describe, expect, it } from "vitest"; +import { CLI_ROOT, read } from "./helpers.js"; + +const HELPER = "tests/helpers/repository-root.ts"; +const CLIMB = /["'`]((?:\.\.\/)+)[^"'`]*["'`]/g; +const CWD_PARENT = /process\.cwd\(\)\s*,\s*["']\.\.["']/g; +const PACKAGE_PARENT = /\b(?:resolve|join)\(\s*CLI_ROOT\s*,\s*["']\.\.["']/g; + +/** What a file does to leave cli/: a literal with more `../` than its depth, a `process.cwd()` + * joined to `..`, or the package root joined to `..`. */ +function climbsAboveCli(file: string, text: string): string[] { + const depth = file.split("/").length - 1; + const found: string[] = []; + for (const match of text.matchAll(CLIMB)) { + if (match[1].length / 3 > depth) found.push(match[0]); + } + for (const match of text.matchAll(CWD_PARENT)) found.push(match[0]); + for (const match of text.matchAll(PACKAGE_PARENT)) found.push(match[0]); + return found; +} + +/** The ratchets read the real checkout as text and never run against a copy, and their + * probes plant climbing literals on purpose. */ +const NEVER_COPIED = new Set(["architecture", "fixtures", "snapshots"]); + +function testFiles(): string[] { + const out: string[] = []; + const walk = (dir: string): void => { + for (const entry of readdirSync(dir)) { + const full = join(dir, entry); + if (statSync(full).isDirectory()) { + if (!NEVER_COPIED.has(entry)) walk(full); + } else if (entry.endsWith(".ts")) out.push(relative(CLI_ROOT, full).replace(/\\/g, "/")); + } + }; + walk(join(CLI_ROOT, "tests")); + return out.sort(); +} + +describe("tests reach the repository through one helper", () => { + it("no test or helper climbs above cli/ on its own", () => { + const offenders = testFiles() + .filter((file) => file !== HELPER) + .flatMap((file) => climbsAboveCli(file, read(file)).map((how) => `${file}: ${how}`)); + + expect(offenders, `import REPOSITORY_ROOT from ${HELPER} instead`).toEqual([]); + }); +}); + +describe("the guard itself", () => { + it("reports a literal climbing past the package, a cwd parent and the package root's parent, and clears a climb that stays inside", () => { + const file = "tests/contexts/a/b.unit.test.ts"; + expect( + climbsAboveCli( + file, + [ + 'import { x } from "../../../src/x.js";', + 'readFileSync(new URL("../../../../plugins/p/README.md", import.meta.url));', + 'resolve(process.cwd(), "..", "plugins");', + 'join(CLI_ROOT, "..")', + 'join(dir, "..")', + ].join("\n") + ) + ).toEqual(['"../../../../plugins/p/README.md"', 'process.cwd(), ".."', 'join(CLI_ROOT, ".."']); + }); +}); diff --git a/cli/tests/architecture/tool-addition-cost.arch.test.ts b/cli/tests/architecture/tool-addition-cost.arch.test.ts new file mode 100644 index 000000000..7c04de3de --- /dev/null +++ b/cli/tests/architecture/tool-addition-cost.arch.test.ts @@ -0,0 +1,184 @@ +/** + * A tool identifier may only appear in that tool's own profile and in the shared vocabulary. + * Everywhere else behaviour is read from the profile rather than branched on the name, or a + * sixth tool means editing N files again. Scope is `src/`: a test naming the tool it tests is + * not coupling. Comments are stripped first — prose about a tool's layout is documentation. + */ +import { describe, expect, it } from "vitest"; +import { expectRatchet, read, sourceFiles } from "./helpers.js"; + +/** The tools, from the tree: a hand-written list leaves the rule blind to the next tool. */ +function toolIds(files: readonly string[]): string[] { + const ids = new Set(); + for (const file of files) { + const match = /^src\/contexts\/tools\/domain\/profiles\/([^/]+)\//.exec(file); + if (match) ids.add(match[1] as string); + } + return [...ids].sort(); +} + +function isAllowed(file: string, ids: readonly string[]): boolean { + if (file === "src/kernel/tool.ts") return true; + return ids.some((id) => file.startsWith(`src/contexts/tools/domain/profiles/${id}/`)); +} + +const BLOCK_COMMENT = /\/\*[\s\S]*?\*\//g; +const LINE_COMMENT = /\/\/[^\n]*/g; + +function code(source: string): string { + return source.replace(BLOCK_COMMENT, "").replace(LINE_COMMENT, ""); +} + +/** + * Five forms force an edit: a quoted literal, an object key, a profile's import path, a + * dotfile directory literal (`".cursor"`, which the exact-match form misses), and a string + * enumerating two or more tools. A string naming one tool is not a list a sixth must join. + */ +function toolsNamedIn(source: string, ids: readonly string[]): string[] { + const alternation = ids.join("|"); + const named = new Set(); + const body = code(source); + const forms = [ + new RegExp(`["'\`](${alternation})["'\`]`, "g"), + new RegExp(`(?:^|[\\s{,(])(${alternation})\\s*:`, "gm"), + new RegExp(`["'][^"']*/(${alternation})/[^"']*["']`, "g"), + new RegExp(`["'\`]\\.(${alternation})["'\`]`, "g"), + ]; + for (const form of forms) { + for (const match of body.matchAll(form)) named.add(match[1] as string); + } + for (const literal of body.matchAll(/(["'`])((?:(?!\1).)*)\1/gs)) { + const inside = literal[2] as string; + const listed = ids.filter((id) => new RegExp(`\\b${id}\\b`).test(inside)); + if (listed.length >= 2) for (const id of listed) named.add(id); + } + return [...named].sort(); +} + +/** + * Files naming a tool outside its profile today, with how many they name; the list may only + * shrink, and a listed file may not take on another tool. Three are not debt: + * `tool-recommendations.ts`, `config-refs.ts` and `plugins-capability.ts`, each naming a tool + * for a reason no profile carries. The rest is registration and words shown to a user. + */ +const BASELINE: readonly { readonly path: string; readonly named: number }[] = [ + { path: "src/contexts/framework/domain/tool-recommendations.ts", named: 4 }, + { path: "src/contexts/tools/domain/capabilities/config-refs.ts", named: 1 }, + { path: "src/contexts/tools/domain/capabilities/plugins-capability.ts", named: 3 }, + { path: "src/presentation/commands/setup.ts", named: 2 }, + { path: "src/presentation/commands/translate.ts", named: 5 }, + { path: "src/presentation/prompts/menu-use-case.ts", named: 6 }, + { path: "src/runtime/assets/asset-loader.ts", named: 6 }, + { path: "src/runtime/wiring/framework.ts", named: 6 }, + { path: "src/runtime/wiring/tools.ts", named: 6 }, + { path: "src/runtime/wiring/translate.ts", named: 6 }, + // A profile cannot name the adapter reading its transcripts without putting infrastructure + // in the domain, so the tool-to-reader map lives at the composition root instead. + { path: "src/runtime/wiring/telemetry.ts", named: 4 }, + // Which file each host keeps its plugin registry in, called from the composition root + // alone; the reader classes beside it name no tool. + { + path: "src/contexts/tools/infrastructure/host-plugin-registry-reader-adapter.ts", + named: 3, + }, + // An adapter for exactly one tool, naming the binary it shells out to. A tool named in + // its own adapter is not a list a new tool joins — a new tool brings its own adapter. + { path: "src/contexts/telemetry/infrastructure/opencode-cost-reader-adapter.ts", named: 1 }, + // Cursor's project hooks file, named after the tool whose file it is: the directory it + // writes into is Cursor's own, not a list a sixth tool joins. + { path: "src/contexts/tools/domain/formats/cursor-hooks-project-merge.ts", named: 1 }, + // Flat-mode plugin extraction, keyed to the one path prefix flat materialization writes + // (`.opencode/`). A second flat-mode tool would need its own prefix check; there is one. + { + path: "src/contexts/framework/application/framework/translator/built-tree-materialization-translator.ts", + named: 1, + }, + // An adapter for exactly one tool, naming its own session-state directory. + { path: "src/contexts/telemetry/infrastructure/copilot-cost-reader-adapter.ts", named: 1 }, + // An adapter for exactly one tool, naming its own hook-trust config path. + { path: "src/contexts/telemetry/infrastructure/hook-trust-reader-adapter.ts", named: 1 }, + // Real coupling, not excused: one shared file reaches into two tools' own directories to + // detect whether either was ever used, and a third tool would extend it. + { path: "src/contexts/telemetry/infrastructure/telemetry-evidence-adapter.ts", named: 2 }, +]; + +describe("a tool identifier stays inside its own profile", () => { + it("no file outside a profile names a tool in a form a new tool would have to join", () => { + const files = sourceFiles(); + const ids = toolIds(files); + expect( + ids.length, + "no profile directory found — the scope of this rule is stale" + ).toBeGreaterThan(1); + + const violations = files + .filter((file) => !isAllowed(file, ids)) + .filter((file) => toolsNamedIn(read(file), ids).length > 0); + + const { added, fixed } = expectRatchet( + violations, + BASELINE.map((entry) => entry.path) + ); + expect(added, "tool named outside its profile — read it from the profile instead").toEqual([]); + expect(fixed, "fixed — remove these from BASELINE").toEqual([]); + }); + + it("holds each admitted file to the number of tools its reason was written around", () => { + const ids = toolIds(sourceFiles()); + const recorded = BASELINE.map(({ path, named }) => `${path}: ${named}`); + const actual = BASELINE.map(({ path }) => `${path}: ${toolsNamedIn(read(path), ids).length}`); + + expect(actual, "an admitted file took on another tool — fix the count and its reason").toEqual( + recorded + ); + }); +}); + +describe("the guard itself", () => { + it("derives the tools from the profiles, so a new one is subject to the rule at once", () => { + const ids = toolIds(["src/contexts/tools/domain/profiles/frobnicator/profile.ts"]); + + expect(ids, "a directory under profiles/ is a tool").toEqual(["frobnicator"]); + expect( + toolsNamedIn('const target = "frobnicator";', ids), + "and the rule matches it without anyone editing a list" + ).toEqual(["frobnicator"]); + }); + + it("sees the four forms a quoted-literal match missed, and ignores prose", () => { + const ids = ["claude", "codex"]; + + expect(toolsNamedIn('codex: { "config.toml": x }', ids), "a bare object key").toEqual([ + "codex", + ]); + expect( + toolsNamedIn('import "../../src/contexts/tools/domain/profiles/codex/build.js";', ids), + "an import path" + ).toEqual(["codex"]); + expect( + toolsNamedIn('"Conversion target (claude, codex)"', ids), + "an enumeration inside one string" + ).toEqual(["claude", "codex"]); + expect(toolsNamedIn("// claude lays its files out differently", ids), "a comment").toEqual([]); + expect( + toolsNamedIn('throw new Error("claude is not installed")', ids), + "one tool named" + ).toEqual([]); + expect( + toolsNamedIn('join(root, ".cursor", "hooks.json")', ["cursor"]), + "a dotfile directory literal" + ).toEqual(["cursor"]); + }); + + it("flags a planted dotfile literal outside the baseline the way a real one would be", () => { + const ids = ["cursor"]; + const file = "src/contexts/framework/application/some-new-helper.ts"; + const content = 'const hooksPath = join(projectRoot, ".cursor", "hooks.json");'; + + expect(isAllowed(file, ids), "the planted file is not inside cursor's own profile").toBe(false); + expect( + toolsNamedIn(content, ids), + "the same literal project-hooks-materializer.ts and plugin-remove-use-case.ts carried" + ).toEqual(["cursor"]); + }); +}); diff --git a/cli/tests/application/use-cases/shared/fetch-marketplace-source-use-case.unit.test.ts b/cli/tests/contexts/distribution/application/fetch-marketplace-source-use-case.unit.test.ts similarity index 95% rename from cli/tests/application/use-cases/shared/fetch-marketplace-source-use-case.unit.test.ts rename to cli/tests/contexts/distribution/application/fetch-marketplace-source-use-case.unit.test.ts index 7f432082b..4e542c338 100644 --- a/cli/tests/application/use-cases/shared/fetch-marketplace-source-use-case.unit.test.ts +++ b/cli/tests/contexts/distribution/application/fetch-marketplace-source-use-case.unit.test.ts @@ -1,9 +1,9 @@ import { join } from "node:path"; import { describe, expect, it, vi } from "vitest"; -import { FetchMarketplaceSourceUseCase } from "../../../../src/application/use-cases/shared/fetch-marketplace-source-use-case.js"; -import { Marketplace } from "../../../../src/domain/models/marketplace.js"; -import type { PluginSourceGitHub } from "../../../../src/domain/models/plugin-source.js"; -import type { RawCatalogFetcher } from "../../../../src/domain/ports/raw-catalog-fetcher.js"; +import { FetchMarketplaceSourceUseCase } from "../../../../src/contexts/distribution/application/fetch-marketplace-source-use-case.js"; +import { Marketplace } from "../../../../src/contexts/distribution/domain/marketplace.js"; +import type { RawCatalogFetcher } from "../../../../src/contexts/distribution/domain/ports/raw-catalog-fetcher.js"; +import type { PluginSourceGitHub } from "../../../../src/kernel/source.js"; import { DeterministicHasher } from "../../../helpers/ports/deterministic-hasher.js"; import { FixturePluginFetcher } from "../../../helpers/ports/fixture-plugin-fetcher.js"; import { InMemoryFileAdapter } from "../../../helpers/ports/in-memory-file-adapter.js"; diff --git a/cli/tests/application/use-cases/marketplace/marketplace-add-use-case.unit.test.ts b/cli/tests/contexts/distribution/application/marketplace-add-use-case.unit.test.ts similarity index 89% rename from cli/tests/application/use-cases/marketplace/marketplace-add-use-case.unit.test.ts rename to cli/tests/contexts/distribution/application/marketplace-add-use-case.unit.test.ts index 1e5a3fd1f..1a9446f4f 100644 --- a/cli/tests/application/use-cases/marketplace/marketplace-add-use-case.unit.test.ts +++ b/cli/tests/contexts/distribution/application/marketplace-add-use-case.unit.test.ts @@ -1,17 +1,17 @@ import { join } from "node:path"; import { describe, expect, it } from "vitest"; -import { MarketplaceAddUseCase } from "../../../../src/application/use-cases/marketplace/marketplace-add-use-case.js"; -import { MarketplaceRemoveUseCase } from "../../../../src/application/use-cases/marketplace/marketplace-remove-use-case.js"; -import { FetchMarketplaceSourceUseCase } from "../../../../src/application/use-cases/shared/fetch-marketplace-source-use-case.js"; -import { ResolveMarketplaceUseCase } from "../../../../src/application/use-cases/shared/resolve-marketplace-use-case.js"; +import { FetchMarketplaceSourceUseCase } from "../../../../src/contexts/distribution/application/fetch-marketplace-source-use-case.js"; +import { MarketplaceAddUseCase } from "../../../../src/contexts/distribution/application/marketplace-add-use-case.js"; +import { ResolveMarketplaceUseCase } from "../../../../src/contexts/distribution/application/resolve-marketplace-use-case.js"; +import { PluginCatalogRepositoryAdapter } from "../../../../src/contexts/distribution/infrastructure/plugin-catalog-repository-adapter.js"; +import { MarketplaceRemoveUseCase } from "../../../../src/contexts/framework/application/flows/marketplace-remove-use-case.js"; import { InvalidMarketplaceNameError, InvalidPluginManifestError, MarketplaceAlreadyRegisteredError, TrustDeniedError, -} from "../../../../src/domain/errors.js"; -import type { Prompter } from "../../../../src/domain/ports/prompter.js"; -import { PluginCatalogRepositoryAdapter } from "../../../../src/infrastructure/adapters/plugin-catalog-repository-adapter.js"; +} from "../../../../src/kernel/errors.js"; +import type { Prompter } from "../../../../src/kernel/ports/prompter.js"; import { DeterministicHasher } from "../../../helpers/ports/deterministic-hasher.js"; import { FixturePluginFetcher } from "../../../helpers/ports/fixture-plugin-fetcher.js"; import { InMemoryFileAdapter } from "../../../helpers/ports/in-memory-file-adapter.js"; diff --git a/cli/tests/application/use-cases/marketplace/marketplace-list-use-case.unit.test.ts b/cli/tests/contexts/distribution/application/marketplace-list-use-case.unit.test.ts similarity index 79% rename from cli/tests/application/use-cases/marketplace/marketplace-list-use-case.unit.test.ts rename to cli/tests/contexts/distribution/application/marketplace-list-use-case.unit.test.ts index 2d7914df4..d513e77fc 100644 --- a/cli/tests/application/use-cases/marketplace/marketplace-list-use-case.unit.test.ts +++ b/cli/tests/contexts/distribution/application/marketplace-list-use-case.unit.test.ts @@ -2,13 +2,13 @@ import { mkdtemp, rm } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { MarketplaceListUseCase } from "../../../../src/application/use-cases/marketplace/marketplace-list-use-case.js"; -import { FetchMarketplaceSourceUseCase } from "../../../../src/application/use-cases/shared/fetch-marketplace-source-use-case.js"; -import { ResolveMarketplaceUseCase } from "../../../../src/application/use-cases/shared/resolve-marketplace-use-case.js"; -import { Marketplace } from "../../../../src/domain/models/marketplace.js"; -import type { PluginCatalog } from "../../../../src/domain/models/plugin-catalog.js"; -import type { PluginCatalogRepository } from "../../../../src/domain/ports/plugin-catalog-repository.js"; -import { MarketplaceRegistryAdapter } from "../../../../src/infrastructure/adapters/marketplace-registry-adapter.js"; +import { FetchMarketplaceSourceUseCase } from "../../../../src/contexts/distribution/application/fetch-marketplace-source-use-case.js"; +import { MarketplaceListUseCase } from "../../../../src/contexts/distribution/application/marketplace-list-use-case.js"; +import { ResolveMarketplaceUseCase } from "../../../../src/contexts/distribution/application/resolve-marketplace-use-case.js"; +import type { PluginCatalog } from "../../../../src/contexts/distribution/domain/catalog.js"; +import { Marketplace } from "../../../../src/contexts/distribution/domain/marketplace.js"; +import type { PluginCatalogRepository } from "../../../../src/contexts/distribution/domain/ports/plugin-catalog-repository.js"; +import { MarketplaceRegistryAdapter } from "../../../../src/contexts/distribution/infrastructure/marketplace-registry-adapter.js"; const SAMPLE_MARKETPLACE = Marketplace.create({ name: "awesome", @@ -21,16 +21,23 @@ describe("MarketplaceListUseCase", () => { let projectRoot: string; let homeDir: string; let originalHome: string | undefined; + let originalConfigDir: string | undefined; beforeEach(async () => { projectRoot = await mkdtemp(join(tmpdir(), "mkt-list-project-")); homeDir = await mkdtemp(join(tmpdir(), "mkt-list-home-")); originalHome = process.env.HOME; + originalConfigDir = process.env.AIDD_USER_CONFIG_DIR; process.env.HOME = homeDir; + // Faking HOME alone is not enough: the CLI falls back to `homedir()` only when + // `AIDD_USER_CONFIG_DIR` is unset, so a value leaking in sends this test at a real registry. + process.env.AIDD_USER_CONFIG_DIR = join(homeDir, ".config", "aidd"); }); afterEach(async () => { process.env.HOME = originalHome; + if (originalConfigDir === undefined) delete process.env.AIDD_USER_CONFIG_DIR; + else process.env.AIDD_USER_CONFIG_DIR = originalConfigDir; await rm(projectRoot, { recursive: true, force: true }); await rm(homeDir, { recursive: true, force: true }); }); @@ -76,7 +83,6 @@ describe("MarketplaceListUseCase", () => { }); const fakeCatalogRepo: PluginCatalogRepository = { load: async () => fakeCatalog, - loadForeign: async () => [], }; const resolveMarketplace = new ResolveMarketplaceUseCase(fakeFetcher, fakeCatalogRepo); @@ -99,7 +105,6 @@ describe("MarketplaceListUseCase", () => { }); const fakeCatalogRepo: PluginCatalogRepository = { load: async () => null, - loadForeign: async () => [], }; const resolveMarketplace = new ResolveMarketplaceUseCase(failingFetcher, fakeCatalogRepo); @@ -122,7 +127,6 @@ describe("MarketplaceListUseCase", () => { }); const fakeCatalogRepo: PluginCatalogRepository = { load: async () => null, - loadForeign: async () => [], }; const resolveMarketplace = new ResolveMarketplaceUseCase(failingFetcher, fakeCatalogRepo); const logger = { info: vi.fn(), debug: vi.fn(), warn: vi.fn() }; diff --git a/cli/tests/application/use-cases/marketplace/marketplace-refresh-progress.unit.test.ts b/cli/tests/contexts/distribution/application/marketplace-refresh-progress.unit.test.ts similarity index 79% rename from cli/tests/application/use-cases/marketplace/marketplace-refresh-progress.unit.test.ts rename to cli/tests/contexts/distribution/application/marketplace-refresh-progress.unit.test.ts index c187aae21..64f485239 100644 --- a/cli/tests/application/use-cases/marketplace/marketplace-refresh-progress.unit.test.ts +++ b/cli/tests/contexts/distribution/application/marketplace-refresh-progress.unit.test.ts @@ -1,11 +1,11 @@ import { join } from "node:path"; import { describe, expect, it } from "vitest"; -import { MarketplaceRefreshUseCase } from "../../../../src/application/use-cases/marketplace/marketplace-refresh-use-case.js"; -import { FetchMarketplaceSourceUseCase } from "../../../../src/application/use-cases/shared/fetch-marketplace-source-use-case.js"; -import { ResolveMarketplaceUseCase } from "../../../../src/application/use-cases/shared/resolve-marketplace-use-case.js"; -import { Marketplace } from "../../../../src/domain/models/marketplace.js"; -import { serializePluginSource } from "../../../../src/domain/models/plugin-source.js"; -import { PluginCatalogRepositoryAdapter } from "../../../../src/infrastructure/adapters/plugin-catalog-repository-adapter.js"; +import { FetchMarketplaceSourceUseCase } from "../../../../src/contexts/distribution/application/fetch-marketplace-source-use-case.js"; +import { MarketplaceRefreshUseCase } from "../../../../src/contexts/distribution/application/marketplace-refresh-use-case.js"; +import { ResolveMarketplaceUseCase } from "../../../../src/contexts/distribution/application/resolve-marketplace-use-case.js"; +import { Marketplace } from "../../../../src/contexts/distribution/domain/marketplace.js"; +import { PluginCatalogRepositoryAdapter } from "../../../../src/contexts/distribution/infrastructure/plugin-catalog-repository-adapter.js"; +import { serializePluginSource } from "../../../../src/kernel/source.js"; import { CapturingLogger } from "../../../helpers/ports/capturing-logger.js"; import { DeterministicHasher } from "../../../helpers/ports/deterministic-hasher.js"; import { FixturePluginFetcher } from "../../../helpers/ports/fixture-plugin-fetcher.js"; diff --git a/cli/tests/application/use-cases/marketplace/marketplace-refresh-use-case.unit.test.ts b/cli/tests/contexts/distribution/application/marketplace-refresh-use-case.unit.test.ts similarity index 91% rename from cli/tests/application/use-cases/marketplace/marketplace-refresh-use-case.unit.test.ts rename to cli/tests/contexts/distribution/application/marketplace-refresh-use-case.unit.test.ts index 587157f8d..7fd5fc5fd 100644 --- a/cli/tests/application/use-cases/marketplace/marketplace-refresh-use-case.unit.test.ts +++ b/cli/tests/contexts/distribution/application/marketplace-refresh-use-case.unit.test.ts @@ -1,12 +1,12 @@ import { join, resolve } from "node:path"; import { describe, expect, it, vi } from "vitest"; -import { MarketplaceRefreshUseCase } from "../../../../src/application/use-cases/marketplace/marketplace-refresh-use-case.js"; -import { FetchMarketplaceSourceUseCase } from "../../../../src/application/use-cases/shared/fetch-marketplace-source-use-case.js"; -import { ResolveMarketplaceUseCase } from "../../../../src/application/use-cases/shared/resolve-marketplace-use-case.js"; -import { Marketplace } from "../../../../src/domain/models/marketplace.js"; -import { MARKETPLACE_CACHE_SUBDIR } from "../../../../src/domain/models/paths.js"; -import { serializePluginSource } from "../../../../src/domain/models/plugin-source.js"; -import { PluginCatalogRepositoryAdapter } from "../../../../src/infrastructure/adapters/plugin-catalog-repository-adapter.js"; +import { FetchMarketplaceSourceUseCase } from "../../../../src/contexts/distribution/application/fetch-marketplace-source-use-case.js"; +import { MarketplaceRefreshUseCase } from "../../../../src/contexts/distribution/application/marketplace-refresh-use-case.js"; +import { ResolveMarketplaceUseCase } from "../../../../src/contexts/distribution/application/resolve-marketplace-use-case.js"; +import { Marketplace } from "../../../../src/contexts/distribution/domain/marketplace.js"; +import { PluginCatalogRepositoryAdapter } from "../../../../src/contexts/distribution/infrastructure/plugin-catalog-repository-adapter.js"; +import { MARKETPLACE_CACHE_SUBDIR } from "../../../../src/kernel/paths.js"; +import { serializePluginSource } from "../../../../src/kernel/source.js"; import { DeterministicHasher } from "../../../helpers/ports/deterministic-hasher.js"; import { FixturePluginFetcher } from "../../../helpers/ports/fixture-plugin-fetcher.js"; import { InMemoryFileAdapter } from "../../../helpers/ports/in-memory-file-adapter.js"; @@ -261,10 +261,8 @@ describe("MarketplaceRefreshUseCase", () => { plugins: [{ name: "aidd-dev", source: "./plugins/aidd-dev" }], }); await fs.writeFile(join(cacheDir, ".claude-plugin/marketplace.json"), freshCatalogJson); - // The stale check resolves this entry via resolve(cacheDir, entry.source.path), which — - // unlike join() — fills in the current drive letter on Windows when cacheDir is a - // rootless absolute path (e.g. "/test-project"). Seed through resolve() too so the - // written key matches what the production lookup builds on every platform. + // `resolve()`, unlike `join()`, fills in the current drive letter on Windows for a + // rootless absolute path, so seeding through it is what makes the written key match. await fs.writeFile(join(resolve(cacheDir, "plugins/aidd-dev"), "plugin.json"), "{}"); const resolveMarketplace = new ResolveMarketplaceUseCase( diff --git a/cli/tests/contexts/distribution/application/marketplace-register-framework-use-case.integration.test.ts b/cli/tests/contexts/distribution/application/marketplace-register-framework-use-case.integration.test.ts new file mode 100644 index 000000000..73bc0acca --- /dev/null +++ b/cli/tests/contexts/distribution/application/marketplace-register-framework-use-case.integration.test.ts @@ -0,0 +1,95 @@ +import { mkdtemp, readFile, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { MarketplaceRegisterFrameworkUseCase } from "../../../../src/contexts/distribution/application/marketplace-register-framework-use-case.js"; +import { + FRAMEWORK_MARKETPLACE_NAME, + Marketplace, +} from "../../../../src/contexts/distribution/domain/marketplace.js"; +import { MarketplaceRegistryAdapter } from "../../../../src/contexts/distribution/infrastructure/marketplace-registry-adapter.js"; + +// Measured against the real `MarketplaceRegistryAdapter`, not the in-memory fake: the fake's +// user store is keyed per `projectRoot` and would hide the machine-wide sharing this proves. +describe("MarketplaceRegisterFrameworkUseCase — machine-scope registration", () => { + let projectA: string; + let projectB: string; + let homeDir: string; + let originalConfigDir: string | undefined; + + beforeEach(async () => { + projectA = await mkdtemp(join(tmpdir(), "register-framework-project-a-")); + projectB = await mkdtemp(join(tmpdir(), "register-framework-project-b-")); + homeDir = await mkdtemp(join(tmpdir(), "register-framework-home-")); + originalConfigDir = process.env.AIDD_USER_CONFIG_DIR; + process.env.AIDD_USER_CONFIG_DIR = join(homeDir, ".config", "aidd"); + }); + + afterEach(async () => { + if (originalConfigDir === undefined) delete process.env.AIDD_USER_CONFIG_DIR; + else process.env.AIDD_USER_CONFIG_DIR = originalConfigDir; + await rm(projectA, { recursive: true, force: true }); + await rm(projectB, { recursive: true, force: true }); + await rm(homeDir, { recursive: true, force: true }); + }); + + it("writes a single entry in userConfigDir()/marketplaces.json for two distinct projectRoots, with the same source", async () => { + const registry = new MarketplaceRegistryAdapter(); + const useCase = new MarketplaceRegisterFrameworkUseCase(registry); + + const first = await useCase.execute({ projectRoot: projectA, frameworkPath: "/src/framework" }); + const second = await useCase.execute({ + projectRoot: projectB, + frameworkPath: "/src/framework", + }); + + expect(first.registered).toBe(true); + expect(second.registered).toBe(false); + + const userFile = join(homeDir, ".config", "aidd", "marketplaces.json"); + const raw = JSON.parse(await readFile(userFile, "utf-8")); + expect(raw.marketplaces).toHaveLength(1); + expect(raw.marketplaces[0].name).toBe(FRAMEWORK_MARKETPLACE_NAME); + expect(raw.marketplaces[0].scope).toBe("user"); + expect(raw.marketplaces[0].source).toEqual({ kind: "local", path: "/src/framework" }); + + // Neither project wrote its own project-scope registry at all. + await expect(readFile(join(projectA, ".aidd", "marketplaces.json"), "utf-8")).rejects.toThrow(); + await expect(readFile(join(projectB, ".aidd", "marketplaces.json"), "utf-8")).rejects.toThrow(); + + const listFromB = await registry.list(projectB); + expect(listFromB).toHaveLength(1); + expect(listFromB[0]?.scope).toBe("user"); + }); + + // `list()` puts a project entry first and filters a user one of the same name out, so writing + // the migrated entry beside a stale project one would leave `list()` answering the old one. + it("migrates a pre-existing project-scope entry to the shared user-scope one on the next run, leaving a single entry", async () => { + const registry = new MarketplaceRegistryAdapter(); + await registry.save( + projectA, + Marketplace.create({ + name: FRAMEWORK_MARKETPLACE_NAME, + scope: "project", + source: { kind: "local", path: "." }, + addedAt: "2026-01-01T00:00:00Z", + }) + ); + const useCase = new MarketplaceRegisterFrameworkUseCase(registry); + + const result = await useCase.execute({ + projectRoot: projectA, + frameworkPath: "/src/framework", + }); + + expect(result.registered).toBe(true); + const list = await registry.list(projectA); + expect(list).toHaveLength(1); + expect(list[0]?.scope).toBe("user"); + + const projectFile = JSON.parse( + await readFile(join(projectA, ".aidd", "marketplaces.json"), "utf-8") + ); + expect(projectFile.marketplaces).toEqual([]); + }); +}); diff --git a/cli/tests/application/use-cases/marketplace/marketplace-register-framework-use-case.unit.test.ts b/cli/tests/contexts/distribution/application/marketplace-register-framework-use-case.unit.test.ts similarity index 84% rename from cli/tests/application/use-cases/marketplace/marketplace-register-framework-use-case.unit.test.ts rename to cli/tests/contexts/distribution/application/marketplace-register-framework-use-case.unit.test.ts index bb7afb290..4536cdae2 100644 --- a/cli/tests/application/use-cases/marketplace/marketplace-register-framework-use-case.unit.test.ts +++ b/cli/tests/contexts/distribution/application/marketplace-register-framework-use-case.unit.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; -import { MarketplaceRegisterFrameworkUseCase } from "../../../../src/application/use-cases/marketplace/marketplace-register-framework-use-case.js"; -import { FRAMEWORK_MARKETPLACE_NAME } from "../../../../src/domain/models/marketplace.js"; +import { MarketplaceRegisterFrameworkUseCase } from "../../../../src/contexts/distribution/application/marketplace-register-framework-use-case.js"; +import { FRAMEWORK_MARKETPLACE_NAME } from "../../../../src/contexts/distribution/domain/marketplace.js"; import { InMemoryMarketplaceRegistry } from "../../../helpers/ports/in-memory-marketplace-registry.js"; const PROJECT_ROOT = "/test-project"; @@ -18,7 +18,9 @@ describe("MarketplaceRegisterFrameworkUseCase", () => { expect(result.registered).toBe(true); const list = await registry.list(PROJECT_ROOT); expect(list[0]?.name).toBe(FRAMEWORK_MARKETPLACE_NAME); - expect(list[0]?.scope).toBe("project"); + // Machine scope: the framework entry is shared by every project on this machine, + // never tied to the one that happened to run `setup` first. + expect(list[0]?.scope).toBe("user"); expect(list[0]?.source.kind).toBe("github"); if (list[0]?.source.kind === "github") { expect(list[0]?.source.repo).toBe("ai-driven-dev/framework"); diff --git a/cli/tests/application/use-cases/shared/resolve-marketplace-use-case.unit.test.ts b/cli/tests/contexts/distribution/application/resolve-marketplace-use-case.unit.test.ts similarity index 84% rename from cli/tests/application/use-cases/shared/resolve-marketplace-use-case.unit.test.ts rename to cli/tests/contexts/distribution/application/resolve-marketplace-use-case.unit.test.ts index 27845e1ab..76e64c2b4 100644 --- a/cli/tests/application/use-cases/shared/resolve-marketplace-use-case.unit.test.ts +++ b/cli/tests/contexts/distribution/application/resolve-marketplace-use-case.unit.test.ts @@ -1,9 +1,9 @@ import { join } from "node:path"; import { describe, expect, it } from "vitest"; -import { FetchMarketplaceSourceUseCase } from "../../../../src/application/use-cases/shared/fetch-marketplace-source-use-case.js"; -import { ResolveMarketplaceUseCase } from "../../../../src/application/use-cases/shared/resolve-marketplace-use-case.js"; -import { Marketplace } from "../../../../src/domain/models/marketplace.js"; -import { PluginCatalogRepositoryAdapter } from "../../../../src/infrastructure/adapters/plugin-catalog-repository-adapter.js"; +import { FetchMarketplaceSourceUseCase } from "../../../../src/contexts/distribution/application/fetch-marketplace-source-use-case.js"; +import { ResolveMarketplaceUseCase } from "../../../../src/contexts/distribution/application/resolve-marketplace-use-case.js"; +import { Marketplace } from "../../../../src/contexts/distribution/domain/marketplace.js"; +import { PluginCatalogRepositoryAdapter } from "../../../../src/contexts/distribution/infrastructure/plugin-catalog-repository-adapter.js"; import { DeterministicHasher } from "../../../helpers/ports/deterministic-hasher.js"; import { FixturePluginFetcher } from "../../../helpers/ports/fixture-plugin-fetcher.js"; import { InMemoryFileAdapter } from "../../../helpers/ports/in-memory-file-adapter.js"; diff --git a/cli/tests/domain/formats/copilot-marketplace-catalog.unit.test.ts b/cli/tests/contexts/distribution/domain/catalog-parsers/copilot-marketplace-catalog.unit.test.ts similarity index 95% rename from cli/tests/domain/formats/copilot-marketplace-catalog.unit.test.ts rename to cli/tests/contexts/distribution/domain/catalog-parsers/copilot-marketplace-catalog.unit.test.ts index ce215971e..5a7e41e42 100644 --- a/cli/tests/domain/formats/copilot-marketplace-catalog.unit.test.ts +++ b/cli/tests/contexts/distribution/domain/catalog-parsers/copilot-marketplace-catalog.unit.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; -import { InvalidPluginManifestError } from "../../../src/domain/errors.js"; -import { parseCopilotMarketplaceCatalog } from "../../../src/domain/formats/copilot-marketplace-catalog.js"; +import { parseCopilotMarketplaceCatalog } from "../../../../../src/contexts/distribution/domain/catalog-parsers/copilot-marketplace-catalog.js"; +import { InvalidPluginManifestError } from "../../../../../src/kernel/errors.js"; const SAMPLE_CATALOG = JSON.stringify({ name: "aidd-framework", diff --git a/cli/tests/contexts/distribution/domain/catalog.unit.test.ts b/cli/tests/contexts/distribution/domain/catalog.unit.test.ts new file mode 100644 index 000000000..7ff8c0092 --- /dev/null +++ b/cli/tests/contexts/distribution/domain/catalog.unit.test.ts @@ -0,0 +1,154 @@ +import { describe, expect, it } from "vitest"; +import { + hasRelativePluginSources, + parsePluginCatalog, +} from "../../../../src/contexts/distribution/domain/catalog.js"; +import { + InvalidPluginManifestError, + InvalidPluginSourceError, +} from "../../../../src/kernel/errors.js"; + +const VALID_RAW = { + plugins: [ + { + name: "dev", + source: { kind: "local", path: "./plugins/dev" }, + description: "Dev plugin", + recommended: true, + strict: true, + }, + { + name: "pm", + source: { kind: "github", repo: "ai-driven-dev/aidd-pm" }, + description: "PM plugin", + recommended: false, + strict: false, + }, + ], +}; + +describe("hasRelativePluginSources", () => { + it("returns true for catalog with a local relative path entry", () => { + const catalog = parsePluginCatalog({ + plugins: [{ name: "x", source: { kind: "local", path: "./plugins/x" } }], + }); + expect(hasRelativePluginSources(catalog)).toBe(true); + }); + + it("returns true for mixed entries when at least one is relative local", () => { + const catalog = parsePluginCatalog({ + plugins: [ + { name: "rel", source: { kind: "local", path: "./plugins/rel" } }, + { name: "abs", source: { kind: "github", repo: "owner/repo" } }, + ], + }); + expect(hasRelativePluginSources(catalog)).toBe(true); + }); + + it("returns false for catalog with only github entries", () => { + const catalog = parsePluginCatalog({ + plugins: [{ name: "g", source: { kind: "github", repo: "owner/repo" } }], + }); + expect(hasRelativePluginSources(catalog)).toBe(false); + }); + + it("returns false for catalog with local absolute paths only", () => { + const catalog = parsePluginCatalog({ + plugins: [{ name: "abs", source: { kind: "local", path: "/absolute/path" } }], + }); + expect(hasRelativePluginSources(catalog)).toBe(false); + }); + + it("returns false for empty plugins array", () => { + const catalog = parsePluginCatalog({ plugins: [] }); + expect(hasRelativePluginSources(catalog)).toBe(false); + }); +}); + +describe("parsePluginCatalog", () => { + describe("valid input", () => { + it("parses two entries from valid fixture", () => { + const catalog = parsePluginCatalog(VALID_RAW); + expect(catalog.plugins).toHaveLength(2); + }); + + it("parses name and source for each entry", () => { + const catalog = parsePluginCatalog(VALID_RAW); + expect(catalog.plugins[0].name).toBe("dev"); + expect(catalog.plugins[0].source).toEqual({ kind: "local", path: "./plugins/dev" }); + expect(catalog.plugins[1].name).toBe("pm"); + expect(catalog.plugins[1].source).toEqual({ kind: "github", repo: "ai-driven-dev/aidd-pm" }); + }); + + it("preserves recommended and strict values", () => { + const catalog = parsePluginCatalog(VALID_RAW); + expect(catalog.plugins[0].recommended).toBe(true); + expect(catalog.plugins[0].strict).toBe(true); + expect(catalog.plugins[1].recommended).toBe(false); + expect(catalog.plugins[1].strict).toBe(false); + }); + + it("defaults recommended to false when absent", () => { + const raw = { plugins: [{ name: "x", source: { kind: "local", path: "./x" } }] }; + const catalog = parsePluginCatalog(raw); + expect(catalog.plugins[0].recommended).toBe(false); + }); + + it("defaults strict to false when absent", () => { + const raw = { plugins: [{ name: "x", source: { kind: "local", path: "./x" } }] }; + const catalog = parsePluginCatalog(raw); + expect(catalog.plugins[0].strict).toBe(false); + }); + + it("includes optional description when present", () => { + const catalog = parsePluginCatalog(VALID_RAW); + expect(catalog.plugins[0].description).toBe("Dev plugin"); + }); + + it("omits description when absent", () => { + const raw = { plugins: [{ name: "x", source: { kind: "local", path: "./x" } }] }; + const catalog = parsePluginCatalog(raw); + expect(catalog.plugins[0].description).toBeUndefined(); + }); + }); + + describe("missing source field", () => { + it("throws InvalidPluginManifestError", () => { + const raw = { plugins: [{ name: "x" }] }; + expect(() => parsePluginCatalog(raw)).toThrow(InvalidPluginManifestError); + }); + }); + + describe("malformed source", () => { + it("throws InvalidPluginSourceError for unknown kind", () => { + const raw = { plugins: [{ name: "x", source: { kind: "svn" } }] }; + expect(() => parsePluginCatalog(raw)).toThrow(InvalidPluginSourceError); + }); + }); + + describe("invalid top-level structure", () => { + it("throws when plugins is not an array", () => { + expect(() => parsePluginCatalog({ plugins: "not-array" })).toThrow( + InvalidPluginManifestError + ); + }); + + it("throws when input is null", () => { + expect(() => parsePluginCatalog(null)).toThrow(InvalidPluginManifestError); + }); + + it("throws when input is an array", () => { + expect(() => parsePluginCatalog([])).toThrow(InvalidPluginManifestError); + }); + + it("throws when name is missing", () => { + const raw = { plugins: [{ source: { kind: "local", path: "./x" } }] }; + expect(() => parsePluginCatalog(raw)).toThrow(InvalidPluginManifestError); + }); + + it("throws when name is empty string", () => { + const raw = { plugins: [{ name: "", source: { kind: "local", path: "./x" } }] }; + expect(() => parsePluginCatalog(raw)).toThrow(InvalidPluginManifestError); + }); + }); +}); diff --git a/cli/tests/domain/models/marketplace-source-mode.unit.test.ts b/cli/tests/contexts/distribution/domain/marketplace-source-mode.unit.test.ts similarity index 98% rename from cli/tests/domain/models/marketplace-source-mode.unit.test.ts rename to cli/tests/contexts/distribution/domain/marketplace-source-mode.unit.test.ts index cf49806f7..0bc31c558 100644 --- a/cli/tests/domain/models/marketplace-source-mode.unit.test.ts +++ b/cli/tests/contexts/distribution/domain/marketplace-source-mode.unit.test.ts @@ -2,7 +2,7 @@ import { describe, expect, it } from "vitest"; import { DEFAULT_FRAMEWORK_REPO, MarketplaceSourceMode, -} from "../../../src/domain/models/marketplace-source-mode.js"; +} from "../../../../src/contexts/distribution/domain/marketplace-source-mode.js"; describe("MarketplaceSourceMode", () => { describe("remote()", () => { diff --git a/cli/tests/domain/models/marketplace.unit.test.ts b/cli/tests/contexts/distribution/domain/marketplace.unit.test.ts similarity index 97% rename from cli/tests/domain/models/marketplace.unit.test.ts rename to cli/tests/contexts/distribution/domain/marketplace.unit.test.ts index d7d946bad..e9c155b45 100644 --- a/cli/tests/domain/models/marketplace.unit.test.ts +++ b/cli/tests/contexts/distribution/domain/marketplace.unit.test.ts @@ -1,15 +1,15 @@ import { describe, expect, it } from "vitest"; -import { - InvalidMarketplaceNameError, - InvalidMarketplaceScopeError, - InvalidPluginSourceError, -} from "../../../src/domain/errors.js"; import { FRAMEWORK_MARKETPLACE_NAME, MARKETPLACE_NAME_REGEX, Marketplace, type MarketplaceData, -} from "../../../src/domain/models/marketplace.js"; +} from "../../../../src/contexts/distribution/domain/marketplace.js"; +import { + InvalidMarketplaceNameError, + InvalidMarketplaceScopeError, + InvalidPluginSourceError, +} from "../../../../src/kernel/errors.js"; const makeData = (overrides: Partial = {}): MarketplaceData => ({ name: "awesome-plugins", diff --git a/cli/tests/contexts/distribution/infrastructure/git-adapter-commit-trailer.integration.test.ts b/cli/tests/contexts/distribution/infrastructure/git-adapter-commit-trailer.integration.test.ts new file mode 100644 index 000000000..1777d1f8a --- /dev/null +++ b/cli/tests/contexts/distribution/infrastructure/git-adapter-commit-trailer.integration.test.ts @@ -0,0 +1,313 @@ +import { execFileSync } from "node:child_process"; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { afterEach, describe, expect, it } from "vitest"; +import { + SESSION_TRAILER_DELEGATE_FILE, + sessionTrailerDelegateScript, + sessionTrailerHookLine, +} from "../../../../src/contexts/telemetry/domain/formats/commit-session-trailer.js"; +import { FileAdapter } from "../../../../src/runtime/filesystem/file-adapter.js"; +import { HasherAdapter } from "../../../../src/runtime/filesystem/hasher-adapter.js"; +import { GitAdapter } from "../../../../src/runtime/git/git-adapter.js"; +import { CapturingLogger } from "../../../helpers/ports/capturing-logger.js"; + +/** + * The real adapter against real repositories: where git looks for a hook is not knowable + * from the shape of `.git` alone — `core.hooksPath` and a linked worktree each move it. + */ +const created: string[] = []; + +function gitEnv(): NodeJS.ProcessEnv { + return Object.fromEntries(Object.entries(process.env).filter(([key]) => !key.startsWith("GIT_"))); +} + +function git(cwd: string, ...args: string[]): string { + return execFileSync("git", args, { cwd, encoding: "utf8", env: gitEnv() }); +} + +function makeRepo(prefix: string): string { + const dir = mkdtempSync(join(tmpdir(), `aidd-trailer-${prefix}-`)); + created.push(dir); + git(dir, "init", "-q", "."); + return dir; +} + +function adapter(): GitAdapter { + return new GitAdapter(new FileAdapter(new HasherAdapter(), new CapturingLogger())); +} + +afterEach(() => { + for (const dir of created) rmSync(dir, { recursive: true, force: true }); + created.length = 0; +}); + +describe("installing the trailer hook where git will actually run it", () => { + it("installs into a plain repository, and says it did", async () => { + const repo = makeRepo("plain"); + + const installed = await adapter().installCommitMessageDelegate( + repo, + SESSION_TRAILER_DELEGATE_FILE, + sessionTrailerDelegateScript() + ); + + const hooks = join(repo, ".git", "hooks"); + expect(installed).toEqual({ lineAdded: true }); + expect(readFileSync(join(hooks, "prepare-commit-msg"), "utf8")).toContain( + sessionTrailerHookLine(join(hooks, SESSION_TRAILER_DELEGATE_FILE)) + ); + expect(existsSync(join(hooks, SESSION_TRAILER_DELEGATE_FILE))).toBe(true); + }); + + // `.git/hooks` is not where git looks when a `core.hooksPath` is set, and a hook written + // there runs on nothing while reporting success. + it("installs where core.hooksPath points, never into .git/hooks it would ignore", async () => { + const repo = makeRepo("hookspath"); + const elsewhere = join(repo, "team-hooks"); + mkdirSync(elsewhere, { recursive: true }); + git(repo, "config", "core.hooksPath", elsewhere); + + await adapter().installCommitMessageDelegate( + repo, + SESSION_TRAILER_DELEGATE_FILE, + sessionTrailerDelegateScript() + ); + + expect(existsSync(join(elsewhere, "prepare-commit-msg"))).toBe(true); + expect(existsSync(join(repo, ".git", "hooks", "prepare-commit-msg"))).toBe(false); + }); + + it("installs into the common repository's hooks from inside a linked worktree", async () => { + const repo = makeRepo("worktree"); + writeFileSync(join(repo, "seed.txt"), "seed\n"); + git(repo, "add", "seed.txt"); + git(repo, "-c", "user.email=t@example.com", "-c", "user.name=T", "commit", "-q", "-m", "seed"); + const linked = join(repo, "linked"); + git(repo, "worktree", "add", "-q", linked); + + await adapter().installCommitMessageDelegate( + linked, + SESSION_TRAILER_DELEGATE_FILE, + sessionTrailerDelegateScript() + ); + + expect(existsSync(join(repo, ".git", "hooks", "prepare-commit-msg"))).toBe(true); + }); + + it("keeps a hook the repository already had, and adds one line to it", async () => { + const repo = makeRepo("existing"); + const hooks = join(repo, ".git", "hooks"); + mkdirSync(hooks, { recursive: true }); + writeFileSync(join(hooks, "prepare-commit-msg"), "#!/bin/sh\necho theirs\n"); + + await adapter().installCommitMessageDelegate( + repo, + SESSION_TRAILER_DELEGATE_FILE, + sessionTrailerDelegateScript() + ); + + const content = readFileSync(join(hooks, "prepare-commit-msg"), "utf8"); + expect(content).toContain("echo theirs"); + expect(content).toContain(sessionTrailerHookLine(join(hooks, SESSION_TRAILER_DELEGATE_FILE))); + }); + + it("adds its line once however often it is installed", async () => { + const repo = makeRepo("twice"); + + const first = await adapter().installCommitMessageDelegate( + repo, + SESSION_TRAILER_DELEGATE_FILE, + sessionTrailerDelegateScript() + ); + const second = await adapter().installCommitMessageDelegate( + repo, + SESSION_TRAILER_DELEGATE_FILE, + sessionTrailerDelegateScript() + ); + + const content = readFileSync(join(repo, ".git", "hooks", "prepare-commit-msg"), "utf8"); + expect(first).toEqual({ lineAdded: true }); + expect(second).toEqual({ lineAdded: false }); + expect(content.split("aidd-session-trailer.sh").length - 1).toBe(1); + }); + + it("reports nothing installed outside a repository, rather than failing", async () => { + const notARepo = mkdtempSync(join(tmpdir(), "aidd-trailer-none-")); + created.push(notARepo); + + await expect( + adapter().installCommitMessageDelegate( + notARepo, + SESSION_TRAILER_DELEGATE_FILE, + sessionTrailerDelegateScript() + ) + ).resolves.toEqual({ lineAdded: false }); + }); + + // A lefthook marker outside a git repository has no `$(git rev-parse --git-common-dir)` + // for a hand-added job to resolve against, so naming a manager there prints a dead job. + it("names no manager outside a repository, even with a lefthook marker at the root", async () => { + const notARepo = mkdtempSync(join(tmpdir(), "aidd-trailer-lefthook-nogit-")); + created.push(notARepo); + writeFileSync(join(notARepo, "lefthook.yml"), "prepare-commit-msg:\n commands: {}\n"); + + await expect( + adapter().installCommitMessageDelegate( + notARepo, + SESSION_TRAILER_DELEGATE_FILE, + sessionTrailerDelegateScript() + ) + ).resolves.toEqual({ lineAdded: false }); + }); +}); + +/** + * Neither `lefthook.yml` nor `.husky/*` is ever written by this CLI, yet the delegate must + * land at `$(git rev-parse --git-common-dir)/hooks`, which husky's `core.hooksPath` misses. + */ +describe("installing where lefthook or husky already owns prepare-commit-msg", () => { + it("reports the manager, writes no line, and touches nothing lefthook owns", async () => { + const repo = makeRepo("lefthook-owned"); + writeFileSync(join(repo, "lefthook.yml"), "prepare-commit-msg:\n commands: {}\n"); + + const result = await adapter().installCommitMessageDelegate( + repo, + SESSION_TRAILER_DELEGATE_FILE, + sessionTrailerDelegateScript() + ); + + expect(result).toEqual({ + hookManager: "lefthook", + managerCallsDelegate: false, + lineAdded: false, + }); + expect(existsSync(join(repo, ".git", "hooks", "prepare-commit-msg"))).toBe(false); + expect(readFileSync(join(repo, "lefthook.yml"), "utf8")).toBe( + "prepare-commit-msg:\n commands: {}\n" + ); + expect(existsSync(join(repo, ".git", "hooks", SESSION_TRAILER_DELEGATE_FILE))).toBe(true); + }); + + it("writes the delegate to the common git dir, never to husky's own core.hooksPath", async () => { + const repo = makeRepo("husky-owned"); + mkdirSync(join(repo, ".husky"), { recursive: true }); + writeFileSync(join(repo, ".husky", "prepare-commit-msg"), "#!/bin/sh\necho theirs\n"); + git(repo, "config", "core.hooksPath", ".husky"); + + const result = await adapter().installCommitMessageDelegate( + repo, + SESSION_TRAILER_DELEGATE_FILE, + sessionTrailerDelegateScript() + ); + + expect(result).toEqual({ hookManager: "husky", managerCallsDelegate: false, lineAdded: false }); + expect(readFileSync(join(repo, ".husky", "prepare-commit-msg"), "utf8")).toBe( + "#!/bin/sh\necho theirs\n" + ); + expect(existsSync(join(repo, ".husky", SESSION_TRAILER_DELEGATE_FILE))).toBe(false); + expect(existsSync(join(repo, ".git", "hooks", SESSION_TRAILER_DELEGATE_FILE))).toBe(true); + }); +}); + +describe("removing it again", () => { + it("takes back its own line and its own file, and says it did", async () => { + const repo = makeRepo("remove"); + await adapter().installCommitMessageDelegate( + repo, + SESSION_TRAILER_DELEGATE_FILE, + sessionTrailerDelegateScript() + ); + + const removed = await adapter().removeCommitMessageDelegate( + repo, + SESSION_TRAILER_DELEGATE_FILE + ); + + const hooks = join(repo, ".git", "hooks"); + expect(removed).toEqual({ removed: true }); + expect(readFileSync(join(hooks, "prepare-commit-msg"), "utf8")).not.toContain( + SESSION_TRAILER_DELEGATE_FILE + ); + expect(existsSync(join(hooks, SESSION_TRAILER_DELEGATE_FILE))).toBe(false); + }); + + it("leaves every other line of somebody else's hook exactly as it found it", async () => { + const repo = makeRepo("shared"); + const hooks = join(repo, ".git", "hooks"); + mkdirSync(hooks, { recursive: true }); + writeFileSync(join(hooks, "prepare-commit-msg"), "#!/bin/sh\necho theirs\nexit 0\n"); + await adapter().installCommitMessageDelegate( + repo, + SESSION_TRAILER_DELEGATE_FILE, + sessionTrailerDelegateScript() + ); + + await adapter().removeCommitMessageDelegate(repo, SESSION_TRAILER_DELEGATE_FILE); + + expect(readFileSync(join(hooks, "prepare-commit-msg"), "utf8")).toBe( + "#!/bin/sh\necho theirs\nexit 0\n" + ); + }); + + it("reports nothing to remove when it was never installed", async () => { + const repo = makeRepo("never"); + + await expect( + adapter().removeCommitMessageDelegate(repo, SESSION_TRAILER_DELEGATE_FILE) + ).resolves.toEqual({ removed: false }); + }); + + /** Once a manager owns `prepare-commit-msg`, `on` writes the delegate to + * `$(git rev-parse --git-common-dir)/hooks`, ignoring `core.hooksPath`; `off` must agree. */ + it("removes the delegate on and off agree on, even where husky moves core.hooksPath", async () => { + const repo = makeRepo("husky-remove"); + mkdirSync(join(repo, ".husky"), { recursive: true }); + writeFileSync(join(repo, ".husky", "prepare-commit-msg"), "#!/bin/sh\necho theirs\n"); + git(repo, "config", "core.hooksPath", ".husky"); + await adapter().installCommitMessageDelegate( + repo, + SESSION_TRAILER_DELEGATE_FILE, + sessionTrailerDelegateScript() + ); + expect(existsSync(join(repo, ".git", "hooks", SESSION_TRAILER_DELEGATE_FILE))).toBe(true); + + const removed = await adapter().removeCommitMessageDelegate( + repo, + SESSION_TRAILER_DELEGATE_FILE + ); + + expect(removed).toEqual({ removed: true, hookManager: "husky", managerCallsDelegate: false }); + expect(existsSync(join(repo, ".git", "hooks", SESSION_TRAILER_DELEGATE_FILE))).toBe(false); + // husky's own hook file is not aidd's to touch, on the way in or on the way out. + expect(readFileSync(join(repo, ".husky", "prepare-commit-msg"), "utf8")).toBe( + "#!/bin/sh\necho theirs\n" + ); + }); +}); + +/** + * A resumed Codex rollout carries a `session_meta.id` — what becomes a record's `vendor_id` + * — different from its own `session_meta.session_id`: thread and rollout are two things. + */ +describe("the reason the Codex half of this join is only a candidate", () => { + it("still holds a resumed rollout whose own id differs from the session it continues", () => { + const rollout = fileURLToPath( + new URL( + "../../../fixtures/local-cost/.codex/sessions/2026/07/29/" + + "rollout-2026-07-29T17-12-26-019fae6f-2009-7cd3-86b2-b8f83481b160.jsonl", + import.meta.url + ) + ); + const first = readFileSync(rollout, "utf8").split("\n")[0] ?? ""; + const meta = JSON.parse(first) as { + payload?: { id?: string; session_id?: string }; + }; + + expect(meta.payload?.id).toBe("019fae6f-2009-7cd3-86b2-b8f83481b160"); + expect(meta.payload?.session_id).toBeDefined(); + expect(meta.payload?.session_id).not.toBe(meta.payload?.id); + }); +}); diff --git a/cli/tests/contexts/distribution/infrastructure/git-commit-trailer-setup.integration.test.ts b/cli/tests/contexts/distribution/infrastructure/git-commit-trailer-setup.integration.test.ts new file mode 100644 index 000000000..f460cae32 --- /dev/null +++ b/cli/tests/contexts/distribution/infrastructure/git-commit-trailer-setup.integration.test.ts @@ -0,0 +1,294 @@ +import { execFileSync } from "node:child_process"; +import { chmod, mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { + SESSION_TRAILER_DELEGATE_FILE, + SESSION_TRAILER_TOKEN, + sessionTrailerHookLine, +} from "../../../../src/contexts/telemetry/domain/formats/commit-session-trailer.js"; +import { FileAdapter } from "../../../../src/runtime/filesystem/file-adapter.js"; +import { GitAdapter } from "../../../../src/runtime/git/git-adapter.js"; +import { environmentWithoutGitVariables } from "../../../../src/runtime/git/git-environment.js"; +import { CapturingLogger } from "../../../helpers/ports/capturing-logger.js"; +import { DeterministicHasher } from "../../../helpers/ports/deterministic-hasher.js"; + +/** Against a real git rather than a fake one: `%(trailers:key=…)` is git's own reader, and + * asserting against a regex of ours would prove only that the regex agrees with itself. */ +let dir: string; +let git: GitAdapter; + +/** Windows records no execute bit: every readable file reports `0o666`, so "present but + * unrunnable" is a state that exists only where the bit does. */ +const REMOVING_THE_BIT_MEANS_SOMETHING = process.platform !== "win32"; + +/** Git exports `GIT_DIR` and friends into everything it spawns, this suite included when it + * runs from a commit hook. Stripped, or these read the repository being committed. */ +function run(args: readonly string[], env: NodeJS.ProcessEnv = {}): void { + execFileSync("git", [...args], { + cwd: dir, + env: { ...environmentWithoutGitVariables(process.env), ...env }, + }); +} + +beforeEach(async () => { + dir = await mkdtemp(join(tmpdir(), "aidd-trailer-setup-")); + execFileSync("git", ["init", "-q", dir], { env: environmentWithoutGitVariables(process.env) }); + run(["config", "user.email", "t@example.com"]); + run(["config", "user.name", "T"]); + git = new GitAdapter(new FileAdapter(new DeterministicHasher(), new CapturingLogger())); +}); + +afterEach(async () => { + await rm(dir, { recursive: true, force: true }); +}); + +function read() { + return git.readCommitTrailerSetup(dir, SESSION_TRAILER_DELEGATE_FILE, SESSION_TRAILER_TOKEN, 20); +} + +async function hooksDir(): Promise { + const at = join(dir, ".git", "hooks"); + await mkdir(at, { recursive: true }); + return at; +} + +async function installDelegate(mode = 0o755): Promise { + const at = join(await hooksDir(), SESSION_TRAILER_DELEGATE_FILE); + await writeFile(at, "#!/bin/sh\nexit 0\n"); + await chmod(at, mode); + return at; +} + +function commit(message: string): void { + run(["commit", "-q", "--allow-empty", "-m", message]); +} + +function commitCarrying(message: string, session: string): void { + run([ + "commit", + "-q", + "--allow-empty", + "-m", + `${message}\n\n${SESSION_TRAILER_TOKEN}: ${session}`, + ]); +} + +describe("what check reads about the commit trailer", () => { + it("names the directory git runs hooks from, not one assumed", async () => { + expect((await read()).hooksDir).toContain(join(".git", "hooks")); + }); + + it("tells a delegate that is not executable from one that is absent", async () => { + expect((await read()).delegate).toBe("absent"); + + if (REMOVING_THE_BIT_MEANS_SOMETHING) { + await installDelegate(0o644); + expect((await read()).delegate).toBe("not-executable"); + } + + await installDelegate(0o755); + expect((await read()).delegate).toBe("executable"); + }); + + it("says whether prepare-commit-msg calls the delegate", async () => { + const delegatePath = await installDelegate(); + expect((await read()).callSite).toBe("no-hook-file"); + + const hookPath = join(await hooksDir(), "prepare-commit-msg"); + await writeFile(hookPath, "#!/bin/sh\n# generated\nexit 0\n"); + expect((await read()).callSite).toBe("missing"); + + await writeFile(hookPath, `#!/bin/sh\n${sessionTrailerHookLine(delegatePath)}\n`); + expect((await read()).callSite).toBe("present"); + }); + + it("says the hook is somebody else's, and does not say whose", async () => { + const delegatePath = await installDelegate(); + const hookPath = join(await hooksDir(), "prepare-commit-msg"); + + await writeFile(hookPath, `#!/bin/sh\n${sessionTrailerHookLine(delegatePath)}\n`); + expect((await read()).hookHasOtherContent).toBe(false); + + await writeFile( + hookPath, + `#!/bin/sh\nlefthook run x\n${sessionTrailerHookLine(delegatePath)}\n` + ); + expect((await read()).hookHasOtherContent).toBe(true); + }); + + /** A count, not a boolean: "some of your commits carry it" is not something a person can + * check, while "0 of the last 3" is the entire finding. */ + it("counts how many recent commits actually carry it", async () => { + commit("one"); + commitCarrying("two", "s-1"); + commitCarrying("three", "s-2"); + + expect((await read()).recentlyCarrying).toEqual({ carrying: 2, examined: 3 }); + }); + + /** A merge carries no trailer by the delegate's own design, so counting one puts a commit + * in the denominator that can never be in the numerator. */ + it("does not count merges, which can never carry it", async () => { + commitCarrying("one", "s-1"); + run(["checkout", "-q", "-b", "side"]); + commitCarrying("side", "s-2"); + run(["checkout", "-q", "-"]); + commitCarrying("main", "s-3"); + run(["merge", "-q", "--no-ff", "-m", "merge", "side"]); + + const counted = (await read()).recentlyCarrying; + + expect(counted).toEqual({ carrying: 3, examined: 3 }); + }); + + // Never `0`: a repository with no commits and one whose commits are all unstamped are + // different facts, and only the second is something to act on. + it("reports no history rather than zero when there are no commits", async () => { + expect((await read()).recentlyCarrying).toBeUndefined(); + }); + + it("says there is no repository, rather than that git could not answer", async () => { + const outside = await mkdtemp(join(tmpdir(), "aidd-trailer-nogit-")); + try { + const setup = await git.readCommitTrailerSetup( + outside, + SESSION_TRAILER_DELEGATE_FILE, + SESSION_TRAILER_TOKEN, + 20 + ); + + expect(setup.hooksDirMissing).toBe("no-repository"); + expect(setup.recentlyCarrying).toBeUndefined(); + expect(setup.delegate).toBe("absent"); + } finally { + await rm(outside, { recursive: true, force: true }); + } + }); + + // Git refuses to run a hook without the bit and prints a hint on every commit, so an + // install that looks perfect can write nothing. The hook's own mode, not the delegate's. + it("says whether the hook itself is executable", async () => { + const delegatePath = await installDelegate(); + const hookPath = join(await hooksDir(), "prepare-commit-msg"); + await writeFile(hookPath, `#!/bin/sh\n${sessionTrailerHookLine(delegatePath)}\n`); + + if (REMOVING_THE_BIT_MEANS_SOMETHING) { + await chmod(hookPath, 0o644); + expect((await read()).hookExecutable).toBe(false); + } + + await chmod(hookPath, 0o755); + expect((await read()).hookExecutable).toBe(true); + }); + + it("has no opinion on a hook's mode when there is no hook", async () => { + await installDelegate(); + + expect((await read()).hookExecutable).toBeUndefined(); + }); +}); + +/** A manager regenerates `prepare-commit-msg` from its own config on every install, so + * anything read from the hook itself is already stale; the root marker file survives that. */ +describe("which manager owns prepare-commit-msg, read from the repository root", () => { + it("names lefthook from lefthook.yml alone, before lefthook has ever generated a hook", async () => { + await writeFile(join(dir, "lefthook.yml"), "prepare-commit-msg:\n commands: {}\n"); + + const setup = await read(); + + expect(setup.hookManager).toBe("lefthook"); + expect(setup.callSite).toBe("no-hook-file"); + }); + + it("names lefthook even once a hook file exists, never reading that file to decide", async () => { + await writeFile(join(dir, "lefthook.yml"), "prepare-commit-msg:\n commands: {}\n"); + const hookPath = join(await hooksDir(), "prepare-commit-msg"); + // What lefthook itself regenerates: no aidd trailer line and no mention of lefthook, so a + // reader deciding from the hook's own contents would have nothing here to key off. + await writeFile(hookPath, "#!/bin/sh\nexit 0\n"); + + expect((await read()).hookManager).toBe("lefthook"); + }); + + it("names husky from a .husky directory at the root", async () => { + await mkdir(join(dir, ".husky"), { recursive: true }); + + expect((await read()).hookManager).toBe("husky"); + }); + + it("names neither manager when no marker sits at the root", async () => { + expect((await read()).hookManager).toBeUndefined(); + }); +}); + +/** This repository's own lefthook job, inlined rather than read off disk: it calls the + * delegate through the dynamic form, never the absolute-path `sessionTrailerHookLine` one. */ +const REAL_LEFTHOOK_PREPARE_COMMIT_MSG_JOB = `prepare-commit-msg: + commands: + aidd-session-trailer: + run: | + delegate="$(git rev-parse --git-common-dir)/hooks/aidd-session-trailer.sh" + if [ -f "$delegate" ]; then sh "$delegate" {1} {2}; fi +`; + +describe("whether the manager's own config already calls the delegate", () => { + it("reports not wired when lefthook.yml exists but names no job for it", async () => { + await writeFile(join(dir, "lefthook.yml"), "commit-msg:\n commands: {}\n"); + + expect((await read()).managerCallsDelegate).toBe(false); + }); + + it("reports wired against this repository's own lefthook.yml job", async () => { + await writeFile(join(dir, "lefthook.yml"), REAL_LEFTHOOK_PREPARE_COMMIT_MSG_JOB); + + const setup = await read(); + + expect(setup.hookManager).toBe("lefthook"); + expect(setup.managerCallsDelegate).toBe(true); + }); + + it("reports wired from .husky/prepare-commit-msg the same way", async () => { + await mkdir(join(dir, ".husky"), { recursive: true }); + await writeFile( + join(dir, ".husky", "prepare-commit-msg"), + 'delegate="$(git rev-parse --git-common-dir)/hooks/aidd-session-trailer.sh"\n' + + '[ -f "$delegate" ] && sh "$delegate" "$@"\n' + ); + + expect((await read()).managerCallsDelegate).toBe(true); + }); +}); + +/** Husky moves `core.hooksPath` under `.husky/` while `on` writes the delegate to the common + * git dir, so following `core.hooksPath` reads it "absent" however often `on` ran. */ +describe("reading the delegate's own state under a manager that moves core.hooksPath", () => { + it("finds the delegate in the common git dir, not wherever core.hooksPath points", async () => { + await mkdir(join(dir, ".husky"), { recursive: true }); + run(["config", "core.hooksPath", ".husky"]); + const commonHooks = join(dir, ".git", "hooks"); + await mkdir(commonHooks, { recursive: true }); + const delegatePath = join(commonHooks, SESSION_TRAILER_DELEGATE_FILE); + await writeFile(delegatePath, "#!/bin/sh\nexit 0\n"); + await chmod(delegatePath, 0o755); + + const setup = await read(); + + expect(setup.hookManager).toBe("husky"); + expect(setup.delegate).toBe("executable"); + }); + + it("reports the delegate not-executable rather than absent, under the same divergence", async () => { + if (!REMOVING_THE_BIT_MEANS_SOMETHING) return; + await mkdir(join(dir, ".husky"), { recursive: true }); + run(["config", "core.hooksPath", ".husky"]); + const commonHooks = join(dir, ".git", "hooks"); + await mkdir(commonHooks, { recursive: true }); + const delegatePath = join(commonHooks, SESSION_TRAILER_DELEGATE_FILE); + await writeFile(delegatePath, "#!/bin/sh\nexit 0\n"); + await chmod(delegatePath, 0o644); + + expect((await read()).delegate).toBe("not-executable"); + }); +}); diff --git a/cli/tests/infrastructure/adapters/github-raw-fetcher-adapter.integration.test.ts b/cli/tests/contexts/distribution/infrastructure/github-raw-fetcher-adapter.integration.test.ts similarity index 88% rename from cli/tests/infrastructure/adapters/github-raw-fetcher-adapter.integration.test.ts rename to cli/tests/contexts/distribution/infrastructure/github-raw-fetcher-adapter.integration.test.ts index fcad79550..8f19b5e3d 100644 --- a/cli/tests/infrastructure/adapters/github-raw-fetcher-adapter.integration.test.ts +++ b/cli/tests/contexts/distribution/infrastructure/github-raw-fetcher-adapter.integration.test.ts @@ -2,23 +2,19 @@ import { mkdtemp, rm } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { GitHubRawFetcherAdapter } from "../../../../src/contexts/distribution/infrastructure/github-raw-fetcher-adapter.js"; import { AuthenticationError, CatalogFetchAuthError, CatalogFetchError, CatalogFetchNotFoundError, -} from "../../../src/domain/errors.js"; -import { GitHubRawFetcherAdapter } from "../../../src/infrastructure/adapters/github-raw-fetcher-adapter.js"; -import { HttpNotFoundError } from "../../../src/infrastructure/errors.js"; -import type { HttpGet } from "../../../src/infrastructure/http/http-client.js"; - -/** A real HttpGet whose one method is a spy, so a call can be both made and asserted. */ -type SpyingHttp = HttpGet & { get: ReturnType }; + HttpNotFoundError, +} from "../../../../src/kernel/errors.js"; const CATALOG_PATH = ".claude-plugin/marketplace.json"; const SAMPLE_CATALOG = JSON.stringify({ plugins: [] }); -function makeHttp(override: Partial<{ get: ReturnType }> = {}): SpyingHttp { +function makeHttp(override: Partial<{ get: ReturnType }> = {}) { return { get: override.get ?? diff --git a/cli/tests/contexts/distribution/infrastructure/marketplace-cache-adapter.integration.test.ts b/cli/tests/contexts/distribution/infrastructure/marketplace-cache-adapter.integration.test.ts new file mode 100644 index 000000000..7c90d6805 --- /dev/null +++ b/cli/tests/contexts/distribution/infrastructure/marketplace-cache-adapter.integration.test.ts @@ -0,0 +1,82 @@ +import { mkdir, mkdtemp, readdir, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { MarketplaceCacheAdapter } from "../../../../src/contexts/distribution/infrastructure/marketplace-cache-adapter.js"; +import { MARKETPLACE_CACHE_SUBDIR } from "../../../../src/kernel/paths.js"; + +describe("MarketplaceCacheAdapter", () => { + let projectRoot: string; + let cacheRoot: string; + let adapter: MarketplaceCacheAdapter; + + beforeEach(async () => { + projectRoot = await mkdtemp(join(tmpdir(), "aidd-marketplace-cache-")); + cacheRoot = join(projectRoot, MARKETPLACE_CACHE_SUBDIR); + await mkdir(cacheRoot, { recursive: true }); + adapter = new MarketplaceCacheAdapter(projectRoot); + }); + + afterEach(async () => { + await rm(projectRoot, { recursive: true, force: true }); + }); + + async function createEntry( + name: string, + files: Record = {}, + lastFetchedAt?: string + ): Promise { + const entryDir = join(cacheRoot, name); + await mkdir(entryDir, { recursive: true }); + for (const [filename, content] of Object.entries(files)) { + await writeFile(join(entryDir, filename), content, "utf-8"); + } + if (lastFetchedAt !== undefined) { + await writeFile( + join(entryDir, ".fetch-meta.json"), + JSON.stringify({ lastFetchedAt }), + "utf-8" + ); + } + return entryDir; + } + + describe("clear(name)", () => { + it("removes a single named entry directory", async () => { + await createEntry("target", { "data.json": "{}" }); + await createEntry("keep", { "data.json": "{}" }); + + await adapter.clear("target"); + + const remaining = await readdir(cacheRoot); + expect(remaining).not.toContain("target"); + expect(remaining).toContain("keep"); + }); + + it("does not throw when named entry does not exist", async () => { + await expect(adapter.clear("nonexistent")).resolves.not.toThrow(); + }); + }); + + describe("clear() — no argument", () => { + it("removes all entries in the cache", async () => { + await createEntry("one", { "a.json": "{}" }); + await createEntry("two", { "b.json": "{}" }); + await createEntry("three", { "c.json": "{}" }); + + await adapter.clear(); + + const remaining = await readdir(cacheRoot); + expect(remaining).toHaveLength(0); + }); + + it("does not throw when cache directory is empty", async () => { + await expect(adapter.clear()).resolves.not.toThrow(); + }); + + it("does not throw when cache directory does not exist", async () => { + await rm(cacheRoot, { recursive: true, force: true }); + await expect(adapter.clear()).resolves.not.toThrow(); + }); + }); +}); diff --git a/cli/tests/infrastructure/adapters/marketplace-registry-adapter.integration.test.ts b/cli/tests/contexts/distribution/infrastructure/marketplace-registry-adapter.integration.test.ts similarity index 88% rename from cli/tests/infrastructure/adapters/marketplace-registry-adapter.integration.test.ts rename to cli/tests/contexts/distribution/infrastructure/marketplace-registry-adapter.integration.test.ts index 49448fe7e..618c9ebea 100644 --- a/cli/tests/infrastructure/adapters/marketplace-registry-adapter.integration.test.ts +++ b/cli/tests/contexts/distribution/infrastructure/marketplace-registry-adapter.integration.test.ts @@ -2,8 +2,11 @@ import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; -import { Marketplace, type MarketplaceData } from "../../../src/domain/models/marketplace.js"; -import { MarketplaceRegistryAdapter } from "../../../src/infrastructure/adapters/marketplace-registry-adapter.js"; +import { + Marketplace, + type MarketplaceData, +} from "../../../../src/contexts/distribution/domain/marketplace.js"; +import { MarketplaceRegistryAdapter } from "../../../../src/contexts/distribution/infrastructure/marketplace-registry-adapter.js"; const baseData = (overrides: Partial = {}): MarketplaceData => ({ name: "awesome", @@ -17,6 +20,7 @@ describe("MarketplaceRegistryAdapter", () => { let projectRoot: string; let homeDir: string; let originalHome: string | undefined; + let originalConfigDir: string | undefined; let originalUserProfile: string | undefined; let adapter: MarketplaceRegistryAdapter; @@ -25,13 +29,19 @@ describe("MarketplaceRegistryAdapter", () => { homeDir = await mkdtemp(join(tmpdir(), "marketplace-registry-home-")); originalHome = process.env.HOME; originalUserProfile = process.env.USERPROFILE; + originalConfigDir = process.env.AIDD_USER_CONFIG_DIR; process.env.HOME = homeDir; process.env.USERPROFILE = homeDir; + // Faking the home alone is not enough: the CLI falls back to `homedir()` only when + // `AIDD_USER_CONFIG_DIR` is unset, so a leaked value sends this test at a real registry. + process.env.AIDD_USER_CONFIG_DIR = join(homeDir, ".config", "aidd"); adapter = new MarketplaceRegistryAdapter(); }); afterEach(async () => { process.env.HOME = originalHome; + if (originalConfigDir === undefined) delete process.env.AIDD_USER_CONFIG_DIR; + else process.env.AIDD_USER_CONFIG_DIR = originalConfigDir; process.env.USERPROFILE = originalUserProfile; await rm(projectRoot, { recursive: true, force: true }); await rm(homeDir, { recursive: true, force: true }); @@ -43,13 +53,8 @@ describe("MarketplaceRegistryAdapter", () => { expect(result).toEqual([]); }); - // A registry file that exists but does not hold the list is not an empty registry, and - // reading it as one is how a person loses every marketplace they registered: `save()` - // reads this same list, appends to it and writes the whole file back, so one silent - // empty read turns into a file with one entry where there were five. Found on a real - // `~/.config/aidd/marketplaces.json` holding `{"version":1}`, which crashed - // `aidd marketplace list` with "Cannot read properties of undefined (reading 'map')" - - // a stack trace naming nothing a person can act on. + // A registry file that exists but holds no list is not an empty registry: `save()` reads + // that same list, appends and writes the file back, so one silent empty read loses all. it("refuses a registry file that carries no list, naming the file rather than crashing", async () => { const userFile = join(homeDir, ".config", "aidd", "marketplaces.json"); await mkdir(join(homeDir, ".config", "aidd"), { recursive: true }); diff --git a/cli/tests/infrastructure/adapters/marketplace-trust-store-adapter.integration.test.ts b/cli/tests/contexts/distribution/infrastructure/marketplace-trust-store-adapter.integration.test.ts similarity index 91% rename from cli/tests/infrastructure/adapters/marketplace-trust-store-adapter.integration.test.ts rename to cli/tests/contexts/distribution/infrastructure/marketplace-trust-store-adapter.integration.test.ts index e246d325c..1495c4bb3 100644 --- a/cli/tests/infrastructure/adapters/marketplace-trust-store-adapter.integration.test.ts +++ b/cli/tests/contexts/distribution/infrastructure/marketplace-trust-store-adapter.integration.test.ts @@ -2,9 +2,9 @@ import { mkdtemp, readFile, rm } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; -import type { PluginSource } from "../../../src/domain/models/plugin-source.js"; -import { HasherAdapter } from "../../../src/infrastructure/adapters/hasher-adapter.js"; -import { MarketplaceTrustStoreAdapter } from "../../../src/infrastructure/adapters/marketplace-trust-store-adapter.js"; +import { MarketplaceTrustStoreAdapter } from "../../../../src/contexts/distribution/infrastructure/marketplace-trust-store-adapter.js"; +import type { PluginSource } from "../../../../src/kernel/source.js"; +import { HasherAdapter } from "../../../../src/runtime/filesystem/hasher-adapter.js"; const githubSource: PluginSource = { kind: "github", repo: "owner/repo" }; const otherSource: PluginSource = { kind: "github", repo: "owner/other" }; diff --git a/cli/tests/contexts/distribution/infrastructure/plugin-catalog-repository-adapter.integration.test.ts b/cli/tests/contexts/distribution/infrastructure/plugin-catalog-repository-adapter.integration.test.ts new file mode 100644 index 000000000..6530538e4 --- /dev/null +++ b/cli/tests/contexts/distribution/infrastructure/plugin-catalog-repository-adapter.integration.test.ts @@ -0,0 +1,176 @@ +import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; +import { PluginCatalogRepositoryAdapter } from "../../../../src/contexts/distribution/infrastructure/plugin-catalog-repository-adapter.js"; +import { + InvalidPluginManifestError, + MalformedMarketplaceCatalogError, +} from "../../../../src/kernel/errors.js"; +import { FileAdapter } from "../../../../src/runtime/filesystem/file-adapter.js"; +import { HasherAdapter } from "../../../../src/runtime/filesystem/hasher-adapter.js"; + +const FIXTURE_DIR = join(process.cwd(), "tests/fixtures/framework"); +const COPILOT_FIXTURE_DIR = join(process.cwd(), "tests/fixtures/plugins/copilot-format"); + +function makeAdapter(): PluginCatalogRepositoryAdapter { + return new PluginCatalogRepositoryAdapter(new FileAdapter(new HasherAdapter())); +} + +describe("PluginCatalogRepositoryAdapter", () => { + describe("marketplace-sample fixture", () => { + it("returns a catalog with two entries", async () => { + const adapter = makeAdapter(); + const catalog = await adapter.load(join(FIXTURE_DIR, "marketplace-sample")); + expect(catalog).not.toBeNull(); + expect(catalog?.plugins).toHaveLength(2); + }); + + it("first entry has recommended true", async () => { + const adapter = makeAdapter(); + const catalog = await adapter.load(join(FIXTURE_DIR, "marketplace-sample")); + expect(catalog?.plugins[0].recommended).toBe(true); + }); + + it("second entry has recommended false", async () => { + const adapter = makeAdapter(); + const catalog = await adapter.load(join(FIXTURE_DIR, "marketplace-sample")); + expect(catalog?.plugins[1].recommended).toBe(false); + }); + + it("resolves relative local source path against framework directory", async () => { + const adapter = makeAdapter(); + const frameworkDir = join(FIXTURE_DIR, "marketplace-sample"); + const catalog = await adapter.load(frameworkDir); + expect(catalog?.plugins[0].source).toEqual({ + kind: "local", + path: join(frameworkDir, "plugins/dev"), + }); + }); + + it("parses github source for second entry", async () => { + const adapter = makeAdapter(); + const catalog = await adapter.load(join(FIXTURE_DIR, "marketplace-sample")); + expect(catalog?.plugins[1].source).toEqual({ + kind: "github", + repo: "ai-driven-dev/aidd-pm", + }); + }); + }); + + describe("marketplace-missing fixture", () => { + it("returns null when marketplace.json is absent", async () => { + const adapter = makeAdapter(); + const catalog = await adapter.load(join(FIXTURE_DIR, "marketplace-missing")); + expect(catalog).toBeNull(); + }); + }); + + describe("marketplace-malformed fixture", () => { + it("throws InvalidPluginManifestError for invalid JSON", async () => { + const adapter = makeAdapter(); + await expect(adapter.load(join(FIXTURE_DIR, "marketplace-malformed"))).rejects.toThrow( + InvalidPluginManifestError + ); + }); + }); +}); + +describe("PluginCatalogRepositoryAdapter.load (Copilot-native path)", () => { + describe("copilot marketplace-multi-sample fixture", () => { + it("returns a catalog with two entries from .plugin/marketplace.json", async () => { + const adapter = makeAdapter(); + const catalog = await adapter.load(join(COPILOT_FIXTURE_DIR, "marketplace-multi-sample")); + expect(catalog).not.toBeNull(); + expect(catalog?.plugins).toHaveLength(2); + }); + + it("carries the catalog name", async () => { + const adapter = makeAdapter(); + const catalog = await adapter.load(join(COPILOT_FIXTURE_DIR, "marketplace-multi-sample")); + expect(catalog?.name).toBe("aidd-framework"); + }); + + it("resolves relative local source path against framework directory", async () => { + const adapter = makeAdapter(); + const frameworkDir = join(COPILOT_FIXTURE_DIR, "marketplace-multi-sample"); + const catalog = await adapter.load(frameworkDir); + expect(catalog?.plugins[0].source).toEqual({ + kind: "local", + path: join(frameworkDir, "plugins/aidd-dev"), + }); + }); + + it("sets recommended and strict to false", async () => { + const adapter = makeAdapter(); + const catalog = await adapter.load(join(COPILOT_FIXTURE_DIR, "marketplace-multi-sample")); + expect(catalog?.plugins[0].recommended).toBe(false); + expect(catalog?.plugins[0].strict).toBe(false); + }); + }); + + describe("copilot marketplace-multi-missing fixture", () => { + it("returns null when neither .plugin/marketplace.json nor .claude-plugin/marketplace.json exists", async () => { + const adapter = makeAdapter(); + const catalog = await adapter.load(join(COPILOT_FIXTURE_DIR, "marketplace-multi-missing")); + expect(catalog).toBeNull(); + }); + }); + + describe("copilot marketplace-multi-malformed fixture", () => { + it("throws InvalidPluginManifestError for invalid JSON in .plugin/marketplace.json", async () => { + const adapter = makeAdapter(); + await expect( + adapter.load(join(COPILOT_FIXTURE_DIR, "marketplace-multi-malformed")) + ).rejects.toThrow(InvalidPluginManifestError); + }); + }); +}); + +// A cached marketplace.json holding a non-array `plugins` must surface an actionable, +// recovery-bearing error, and the hint differs for a cache source and a user-provided one. +describe("PluginCatalogRepositoryAdapter.load — malformed catalog recovery", () => { + async function writeCatalog(frameworkDir: string, content: string): Promise { + await mkdir(join(frameworkDir, ".claude-plugin"), { recursive: true }); + await writeFile(join(frameworkDir, ".claude-plugin/marketplace.json"), content, "utf-8"); + } + + it("non-array object under a cache path → MalformedMarketplaceCatalogError with refresh hint", async () => { + const tmp = await mkdtemp(join(tmpdir(), "aidd-catalog-cache-")); + const cacheDir = join(tmp, ".aidd/cache/marketplaces/aidd-framework/github-x"); + await writeCatalog(cacheDir, '{"message":"API rate limit exceeded"}'); + const adapter = makeAdapter(); + try { + await expect(adapter.load(cacheDir)).rejects.toThrow(MalformedMarketplaceCatalogError); + await expect(adapter.load(cacheDir)).rejects.toThrow(/marketplace refresh --force/); + // Backward-compat: still an InvalidPluginManifestError for existing catchers. + await expect(adapter.load(cacheDir)).rejects.toThrow(InvalidPluginManifestError); + } finally { + await rm(tmp, { recursive: true, force: true }); + } + }); + + it("malformed JSON under a cache path → recovery hint, never a raw JSON.parse crash", async () => { + const tmp = await mkdtemp(join(tmpdir(), "aidd-catalog-cache-")); + const cacheDir = join(tmp, ".aidd/cache/marketplaces/aidd-framework/github-x"); + await writeCatalog(cacheDir, "{ not valid json"); + const adapter = makeAdapter(); + try { + await expect(adapter.load(cacheDir)).rejects.toThrow(/marketplace refresh --force/); + } finally { + await rm(tmp, { recursive: true, force: true }); + } + }); + + it("malformed catalog from a user-provided (non-cache) source → fix-the-file hint", async () => { + const tmp = await mkdtemp(join(tmpdir(), "aidd-catalog-local-")); + await writeCatalog(tmp, '{"plugins":{}}'); + const adapter = makeAdapter(); + try { + await expect(adapter.load(tmp)).rejects.toThrow(MalformedMarketplaceCatalogError); + await expect(adapter.load(tmp)).rejects.toThrow(/Fix or re-create/); + } finally { + await rm(tmp, { recursive: true, force: true }); + } + }); +}); diff --git a/cli/tests/infrastructure/adapters/plugin-fetcher-adapter.integration.test.ts b/cli/tests/contexts/distribution/infrastructure/plugin-fetcher-adapter.integration.test.ts similarity index 95% rename from cli/tests/infrastructure/adapters/plugin-fetcher-adapter.integration.test.ts rename to cli/tests/contexts/distribution/infrastructure/plugin-fetcher-adapter.integration.test.ts index b2bebaf0d..2715b5a66 100644 --- a/cli/tests/infrastructure/adapters/plugin-fetcher-adapter.integration.test.ts +++ b/cli/tests/contexts/distribution/infrastructure/plugin-fetcher-adapter.integration.test.ts @@ -5,10 +5,10 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { promisify } from "node:util"; import { describe, expect, it } from "vitest"; -import { PluginFetchError } from "../../../src/domain/errors.js"; -import { FileAdapter } from "../../../src/infrastructure/adapters/file-adapter.js"; -import { HasherAdapter } from "../../../src/infrastructure/adapters/hasher-adapter.js"; -import { PluginFetcherAdapter } from "../../../src/infrastructure/adapters/plugin-fetcher-adapter.js"; +import { PluginFetcherAdapter } from "../../../../src/contexts/distribution/infrastructure/plugin-fetcher-adapter.js"; +import { PluginFetchError } from "../../../../src/kernel/errors.js"; +import { FileAdapter } from "../../../../src/runtime/filesystem/file-adapter.js"; +import { HasherAdapter } from "../../../../src/runtime/filesystem/hasher-adapter.js"; const execFileAsync = promisify(execFile); const FIXTURE_DIR = join(process.cwd(), "tests/fixtures/plugins"); diff --git a/cli/tests/contexts/distribution/infrastructure/plugin-fetcher-cache-and-auth.unit.test.ts b/cli/tests/contexts/distribution/infrastructure/plugin-fetcher-cache-and-auth.unit.test.ts new file mode 100644 index 000000000..ddab64382 --- /dev/null +++ b/cli/tests/contexts/distribution/infrastructure/plugin-fetcher-cache-and-auth.unit.test.ts @@ -0,0 +1,417 @@ +/** + * A cache key that changes between two runs re-clones every time, and one two sources share + * hands one plugin's tree to the other; a failure message must not carry the user's token. + */ +import { join } from "node:path"; +import { describe, expect, it, vi } from "vitest"; + +const mockEnvFn = vi.fn().mockReturnThis(); +const mockCloneFn = vi.fn().mockResolvedValue(undefined); +const mockRawFn = vi.fn().mockResolvedValue(undefined); +const mockCheckoutFn = vi.fn().mockResolvedValue(undefined); + +const mockGitInstance = { + env: mockEnvFn, + clone: mockCloneFn, + raw: mockRawFn, + checkout: mockCheckoutFn, +}; + +const mockSimpleGit = vi.fn(() => mockGitInstance); + +vi.mock("simple-git", () => ({ + simpleGit: (...args: unknown[]) => mockSimpleGit(...(args as [])), +})); + +const mockExecFile = vi.fn().mockResolvedValue({ stdout: "", stderr: "" }); + +vi.mock("node:child_process", () => ({ + execFile: ( + cmd: string, + args: string[], + cb: (err: unknown, result?: { stdout: string; stderr: string }) => void + ) => { + try { + mockExecFile(cmd, args); + cb(null, { stdout: "", stderr: "" }); + } catch (err) { + cb(err); + } + }, +})); + +import { PluginFetcherAdapter } from "../../../../src/contexts/distribution/infrastructure/plugin-fetcher-adapter.js"; +import { PluginFetchError } from "../../../../src/kernel/errors.js"; +import type { PluginSource } from "../../../../src/kernel/source.js"; +import type { TokenProvider } from "../../../../src/runtime/auth/ports/token-provider.js"; +import { DeterministicHasher } from "../../../helpers/ports/deterministic-hasher.js"; +import { InMemoryFileAdapter } from "../../../helpers/ports/in-memory-file-adapter.js"; + +const CACHE = "/tmp/cache"; + +function reset(): void { + mockCloneFn.mockClear().mockResolvedValue(undefined); + mockRawFn.mockClear().mockResolvedValue(undefined); + mockCheckoutFn.mockClear().mockResolvedValue(undefined); + mockSimpleGit.mockClear(); + mockExecFile.mockClear().mockReturnValue(undefined); +} + +let lastFs: InMemoryFileAdapter; + +function adapter(token?: string, files: Record = {}): PluginFetcherAdapter { + reset(); + lastFs = new InMemoryFileAdapter(files, new DeterministicHasher()); + const provider: TokenProvider | undefined = + token === undefined ? undefined : { resolve: async () => token }; + return new PluginFetcherAdapter(lastFs, provider); +} + +function clonedInto(): string { + return mockCloneFn.mock.calls[0]?.[1] as string; +} + +/** The URL the clone was given — the one that may legitimately carry a token. */ +function clonedFrom(): string { + return mockCloneFn.mock.calls[0]?.[0] as string; +} + +describe("where a fetched source lands in the cache", () => { + describe("a github repo", () => { + it("keys on owner, repo and ref, so two refs of one repo do not share a tree", async () => { + const source: PluginSource = { kind: "github", repo: "acme/widgets", ref: "v2" }; + + await adapter().fetch(source, CACHE); + + expect(clonedInto()).toBe(join(CACHE, "github-acme-widgets-v2")); + }); + + it("keys an unpinned repo on HEAD, not on an empty ref", async () => { + await adapter().fetch({ kind: "github", repo: "acme/widgets" }, CACHE); + + expect(clonedInto()).toBe(join(CACHE, "github-acme-widgets-HEAD")); + }); + + it("is handed back from the cache instead of cloned a second time", async () => { + const fetcher = adapter(undefined, { + [`${CACHE}/github-acme-widgets-HEAD/plugin.json`]: "{}", + }); + + const result = await fetcher.fetch({ kind: "github", repo: "acme/widgets" }, CACHE); + + expect(mockCloneFn).not.toHaveBeenCalled(); + expect(result).toBe(join(CACHE, "github-acme-widgets-HEAD")); + }); + }); + + describe("a bare git url", () => { + it("encodes the url and marks it HEAD when no ref is pinned", async () => { + await adapter().fetch({ kind: "url", url: "https://example.com/repo.git" }, CACHE); + + expect(clonedInto()).toBe(join(CACHE, "https___example_com_repo_git-HEAD")); + }); + + it("appends the pinned ref, so a pin does not reuse the unpinned tree", async () => { + await adapter().fetch({ kind: "url", url: "https://example.com/repo.git", ref: "v1" }, CACHE); + + expect(clonedInto()).toBe(join(CACHE, "https___example_com_repo_git-v1")); + }); + + it("truncates the key at 64 characters, the documented path-length guard", async () => { + const long = `https://example.com/${"a".repeat(200)}.git`; + + await adapter().fetch({ kind: "url", url: long }, CACHE); + + const key = clonedInto().slice(CACHE.length + 1); + expect(key, "64 encoded characters plus the -HEAD suffix").toBe( + `${"https___example_com_".concat("a".repeat(44))}-HEAD` + ); + }); + }); + + describe("a subdirectory of a git repo", () => { + it("keys on url, subpath and ref, and returns the subdirectory itself", async () => { + const result = await adapter().fetch( + { + kind: "git-subdir", + url: "https://example.com/mono.git", + path: "packages/one", + ref: "main", + }, + CACHE + ); + + const dir = join(CACHE, "https___example_com_mono_git-subdir-packages_one-main"); + expect(clonedInto()).toBe(dir); + expect(result, "the caller wants the subdirectory, not the clone root").toBe( + join(dir, "packages", "one") + ); + }); + }); + + describe("a url the user typed their own credential into", () => { + const url = "https://user:ghp_SECRET@example.com/private.git"; + + it("keeps the credential out of the directory name written to disk", async () => { + await adapter().fetch({ kind: "url", url }, CACHE); + + expect(clonedInto(), "a secret must not become a filename").not.toContain("ghp_SECRET"); + expect(clonedInto()).toBe(join(CACHE, "https___example_com_private_git-HEAD")); + }); + + it("still hands the credential to git, which is what it is for", async () => { + await adapter().fetch({ kind: "url", url }, CACHE); + + expect(clonedFrom()).toBe(url); + }); + }); +}); + +describe("the token the CLI adds for the user", () => { + it("injects a resolved token into an https url", async () => { + await adapter("tok").fetch({ kind: "github", repo: "acme/widgets" }, CACHE); + + expect(clonedFrom()).toBe("https://x-access-token:tok@github.com/acme/widgets.git"); + }); + + it("leaves an ssh url alone, where a token means nothing", async () => { + await adapter("tok").fetch({ kind: "url", url: "git@example.com:acme/repo.git" }, CACHE); + + expect(clonedFrom()).toBe("git@example.com:acme/repo.git"); + }); + + it("injects it into a bare https url too, not only a github shorthand", async () => { + await adapter("tok").fetch({ kind: "url", url: "https://example.com/private.git" }, CACHE); + + expect(clonedFrom()).toBe("https://tok@example.com/private.git"); + }); + + it("injects it when only a subdirectory of a private repo is wanted", async () => { + await adapter("tok").fetch( + { kind: "git-subdir", url: "https://example.com/mono.git", path: "packages/one" }, + CACHE + ); + + expect(clonedFrom()).toBe("https://tok@example.com/mono.git"); + }); + + it("clones unauthenticated when no provider is wired", async () => { + await adapter().fetch({ kind: "url", url: "https://example.com/repo.git" }, CACHE); + + expect(clonedFrom()).toBe("https://example.com/repo.git"); + }); +}); + +describe("how each kind of clone is asked for", () => { + it("clones shallow, and asks for the branch only when one is pinned", async () => { + await adapter().fetch({ kind: "github", repo: "acme/widgets", ref: "v2" }, CACHE); + expect(mockCloneFn.mock.calls[0]?.[2]).toEqual(["--depth", "1", "--branch", "v2"]); + + await adapter().fetch({ kind: "github", repo: "acme/widgets" }, CACHE); + expect(mockCloneFn.mock.calls[0]?.[2]).toEqual(["--depth", "1"]); + }); + + it("fetches a subdirectory without blobs, then narrows, then checks out", async () => { + const fetcher = adapter(); + + await fetcher.fetch( + { kind: "git-subdir", url: "https://example.com/mono.git", path: "packages/one" }, + CACHE + ); + + expect(mockCloneFn.mock.calls[0]?.[2]).toEqual(["--filter=blob:none", "--no-checkout"]); + expect(mockRawFn).toHaveBeenCalledWith(["sparse-checkout", "set", "packages/one"]); + expect(mockCheckoutFn, "no ref pinned means HEAD").toHaveBeenCalledWith("HEAD"); + }); + + it("checks out the pinned ref of a subdirectory source", async () => { + await adapter().fetch( + { + kind: "git-subdir", + url: "https://example.com/mono.git", + path: "packages/one", + ref: "release", + }, + CACHE + ); + + expect(mockCheckoutFn).toHaveBeenCalledWith("release"); + }); +}); + +describe("an npm package as a source", () => { + it("asks for the pinned version", async () => { + await adapter().fetch({ kind: "npm", package: "@acme/plugin", version: "1.2.3" }, CACHE); + + expect(mockExecFile).toHaveBeenCalledWith("pnpm", [ + "add", + "--prefix", + CACHE, + "--", + "@acme/plugin@1.2.3", + ]); + }); + + it("falls back to latest when no version is pinned", async () => { + await adapter().fetch({ kind: "npm", package: "@acme/plugin" }, CACHE); + + expect(mockExecFile.mock.calls[0]?.[1]?.at(-1)).toBe("@acme/plugin@latest"); + }); + + it("names the spec it could not install", async () => { + const fetcher = adapter(); + mockExecFile.mockImplementation(() => { + throw new Error("ERR_PNPM_FETCH_404"); + }); + + await expect( + fetcher.fetch({ kind: "npm", package: "@acme/plugin", version: "9.9.9" }, CACHE) + ).rejects.toThrow(/@acme\/plugin@9\.9\.9/); + }); +}); + +describe("what the user is told when a clone fails", () => { + const url = "https://user:ghp_SECRET@example.com/private.git"; + + it("does not print the credential the user typed", async () => { + const fetcher = adapter(); + mockCloneFn.mockRejectedValue(new Error("fatal: repository not reachable")); + + await expect(fetcher.fetch({ kind: "url", url }, CACHE)).rejects.toThrow( + expect.objectContaining({ + message: expect.not.stringContaining("ghp_SECRET"), + }) + ); + }); + + it("does not print the token the CLI itself injected", async () => { + const fetcher = adapter("injected-token"); + mockCloneFn.mockRejectedValue( + new Error("fatal: could not clone https://x-access-token:injected-token@github.com/a/b.git") + ); + + await expect(fetcher.fetch({ kind: "github", repo: "acme/widgets" }, CACHE)).rejects.toThrow( + expect.objectContaining({ message: expect.not.stringContaining("injected-token") }) + ); + }); + + it("keeps the rest of git's message, so the failure is still diagnosable", async () => { + const fetcher = adapter("injected-token"); + mockCloneFn.mockRejectedValue( + new Error("fatal: could not clone https://x-access-token:injected-token@github.com/a/b.git") + ); + + await expect(fetcher.fetch({ kind: "github", repo: "acme/widgets" }, CACHE)).rejects.toThrow( + "fatal: could not clone https://github.com/a/b.git" + ); + }); + + describe("when the remote refused the credentials", () => { + it("tells an https user how to supply a token", async () => { + const fetcher = adapter(); + mockCloneFn.mockRejectedValue(new Error("remote: Repository not found")); + + await expect( + fetcher.fetch({ kind: "url", url: "https://example.com/private.git" }, CACHE) + ).rejects.toThrow(/aidd auth login/); + }); + + it("tells an ssh user to check their key instead", async () => { + const fetcher = adapter(); + mockCloneFn.mockRejectedValue(new Error("Permission denied (publickey)")); + + await expect( + fetcher.fetch({ kind: "url", url: "git@example.com:acme/private.git" }, CACHE) + ).rejects.toThrow(/SSH key/); + }); + }); + + it("reports an unrecognised failure as a clone failure, not an auth one", async () => { + const fetcher = adapter(); + mockCloneFn.mockRejectedValue(new Error("fatal: unable to access: server hung up")); + + await expect( + fetcher.fetch({ kind: "url", url: "https://example.com/repo.git" }, CACHE) + ).rejects.toThrow(/git clone failed/); + }); +}); + +describe("a tree already in the cache", () => { + const dir = join(CACHE, "https___example_com_mono_git-subdir-packages_one-HEAD"); + const source: PluginSource = { + kind: "git-subdir", + url: "https://example.com/mono.git", + path: "packages/one", + }; + + it("surfaces a failure of the narrowing clone rather than swallowing it", async () => { + const fetcher = adapter(); + mockCloneFn.mockRejectedValue(new Error("fatal: filter not supported")); + + await expect(fetcher.fetch(source, CACHE)).rejects.toThrow(PluginFetchError); + }); + + it("is handed back without cloning again", async () => { + const fetcher = adapter(undefined, { [`${dir}/packages/one/plugin.json`]: "{}" }); + + const result = await fetcher.fetch(source, CACHE); + + expect(mockCloneFn, "a cached tree is the whole point of the cache").not.toHaveBeenCalled(); + expect(result).toBe(join(dir, "packages", "one")); + }); + + it("is kept when the caller passes no options at all", async () => { + const fetcher = adapter(undefined, { [`${dir}/packages/one/plugin.json`]: "{}" }); + + await fetcher.fetch(source, CACHE); + + expect(mockCloneFn, "no options must not mean force-refresh").not.toHaveBeenCalled(); + }); + + it("is thrown away and re-cloned when the caller forces a refresh", async () => { + const fetcher = adapter(undefined, { [`${dir}/packages/one/plugin.json`]: "{}" }); + + await fetcher.fetch(source, CACHE, { forceRefresh: true }); + + expect(mockCloneFn).toHaveBeenCalledTimes(1); + }); +}); + +describe("an npm package already installed in the cache", () => { + const source: PluginSource = { kind: "npm", package: "@acme/plugin" }; + const installed = { [`${CACHE}/node_modules/@acme/plugin/package.json`]: "{}" }; + + it("is reinstalled from scratch when the caller forces a refresh", async () => { + const fetcher = adapter(undefined, installed); + + const result = await fetcher.fetch(source, CACHE, { forceRefresh: true }); + + expect( + await lastFs.fileExists(`${CACHE}/node_modules/@acme/plugin/package.json`), + "the stale install is wiped, not installed over" + ).toBe(false); + expect(result).toBe(join(CACHE, "node_modules", "@acme", "plugin")); + expect(mockExecFile, "the reinstall still happens after the wipe").toHaveBeenCalledTimes(1); + }); + + it("is left in place when no refresh is asked for", async () => { + const fetcher = adapter(undefined, installed); + + await fetcher.fetch(source, CACHE); + + expect(await lastFs.fileExists(`${CACHE}/node_modules/@acme/plugin/package.json`)).toBe(true); + }); +}); + +describe("a local path as a source", () => { + it("names the path it resolved, not the one that was typed", async () => { + const fetcher = adapter(); + + await expect(fetcher.fetch({ kind: "local", path: "./missing" }, CACHE)).rejects.toThrow( + PluginFetchError + ); + await expect(fetcher.fetch({ kind: "local", path: "./missing" }, CACHE)).rejects.toThrow( + new RegExp(process.cwd().replace(/[.*+?^${}()|[\]\\]/g, "\\$&")) + ); + }); +}); diff --git a/cli/tests/infrastructure/adapters/plugin-fetcher-failfast.unit.test.ts b/cli/tests/contexts/distribution/infrastructure/plugin-fetcher-failfast.unit.test.ts similarity index 89% rename from cli/tests/infrastructure/adapters/plugin-fetcher-failfast.unit.test.ts rename to cli/tests/contexts/distribution/infrastructure/plugin-fetcher-failfast.unit.test.ts index 6ff5e8625..c10d3ad98 100644 --- a/cli/tests/infrastructure/adapters/plugin-fetcher-failfast.unit.test.ts +++ b/cli/tests/contexts/distribution/infrastructure/plugin-fetcher-failfast.unit.test.ts @@ -29,9 +29,9 @@ vi.mock("node:child_process", () => ({ }, })); -import { PluginFetcherAdapter } from "../../../src/infrastructure/adapters/plugin-fetcher-adapter.js"; -import { DeterministicHasher } from "../../helpers/ports/deterministic-hasher.js"; -import { InMemoryFileAdapter } from "../../helpers/ports/in-memory-file-adapter.js"; +import { PluginFetcherAdapter } from "../../../../src/contexts/distribution/infrastructure/plugin-fetcher-adapter.js"; +import { DeterministicHasher } from "../../../helpers/ports/deterministic-hasher.js"; +import { InMemoryFileAdapter } from "../../../helpers/ports/in-memory-file-adapter.js"; function makeAdapter(): PluginFetcherAdapter { const fs = new InMemoryFileAdapter({}, new DeterministicHasher()); diff --git a/cli/tests/contexts/framework/application/clean-native-cache.integration.test.ts b/cli/tests/contexts/framework/application/clean-native-cache.integration.test.ts new file mode 100644 index 000000000..f4df0480a --- /dev/null +++ b/cli/tests/contexts/framework/application/clean-native-cache.integration.test.ts @@ -0,0 +1,551 @@ +/** + * The two measured leftovers this covers: `claude` leaves the built tree in full, marked + * `.orphaned_at`; `codex` deletes a marketplace's content but leaves the empty shell behind. + */ +import { homedir } from "node:os"; +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; +import "../../../../src/contexts/tools/domain/profiles/claude/profile.js"; +import "../../../../src/contexts/tools/domain/profiles/codex/profile.js"; +import "../../../../src/contexts/tools/domain/profiles/copilot/profile.js"; +// hostMarketplaceRegistryReaders (used by the HOME-parity test below) iterates every +// AI_TOOL_IDS entry, so every profile must be registered here too. +import "../../../../src/contexts/tools/domain/profiles/cursor/profile.js"; +import "../../../../src/contexts/tools/domain/profiles/opencode/profile.js"; +import { Marketplace } from "../../../../src/contexts/distribution/domain/marketplace.js"; +import { CleanUseCase } from "../../../../src/contexts/framework/application/clean-use-case.js"; +import { GitignoreUseCase } from "../../../../src/contexts/framework/application/gitignore-use-case.js"; +import { Manifest } from "../../../../src/contexts/framework/domain/manifest.js"; +import type { + HostMarketplaceRegistryReader, + HostMarketplaceRegistryReading, +} from "../../../../src/contexts/tools/domain/ports/host-marketplace-registry-reader.js"; +import { hostMarketplaceRegistryReaders } from "../../../../src/contexts/tools/infrastructure/host-marketplace-registry-reader-adapter.js"; +import { AIDD_DIR } from "../../../../src/kernel/paths.js"; +import type { AiToolId } from "../../../../src/kernel/tool.js"; +import { CapturingLogger } from "../../../helpers/ports/capturing-logger.js"; +import { FakeHostMarketplaceRegistryReader } from "../../../helpers/ports/fake-host-marketplace-registry-reader.js"; +import { FakeNativePluginActivator } from "../../../helpers/ports/fake-native-plugin-activator.js"; +import { InMemoryFileAdapter } from "../../../helpers/ports/in-memory-file-adapter.js"; +import { InMemoryManifestRepository } from "../../../helpers/ports/in-memory-manifest-repository.js"; +import { InMemoryMarketplaceRegistry } from "../../../helpers/ports/in-memory-marketplace-registry.js"; + +const PROJECT_ROOT = "/test-project"; +// Not injectable: CleanUseCase.execute() resolves `nodeHomedir()` itself, so the same real +// function here — never a fixed literal — is what keeps every path below in sync with it. +const HOME = homedir(); +const CLAUDE_CACHE_ROOT = join(HOME, ".claude", "plugins", "cache"); +const CODEX_CACHE_ROOT = join(HOME, ".codex", "plugins", "cache"); +const MARKETPLACE = "probe-mkt"; +const REF = "plugin-a@probe-mkt"; + +/** Records every `deleteDirectory` call, so a test can prove containment refused one + * without ever letting it run. */ +class RecordingFileAdapter extends InMemoryFileAdapter { + readonly deletedDirectories: string[] = []; + + override async deleteDirectory(path: string): Promise { + this.deletedDirectories.push(path); + return super.deleteDirectory(path); + } +} + +/** Fails `realpath` with a non-ENOENT error (EACCES) for one exact path, so a test can prove + * `clean` treats that failure like containment — named and skipped — not as an abort. */ +class RealpathDeniedFileAdapter extends RecordingFileAdapter { + constructor(private readonly deniedPath: string) { + super(); + } + + override async realpath(path: string): Promise { + if (path === this.deniedPath) { + throw Object.assign(new Error("permission denied"), { code: "EACCES" }); + } + return super.realpath(path); + } +} + +/** + * Mirrors `activator.removedMarketplaces` at read time rather than answering a canned "gone", + * which is what proves ordering: a registry read before `removeMarketplace` still sees it. + */ +class RegistryMirroringActivatorState implements HostMarketplaceRegistryReader { + reads = 0; + + constructor( + private readonly location: string, + private readonly initialNames: readonly string[], + private readonly activator: FakeNativePluginActivator + ) {} + + async read(): Promise { + this.reads += 1; + const entries = new Map(); + for (const name of this.initialNames) { + if (!this.activator.removedMarketplaces.includes(name)) { + entries.set(name, "/resolved/source"); + } + } + return { location: this.location, entries }; + } +} + +function seedManifest(toolId: "claude" | "codex", hostName: string, alias: string): Manifest { + const manifest = Manifest.create(); + manifest.addTool(toolId, "1.0.0", []); + manifest.setNativeRegistrations(toolId, { + binary: toolId, + marketplaces: [{ alias, hostName }], + pluginRefs: [REF], + }); + return manifest; +} + +function seedAiddMarketplaceRegistry(alias: string): InMemoryMarketplaceRegistry { + const registry = new InMemoryMarketplaceRegistry(); + registry.save( + PROJECT_ROOT, + Marketplace.create({ + name: alias, + source: { kind: "local", path: "/some/built/path" }, + scope: "project", + addedAt: "2026-01-01T00:00:00.000Z", + }) + ); + return registry; +} + +function buildUseCase(deps: { + fs: InMemoryFileAdapter; + manifest: Manifest; + activator: FakeNativePluginActivator; + binary: string; + logger: CapturingLogger; + aiddMarketplaceRegistry: InMemoryMarketplaceRegistry; + hostMarketplaceRegistries?: ReadonlyMap; + homeDir?: () => string; +}): CleanUseCase { + const manifestRepo = new InMemoryManifestRepository(deps.manifest, PROJECT_ROOT); + return new CleanUseCase( + deps.fs, + manifestRepo, + deps.logger, + new GitignoreUseCase(deps.fs), + new Map([[deps.binary, deps.activator]]), + deps.aiddMarketplaceRegistry, + undefined, + deps.hostMarketplaceRegistries ?? new Map(), + deps.homeDir + ); +} + +describe("clean purges a host's own plugin cache", () => { + it("purges claude's cache once undoing the registration actually frees the name", async () => { + const fs = new RecordingFileAdapter(); + const cacheEntry = join(CLAUDE_CACHE_ROOT, MARKETPLACE, "plugin-a", "1.0.0", "plugin.json"); + await fs.writeFile(cacheEntry, "{}"); + await fs.writeFile( + join(CLAUDE_CACHE_ROOT, MARKETPLACE, "plugin-a", "1.0.0", ".orphaned_at"), + "now" + ); + + const activator = new FakeNativePluginActivator({ available: true }); + const reader = new RegistryMirroringActivatorState( + "known_marketplaces.json", + [MARKETPLACE], + activator + ); + const useCase = buildUseCase({ + fs, + manifest: seedManifest("claude", MARKETPLACE, MARKETPLACE), + activator, + binary: "claude", + logger: new CapturingLogger(), + aiddMarketplaceRegistry: seedAiddMarketplaceRegistry(MARKETPLACE), + hostMarketplaceRegistries: new Map([["claude", reader]]), + }); + + await useCase.execute({ projectRoot: PROJECT_ROOT, force: true }); + + expect(await fs.fileExists(cacheEntry)).toBe(false); + expect(activator.removedMarketplaces).toContain(MARKETPLACE); + expect(reader.reads).toBe(1); + }); + + it("leaves claude's cache in place, and names it, when the claude CLI is not on PATH", async () => { + const fs = new RecordingFileAdapter(); + const cacheEntry = join(CLAUDE_CACHE_ROOT, MARKETPLACE, "plugin-a", "1.0.0", "plugin.json"); + await fs.writeFile(cacheEntry, "{}"); + + const activator = new FakeNativePluginActivator({ available: false }); + const reader = new RegistryMirroringActivatorState( + "known_marketplaces.json", + [MARKETPLACE], + activator + ); + const logger = new CapturingLogger(); + const useCase = buildUseCase({ + fs, + manifest: seedManifest("claude", MARKETPLACE, MARKETPLACE), + activator, + binary: "claude", + logger, + aiddMarketplaceRegistry: seedAiddMarketplaceRegistry(MARKETPLACE), + hostMarketplaceRegistries: new Map([["claude", reader]]), + }); + + await useCase.execute({ projectRoot: PROJECT_ROOT, force: true }); + + expect(await fs.fileExists(cacheEntry)).toBe(true); + expect(reader.reads).toBe(0); + // Not just that the cache survives — the output says where, the same absolute path the + // dry-run preview would have announced. + expect( + logger.warnMessages.some( + (m) => m.includes("not on the PATH") && m.includes(join(CLAUDE_CACHE_ROOT, MARKETPLACE)) + ) + ).toBe(true); + }); + + it("leaves claude's cache in place when a fresh read still names it", async () => { + // `removeMarketplace` throws (host refused, or the call failed for any other + // reason `bestEffort` swallows), so the registry mirror never drops the name. + const fs = new RecordingFileAdapter(); + const cacheEntry = join(CLAUDE_CACHE_ROOT, MARKETPLACE, "plugin-a", "1.0.0", "plugin.json"); + await fs.writeFile(cacheEntry, "{}"); + + const activator = new FakeNativePluginActivator({ available: true, throwOnRemove: true }); + const reader = new RegistryMirroringActivatorState( + "known_marketplaces.json", + [MARKETPLACE], + activator + ); + const logger = new CapturingLogger(); + const useCase = buildUseCase({ + fs, + manifest: seedManifest("claude", MARKETPLACE, MARKETPLACE), + activator, + binary: "claude", + logger, + aiddMarketplaceRegistry: seedAiddMarketplaceRegistry(MARKETPLACE), + hostMarketplaceRegistries: new Map([["claude", reader]]), + }); + + await useCase.execute({ projectRoot: PROJECT_ROOT, force: true }); + + expect(await fs.fileExists(cacheEntry)).toBe(true); + expect(reader.reads).toBe(1); + expect(logger.warnMessages.some((m) => m.includes("still names it"))).toBe(true); + }); + + it("purges claude's cache when its registry does not exist at all", async () => { + const fs = new RecordingFileAdapter(); + const cacheEntry = join(CLAUDE_CACHE_ROOT, MARKETPLACE, "plugin-a", "1.0.0", "plugin.json"); + await fs.writeFile(cacheEntry, "{}"); + + const activator = new FakeNativePluginActivator({ available: true }); + const reader = new FakeHostMarketplaceRegistryReader({ + location: "known_marketplaces.json", + absent: true, + }); + const useCase = buildUseCase({ + fs, + manifest: seedManifest("claude", MARKETPLACE, MARKETPLACE), + activator, + binary: "claude", + logger: new CapturingLogger(), + aiddMarketplaceRegistry: seedAiddMarketplaceRegistry(MARKETPLACE), + hostMarketplaceRegistries: new Map([["claude", reader]]), + }); + + await useCase.execute({ projectRoot: PROJECT_ROOT, force: true }); + + expect(await fs.fileExists(cacheEntry)).toBe(false); + }); + + it("leaves claude's cache in place, and says so, when its registry could not be read", async () => { + const fs = new RecordingFileAdapter(); + const cacheEntry = join(CLAUDE_CACHE_ROOT, MARKETPLACE, "plugin-a", "1.0.0", "plugin.json"); + await fs.writeFile(cacheEntry, "{}"); + + const activator = new FakeNativePluginActivator({ available: true }); + const reader = new FakeHostMarketplaceRegistryReader({ + location: "known_marketplaces.json", + unreadable: "EACCES", + }); + const logger = new CapturingLogger(); + const useCase = buildUseCase({ + fs, + manifest: seedManifest("claude", MARKETPLACE, MARKETPLACE), + activator, + binary: "claude", + logger, + aiddMarketplaceRegistry: seedAiddMarketplaceRegistry(MARKETPLACE), + hostMarketplaceRegistries: new Map([["claude", reader]]), + }); + + await useCase.execute({ projectRoot: PROJECT_ROOT, force: true }); + + expect(await fs.fileExists(cacheEntry)).toBe(true); + expect( + logger.warnMessages.some( + (m) => + m.includes("claude: plugin cache left in place, its registry could not be read") && + m.includes("known_marketplaces.json") + ) + ).toBe(true); + }); + + it("purges the cache under the same HOME its own registry reader resolves its file from", async () => { + // A sentinel, never this machine's real home: a cache root composed from `os.homedir()` + // directly ignores the injected `homeDir` and looks under the real home instead. + const SENTINEL_HOME = "/sentinel-home-clean-cache-parity"; + const fs = new RecordingFileAdapter(); + const cacheEntry = join( + SENTINEL_HOME, + ".claude", + "plugins", + "cache", + MARKETPLACE, + "plugin-a", + "1.0.0", + "plugin.json" + ); + await fs.writeFile(cacheEntry, "{}"); + + const activator = new FakeNativePluginActivator({ available: true }); + // The real adapter, resolved from the same sentinel the cache root is composed from — a + // fake reader built independently of `homeDir` could never catch the two halves drifting. + const reader = hostMarketplaceRegistryReaders(SENTINEL_HOME).get("claude"); + if (reader === undefined) throw new Error("claude must declare marketplaceRegistry"); + + const useCase = buildUseCase({ + fs, + manifest: seedManifest("claude", MARKETPLACE, MARKETPLACE), + activator, + binary: "claude", + logger: new CapturingLogger(), + aiddMarketplaceRegistry: seedAiddMarketplaceRegistry(MARKETPLACE), + hostMarketplaceRegistries: new Map([["claude", reader]]), + homeDir: () => SENTINEL_HOME, + }); + + await useCase.execute({ projectRoot: PROJECT_ROOT, force: true }); + + // The real reader finds no known_marketplaces.json under a sentinel home that does not + // exist on disk — absent, not unreadable — so purging proves both halves used it. + expect(await fs.fileExists(cacheEntry)).toBe(false); + }); + + it("refuses a '..' segment in a manifest's own hostName, never consulting the registry", async () => { + const hostName = "../../../evil"; + const fs = new RecordingFileAdapter(); + const witness = join(CLAUDE_CACHE_ROOT, hostName); + // Written exactly where an unresolved join lands — so the guard is proven against the + // collapse `path.join` already performs, not a path this test invents. + await fs.writeFile(join(witness, "keep-me.txt"), "still here"); + + const activator = new FakeNativePluginActivator({ available: true }); + const reader = new RegistryMirroringActivatorState( + "known_marketplaces.json", + [hostName], + activator + ); + const logger = new CapturingLogger(); + const useCase = buildUseCase({ + fs, + manifest: seedManifest("claude", hostName, "safe-alias"), + activator, + binary: "claude", + logger, + aiddMarketplaceRegistry: seedAiddMarketplaceRegistry("safe-alias"), + hostMarketplaceRegistries: new Map([["claude", reader]]), + }); + + await useCase.execute({ projectRoot: PROJECT_ROOT, force: true }); + + expect(await fs.fileExists(join(witness, "keep-me.txt"))).toBe(true); + expect(fs.deletedDirectories).not.toContain(witness); + expect(reader.reads).toBe(0); + expect(logger.warnMessages.some((m) => m.includes("does not resolve inside"))).toBe(true); + }); + + it("refuses a cache entry that resolves outside the declared cache root through a symlink", async () => { + const fs = new RecordingFileAdapter(); + const candidate = join(CLAUDE_CACHE_ROOT, MARKETPLACE); + fs.setSymlink(candidate, "/outside/evil-target"); + + const activator = new FakeNativePluginActivator({ available: true }); + const reader = new RegistryMirroringActivatorState( + "known_marketplaces.json", + [MARKETPLACE], + activator + ); + const logger = new CapturingLogger(); + const useCase = buildUseCase({ + fs, + manifest: seedManifest("claude", MARKETPLACE, MARKETPLACE), + activator, + binary: "claude", + logger, + aiddMarketplaceRegistry: seedAiddMarketplaceRegistry(MARKETPLACE), + hostMarketplaceRegistries: new Map([["claude", reader]]), + }); + + await useCase.execute({ projectRoot: PROJECT_ROOT, force: true }); + + expect(fs.deletedDirectories).not.toContain(candidate); + expect(logger.warnMessages.some((m) => m.includes("does not resolve inside"))).toBe(true); + }); + + it("keeps and names a cache path whose realpath fails with EACCES, without aborting the rest of clean", async () => { + const candidate = join(CLAUDE_CACHE_ROOT, MARKETPLACE); + const fs = new RealpathDeniedFileAdapter(candidate); + const cacheEntry = join(candidate, "plugin-a", "1.0.0", "plugin.json"); + await fs.writeFile(cacheEntry, "{}"); + + const activator = new FakeNativePluginActivator({ available: true }); + const reader = new RegistryMirroringActivatorState( + "known_marketplaces.json", + [MARKETPLACE], + activator + ); + const logger = new CapturingLogger(); + const useCase = buildUseCase({ + fs, + manifest: seedManifest("claude", MARKETPLACE, MARKETPLACE), + activator, + binary: "claude", + logger, + aiddMarketplaceRegistry: seedAiddMarketplaceRegistry(MARKETPLACE), + hostMarketplaceRegistries: new Map([["claude", reader]]), + }); + + await useCase.execute({ projectRoot: PROJECT_ROOT, force: true }); + + expect(await fs.fileExists(cacheEntry)).toBe(true); + expect(logger.warnMessages.some((m) => m.includes(candidate))).toBe(true); + // removeAiddState sits well past the cache purge in execute()'s own order, so its + // running is what proves nothing aborted mid-course. + expect(fs.deletedDirectories).toContain(join(PROJECT_ROOT, AIDD_DIR, "cache")); + }); + + it("touches nothing under HOME for a tool whose profile declares no pluginCacheDir", async () => { + const fs = new RecordingFileAdapter(); + const activator = new FakeNativePluginActivator({ available: true }); + const manifest = Manifest.create(); + manifest.addTool("copilot", "1.0.0", []); + manifest.setNativeRegistrations("copilot", { + binary: "copilot", + marketplaces: [{ alias: MARKETPLACE, hostName: MARKETPLACE }], + pluginRefs: [REF], + }); + const useCase = buildUseCase({ + fs, + manifest, + activator, + binary: "copilot", + logger: new CapturingLogger(), + aiddMarketplaceRegistry: seedAiddMarketplaceRegistry(MARKETPLACE), + }); + + await useCase.execute({ projectRoot: PROJECT_ROOT, force: true }); + + expect(fs.deletedDirectories.some((p) => p.startsWith(HOME))).toBe(false); + }); + + it("purges under the catalog's own hostName, never the project's local alias", async () => { + const alias = "my-local-alias"; + const hostName = "upstream-catalog-name"; + const fs = new RecordingFileAdapter(); + const cacheEntry = join(CLAUDE_CACHE_ROOT, hostName, "plugin-a", "1.0.0", "plugin.json"); + await fs.writeFile(cacheEntry, "{}"); + + const activator = new FakeNativePluginActivator({ available: true }); + const reader = new RegistryMirroringActivatorState( + "known_marketplaces.json", + [hostName], + activator + ); + const useCase = buildUseCase({ + fs, + manifest: seedManifest("claude", hostName, alias), + activator, + binary: "claude", + logger: new CapturingLogger(), + aiddMarketplaceRegistry: seedAiddMarketplaceRegistry(alias), + hostMarketplaceRegistries: new Map([["claude", reader]]), + }); + + await useCase.execute({ projectRoot: PROJECT_ROOT, force: true }); + + expect(await fs.fileExists(cacheEntry)).toBe(false); + expect(fs.deletedDirectories).not.toContain(join(CLAUDE_CACHE_ROOT, alias)); + }); + + it("purges codex's empty cache shell once its own CLI has removed the marketplace", async () => { + // No reader registered for codex — its profile declares `pluginCacheDir` alone, no + // `marketplaceRegistry`, so `purgeOneMarketplaceCache` proves emptiness instead. + const fs = new RecordingFileAdapter(); + const activator = new FakeNativePluginActivator({ available: true }); + const useCase = buildUseCase({ + fs, + manifest: seedManifest("codex", MARKETPLACE, MARKETPLACE), + activator, + binary: "codex", + logger: new CapturingLogger(), + aiddMarketplaceRegistry: seedAiddMarketplaceRegistry(MARKETPLACE), + }); + + await useCase.execute({ projectRoot: PROJECT_ROOT, force: true }); + + expect(fs.deletedDirectories).toContain(join(CODEX_CACHE_ROOT, MARKETPLACE)); + }); + + it("leaves codex's cache in place, even empty, when marketplace removal was not confirmed", async () => { + // Nothing under the cache directory — the shape `purgeCacheIfEmpty` reads as safe — while + // `removeMarketplace` throws, so the host was never confirmed to have forgotten the name. + const fs = new RecordingFileAdapter(); + + const activator = new FakeNativePluginActivator({ available: true, throwOnRemove: true }); + const logger = new CapturingLogger(); + const useCase = buildUseCase({ + fs, + manifest: seedManifest("codex", MARKETPLACE, MARKETPLACE), + activator, + binary: "codex", + logger, + aiddMarketplaceRegistry: seedAiddMarketplaceRegistry(MARKETPLACE), + }); + + await useCase.execute({ projectRoot: PROJECT_ROOT, force: true }); + + expect(fs.deletedDirectories).not.toContain(join(CODEX_CACHE_ROOT, MARKETPLACE)); + expect(logger.warnMessages.some((m) => m.includes("its own removal was not confirmed"))).toBe( + true + ); + }); + + it("leaves codex's cache in place, and names it, when it still holds content", async () => { + const fs = new RecordingFileAdapter(); + const leftover = join(CODEX_CACHE_ROOT, MARKETPLACE, "leftover.txt"); + await fs.writeFile(leftover, "still here"); + + const activator = new FakeNativePluginActivator({ available: true }); + const logger = new CapturingLogger(); + const useCase = buildUseCase({ + fs, + manifest: seedManifest("codex", MARKETPLACE, MARKETPLACE), + activator, + binary: "codex", + logger, + aiddMarketplaceRegistry: seedAiddMarketplaceRegistry(MARKETPLACE), + }); + + await useCase.execute({ projectRoot: PROJECT_ROOT, force: true }); + + expect(await fs.fileExists(leftover)).toBe(true); + expect(fs.deletedDirectories).not.toContain(join(CODEX_CACHE_ROOT, MARKETPLACE)); + expect(logger.warnMessages.some((m) => m.includes("still holds"))).toBe(true); + }); +}); diff --git a/cli/tests/contexts/framework/application/clean-use-case.unit.test.ts b/cli/tests/contexts/framework/application/clean-use-case.unit.test.ts new file mode 100644 index 000000000..77051b099 --- /dev/null +++ b/cli/tests/contexts/framework/application/clean-use-case.unit.test.ts @@ -0,0 +1,1251 @@ +import { homedir } from "node:os"; +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; +import "../../../../src/contexts/tools/domain/profiles/claude/profile.js"; +import "../../../../src/contexts/tools/domain/profiles/vscode/profile.js"; +import "../../../../src/contexts/tools/domain/profiles/cursor/profile.js"; +import "../../../../src/contexts/tools/domain/profiles/codex/profile.js"; +import { Marketplace } from "../../../../src/contexts/distribution/domain/marketplace.js"; +import { CleanUseCase } from "../../../../src/contexts/framework/application/clean-use-case.js"; +import { GitignoreUseCase } from "../../../../src/contexts/framework/application/gitignore-use-case.js"; +import { Manifest } from "../../../../src/contexts/framework/domain/manifest.js"; +import { InstalledPlugin } from "../../../../src/contexts/framework/domain/plugins/installed-plugin.js"; +import { UserSourceReferencesAdapter } from "../../../../src/contexts/framework/infrastructure/user-source-references-adapter.js"; +import { cursorProjectHooksScriptDir } from "../../../../src/contexts/tools/domain/formats/cursor-hooks-project-merge.js"; +import type { NativePluginActivator } from "../../../../src/contexts/tools/domain/ports/native-plugin-activator.js"; +import type { AiToolId, ToolId } from "../../../../src/kernel/tool.js"; +import { buildUnitDeps, initAndInstall } from "../../../helpers/ports/build-unit-deps.js"; +import { CapturingLogger } from "../../../helpers/ports/capturing-logger.js"; +import { FakeHostPluginRegistryReader } from "../../../helpers/ports/fake-host-plugin-registry-reader.js"; +import { FakeNativePluginActivator } from "../../../helpers/ports/fake-native-plugin-activator.js"; +import { InMemoryFileAdapter } from "../../../helpers/ports/in-memory-file-adapter.js"; +import { InMemoryManifestRepository } from "../../../helpers/ports/in-memory-manifest-repository.js"; +import { InMemoryMarketplaceRegistry } from "../../../helpers/ports/in-memory-marketplace-registry.js"; + +const PROJECT_ROOT = "/test-project"; + +/** Records every path `deleteFile` is called with, so a test can prove where a plugin's + * file actually got deleted from without inspecting private use-case state. */ +class RecordingFileAdapter extends InMemoryFileAdapter { + readonly deletedPaths: string[] = []; + + override async deleteFile(path: string): Promise { + this.deletedPaths.push(path); + return super.deleteFile(path); + } +} + +// Cursor Mode B: the file key is base-relative — no absolute prefix, resolved against +// the user plugins dir. +const PLUGIN_KEY = "aidd-context/commands/hello.md"; + +describe("clean", () => { + it("with force removes .aidd/cache/ entry from .gitignore", async () => { + const deps = await buildUnitDeps(PROJECT_ROOT); + await initAndInstall(deps, PROJECT_ROOT, "claude" as ToolId); + + const gitignorePath = join(PROJECT_ROOT, ".gitignore"); + await deps.fs.writeFile(gitignorePath, "node_modules/\n.aidd/cache/\ndist/\n"); + + const useCase = new CleanUseCase( + deps.fs, + deps.manifestRepo, + deps.logger, + deps.gitignoreUseCase + ); + await useCase.execute({ projectRoot: PROJECT_ROOT, force: true }); + + const content = deps.fs.getFile(gitignorePath); + expect(content).not.toContain(".aidd/cache/"); + expect(content).toContain("node_modules/"); + expect(content).toContain("dist/"); + }); + + it("with force removes aidd_docs/runs/ entry from .gitignore, same as the pipeline adds", async () => { + const deps = await buildUnitDeps(PROJECT_ROOT); + await initAndInstall(deps, PROJECT_ROOT, "claude" as ToolId); + + const gitignorePath = join(PROJECT_ROOT, ".gitignore"); + await deps.fs.writeFile(gitignorePath, "node_modules/\n.aidd/cache/\naidd_docs/runs/\ndist/\n"); + + const useCase = new CleanUseCase( + deps.fs, + deps.manifestRepo, + deps.logger, + deps.gitignoreUseCase + ); + await useCase.execute({ projectRoot: PROJECT_ROOT, force: true }); + + const content = deps.fs.getFile(gitignorePath); + expect(content).not.toContain(".aidd/cache/"); + expect(content).not.toContain("aidd_docs/runs/"); + expect(content).toContain("node_modules/"); + expect(content).toContain("dist/"); + }); + + it("with force leaves .gitignore unchanged when entry absent", async () => { + const deps = await buildUnitDeps(PROJECT_ROOT); + await initAndInstall(deps, PROJECT_ROOT, "claude" as ToolId); + + const gitignorePath = join(PROJECT_ROOT, ".gitignore"); + await deps.fs.writeFile(gitignorePath, "node_modules/\n"); + + const useCase = new CleanUseCase( + deps.fs, + deps.manifestRepo, + deps.logger, + deps.gitignoreUseCase + ); + await useCase.execute({ projectRoot: PROJECT_ROOT, force: true }); + + const content = deps.fs.getFile(gitignorePath); + expect(content).toBe("node_modules/\n"); + }); + + it("preserves untracked user files", async () => { + const deps = await buildUnitDeps(PROJECT_ROOT); + await initAndInstall(deps, PROJECT_ROOT, "claude" as ToolId); + + const userFile = join(PROJECT_ROOT, "my-custom-file.txt"); + await deps.fs.writeFile(userFile, "user content"); + + const useCase = new CleanUseCase( + deps.fs, + deps.manifestRepo, + deps.logger, + deps.gitignoreUseCase + ); + await useCase.execute({ projectRoot: PROJECT_ROOT, force: true }); + + expect(deps.fs.has(userFile)).toBe(true); + }); + + it("keeps .aidd/config.json and deletes .aidd/cache/ when config.json exists", async () => { + const deps = await buildUnitDeps(PROJECT_ROOT); + await initAndInstall(deps, PROJECT_ROOT, "claude" as ToolId); + + const configPath = join(PROJECT_ROOT, ".aidd", "config.json"); + const cacheFile = join(PROJECT_ROOT, ".aidd", "cache", "built", "leftover.json"); + await deps.fs.writeFile(configPath, '{"telemetry":{"enabled":true}}'); + await deps.fs.writeFile(cacheFile, "{}"); + + const useCase = new CleanUseCase( + deps.fs, + deps.manifestRepo, + deps.logger, + deps.gitignoreUseCase + ); + await useCase.execute({ projectRoot: PROJECT_ROOT, force: true }); + + expect(deps.fs.has(configPath)).toBe(true); + expect(deps.fs.has(cacheFile)).toBe(false); + expect(deps.manifestRepo.getCurrent()).toBeNull(); + }); + + it("removes .aidd/plugin-cache/, which no install writes but plugin add does", async () => { + const deps = await buildUnitDeps(PROJECT_ROOT); + await initAndInstall(deps, PROJECT_ROOT, "claude" as ToolId); + + const pluginCacheFile = join(PROJECT_ROOT, ".aidd", "plugin-cache", "some-plugin", "x.json"); + await deps.fs.writeFile(pluginCacheFile, "{}"); + + const useCase = new CleanUseCase( + deps.fs, + deps.manifestRepo, + deps.logger, + deps.gitignoreUseCase + ); + await useCase.execute({ projectRoot: PROJECT_ROOT, force: true }); + + expect(deps.fs.has(pluginCacheFile)).toBe(false); + expect(deps.fs.listUnder(join(PROJECT_ROOT, ".aidd")).length).toBe(0); + }); + + it("removes .aidd/ entirely when nothing but the manifest and cache lived there", async () => { + const deps = await buildUnitDeps(PROJECT_ROOT); + await initAndInstall(deps, PROJECT_ROOT, "claude" as ToolId); + + const cacheFile = join(PROJECT_ROOT, ".aidd", "cache", "built", "leftover.json"); + await deps.fs.writeFile(cacheFile, "{}"); + + const useCase = new CleanUseCase( + deps.fs, + deps.manifestRepo, + deps.logger, + deps.gitignoreUseCase + ); + await useCase.execute({ projectRoot: PROJECT_ROOT, force: true }); + + expect(deps.fs.listUnder(join(PROJECT_ROOT, ".aidd")).length).toBe(0); + }); + it("removes the marketplaces this project registered, which `marketplace add` wrote", async () => { + const deps = await buildUnitDeps(PROJECT_ROOT); + await initAndInstall(deps, PROJECT_ROOT, "claude" as ToolId); + + // Written by `marketplace add`, not by an install — so it survived a clean that removed + // only the caches and the manifest, and its presence kept `.aidd/` alive with it. + const registry = join(PROJECT_ROOT, ".aidd", "marketplaces.json"); + await deps.fs.writeFile(registry, JSON.stringify({ version: 1, marketplaces: [] })); + + const useCase = new CleanUseCase( + deps.fs, + deps.manifestRepo, + deps.logger, + deps.gitignoreUseCase + ); + await useCase.execute({ projectRoot: PROJECT_ROOT, force: true }); + + expect(deps.fs.has(registry)).toBe(false); + expect(deps.fs.listUnder(join(PROJECT_ROOT, ".aidd")).length).toBe(0); + }); + + it("removes the registry and still keeps config.json, when a project has both", async () => { + const deps = await buildUnitDeps(PROJECT_ROOT); + await initAndInstall(deps, PROJECT_ROOT, "claude" as ToolId); + + // The interaction the two rules meet in: one file clean wrote and must take back, one it + // never wrote and must leave. Each proven alone says nothing about the pair. + const config = join(PROJECT_ROOT, ".aidd", "config.json"); + const registry = join(PROJECT_ROOT, ".aidd", "marketplaces.json"); + await deps.fs.writeFile(config, JSON.stringify({ telemetry: { enabled: true } })); + await deps.fs.writeFile(registry, JSON.stringify({ version: 1, marketplaces: [] })); + + const useCase = new CleanUseCase( + deps.fs, + deps.manifestRepo, + deps.logger, + deps.gitignoreUseCase + ); + await useCase.execute({ projectRoot: PROJECT_ROOT, force: true }); + + expect(deps.fs.has(registry)).toBe(false); + expect(deps.fs.has(config)).toBe(true); + }); + + it("deletes a user-scope (cursor) plugin's file from its resolved home directory, not projectRoot", async () => { + const manifest = Manifest.create(); + manifest.addTool("cursor", "1.0.0", []); + manifest.addPlugin( + "cursor", + InstalledPlugin.fromJSON({ + name: "aidd-context", + source: { kind: "local", path: "/some/path" }, + version: "1.0.0", + strict: false, + files: { [PLUGIN_KEY]: "abc123abc123abc123abc123abc123ab" }, + scope: "user", + }) + ); + + const fs = new RecordingFileAdapter(); + const manifestRepo = new InMemoryManifestRepository(manifest, PROJECT_ROOT); + const useCase = new CleanUseCase( + fs, + manifestRepo, + new CapturingLogger(), + new GitignoreUseCase(fs) + ); + await useCase.execute({ projectRoot: PROJECT_ROOT, force: true }); + + expect( + fs.deletedPaths.some((p) => p.endsWith(join(".cursor", "plugins", "local", PLUGIN_KEY))) + ).toBe(true); + expect(fs.deletedPaths).not.toContain(join(PROJECT_ROOT, PLUGIN_KEY)); + }); + + it("deletes a cursor plugin's file under projectRoot, not ~/.cursor/plugins/local, when the manifest says scope: project", async () => { + const manifest = Manifest.create(); + manifest.addTool("cursor", "1.0.0", []); + manifest.addPlugin( + "cursor", + InstalledPlugin.fromJSON({ + name: "aidd-context", + source: { kind: "local", path: "/some/path" }, + version: "1.0.0", + strict: false, + files: { [PLUGIN_KEY]: "abc123abc123abc123abc123abc123ab" }, + // Disagrees with cursor's own profile, which declares installScope "user". + scope: "project", + }) + ); + + const fs = new RecordingFileAdapter(); + const manifestRepo = new InMemoryManifestRepository(manifest, PROJECT_ROOT); + const useCase = new CleanUseCase( + fs, + manifestRepo, + new CapturingLogger(), + new GitignoreUseCase(fs) + ); + await useCase.execute({ projectRoot: PROJECT_ROOT, force: true }); + + expect(fs.deletedPaths).toContain(join(PROJECT_ROOT, PLUGIN_KEY)); + expect(fs.deletedPaths.some((p) => p.includes(join(".cursor", "plugins", "local")))).toBe( + false + ); + }); + + describe("undoing a host's own native registration", () => { + const BINARY = "codex"; + const MARKETPLACE = "aidd-framework"; + const REF = "aidd-context@aidd-framework"; + + function seedManifestWithNativeRegistrations(): Manifest { + const manifest = Manifest.create(); + manifest.addTool("codex", "1.0.0", []); + manifest.setNativeRegistrations("codex", { + binary: BINARY, + marketplaces: [{ alias: MARKETPLACE, hostName: MARKETPLACE }], + pluginRefs: [REF], + }); + return manifest; + } + + function seedMarketplaceRegistry(): InMemoryMarketplaceRegistry { + const registry = new InMemoryMarketplaceRegistry(); + registry.save( + PROJECT_ROOT, + Marketplace.create({ + name: MARKETPLACE, + source: { kind: "local", path: "/some/built/path" }, + scope: "project", + addedAt: "2026-01-01T00:00:00.000Z", + }) + ); + return registry; + } + + it("uninstalls the registered plugin ref and removes the marketplace it came from", async () => { + const manifest = seedManifestWithNativeRegistrations(); + const fs = new InMemoryFileAdapter(); + const manifestRepo = new InMemoryManifestRepository(manifest, PROJECT_ROOT); + const activator = new FakeNativePluginActivator({ available: true }); + const useCase = new CleanUseCase( + fs, + manifestRepo, + new CapturingLogger(), + new GitignoreUseCase(fs), + new Map([[BINARY, activator]]), + seedMarketplaceRegistry() + ); + + await useCase.execute({ projectRoot: PROJECT_ROOT, force: true }); + + expect(activator.uninstalledPlugins).toEqual([REF]); + expect(activator.removedMarketplaces).toEqual([MARKETPLACE]); + }); + + it("leaves a machine-scope marketplace registered — every other project on this machine shares it — while still uninstalling this project's own plugin ref", async () => { + const manifest = seedManifestWithNativeRegistrations(); + const fs = new InMemoryFileAdapter(); + const manifestRepo = new InMemoryManifestRepository(manifest, PROJECT_ROOT); + const activator = new FakeNativePluginActivator({ available: true }); + const registry = new InMemoryMarketplaceRegistry(); + registry.save( + PROJECT_ROOT, + Marketplace.create({ + name: MARKETPLACE, + source: { kind: "local", path: "/shared/built/path" }, + scope: "user", + addedAt: "2026-01-01T00:00:00.000Z", + }) + ); + const logger = new CapturingLogger(); + const HOME = "/fake-home"; + const useCase = new CleanUseCase( + fs, + manifestRepo, + logger, + new GitignoreUseCase(fs), + new Map([[BINARY, activator]]), + registry, + undefined, + new Map(), + () => HOME + ); + + await useCase.execute({ projectRoot: PROJECT_ROOT, force: true }); + + expect(activator.uninstalledPlugins).toEqual([REF]); + expect(activator.removedMarketplaces).toEqual([]); + // The message must name all three survivors: the host's own registration, + // `userConfigDir()/marketplaces.json`, and the tool's own plugin cache. + const message = logger.warnMessages.find( + (m) => m.includes(MARKETPLACE) && m.includes("shared by every project") + ); + expect(message).toBeDefined(); + expect(message).toContain("userConfigDir()/marketplaces.json"); + expect(message).toContain(join(HOME, ".codex", "plugins", "cache", MARKETPLACE)); + }); + + describe("uninstalling at the scope a plugin was actually registered at", () => { + const CLAUDE_BINARY = "claude"; + const CLAUDE_MARKETPLACE = "aidd-framework"; + const CLAUDE_REF = "aidd-context@aidd-framework"; + + function seedClaudeManifest(pluginScope: "project" | "user"): Manifest { + const manifest = Manifest.create(); + manifest.addTool("claude", "1.0.0", []); + manifest.addPlugin( + "claude", + InstalledPlugin.fromMetadata( + "aidd-context", + "1.0.0", + { kind: "github", repo: "ai-driven-dev/framework" }, + true, + pluginScope, + CLAUDE_MARKETPLACE + ) + ); + manifest.setNativeRegistrations("claude", { + binary: CLAUDE_BINARY, + marketplaces: [{ alias: CLAUDE_MARKETPLACE, hostName: CLAUDE_MARKETPLACE }], + pluginRefs: [CLAUDE_REF], + }); + return manifest; + } + + function seedClaudeMarketplaceRegistry(): InMemoryMarketplaceRegistry { + const registry = new InMemoryMarketplaceRegistry(); + registry.save( + PROJECT_ROOT, + Marketplace.create({ + name: CLAUDE_MARKETPLACE, + source: { kind: "local", path: "/some/built/path" }, + scope: "project", + addedAt: "2026-01-01T00:00:00.000Z", + }) + ); + return registry; + } + + // A real `claude` binary registers at its own implicit default `"user"` whatever the + // manifest recorded, so a `"project"`-only uninstall left the plugin behind silently. + it("falls back to the other scope when the manifest's own scope does not match what was actually registered, with no host registry to ask", async () => { + const manifest = seedClaudeManifest("project"); + const fs = new InMemoryFileAdapter(); + const manifestRepo = new InMemoryManifestRepository(manifest, PROJECT_ROOT); + const activator = new FakeNativePluginActivator({ + available: true, + installedAtScope: new Map([[CLAUDE_REF, "user"]]), + }); + const useCase = new CleanUseCase( + fs, + manifestRepo, + new CapturingLogger(), + new GitignoreUseCase(fs), + new Map([[CLAUDE_BINARY, activator]]), + seedClaudeMarketplaceRegistry() + ); + + await useCase.execute({ projectRoot: PROJECT_ROOT, force: true }); + + expect(activator.uninstalledPlugins).toEqual([CLAUDE_REF]); + expect(activator.uninstalledPluginScopes).toEqual(["project", "user"]); + }); + + it("uninstalls at the scope the host's own registry names directly, one attempt, when it answers for this ref", async () => { + const manifest = seedClaudeManifest("project"); + const fs = new InMemoryFileAdapter(); + const manifestRepo = new InMemoryManifestRepository(manifest, PROJECT_ROOT); + const activator = new FakeNativePluginActivator({ + available: true, + installedAtScope: new Map([[CLAUDE_REF, "user"]]), + }); + const hostPluginRegistries = new Map([ + [ + "claude", + new FakeHostPluginRegistryReader({ + location: "/registry", + refs: new Map([[CLAUDE_REF, { enabled: true, scope: "user" }]]), + }), + ], + ]); + const useCase = new CleanUseCase( + fs, + manifestRepo, + new CapturingLogger(), + new GitignoreUseCase(fs), + new Map([[CLAUDE_BINARY, activator]]), + seedClaudeMarketplaceRegistry(), + undefined, + new Map(), + undefined, + undefined, + hostPluginRegistries + ); + + await useCase.execute({ projectRoot: PROJECT_ROOT, force: true }); + + expect(activator.uninstalledPlugins).toEqual([CLAUDE_REF]); + expect(activator.uninstalledPluginScopes).toEqual(["user"]); + }); + }); + + describe("this project's own reference to the shared source", () => { + const OTHER_PROJECT_ROOT = "/other-project"; + + function seedReferences( + fs: InMemoryFileAdapter, + projectRoots: readonly string[] + ): UserSourceReferencesAdapter { + for (const root of projectRoots) fs.setFile(join(root, "marker"), ""); + return new UserSourceReferencesAdapter(fs, () => "/fake-home/.config/aidd"); + } + + it("drops its own claim but leaves the shared marketplace registered — the other project still sees it", async () => { + const manifest = seedManifestWithNativeRegistrations(); + const fs = new InMemoryFileAdapter(); + const manifestRepo = new InMemoryManifestRepository(manifest, PROJECT_ROOT); + const activator = new FakeNativePluginActivator({ available: true }); + const registry = new InMemoryMarketplaceRegistry(); + await registry.save( + PROJECT_ROOT, + Marketplace.create({ + name: MARKETPLACE, + source: { kind: "local", path: "/shared/built/path" }, + scope: "user", + addedAt: "2026-01-01T00:00:00.000Z", + }) + ); + const userSourceReferences = seedReferences(fs, [PROJECT_ROOT, OTHER_PROJECT_ROOT]); + await userSourceReferences.addReference("1.0.0", PROJECT_ROOT); + await userSourceReferences.addReference("1.0.0", OTHER_PROJECT_ROOT); + const useCase = new CleanUseCase( + fs, + manifestRepo, + new CapturingLogger(), + new GitignoreUseCase(fs), + new Map([[BINARY, activator]]), + registry, + undefined, + new Map(), + () => "/fake-home", + userSourceReferences + ); + + await useCase.execute({ projectRoot: PROJECT_ROOT, force: true }); + + // The host-side guard already in place: the shared marketplace is never + // unregistered from claude/codex/copilot by a single project's own `clean`. + expect(activator.removedMarketplaces).toEqual([]); + // Only this project's own claim is dropped, and the other project — reading the + // very same registry — still finds the marketplace registered. + expect(await userSourceReferences.listAllReferencingProjects()).toContain( + OTHER_PROJECT_ROOT + ); + expect((await registry.list(OTHER_PROJECT_ROOT)).map((m) => m.name)).toContain(MARKETPLACE); + }); + + it("names how many other projects still reference the source", async () => { + const manifest = seedManifestWithNativeRegistrations(); + const fs = new InMemoryFileAdapter(); + const manifestRepo = new InMemoryManifestRepository(manifest, PROJECT_ROOT); + const activator = new FakeNativePluginActivator({ available: true }); + const registry = new InMemoryMarketplaceRegistry(); + await registry.save( + PROJECT_ROOT, + Marketplace.create({ + name: MARKETPLACE, + source: { kind: "local", path: "/shared/built/path" }, + scope: "user", + addedAt: "2026-01-01T00:00:00.000Z", + }) + ); + const userSourceReferences = seedReferences(fs, [PROJECT_ROOT, OTHER_PROJECT_ROOT]); + await userSourceReferences.addReference("1.0.0", PROJECT_ROOT); + await userSourceReferences.addReference("1.0.0", OTHER_PROJECT_ROOT); + const logger = new CapturingLogger(); + const useCase = new CleanUseCase( + fs, + manifestRepo, + logger, + new GitignoreUseCase(fs), + new Map([[BINARY, activator]]), + registry, + undefined, + new Map(), + () => "/fake-home", + userSourceReferences + ); + + await useCase.execute({ projectRoot: PROJECT_ROOT, force: true }); + + const message = logger.warnMessages.find((m) => m.includes("shared by every project")); + expect(message).toBeDefined(); + expect(message).toContain("Still referenced by 1 other project on this machine."); + }); + + // A verb-agreement bug hides in the plural alone: the passive phrasing sidesteps the + // question for either count, so only the plural case can pin it. + it("names the plural correctly when more than one other project still references the source", async () => { + const manifest = seedManifestWithNativeRegistrations(); + const fs = new InMemoryFileAdapter(); + const manifestRepo = new InMemoryManifestRepository(manifest, PROJECT_ROOT); + const activator = new FakeNativePluginActivator({ available: true }); + const registry = new InMemoryMarketplaceRegistry(); + await registry.save( + PROJECT_ROOT, + Marketplace.create({ + name: MARKETPLACE, + source: { kind: "local", path: "/shared/built/path" }, + scope: "user", + addedAt: "2026-01-01T00:00:00.000Z", + }) + ); + const THIRD_PROJECT_ROOT = "/third-project"; + const userSourceReferences = seedReferences(fs, [ + PROJECT_ROOT, + OTHER_PROJECT_ROOT, + THIRD_PROJECT_ROOT, + ]); + await userSourceReferences.addReference("1.0.0", PROJECT_ROOT); + await userSourceReferences.addReference("1.0.0", OTHER_PROJECT_ROOT); + await userSourceReferences.addReference("1.0.0", THIRD_PROJECT_ROOT); + const logger = new CapturingLogger(); + const useCase = new CleanUseCase( + fs, + manifestRepo, + logger, + new GitignoreUseCase(fs), + new Map([[BINARY, activator]]), + registry, + undefined, + new Map(), + () => "/fake-home", + userSourceReferences + ); + + await useCase.execute({ projectRoot: PROJECT_ROOT, force: true }); + + const message = logger.warnMessages.find((m) => m.includes("shared by every project")); + expect(message).toBeDefined(); + expect(message).toContain("Still referenced by 2 other projects on this machine."); + }); + + it("names that nothing removes the source yet once this was the last reference", async () => { + const manifest = seedManifestWithNativeRegistrations(); + const fs = new InMemoryFileAdapter(); + const manifestRepo = new InMemoryManifestRepository(manifest, PROJECT_ROOT); + const activator = new FakeNativePluginActivator({ available: true }); + const registry = new InMemoryMarketplaceRegistry(); + await registry.save( + PROJECT_ROOT, + Marketplace.create({ + name: MARKETPLACE, + source: { kind: "local", path: "/shared/built/path" }, + scope: "user", + addedAt: "2026-01-01T00:00:00.000Z", + }) + ); + const userSourceReferences = seedReferences(fs, [PROJECT_ROOT]); + await userSourceReferences.addReference("1.0.0", PROJECT_ROOT); + const logger = new CapturingLogger(); + const useCase = new CleanUseCase( + fs, + manifestRepo, + logger, + new GitignoreUseCase(fs), + new Map([[BINARY, activator]]), + registry, + undefined, + new Map(), + () => "/fake-home", + userSourceReferences + ); + + await useCase.execute({ projectRoot: PROJECT_ROOT, force: true }); + + const message = logger.warnMessages.find((m) => m.includes("shared by every project")); + expect(message).toBeDefined(); + expect(message).toContain("No project on this machine still references it"); + expect(message).toContain("aidd clean"); + expect(await userSourceReferences.listAllReferencingProjects()).not.toContain(PROJECT_ROOT); + }); + + it("names the other project still referencing the shared source in a dry-run, without dropping anything", async () => { + const manifest = seedManifestWithNativeRegistrations(); + const fs = new InMemoryFileAdapter(); + const manifestRepo = new InMemoryManifestRepository(manifest, PROJECT_ROOT); + const activator = new FakeNativePluginActivator({ available: true }); + const registry = new InMemoryMarketplaceRegistry(); + await registry.save( + PROJECT_ROOT, + Marketplace.create({ + name: MARKETPLACE, + source: { kind: "local", path: "/shared/built/path" }, + scope: "user", + addedAt: "2026-01-01T00:00:00.000Z", + }) + ); + const userSourceReferences = seedReferences(fs, [PROJECT_ROOT, OTHER_PROJECT_ROOT]); + await userSourceReferences.addReference("1.0.0", PROJECT_ROOT); + await userSourceReferences.addReference("1.0.0", OTHER_PROJECT_ROOT); + const useCase = new CleanUseCase( + fs, + manifestRepo, + new CapturingLogger(), + new GitignoreUseCase(fs), + new Map([[BINARY, activator]]), + registry, + undefined, + new Map(), + () => "/fake-home", + userSourceReferences + ); + + const result = await useCase.execute({ projectRoot: PROJECT_ROOT, force: false }); + + expect(result.dryRun).toBe(true); + expect(result.preview.sharedSourceOtherProjects).toEqual([OTHER_PROJECT_ROOT]); + // A dry-run must never write: both projects still hold their reference. + expect(await userSourceReferences.listAllReferencingProjects()).toEqual( + expect.arrayContaining([PROJECT_ROOT, OTHER_PROJECT_ROOT]) + ); + }); + + // Two projects synced under different CLI versions record their claims under two + // different keys, so only a read across every key sees the other project's. + it("names the other project in a dry-run even when the two projects were recorded under different CLI versions", async () => { + const manifest = seedManifestWithNativeRegistrations(); + const fs = new InMemoryFileAdapter(); + const manifestRepo = new InMemoryManifestRepository(manifest, PROJECT_ROOT); + const activator = new FakeNativePluginActivator({ available: true }); + const registry = new InMemoryMarketplaceRegistry(); + await registry.save( + PROJECT_ROOT, + Marketplace.create({ + name: MARKETPLACE, + source: { kind: "local", path: "/shared/built/path" }, + scope: "user", + addedAt: "2026-01-01T00:00:00.000Z", + }) + ); + const userSourceReferences = seedReferences(fs, [PROJECT_ROOT, OTHER_PROJECT_ROOT]); + await userSourceReferences.addReference("1.0.0", PROJECT_ROOT); + await userSourceReferences.addReference("2.0.0", OTHER_PROJECT_ROOT); + const useCase = new CleanUseCase( + fs, + manifestRepo, + new CapturingLogger(), + new GitignoreUseCase(fs), + new Map([[BINARY, activator]]), + registry, + undefined, + new Map(), + () => "/fake-home", + userSourceReferences + ); + + const result = await useCase.execute({ projectRoot: PROJECT_ROOT, force: false }); + + expect(result.dryRun).toBe(true); + expect(result.preview.sharedSourceOtherProjects).toEqual([OTHER_PROJECT_ROOT]); + }); + + // A reference sits under the CLI version that wrote it, which the running binary may + // have moved past; `CleanUseCase` has no version reader and must still find it. + it("drops this project's reference even though it was recorded under an older CLI version", async () => { + const manifest = seedManifestWithNativeRegistrations(); + const fs = new InMemoryFileAdapter(); + const manifestRepo = new InMemoryManifestRepository(manifest, PROJECT_ROOT); + const activator = new FakeNativePluginActivator({ available: true }); + const registry = new InMemoryMarketplaceRegistry(); + await registry.save( + PROJECT_ROOT, + Marketplace.create({ + name: MARKETPLACE, + source: { kind: "local", path: "/shared/built/path" }, + scope: "user", + addedAt: "2026-01-01T00:00:00.000Z", + }) + ); + const userSourceReferences = seedReferences(fs, [PROJECT_ROOT, OTHER_PROJECT_ROOT]); + // Both projects registered at an older CLI version — this use case has no way to + // know or care what version is "current" today. + await userSourceReferences.addReference("0.9.0", PROJECT_ROOT); + await userSourceReferences.addReference("0.9.0", OTHER_PROJECT_ROOT); + const logger = new CapturingLogger(); + const useCase = new CleanUseCase( + fs, + manifestRepo, + logger, + new GitignoreUseCase(fs), + new Map([[BINARY, activator]]), + registry, + undefined, + new Map(), + () => "/fake-home", + userSourceReferences + ); + + await useCase.execute({ projectRoot: PROJECT_ROOT, force: true }); + + expect(await userSourceReferences.listAllReferencingProjects()).not.toContain(PROJECT_ROOT); + expect(await userSourceReferences.listAllReferencingProjects()).toContain( + OTHER_PROJECT_ROOT + ); + const message = logger.warnMessages.find((m) => m.includes("shared by every project")); + expect(message).toBeDefined(); + expect(message).toContain("Still referenced by 1 other project on this machine."); + }); + + // `references.json` is a help, not an authority — a corrupted copy must never block + // the destructive command that does not depend on it. + it("warns and still previews a dry-run when references.json is corrupted", async () => { + const manifest = seedManifestWithNativeRegistrations(); + const fs = new InMemoryFileAdapter(); + const manifestRepo = new InMemoryManifestRepository(manifest, PROJECT_ROOT); + const activator = new FakeNativePluginActivator({ available: true }); + const registry = new InMemoryMarketplaceRegistry(); + await registry.save( + PROJECT_ROOT, + Marketplace.create({ + name: MARKETPLACE, + source: { kind: "local", path: "/shared/built/path" }, + scope: "user", + addedAt: "2026-01-01T00:00:00.000Z", + }) + ); + fs.setFile("/fake-home/.config/aidd/references.json", "not json"); + const userSourceReferences = new UserSourceReferencesAdapter( + fs, + () => "/fake-home/.config/aidd" + ); + const logger = new CapturingLogger(); + const useCase = new CleanUseCase( + fs, + manifestRepo, + logger, + new GitignoreUseCase(fs), + new Map([[BINARY, activator]]), + registry, + undefined, + new Map(), + () => "/fake-home", + userSourceReferences + ); + + const result = await useCase.execute({ projectRoot: PROJECT_ROOT, force: false }); + + expect(result.dryRun).toBe(true); + expect(result.preview.sharedSourceOtherProjects).toBeUndefined(); + expect(logger.warnMessages.some((m) => m.includes("references.json"))).toBe(true); + }); + + it("warns and still drops the rest when --force runs against a corrupted references.json", async () => { + const manifest = seedManifestWithNativeRegistrations(); + const fs = new InMemoryFileAdapter(); + const manifestRepo = new InMemoryManifestRepository(manifest, PROJECT_ROOT); + const activator = new FakeNativePluginActivator({ available: true }); + const registry = new InMemoryMarketplaceRegistry(); + await registry.save( + PROJECT_ROOT, + Marketplace.create({ + name: MARKETPLACE, + source: { kind: "local", path: "/shared/built/path" }, + scope: "user", + addedAt: "2026-01-01T00:00:00.000Z", + }) + ); + fs.setFile("/fake-home/.config/aidd/references.json", "not json"); + const userSourceReferences = new UserSourceReferencesAdapter( + fs, + () => "/fake-home/.config/aidd" + ); + const logger = new CapturingLogger(); + const useCase = new CleanUseCase( + fs, + manifestRepo, + logger, + new GitignoreUseCase(fs), + new Map([[BINARY, activator]]), + registry, + undefined, + new Map(), + () => "/fake-home", + userSourceReferences + ); + + const result = await useCase.execute({ projectRoot: PROJECT_ROOT, force: true }); + + expect(result.dryRun).toBe(false); + expect(logger.warnMessages.some((m) => m.includes("references.json"))).toBe(true); + }); + }); + + it("warns and leaves the registration in place when the tool's CLI is not on PATH", async () => { + const manifest = seedManifestWithNativeRegistrations(); + const fs = new InMemoryFileAdapter(); + const manifestRepo = new InMemoryManifestRepository(manifest, PROJECT_ROOT); + const activator = new FakeNativePluginActivator({ available: false }); + const logger = new CapturingLogger(); + const useCase = new CleanUseCase( + fs, + manifestRepo, + logger, + new GitignoreUseCase(fs), + new Map([[BINARY, activator]]), + seedMarketplaceRegistry() + ); + + await useCase.execute({ projectRoot: PROJECT_ROOT, force: true }); + + expect(activator.uninstalledPlugins).toEqual([]); + expect(activator.removedMarketplaces).toEqual([]); + // The cache `purgeNativeCaches` never gets to consider — this tool's absent binary + // keeps it out of `undone` — must still be named, by the same absolute path. + const survivingCache = join(homedir(), ".codex", "plugins", "cache", MARKETPLACE); + expect( + logger.warnMessages.some( + (m) => + m.startsWith("codex: registration left in place, the codex CLI is not on the PATH.") && + m.includes(survivingCache) + ) + ).toBe(true); + }); + + it("uninstalls the plugin ref before removing the marketplace, for the same tool", async () => { + const manifest = seedManifestWithNativeRegistrations(); + const fs = new InMemoryFileAdapter(); + const manifestRepo = new InMemoryManifestRepository(manifest, PROJECT_ROOT); + const calls: string[] = []; + const activator = new OrderRecordingActivator(calls); + const useCase = new CleanUseCase( + fs, + manifestRepo, + new CapturingLogger(), + new GitignoreUseCase(fs), + new Map([[BINARY, activator]]), + seedMarketplaceRegistry() + ); + + await useCase.execute({ projectRoot: PROJECT_ROOT, force: true }); + + expect(calls).toEqual([`uninstall:${REF}`, `removeMarketplace:${MARKETPLACE}`]); + }); + + it("undoes the native registration before the built marketplace tree it points at is deleted", async () => { + const manifest = seedManifestWithNativeRegistrations(); + const fs = new InMemoryFileAdapter(); + const builtPath = join( + PROJECT_ROOT, + ".aidd", + "cache", + "built", + MARKETPLACE, + BINARY, + "x.json" + ); + await fs.writeFile(builtPath, "{}"); + const manifestRepo = new InMemoryManifestRepository(manifest, PROJECT_ROOT); + let builtTreeExistedAtRemoveMarketplace: boolean | null = null; + const activator = new AssertingActivator(async () => { + builtTreeExistedAtRemoveMarketplace = fs.has(builtPath); + }); + const useCase = new CleanUseCase( + fs, + manifestRepo, + new CapturingLogger(), + new GitignoreUseCase(fs), + new Map([[BINARY, activator]]), + seedMarketplaceRegistry() + ); + + await useCase.execute({ projectRoot: PROJECT_ROOT, force: true }); + + expect(builtTreeExistedAtRemoveMarketplace).toBe(true); + }); + + it("names the native registration a dry-run preview will undo, without touching it", async () => { + const manifest = seedManifestWithNativeRegistrations(); + const fs = new InMemoryFileAdapter(); + const manifestRepo = new InMemoryManifestRepository(manifest, PROJECT_ROOT); + const activator = new FakeNativePluginActivator({ available: true }); + const useCase = new CleanUseCase( + fs, + manifestRepo, + new CapturingLogger(), + new GitignoreUseCase(fs), + new Map([[BINARY, activator]]), + seedMarketplaceRegistry() + ); + + const result = await useCase.execute({ projectRoot: PROJECT_ROOT, force: false }); + + expect(result.dryRun).toBe(true); + expect(result.preview.nativeRegistrations).toEqual([ + { + toolId: "codex", + binary: BINARY, + marketplaceCount: 1, + pluginRefCount: 1, + cachePaths: [join(homedir(), ".codex", "plugins", "cache", MARKETPLACE)], + }, + ]); + expect(activator.uninstalledPlugins).toEqual([]); + expect(activator.removedMarketplaces).toEqual([]); + }); + + it("removes the marketplace under its catalog's own host name, not this project's local alias for it", async () => { + const ALIAS = "userscoped"; + const HOST_NAME = "user-mkt"; + const manifest = Manifest.create(); + manifest.addTool("codex", "1.0.0", []); + manifest.setNativeRegistrations("codex", { + binary: BINARY, + marketplaces: [{ alias: ALIAS, hostName: HOST_NAME }], + pluginRefs: [], + }); + const fs = new InMemoryFileAdapter(); + const manifestRepo = new InMemoryManifestRepository(manifest, PROJECT_ROOT); + const activator = new FakeNativePluginActivator({ available: true }); + const registry = new InMemoryMarketplaceRegistry(); + registry.save( + PROJECT_ROOT, + Marketplace.create({ + name: ALIAS, + source: { kind: "local", path: "/some/built/path" }, + scope: "project", + addedAt: "2026-01-01T00:00:00.000Z", + }) + ); + const useCase = new CleanUseCase( + fs, + manifestRepo, + new CapturingLogger(), + new GitignoreUseCase(fs), + new Map([[BINARY, activator]]), + registry + ); + + await useCase.execute({ projectRoot: PROJECT_ROOT, force: true }); + + expect(activator.removedMarketplaces).toEqual([HOST_NAME]); + expect(activator.removedMarketplaces).not.toContain(ALIAS); + }); + }); + + describe("machine-local files a tool's own materialization writes outside the manifest", () => { + it("removes .claude/settings.local.json, which install writes but the manifest never tracks", async () => { + const deps = await buildUnitDeps(PROJECT_ROOT); + await initAndInstall(deps, PROJECT_ROOT, "claude" as ToolId); + const settingsLocalPath = join(PROJECT_ROOT, ".claude", "settings.local.json"); + await deps.fs.writeFile(settingsLocalPath, "{}"); + + const useCase = new CleanUseCase( + deps.fs, + deps.manifestRepo, + deps.logger, + deps.gitignoreUseCase + ); + await useCase.execute({ projectRoot: PROJECT_ROOT, force: true }); + + expect(deps.fs.has(settingsLocalPath)).toBe(false); + }); + + it("deletes .cursor/hooks.json entirely once unmerging leaves it empty, and its script directory", async () => { + const pluginName = "aidd-context"; + const manifest = Manifest.create(); + manifest.addTool("cursor", "1.0.0", []); + manifest.addPlugin( + "cursor", + InstalledPlugin.fromJSON({ + name: pluginName, + source: { kind: "local", path: "/some/path" }, + version: "1.0.0", + strict: false, + files: {}, + scope: "project", + }) + ); + const fs = new InMemoryFileAdapter(); + const hooksPath = join(PROJECT_ROOT, ".cursor", "hooks.json"); + const scriptMarker = cursorProjectHooksScriptDir(pluginName); + await fs.writeFile( + hooksPath, + JSON.stringify({ + version: 1, + hooks: { PreToolUse: [{ command: `./${scriptMarker}run.js` }] }, + }) + ); + const scriptPath = join(PROJECT_ROOT, scriptMarker, "run.js"); + await fs.writeFile(scriptPath, "// hook script"); + const manifestRepo = new InMemoryManifestRepository(manifest, PROJECT_ROOT); + + const useCase = new CleanUseCase( + fs, + manifestRepo, + new CapturingLogger(), + new GitignoreUseCase(fs) + ); + await useCase.execute({ projectRoot: PROJECT_ROOT, force: true }); + + expect(fs.has(hooksPath)).toBe(false); + expect(fs.has(scriptPath)).toBe(false); + }); + + it("keeps .cursor/hooks.json when another plugin's entries remain in it", async () => { + const pluginName = "aidd-context"; + const otherPluginName = "aidd-dev"; + const manifest = Manifest.create(); + manifest.addTool("cursor", "1.0.0", []); + manifest.addPlugin( + "cursor", + InstalledPlugin.fromJSON({ + name: pluginName, + source: { kind: "local", path: "/some/path" }, + version: "1.0.0", + strict: false, + files: {}, + scope: "project", + }) + ); + const fs = new InMemoryFileAdapter(); + const hooksPath = join(PROJECT_ROOT, ".cursor", "hooks.json"); + const scriptMarker = cursorProjectHooksScriptDir(pluginName); + const otherScriptMarker = cursorProjectHooksScriptDir(otherPluginName); + await fs.writeFile( + hooksPath, + JSON.stringify({ + version: 1, + hooks: { + PreToolUse: [ + { command: `./${scriptMarker}run.js` }, + { command: `./${otherScriptMarker}run.js` }, + ], + }, + }) + ); + const otherScriptPath = join(PROJECT_ROOT, otherScriptMarker, "run.js"); + await fs.writeFile( + otherScriptPath, + "// hook script belonging to a plugin clean never tracked" + ); + const manifestRepo = new InMemoryManifestRepository(manifest, PROJECT_ROOT); + + const useCase = new CleanUseCase( + fs, + manifestRepo, + new CapturingLogger(), + new GitignoreUseCase(fs) + ); + await useCase.execute({ projectRoot: PROJECT_ROOT, force: true }); + + const remainingHooks = fs.getFile(hooksPath); + expect(remainingHooks).toBeDefined(); + expect(remainingHooks).not.toContain(scriptMarker); + expect(remainingHooks).toContain(otherScriptMarker); + expect(fs.has(otherScriptPath)).toBe(true); + }); + }); + + describe("user-scope containment for a plugin's own files", () => { + it("refuses to delete a manifest entry whose relative path escapes the user-scope directory via `..`, while still deleting its legitimate sibling", async () => { + const manifest = Manifest.create(); + manifest.addTool("cursor", "1.0.0", []); + manifest.addPlugin( + "cursor", + InstalledPlugin.fromJSON({ + name: "aidd-context", + source: { kind: "local", path: "/some/path" }, + version: "1.0.0", + strict: false, + files: { + [PLUGIN_KEY]: "abc123abc123abc123abc123abc123ab", + "../../../.ssh/id_rsa": "def456def456def456def456def456de", + }, + scope: "user", + }) + ); + const fs = new RecordingFileAdapter(); + const manifestRepo = new InMemoryManifestRepository(manifest, PROJECT_ROOT); + const logger = new CapturingLogger(); + const useCase = new CleanUseCase(fs, manifestRepo, logger, new GitignoreUseCase(fs)); + + await useCase.execute({ projectRoot: PROJECT_ROOT, force: true }); + + expect( + fs.deletedPaths.some((p) => p.endsWith(join(".cursor", "plugins", "local", PLUGIN_KEY))) + ).toBe(true); + expect(fs.deletedPaths.some((p) => p.includes(join(".ssh", "id_rsa")))).toBe(false); + expect(logger.warnMessages.some((m) => m.includes("id_rsa"))).toBe(true); + }); + + it("refuses to delete a plugin whose own directory is a symlink resolving outside the user-scope directory", async () => { + const manifest = Manifest.create(); + manifest.addTool("cursor", "1.0.0", []); + manifest.addPlugin( + "cursor", + InstalledPlugin.fromJSON({ + name: "aidd-context", + source: { kind: "local", path: "/some/path" }, + version: "1.0.0", + strict: false, + files: { [PLUGIN_KEY]: "abc123abc123abc123abc123abc123ab" }, + scope: "user", + }) + ); + const fs = new RecordingFileAdapter(); + const boundary = join(homedir(), ".cursor", "plugins", "local"); + fs.setSymlink(join(boundary, "aidd-context"), "/tmp/evil-aidd-context"); + const manifestRepo = new InMemoryManifestRepository(manifest, PROJECT_ROOT); + const logger = new CapturingLogger(); + const useCase = new CleanUseCase(fs, manifestRepo, logger, new GitignoreUseCase(fs)); + + await useCase.execute({ projectRoot: PROJECT_ROOT, force: true }); + + expect(fs.deletedPaths.some((p) => p.includes("evil"))).toBe(false); + expect( + fs.deletedPaths.some((p) => p.endsWith(join(".cursor", "plugins", "local", PLUGIN_KEY))) + ).toBe(false); + expect(logger.warnMessages.some((m) => m.includes(PLUGIN_KEY))).toBe(true); + }); + }); +}); + +/** Records `uninstallPlugin`/`removeMarketplace` calls in the order they happen, so a test + * can assert one came before the other. */ +class OrderRecordingActivator implements NativePluginActivator { + constructor(private readonly calls: string[]) {} + isAvailable(): boolean { + return true; + } + addMarketplace(): void {} + enablesPlugins(): boolean { + return false; + } + removeMarketplace(name: string): void { + this.calls.push(`removeMarketplace:${name}`); + } + registrationState(): "live" | "dead" | "unknown" { + return "live"; + } + upgradeMarketplaces(): void {} + enablePlugin(): void {} + uninstallPlugin(pluginRef: string): void { + this.calls.push(`uninstall:${pluginRef}`); + } +} + +/** Runs `onRemoveMarketplace` at the exact moment `removeMarketplace` is called, so a test + * can prove native undo happens before the built tree it depends on is deleted. */ +class AssertingActivator implements NativePluginActivator { + constructor(private readonly onRemoveMarketplace: () => void | Promise) {} + isAvailable(): boolean { + return true; + } + addMarketplace(): void {} + enablesPlugins(): boolean { + return false; + } + removeMarketplace(): void { + void this.onRemoveMarketplace(); + } + registrationState(): "live" | "dead" | "unknown" { + return "live"; + } + upgradeMarketplaces(): void {} + enablePlugin(): void {} + uninstallPlugin(): void {} +} diff --git a/cli/tests/contexts/framework/application/clean/clean-shared-ref-guard.integration.test.ts b/cli/tests/contexts/framework/application/clean/clean-shared-ref-guard.integration.test.ts new file mode 100644 index 000000000..5a5ee8a05 --- /dev/null +++ b/cli/tests/contexts/framework/application/clean/clean-shared-ref-guard.integration.test.ts @@ -0,0 +1,245 @@ +/** At a host that enables a plugin machine-wide (no `NativeActivation.scopeArgs` — codex, + * copilot), a ref is left enabled while another project still references its shared source. */ +import "../../../../../src/contexts/tools/domain/profiles/claude/profile.js"; +import "../../../../../src/contexts/tools/domain/profiles/codex/profile.js"; +import { describe, expect, it } from "vitest"; +import { + FRAMEWORK_MARKETPLACE_NAME, + Marketplace, +} from "../../../../../src/contexts/distribution/domain/marketplace.js"; +import { CleanUseCase } from "../../../../../src/contexts/framework/application/clean-use-case.js"; +import { GitignoreUseCase } from "../../../../../src/contexts/framework/application/gitignore-use-case.js"; +import type { NativeMarketplaceRegistration } from "../../../../../src/contexts/framework/domain/manifest/native-registrations.js"; +import { Manifest } from "../../../../../src/contexts/framework/domain/manifest.js"; +import { UserSourceReferencesAdapter } from "../../../../../src/contexts/framework/infrastructure/user-source-references-adapter.js"; +import { CapturingLogger } from "../../../../helpers/ports/capturing-logger.js"; +import { DeterministicHasher } from "../../../../helpers/ports/deterministic-hasher.js"; +import { FakeNativePluginActivator } from "../../../../helpers/ports/fake-native-plugin-activator.js"; +import { InMemoryFileAdapter } from "../../../../helpers/ports/in-memory-file-adapter.js"; +import { InMemoryManifestRepository } from "../../../../helpers/ports/in-memory-manifest-repository.js"; +import { InMemoryMarketplaceRegistry } from "../../../../helpers/ports/in-memory-marketplace-registry.js"; + +const PROJECT_ROOT = "/test-project"; +const OTHER_PROJECT = "/other-project"; +const USER_CONFIG_DIR = "/fake-home/.config/aidd"; + +function seedManifest( + toolId: "codex" | "claude", + marketplaces: readonly NativeMarketplaceRegistration[], + pluginRefs: readonly string[] +): Manifest { + const manifest = Manifest.create(); + manifest.addTool(toolId, "1.0.0", []); + manifest.setNativeRegistrations(toolId, { + binary: toolId, + marketplaces: [...marketplaces], + pluginRefs: [...pluginRefs], + }); + return manifest; +} + +function seedSharedMarketplaceRegistry(): InMemoryMarketplaceRegistry { + const registry = new InMemoryMarketplaceRegistry(); + registry.save( + PROJECT_ROOT, + Marketplace.create({ + name: FRAMEWORK_MARKETPLACE_NAME, + source: { kind: "local", path: "/some/built/path" }, + scope: "user", + addedAt: "2026-01-01T00:00:00.000Z", + }) + ); + return registry; +} + +function seedReferences(fs: InMemoryFileAdapter, roots: readonly string[]): void { + fs.setFile( + `${USER_CONFIG_DIR}/references.json`, + JSON.stringify({ "1.0.0": [PROJECT_ROOT, ...roots] }) + ); + // `listAllReferencingProjects` filters by `fs.fileExists(root)`, so every root seeded here + // needs a marker of its own or it reads back as no project at all. + fs.setFile(`${PROJECT_ROOT}/marker`, ""); + for (const root of roots) fs.setFile(`${root}/marker`, ""); +} + +function buildUseCase(deps: { + fs: InMemoryFileAdapter; + manifest: Manifest; + activator: FakeNativePluginActivator; + binary: string; + logger: CapturingLogger; + aiddMarketplaceRegistry: InMemoryMarketplaceRegistry; +}): CleanUseCase { + const manifestRepo = new InMemoryManifestRepository(deps.manifest, PROJECT_ROOT); + const userSourceReferences = new UserSourceReferencesAdapter(deps.fs, () => USER_CONFIG_DIR); + return new CleanUseCase( + deps.fs, + manifestRepo, + deps.logger, + new GitignoreUseCase(deps.fs), + new Map([[deps.binary, deps.activator]]), + deps.aiddMarketplaceRegistry, + undefined, + new Map(), + undefined, + userSourceReferences, + new Map() + ); +} + +describe("clean guards a ref another project on this machine still needs", () => { + it("keeps codex's ref enabled and names the other project still referencing the shared source", async () => { + const fs = new InMemoryFileAdapter({}, new DeterministicHasher()); + seedReferences(fs, [OTHER_PROJECT]); + const activator = new FakeNativePluginActivator({ available: true }); + const logger = new CapturingLogger(); + + const useCase = buildUseCase({ + fs, + manifest: seedManifest( + "codex", + [{ alias: "aidd-framework", hostName: "aidd-framework" }], + ["aidd-vcs@aidd-framework"] + ), + activator, + binary: "codex", + logger, + aiddMarketplaceRegistry: seedSharedMarketplaceRegistry(), + }); + + await useCase.execute({ projectRoot: PROJECT_ROOT, force: true }); + + expect(activator.uninstalledPlugins).not.toContain("aidd-vcs@aidd-framework"); + expect( + logger.warnMessages.some((m) => m.includes("left enabled") && m.includes(OTHER_PROJECT)) + ).toBe(true); + }); + + it("disables codex's ref once this project holds the last reference to the shared source", async () => { + const fs = new InMemoryFileAdapter({}, new DeterministicHasher()); + seedReferences(fs, []); + const activator = new FakeNativePluginActivator({ available: true }); + + const useCase = buildUseCase({ + fs, + manifest: seedManifest( + "codex", + [{ alias: "aidd-framework", hostName: "aidd-framework" }], + ["aidd-vcs@aidd-framework"] + ), + activator, + binary: "codex", + logger: new CapturingLogger(), + aiddMarketplaceRegistry: seedSharedMarketplaceRegistry(), + }); + + await useCase.execute({ projectRoot: PROJECT_ROOT, force: true }); + + expect(activator.uninstalledPlugins).toContain("aidd-vcs@aidd-framework"); + }); + + it("still disables claude's ref even with another project referencing the shared source", async () => { + const fs = new InMemoryFileAdapter({}, new DeterministicHasher()); + seedReferences(fs, [OTHER_PROJECT]); + const activator = new FakeNativePluginActivator({ available: true }); + + const useCase = buildUseCase({ + fs, + manifest: seedManifest( + "claude", + [{ alias: "aidd-framework", hostName: "aidd-framework" }], + ["aidd-vcs@aidd-framework"] + ), + activator, + binary: "claude", + logger: new CapturingLogger(), + aiddMarketplaceRegistry: seedSharedMarketplaceRegistry(), + }); + + await useCase.execute({ projectRoot: PROJECT_ROOT, force: true }); + + expect(activator.uninstalledPlugins).toContain("aidd-vcs@aidd-framework"); + }); + + it("still disables a ref from a marketplace that is not the shared source, in the same run", async () => { + const fs = new InMemoryFileAdapter({}, new DeterministicHasher()); + seedReferences(fs, [OTHER_PROJECT]); + const activator = new FakeNativePluginActivator({ available: true }); + + const useCase = buildUseCase({ + fs, + manifest: seedManifest( + "codex", + [ + { alias: "aidd-framework", hostName: "aidd-framework" }, + { alias: "other-mkt", hostName: "other-mkt" }, + ], + ["aidd-vcs@aidd-framework", "plugin-b@other-mkt"] + ), + activator, + binary: "codex", + logger: new CapturingLogger(), + aiddMarketplaceRegistry: seedSharedMarketplaceRegistry(), + }); + + await useCase.execute({ projectRoot: PROJECT_ROOT, force: true }); + + expect(activator.uninstalledPlugins).not.toContain("aidd-vcs@aidd-framework"); + expect(activator.uninstalledPlugins).toContain("plugin-b@other-mkt"); + }); + + it("disables codex's ref when no claim was ever recorded for this project, and no other project references it either", async () => { + const fs = new InMemoryFileAdapter({}, new DeterministicHasher()); + // No references.json at all: no claim of this project's own to drop, and nothing else + // referencing the source to guard on either. + const activator = new FakeNativePluginActivator({ available: true }); + + const useCase = buildUseCase({ + fs, + manifest: seedManifest( + "codex", + [{ alias: "aidd-framework", hostName: "aidd-framework" }], + ["aidd-vcs@aidd-framework"] + ), + activator, + binary: "codex", + logger: new CapturingLogger(), + aiddMarketplaceRegistry: seedSharedMarketplaceRegistry(), + }); + + await useCase.execute({ projectRoot: PROJECT_ROOT, force: true }); + + expect(activator.uninstalledPlugins).toContain("aidd-vcs@aidd-framework"); + }); + + // "This project's own claim was never recorded" and "no other project references it" are + // different facts: only `OTHER_PROJECT` is named here, and its live claim still guards. + it("keeps codex's ref enabled when this project's own claim was never recorded but another project's still is", async () => { + const fs = new InMemoryFileAdapter({}, new DeterministicHasher()); + fs.setFile(`${USER_CONFIG_DIR}/references.json`, JSON.stringify({ "1.0.0": [OTHER_PROJECT] })); + fs.setFile(`${OTHER_PROJECT}/marker`, ""); + const activator = new FakeNativePluginActivator({ available: true }); + const logger = new CapturingLogger(); + + const useCase = buildUseCase({ + fs, + manifest: seedManifest( + "codex", + [{ alias: "aidd-framework", hostName: "aidd-framework" }], + ["aidd-vcs@aidd-framework"] + ), + activator, + binary: "codex", + logger, + aiddMarketplaceRegistry: seedSharedMarketplaceRegistry(), + }); + + await useCase.execute({ projectRoot: PROJECT_ROOT, force: true }); + + expect(activator.uninstalledPlugins).not.toContain("aidd-vcs@aidd-framework"); + expect( + logger.warnMessages.some((m) => m.includes("left enabled") && m.includes(OTHER_PROJECT)) + ).toBe(true); + }); +}); diff --git a/cli/tests/contexts/framework/application/clean/clean-user-scope-use-case.integration.test.ts b/cli/tests/contexts/framework/application/clean/clean-user-scope-use-case.integration.test.ts new file mode 100644 index 000000000..2a483db83 --- /dev/null +++ b/cli/tests/contexts/framework/application/clean/clean-user-scope-use-case.integration.test.ts @@ -0,0 +1,541 @@ +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; +import "../../../../../src/contexts/tools/domain/profiles/claude/profile.js"; +import "../../../../../src/contexts/tools/domain/profiles/cursor/profile.js"; +import { + FRAMEWORK_MARKETPLACE_NAME, + Marketplace, +} from "../../../../../src/contexts/distribution/domain/marketplace.js"; +import { CleanUserScopeUseCase } from "../../../../../src/contexts/framework/application/clean/clean-user-scope-use-case.js"; +import { Manifest } from "../../../../../src/contexts/framework/domain/manifest.js"; +import { InstalledPlugin } from "../../../../../src/contexts/framework/domain/plugins/installed-plugin.js"; +import { UserSourceReferencesAdapter } from "../../../../../src/contexts/framework/infrastructure/user-source-references-adapter.js"; +import type { NativePluginActivator } from "../../../../../src/contexts/tools/domain/ports/native-plugin-activator.js"; +import type { Prompter } from "../../../../../src/kernel/ports/prompter.js"; +import type { MarketplaceScope } from "../../../../../src/kernel/scope.js"; +import { CapturingLogger } from "../../../../helpers/ports/capturing-logger.js"; +import { FakeHostMarketplaceRegistryReader } from "../../../../helpers/ports/fake-host-marketplace-registry-reader.js"; +import { InMemoryFileAdapter } from "../../../../helpers/ports/in-memory-file-adapter.js"; +import { InMemoryManifestRepository } from "../../../../helpers/ports/in-memory-manifest-repository.js"; +import { InMemoryMarketplaceRegistry } from "../../../../helpers/ports/in-memory-marketplace-registry.js"; + +const USER_CONFIG_DIR = "/fake-home/.config/aidd"; +const HOME = "/fake-home"; + +/** Records every delete in order, so an ordering constraint can be proved without reading + * the use case's own private state. A shared array correlates with another recorder. */ +class RecordingFileAdapter extends InMemoryFileAdapter { + readonly order: string[]; + + constructor(order: string[] = []) { + super(); + this.order = order; + } + + override async deleteDirectory(path: string): Promise { + this.order.push(`deleteDirectory:${path}`); + return super.deleteDirectory(path); + } + + override async deleteFile(path: string): Promise { + this.order.push(`deleteFile:${path}`); + return super.deleteFile(path); + } +} + +/** Pushes into the same shared `order` log the file adapter above writes to, so "the host was + * asked to forget this marketplace" and "its cache was deleted" sit on one timeline. */ +class RecordingActivator implements NativePluginActivator { + readonly order: string[]; + readonly removedMarketplaces: string[] = []; + readonly removedMarketplaceScopes: MarketplaceScope[] = []; + readonly uninstalledPlugins: string[] = []; + readonly uninstalledPluginScopes: MarketplaceScope[] = []; + + constructor(order: string[]) { + this.order = order; + } + + isAvailable(): boolean { + return true; + } + addMarketplace(): void {} + enablesPlugins(): boolean { + return true; + } + removeMarketplace(name: string, scope: MarketplaceScope): void { + this.order.push(`removeMarketplace:${name}`); + this.removedMarketplaces.push(name); + this.removedMarketplaceScopes.push(scope); + } + registrationState(): "live" | "dead" | "unknown" { + return "unknown"; + } + upgradeMarketplaces(): void {} + enablePlugin(): void {} + uninstallPlugin(pluginRef: string, scope: MarketplaceScope = "project"): void { + this.order.push(`uninstallPlugin:${pluginRef}`); + this.uninstalledPlugins.push(pluginRef); + this.uninstalledPluginScopes.push(scope); + } +} + +/** Answers a fixed way and keeps the message, so the confirmation's exact wording and the + * "answered no" branch can both be pinned. */ +class RecordingPrompter implements Prompter { + lastConfirmMessage: string | undefined; + + constructor(private readonly answer: boolean) {} + + async confirm(message: string): Promise { + this.lastConfirmMessage = message; + return this.answer; + } + async resolveConflict(): Promise<"keep" | "overwrite"> { + return "keep"; + } + async resolveConflictBulk(): Promise<"keep" | "overwrite" | "overwrite-all" | "skip-all"> { + return "keep"; + } + async input(): Promise { + return ""; + } + async select(): Promise { + throw new Error("not implemented"); + } + async checkbox(): Promise { + return []; + } +} + +function manifestWithClaude(): Manifest { + const manifest = Manifest.create(); + manifest.addTool("claude", "1.0.0", []); + manifest.setNativeRegistrations("claude", { + binary: "claude", + marketplaces: [{ alias: "aidd-framework", hostName: "aidd-framework" }], + pluginRefs: ["aidd-context@aidd-framework"], + }); + return manifest; +} + +/** Seeds the cache path claude's own profile declares with a marker file: an empty or absent + * directory is silently skipped rather than purged. */ +function seedClaudeCache(fs: InMemoryFileAdapter, hostName = "aidd-framework"): string { + const cachePath = join(HOME, ".claude", "plugins", "cache", hostName); + fs.setFile(join(cachePath, "marker.json"), "{}"); + return cachePath; +} + +describe("clean --scope user", () => { + describe("no user manifest, machine state from a project-scope setup", () => { + it("still purges the whitelist and touches no host, naming the referencing projects", async () => { + const order: string[] = []; + const fs = new RecordingFileAdapter(order); + fs.setFile( + join(USER_CONFIG_DIR, "cache", "built", "1.0.0", "aidd-framework", "claude", "x"), + "1" + ); + const registry = new InMemoryMarketplaceRegistry(); + await registry.save( + "/wherever", + Marketplace.create({ + name: FRAMEWORK_MARKETPLACE_NAME, + source: { kind: "local", path: "/src/framework" }, + scope: "user", + addedAt: "2026-01-01T00:00:00Z", + }) + ); + const userSourceReferences = new UserSourceReferencesAdapter(fs, () => USER_CONFIG_DIR); + await userSourceReferences.addReference("1.0.0", "/project-a"); + fs.setFile("/project-a/marker", ""); + const activator = new RecordingActivator(order); + const logger = new CapturingLogger(); + const useCase = new CleanUserScopeUseCase( + fs, + new InMemoryManifestRepository(null), + logger, + registry, + () => USER_CONFIG_DIR, + new Map([["claude", activator]]), + new Map(), + () => HOME, + userSourceReferences + ); + + const result = await useCase.execute({ projectRoot: "/wherever", force: true }); + + expect(result.manifestFound).toBe(false); + // The whitelist purge runs regardless of the manifest — it reads nothing from it. + expect(fs.order).toContain(`deleteDirectory:${join(USER_CONFIG_DIR, "cache", "built")}`); + // No manifest means no `nativeRegistrations` to drive a host's own CLI through — + // the activator that would have recorded a real call never sees one. + expect(activator.removedMarketplaces).toEqual([]); + expect(activator.uninstalledPlugins).toEqual([]); + const info = logger.infoMessages.find((m) => m.includes("No host registration")); + expect(info).toBeDefined(); + expect(info).toContain("/project-a"); + // Both removal commands, in order: `aidd clean --scope user` also satisfies a bare + // `.toContain("aidd clean")`, so the per-project half needs its own assertion. + expect(info).toContain("`aidd clean`"); + expect(info).toContain("`aidd clean --scope user`"); + expect(info?.indexOf("`aidd clean`")).toBeLessThan( + info?.indexOf("`aidd clean --scope user`") ?? -1 + ); + }); + }); + + describe("order", () => { + it("unregisters the marketplace through the host CLI before purging its cache", async () => { + const order: string[] = []; + const fs = new RecordingFileAdapter(order); + seedClaudeCache(fs); + const activator = new RecordingActivator(order); + const manifestRepo = new InMemoryManifestRepository(manifestWithClaude()); + const useCase = new CleanUserScopeUseCase( + fs, + manifestRepo, + new CapturingLogger(), + new InMemoryMarketplaceRegistry(), + () => USER_CONFIG_DIR, + new Map([["claude", activator]]), + new Map([ + [ + "claude", + new FakeHostMarketplaceRegistryReader({ + location: "known_marketplaces.json", + entries: new Map(), + }), + ], + ]), + () => HOME + ); + + await useCase.execute({ projectRoot: "/wherever", force: true }); + + const removeIndex = order.indexOf("removeMarketplace:aidd-framework"); + const purgeIndex = order.findIndex((entry) => entry.startsWith("deleteDirectory:")); + expect(removeIndex).toBeGreaterThanOrEqual(0); + expect(purgeIndex).toBeGreaterThanOrEqual(0); + expect(removeIndex).toBeLessThan(purgeIndex); + }); + }); + + describe("whitelist", () => { + it("deletes exactly cache/built, manifest.json, references.json and the aidd-framework marketplaces.json entry — nothing else", async () => { + const fs = new RecordingFileAdapter(); + fs.setFile( + join(USER_CONFIG_DIR, "cache", "built", "1.0.0", "aidd-framework", "claude", "x"), + "1" + ); + const manifestRepo = new InMemoryManifestRepository(Manifest.create()); + const registry = new InMemoryMarketplaceRegistry(); + await registry.save( + "/wherever", + Marketplace.create({ + name: FRAMEWORK_MARKETPLACE_NAME, + source: { kind: "local", path: "/src/framework" }, + scope: "user", + addedAt: "2026-01-01T00:00:00Z", + }) + ); + await registry.save( + "/wherever", + Marketplace.create({ + name: "other-marketplace", + source: { kind: "local", path: "/src/other" }, + scope: "user", + addedAt: "2026-01-01T00:00:00Z", + }) + ); + const userSourceReferences = new UserSourceReferencesAdapter(fs, () => USER_CONFIG_DIR); + await userSourceReferences.addReference("1.0.0", "/project-a"); + fs.setFile("/project-a/marker", ""); + const useCase = new CleanUserScopeUseCase( + fs, + manifestRepo, + new CapturingLogger(), + registry, + () => USER_CONFIG_DIR, + new Map(), + new Map(), + () => HOME, + userSourceReferences + ); + + await useCase.execute({ projectRoot: "/wherever", force: true }); + + expect(fs.order).toContain(`deleteDirectory:${join(USER_CONFIG_DIR, "cache", "built")}`); + expect(fs.order).toContain(`deleteFile:${join(USER_CONFIG_DIR, "references.json")}`); + expect(manifestRepo.getCurrent()).toBeNull(); + const remaining = registry.getAll("/wherever").map((m) => m.name); + expect(remaining).not.toContain(FRAMEWORK_MARKETPLACE_NAME); + expect(remaining).toContain("other-marketplace"); + }); + + it("deletes the update-check cache too, so nothing is left to keep the cache/ shell alive", async () => { + const fs = new RecordingFileAdapter(); + fs.setFile( + join(USER_CONFIG_DIR, "cache", "built", "1.0.0", "aidd-framework", "claude", "x"), + "1" + ); + // Written by any online command into the same `cache/` directory `cache/built/` sits + // in — the occupant that used to survive a machine-scope clean and keep the shell alive. + fs.setFile(join(USER_CONFIG_DIR, "cache", "update-check.json"), '{"latest":"9.9.9"}'); + // And where an older CLI wrote the same cache, before it moved under `cache/`. + fs.setFile(join(USER_CONFIG_DIR, "update-check.json"), '{"latest":"8.0.0"}'); + const useCase = new CleanUserScopeUseCase( + fs, + new InMemoryManifestRepository(Manifest.create()), + new CapturingLogger(), + new InMemoryMarketplaceRegistry(), + () => USER_CONFIG_DIR + ); + + await useCase.execute({ projectRoot: "/wherever", force: true }); + + expect(fs.order).toContain( + `deleteFile:${join(USER_CONFIG_DIR, "cache", "update-check.json")}` + ); + expect(await fs.fileExists(join(USER_CONFIG_DIR, "cache", "update-check.json"))).toBe(false); + expect(fs.order).toContain(`deleteFile:${join(USER_CONFIG_DIR, "update-check.json")}`); + // Nothing else sat beside it, so the shell itself goes with it. + expect(fs.order).toContain(`deleteDirectory:${join(USER_CONFIG_DIR, "cache")}`); + }); + + it("never deletes userConfigDir() itself, even when references.json resolves back to it through a symlink", async () => { + const fs = new RecordingFileAdapter(); + fs.setFile(join(USER_CONFIG_DIR, "marker"), "1"); + // A corrupted or attacker-controlled state: the references file has become a + // symlink pointing straight back at userConfigDir() itself. + fs.setSymlink(join(USER_CONFIG_DIR, "references.json"), USER_CONFIG_DIR); + const logger = new CapturingLogger(); + const useCase = new CleanUserScopeUseCase( + fs, + new InMemoryManifestRepository(Manifest.create()), + logger, + new InMemoryMarketplaceRegistry(), + () => USER_CONFIG_DIR + ); + + await useCase.execute({ projectRoot: "/wherever", force: true }); + + // references.json's own candidate resolves straight back to userConfigDir() itself, + // which containment refuses regardless of equality. + expect(fs.order).not.toContain(`deleteFile:${join(USER_CONFIG_DIR, "references.json")}`); + expect(fs.order).not.toContain(`deleteFile:${USER_CONFIG_DIR}`); + expect(fs.order.some((entry) => entry.endsWith(USER_CONFIG_DIR))).toBe(false); + // userConfigDir() itself must still exist — the marker file placed directly under it + // proves the directory was never recursively removed. + expect(await fs.fileExists(join(USER_CONFIG_DIR, "marker"))).toBe(true); + }); + + it("leaves a cursor plugin file in place when it resolves outside the user plugins boundary through a symlink", async () => { + const fs = new RecordingFileAdapter(); + const boundary = join(HOME, ".cursor", "plugins", "local"); + // A sibling sharing `boundary`'s name as a string prefix, not a path under it: a raw + // `startsWith` would call it contained, where `relative()` sees the leading `..`. + const escapeTarget = `${boundary}-evil`; + fs.setFile(join(escapeTarget, "evil.md"), "danger"); + // The manifest's own entry names a plugin directory that, since install, became a + // symlink escaping the declared user-scope boundary. + fs.setSymlink(join(boundary, "rogue-plugin"), escapeTarget); + const manifest = Manifest.create(); + manifest.addTool("cursor", "1.0.0", []); + manifest.addPlugin( + "cursor", + InstalledPlugin.fromMetadata( + "rogue-plugin", + "1.0.0", + { kind: "local", path: "/whatever" }, + false, + "user" + ).withFiles(new Map([["rogue-plugin/evil.md", "hash"]])) + ); + const logger = new CapturingLogger(); + const useCase = new CleanUserScopeUseCase( + fs, + new InMemoryManifestRepository(manifest), + logger, + new InMemoryMarketplaceRegistry(), + () => USER_CONFIG_DIR, + new Map(), + new Map(), + () => HOME + ); + + await useCase.execute({ projectRoot: "/wherever", force: true }); + + expect(await fs.fileExists(join(escapeTarget, "evil.md"))).toBe(true); + expect(logger.warnMessages.some((m) => m.includes("does not resolve inside"))).toBe(true); + }); + }); + + describe("binary absent", () => { + it("names the binary and what it would have undone, without touching its own cache", async () => { + const fs = new RecordingFileAdapter(); + const claudeCachePath = seedClaudeCache(fs); + const logger = new CapturingLogger(); + const useCase = new CleanUserScopeUseCase( + fs, + new InMemoryManifestRepository(manifestWithClaude()), + logger, + new InMemoryMarketplaceRegistry(), + () => USER_CONFIG_DIR, + new Map(), // no activator registered for "claude" — the binary is absent + new Map(), + () => HOME + ); + + await useCase.execute({ projectRoot: "/wherever", force: true }); + + const warning = logger.warnMessages.find( + (m) => m.includes("claude") && m.includes("not on the PATH") + ); + expect(warning).toBeDefined(); + expect(warning).toContain("1 marketplace(s)"); + expect(warning).toContain("1 plugin ref(s)"); + expect(warning).toContain(claudeCachePath); + // The whitelist step still purges userConfigDir()'s own cache/built/, unrelated + // to this host's own cache — only claude's own directory must survive untouched. + expect(fs.order).not.toContain(`deleteDirectory:${claudeCachePath}`); + expect(await fs.fileExists(join(claudeCachePath, "marker.json"))).toBe(true); + }); + }); + + describe("confirmation", () => { + it("names the versions built and the projects still referencing the source", async () => { + const fs = new InMemoryFileAdapter(); + fs.setFile( + join(USER_CONFIG_DIR, "cache", "built", "1.2.3", "aidd-framework", "claude", "x"), + "1" + ); + fs.setFile("/project-a/marker", ""); + const userSourceReferences = new UserSourceReferencesAdapter(fs, () => USER_CONFIG_DIR); + await userSourceReferences.addReference("1.2.3", "/project-a"); + const useCase = new CleanUserScopeUseCase( + fs, + new InMemoryManifestRepository(Manifest.create()), + new CapturingLogger(), + new InMemoryMarketplaceRegistry(), + () => USER_CONFIG_DIR, + new Map(), + new Map(), + () => HOME, + userSourceReferences + ); + + const result = await useCase.execute({ projectRoot: "/wherever", force: false }); + + expect(result.dryRun).toBe(true); + expect(result.preview.builtVersions).toEqual(["1.2.3"]); + expect(result.preview.referencingProjects).toEqual(["/project-a"]); + }); + + it("ignores a referencing project whose own path no longer exists", async () => { + const fs = new InMemoryFileAdapter(); + const userSourceReferences = new UserSourceReferencesAdapter(fs, () => USER_CONFIG_DIR); + await userSourceReferences.addReference("1.0.0", "/gone"); + const useCase = new CleanUserScopeUseCase( + fs, + new InMemoryManifestRepository(Manifest.create()), + new CapturingLogger(), + new InMemoryMarketplaceRegistry(), + () => USER_CONFIG_DIR, + new Map(), + new Map(), + () => HOME, + userSourceReferences + ); + + const result = await useCase.execute({ projectRoot: "/wherever", force: false }); + + expect(result.preview.referencingProjects).toEqual([]); + }); + + it("--force skips confirmation and proceeds even when projects still reference it", async () => { + const fs = new InMemoryFileAdapter(); + fs.setFile("/project-a/marker", ""); + const userSourceReferences = new UserSourceReferencesAdapter(fs, () => USER_CONFIG_DIR); + await userSourceReferences.addReference("1.0.0", "/project-a"); + const manifestRepo = new InMemoryManifestRepository(Manifest.create()); + const useCase = new CleanUserScopeUseCase( + fs, + manifestRepo, + new CapturingLogger(), + new InMemoryMarketplaceRegistry(), + () => USER_CONFIG_DIR, + new Map(), + new Map(), + () => HOME, + userSourceReferences + ); + + const result = await useCase.execute({ projectRoot: "/wherever", force: true }); + + expect(result.dryRun).toBe(false); + expect(manifestRepo.getCurrent()).toBeNull(); + }); + }); + + describe("confirmation prompt", () => { + it("proceeds and purges once the interactive prompt is answered yes, pinning its wording", async () => { + const fs = new RecordingFileAdapter(); + const manifestRepo = new InMemoryManifestRepository(Manifest.create()); + const prompter = new RecordingPrompter(true); + const useCase = new CleanUserScopeUseCase( + fs, + manifestRepo, + new CapturingLogger(), + new InMemoryMarketplaceRegistry(), + () => USER_CONFIG_DIR, + new Map(), + new Map(), + () => HOME, + undefined, + prompter + ); + + const result = await useCase.execute({ + projectRoot: "/wherever", + force: false, + interactive: true, + }); + + expect(prompter.lastConfirmMessage).toBe( + "Remove the shared 'aidd-framework' source for this machine " + + "(versions: none built yet)? Still referenced by: no other project." + ); + expect(result.dryRun).toBe(false); + expect(manifestRepo.getCurrent()).toBeNull(); + }); + + it("does nothing once the interactive prompt is answered no", async () => { + const fs = new RecordingFileAdapter(); + const manifestRepo = new InMemoryManifestRepository(Manifest.create()); + const prompter = new RecordingPrompter(false); + const useCase = new CleanUserScopeUseCase( + fs, + manifestRepo, + new CapturingLogger(), + new InMemoryMarketplaceRegistry(), + () => USER_CONFIG_DIR, + new Map(), + new Map(), + () => HOME, + undefined, + prompter + ); + + const result = await useCase.execute({ + projectRoot: "/wherever", + force: false, + interactive: true, + }); + + expect(result.dryRun).toBe(true); + expect(manifestRepo.getCurrent()).not.toBeNull(); + expect(fs.order).toEqual([]); + }); + }); +}); diff --git a/cli/tests/contexts/framework/application/detect-plugin-drift-use-case.unit.test.ts b/cli/tests/contexts/framework/application/detect-plugin-drift-use-case.unit.test.ts new file mode 100644 index 000000000..84494377b --- /dev/null +++ b/cli/tests/contexts/framework/application/detect-plugin-drift-use-case.unit.test.ts @@ -0,0 +1,161 @@ +import { join } from "node:path"; +import "../../../../src/contexts/tools/domain/profiles/claude/profile.js"; +import "../../../../src/contexts/tools/domain/profiles/cursor/profile.js"; +import { describe, expect, it } from "vitest"; +import { DetectPluginDriftUseCase } from "../../../../src/contexts/framework/application/shared/detect-plugin-drift-use-case.js"; +import { Manifest } from "../../../../src/contexts/framework/domain/manifest.js"; +import { InstalledPlugin } from "../../../../src/contexts/framework/domain/plugins/installed-plugin.js"; +import { FileHash } from "../../../../src/kernel/file.js"; +import type { FileReader } from "../../../../src/kernel/ports/file-reader.js"; + +const HASH_A = "abc123abc123abc123abc123abc123ab"; +const HASH_B = "def456def456def456def456def456de"; + +function cursorManifestWithFiles(files: Record): Manifest { + const manifest = Manifest.create(); + manifest.addTool("cursor", "1.0.0", []); + manifest.addPlugin( + "cursor", + InstalledPlugin.fromJSON({ + name: "aidd-test", + source: { kind: "local", path: "/some/path" }, + version: "1.0.0", + strict: false, + files, + scope: "user", + }) + ); + return manifest; +} + +function makeFs(missing: Set): FileReader { + return { + fileExists: async (p: string) => ![...missing].some((m) => p.endsWith(m)), + isExecutable: async () => false, + realpath: async (path: string) => path, + // Present files always match their own manifest hash: this fixture is about which + // files are missing, not about hash mismatches, which the other unit tests cover. + readFileHash: async (p: string) => new FileHash(p.endsWith("two.md") ? HASH_B : HASH_A), + readFile: async () => "", + listDirectory: async () => [], + listFilesRecursive: async () => [], + }; +} + +describe("DetectPluginDriftUseCase — user-scope tool never installed on this machine", () => { + it("collapses a plugin whose every tracked file is missing into one not-installed entry", async () => { + const files = { "a/one.md": HASH_A, "a/two.md": HASH_B }; + const manifest = cursorManifestWithFiles(files); + const fs = makeFs(new Set(["one.md", "two.md"])); + const useCase = new DetectPluginDriftUseCase(fs); + + const drifts = await useCase.execute({ manifest, projectRoot: "/proj", toolIds: ["cursor"] }); + + expect(drifts).toHaveLength(1); + expect(drifts[0].notInstalledOnMachine).toBe(true); + expect(drifts[0].files).toHaveLength(0); + }); + + it("still reports per-file drift when only one of several tracked files is missing", async () => { + const files = { "a/one.md": HASH_A, "a/two.md": HASH_B }; + const manifest = cursorManifestWithFiles(files); + const fs = makeFs(new Set(["one.md"])); + const useCase = new DetectPluginDriftUseCase(fs); + + const drifts = await useCase.execute({ manifest, projectRoot: "/proj", toolIds: ["cursor"] }); + + expect(drifts).toHaveLength(1); + expect(drifts[0].notInstalledOnMachine).toBe(false); + expect(drifts[0].files).toHaveLength(1); + expect(drifts[0].files[0]).toEqual({ relativePath: "a/one.md", kind: "missing" }); + }); + + it("does not collapse an all-missing plugin for a project-scope tool", async () => { + const manifest = Manifest.create(); + manifest.addTool("claude", "1.0.0", []); + manifest.addPlugin( + "claude", + InstalledPlugin.fromJSON({ + name: "aidd-test", + source: { kind: "local", path: "/some/path" }, + version: "1.0.0", + strict: false, + files: { "a/one.md": HASH_A, "a/two.md": HASH_B }, + scope: "project", + }) + ); + const fs = makeFs(new Set(["one.md", "two.md"])); + const useCase = new DetectPluginDriftUseCase(fs); + + const drifts = await useCase.execute({ manifest, projectRoot: "/proj", toolIds: ["claude"] }); + + expect(drifts).toHaveLength(1); + expect(drifts[0].notInstalledOnMachine).toBe(false); + expect(drifts[0].files).toHaveLength(2); + }); +}); + +describe("DetectPluginDriftUseCase — the manifest's recorded scope wins over the profile", () => { + it("checks a cursor plugin under projectRoot, not ~/.cursor/plugins/local, when scope: project disagrees with cursor's profile", async () => { + const manifest = Manifest.create(); + manifest.addTool("cursor", "1.0.0", []); + manifest.addPlugin( + "cursor", + InstalledPlugin.fromJSON({ + name: "aidd-test", + source: { kind: "local", path: "/some/path" }, + version: "1.0.0", + strict: false, + files: { "a/one.md": HASH_A }, + // Disagrees with cursor's own profile, which declares installScope "user". + scope: "project", + }) + ); + const checkedPaths: string[] = []; + const fs: FileReader = { + fileExists: async (p: string) => { + checkedPaths.push(p); + return true; + }, + isExecutable: async () => false, + realpath: async (path: string) => path, + readFileHash: async () => new FileHash(HASH_A), + readFile: async () => "", + listDirectory: async () => [], + listFilesRecursive: async () => [], + }; + const useCase = new DetectPluginDriftUseCase(fs); + + const drifts = await useCase.execute({ manifest, projectRoot: "/proj", toolIds: ["cursor"] }); + + expect(drifts).toHaveLength(0); + expect(checkedPaths).toContain(join("/proj", "a", "one.md")); + expect(checkedPaths.some((p) => p.includes(".cursor"))).toBe(false); + }); + + // The `notInstalledOnMachine` collapse exists for a user-scope directory a fresh machine + // has not populated; what it measures is the manifest's recorded scope, not the profile's. + it("reports real per-file drift, not a collapsed not-installed entry, for a cursor plugin recorded scope: project", async () => { + const manifest = Manifest.create(); + manifest.addTool("cursor", "1.0.0", []); + manifest.addPlugin( + "cursor", + InstalledPlugin.fromJSON({ + name: "aidd-test", + source: { kind: "local", path: "/some/path" }, + version: "1.0.0", + strict: false, + files: { "a/one.md": HASH_A, "a/two.md": HASH_B }, + scope: "project", + }) + ); + const fs = makeFs(new Set(["one.md", "two.md"])); + const useCase = new DetectPluginDriftUseCase(fs); + + const drifts = await useCase.execute({ manifest, projectRoot: "/proj", toolIds: ["cursor"] }); + + expect(drifts).toHaveLength(1); + expect(drifts[0].notInstalledOnMachine).toBe(false); + expect(drifts[0].files).toHaveLength(2); + }); +}); diff --git a/cli/tests/contexts/framework/application/doctor-marketplace-sources.integration.test.ts b/cli/tests/contexts/framework/application/doctor-marketplace-sources.integration.test.ts new file mode 100644 index 000000000..5c68a2241 --- /dev/null +++ b/cli/tests/contexts/framework/application/doctor-marketplace-sources.integration.test.ts @@ -0,0 +1,416 @@ +import { resolve } from "node:path"; +import { describe, expect, it } from "vitest"; +import "../../../../src/contexts/tools/domain/profiles/claude/profile.js"; +import { Marketplace } from "../../../../src/contexts/distribution/domain/marketplace.js"; +import { DoctorRegistrationUseCase } from "../../../../src/contexts/framework/application/doctor/doctor-registration-use-case.js"; +import { Manifest } from "../../../../src/contexts/framework/domain/manifest.js"; +import { builtMarketplaceDir, userBuiltMarketplaceDir } from "../../../../src/kernel/paths.js"; +import type { FileReader } from "../../../../src/kernel/ports/file-reader.js"; +import type { VersionReader } from "../../../../src/kernel/ports/version-reader.js"; +import { FakeHostMarketplaceRegistryReader } from "../../../helpers/ports/fake-host-marketplace-registry-reader.js"; +import { InMemoryFileAdapter } from "../../../helpers/ports/in-memory-file-adapter.js"; +import { InMemoryMarketplaceRegistry } from "../../../helpers/ports/in-memory-marketplace-registry.js"; + +function fakeVersion(value: string): VersionReader { + return { get: () => value }; +} + +// resolve(): compared in `marketplaceSourceDrift` against an already-resolved source, so a +// drive-less literal makes win32 misclassify this project's own pre-migration cache as foreign. +const PROJECT_ROOT = resolve("/project"); +const NAME = "probe-mkt"; +const CATALOG_RELATIVE = ".claude-plugin/marketplace.json"; +const REGISTRY_LOCATION = "/home/.claude/plugins/known_marketplaces.json"; + +/** The exact path `MarketplaceSyncSettingsUseCase`'s own guard would compare against — + * recomputed the same way `checkMarketplaceSources` does, never a value invented here. */ +function expectedBuiltDir(): string { + return resolve(builtMarketplaceDir(PROJECT_ROOT, NAME, "claude")); +} + +/** A `FileReader` whose `realpath` always throws, standing in for a built tree that was + * never built, or built and then deleted: the one case `checkMarketplaceSources` stays silent + * on rather than inventing a conflict against a path nothing resolves to. */ +class UnresolvableFileReader implements FileReader { + async readFile(): Promise { + throw new Error("not used by this test"); + } + async listDirectory(): Promise { + return []; + } + async fileExists(): Promise { + return false; + } + async readFileHash(): Promise { + throw new Error("not used by this test"); + } + async listFilesRecursive(): Promise { + return []; + } + async isExecutable(): Promise { + return false; + } + async realpath(): Promise { + const error = new Error("ENOENT") as NodeJS.ErrnoException; + error.code = "ENOENT"; + throw error; + } +} + +interface CatalogAt { + readonly path: string; + readonly name?: string; + readonly version?: string; + readonly pluginNames?: readonly string[]; +} + +function catalogFile(catalog: CatalogAt): [string, string] { + return [ + `${catalog.path}/${CATALOG_RELATIVE}`, + JSON.stringify({ + name: catalog.name ?? NAME, + version: catalog.version, + plugins: (catalog.pluginNames ?? []).map((name) => ({ name })), + }), + ]; +} + +async function issuesFor( + hostReader: FakeHostMarketplaceRegistryReader, + options: { fs?: FileReader; requested?: CatalogAt; registered?: CatalogAt } = {} +) { + let fs = options.fs ?? new InMemoryFileAdapter(); + if (options.fs === undefined) { + const seed: Record = {}; + const requested = options.requested ?? { path: expectedBuiltDir() }; + const [requestedPath, requestedContent] = catalogFile(requested); + seed[requestedPath] = requestedContent; + if (options.registered !== undefined) { + const [registeredPath, registeredContent] = catalogFile(options.registered); + seed[registeredPath] = registeredContent; + } + fs = new InMemoryFileAdapter(seed); + } + const registry = new InMemoryMarketplaceRegistry(); + await registry.save( + PROJECT_ROOT, + Marketplace.create({ + name: NAME, + source: { kind: "local", path: "/source" }, + scope: "project", + addedAt: "2026-01-01T00:00:00Z", + }) + ); + const manifest = Manifest.create(); + manifest.addTool("claude", "test", []); + const useCase = new DoctorRegistrationUseCase( + fs, + registry, + new Map(), + new Map(), + new Map([["claude", hostReader]]), + () => "/user-cache", + fakeVersion("1.0.0") + ); + const issues = await useCase.execute({ manifest, projectRoot: PROJECT_ROOT, allowedIds: null }); + // Not filtered by `NAME`, this project's own local alias: a conflict message names the + // catalog's own declared name, which a test may diverge from the alias entirely. + return issues.filter((issue) => issue.message.includes("catalog")); +} + +describe("DoctorRegistrationUseCase — marketplace source conflicts", () => { + it("reports a conflict when a different catalog is registered under the same name", async () => { + const hostReader = new FakeHostMarketplaceRegistryReader({ + location: REGISTRY_LOCATION, + entries: new Map([[NAME, "/other/src"]]), + }); + + const issues = await issuesFor(hostReader, { + requested: { path: expectedBuiltDir(), pluginNames: ["sample-plugin"] }, + registered: { path: "/other/src", pluginNames: ["different-plugin"] }, + }); + + expect(issues).toHaveLength(1); + expect(issues[0]?.severity).toBe("error"); + expect(issues[0]?.message).toMatch(/different catalog/); + expect(issues[0]?.message).toMatch(/\+sample-plugin/); + expect(issues[0]?.message).toMatch(/-different-plugin/); + expect(issues[0]?.fix).toMatch(/claude plugin marketplace remove/); + }); + + it("does not report a conflict when only the version differs under the same name and plugin set — an upgrade, not a conflict", async () => { + const hostReader = new FakeHostMarketplaceRegistryReader({ + location: REGISTRY_LOCATION, + entries: new Map([[NAME, "/other/src"]]), + }); + + const issues = await issuesFor(hostReader, { + requested: { path: expectedBuiltDir(), version: "2.0.0", pluginNames: ["sample-plugin"] }, + registered: { path: "/other/src", version: "1.0.0", pluginNames: ["sample-plugin"] }, + }); + + expect(issues).toEqual([]); + }); + + it("reports a conflict keyed by the catalog's own declared name even when this project's local alias would have missed it entirely — the alias never held the entry to begin with", async () => { + const HOST_NAME = "upstream"; + const hostReader = new FakeHostMarketplaceRegistryReader({ + location: REGISTRY_LOCATION, + // Nothing is registered under this project's local alias, only under the catalog's + // declared name: a pass keyed by `marketplace.name` would find nothing and stay silent. + entries: new Map([[HOST_NAME, "/other/src"]]), + }); + + const issues = await issuesFor(hostReader, { + requested: { path: expectedBuiltDir(), name: HOST_NAME, pluginNames: ["sample-plugin"] }, + registered: { path: "/other/src", name: HOST_NAME, pluginNames: ["different-plugin"] }, + }); + + expect(issues).toHaveLength(1); + expect(issues[0]?.severity).toBe("error"); + expect(issues[0]?.message).toMatch(/different catalog/); + }); + + it("reports nothing when the host's registry already holds the same resolved source", async () => { + const hostReader = new FakeHostMarketplaceRegistryReader({ + location: REGISTRY_LOCATION, + entries: new Map([[NAME, expectedBuiltDir()]]), + }); + + expect(await issuesFor(hostReader, { requested: { path: expectedBuiltDir() } })).toEqual([]); + }); + + it("reports nothing when the same catalog is registered from a different, resolved path — two projects sharing one build", async () => { + const hostReader = new FakeHostMarketplaceRegistryReader({ + location: REGISTRY_LOCATION, + entries: new Map([[NAME, "/other-project/built/claude"]]), + }); + + const issues = await issuesFor(hostReader, { + requested: { path: expectedBuiltDir(), version: "1.0.0", pluginNames: ["sample-plugin"] }, + registered: { + path: "/other-project/built/claude", + version: "1.0.0", + pluginNames: ["sample-plugin"], + }, + }); + + expect(issues).toEqual([]); + }); + + it("reports nothing when the registered source no longer resolves to a readable catalog — a dead entry a re-add repairs", async () => { + const hostReader = new FakeHostMarketplaceRegistryReader({ + location: REGISTRY_LOCATION, + entries: new Map([[NAME, "/gone"]]), + }); + + // No `registered` catalog written for "/gone" — nothing is there to read. + expect(await issuesFor(hostReader, { requested: { path: expectedBuiltDir() } })).toEqual([]); + }); + + it("reports nothing when the host's registry cannot be read", async () => { + const hostReader = new FakeHostMarketplaceRegistryReader({ + location: REGISTRY_LOCATION, + unreadable: "ENOENT", + }); + + expect(await issuesFor(hostReader, { requested: { path: expectedBuiltDir() } })).toEqual([]); + }); + + it("reports nothing when this project's build was never made, or no longer resolves", async () => { + const hostReader = new FakeHostMarketplaceRegistryReader({ + location: REGISTRY_LOCATION, + entries: new Map([[NAME, "/other/src"]]), + }); + + expect(await issuesFor(hostReader, { fs: new UnresolvableFileReader() })).toEqual([]); + }); + + it("reports nothing when this project's local alias differs from its catalog's own name, even when the alias coincidentally names a different registered entry", async () => { + const CATALOG_NAME = "probe-mkt-catalog"; + const hostReader = new FakeHostMarketplaceRegistryReader({ + location: REGISTRY_LOCATION, + entries: new Map([ + // Some unrelated catalog happens to be registered under this project's local alias, a fact + // `checkMarketplaceSources` must never look up: the host never keyed this project by it. + [NAME, "/other/unrelated/src"], + // The host's real key for this project's own catalog: its own declared name, + // pointed at the exact tree this project built. + [CATALOG_NAME, expectedBuiltDir()], + ]), + }); + const fs = new InMemoryFileAdapter({ + [`${expectedBuiltDir()}/${CATALOG_RELATIVE}`]: JSON.stringify({ + name: CATALOG_NAME, + version: "1.0.0", + plugins: [], + }), + "/other/unrelated/src/.claude-plugin/marketplace.json": JSON.stringify({ + name: "unrelated", + version: "9.9.9", + plugins: [], + }), + }); + + expect(await issuesFor(hostReader, { fs })).toEqual([]); + }); + + it("reads the host registry once per marketplace, the same cadence the sync-time guard uses — not once per tool, reused across every marketplace that tool has", async () => { + const SECOND_NAME = "probe-mkt-2"; + const hostReader = new FakeHostMarketplaceRegistryReader({ + location: REGISTRY_LOCATION, + entries: new Map(), + }); + const fs = new InMemoryFileAdapter({ + [`${expectedBuiltDir()}/${CATALOG_RELATIVE}`]: JSON.stringify({ + name: NAME, + plugins: [], + }), + [`${resolve(builtMarketplaceDir(PROJECT_ROOT, SECOND_NAME, "claude"))}/${CATALOG_RELATIVE}`]: + JSON.stringify({ name: SECOND_NAME, plugins: [] }), + }); + const registry = new InMemoryMarketplaceRegistry(); + await registry.save( + PROJECT_ROOT, + Marketplace.create({ + name: NAME, + source: { kind: "local", path: "/source" }, + scope: "project", + addedAt: "2026-01-01T00:00:00Z", + }) + ); + await registry.save( + PROJECT_ROOT, + Marketplace.create({ + name: SECOND_NAME, + source: { kind: "local", path: "/source-2" }, + scope: "project", + addedAt: "2026-01-01T00:00:00Z", + }) + ); + const manifest = Manifest.create(); + manifest.addTool("claude", "test", []); + const useCase = new DoctorRegistrationUseCase( + fs, + registry, + new Map(), + new Map(), + new Map([["claude", hostReader]]), + () => "/user-cache", + fakeVersion("1.0.0") + ); + + await useCase.execute({ manifest, projectRoot: PROJECT_ROOT, allowedIds: null }); + + expect(hostReader.reads).toBe(2); + }); +}); + +describe("DoctorRegistrationUseCase — user-scope marketplace source drift", () => { + // resolve(): compared against an already-resolved requested/registered source, so a + // drive-less literal misses the base compare and every drift below returns undefined. + const USER_CACHE_ROOT = resolve("/user-cache"); + const CURRENT_VERSION = "2.0.0"; + + function sharedPath(version: string): string { + return resolve(userBuiltMarketplaceDir(USER_CACHE_ROOT, version, NAME, "claude")); + } + + async function driftIssuesFor(hostReader: FakeHostMarketplaceRegistryReader) { + const fs = new InMemoryFileAdapter({ + [`${sharedPath(CURRENT_VERSION)}/${CATALOG_RELATIVE}`]: JSON.stringify({ + name: NAME, + plugins: [], + }), + }); + const registry = new InMemoryMarketplaceRegistry(); + await registry.save( + PROJECT_ROOT, + Marketplace.create({ + name: NAME, + source: { kind: "local", path: "/source" }, + scope: "user", + addedAt: "2026-01-01T00:00:00Z", + }) + ); + const manifest = Manifest.create(); + manifest.addTool("claude", "test", []); + const useCase = new DoctorRegistrationUseCase( + fs, + registry, + new Map(), + new Map(), + new Map([["claude", hostReader]]), + () => USER_CACHE_ROOT, + fakeVersion(CURRENT_VERSION) + ); + return useCase.execute({ manifest, projectRoot: PROJECT_ROOT, allowedIds: null }); + } + + it("warns naming both versions when the host already follows a newer aidd version than this run", async () => { + const hostReader = new FakeHostMarketplaceRegistryReader({ + location: REGISTRY_LOCATION, + entries: new Map([[NAME, sharedPath("3.0.0")]]), + }); + + const issues = await driftIssuesFor(hostReader); + + expect(issues).toHaveLength(1); + expect(issues[0]?.severity).toBe("warning"); + expect(issues[0]?.message).toContain("3.0.0"); + expect(issues[0]?.message).toContain(CURRENT_VERSION); + expect(issues[0]?.fix).toContain("aidd update"); + }); + + it("warns naming `aidd sync` when the host still points at this project's own pre-migration cache", async () => { + const projectCache = resolve(builtMarketplaceDir(PROJECT_ROOT, NAME, "claude")); + const hostReader = new FakeHostMarketplaceRegistryReader({ + location: REGISTRY_LOCATION, + entries: new Map([[NAME, projectCache]]), + }); + + const issues = await driftIssuesFor(hostReader); + + expect(issues).toHaveLength(1); + expect(issues[0]?.severity).toBe("warning"); + expect(issues[0]?.message).toContain(projectCache); + expect(issues[0]?.fix).toContain("aidd sync"); + }); + + it("warns naming `aidd sync` when the host still points at another project's pre-migration cache", async () => { + const foreignProjectCache = resolve(builtMarketplaceDir("/other-project", NAME, "claude")); + const hostReader = new FakeHostMarketplaceRegistryReader({ + location: REGISTRY_LOCATION, + entries: new Map([[NAME, foreignProjectCache]]), + }); + + const issues = await driftIssuesFor(hostReader); + + expect(issues).toHaveLength(1); + expect(issues[0]?.severity).toBe("warning"); + expect(issues[0]?.message).toContain(foreignProjectCache); + // Never the wording pinned for *this* project's own cache above — a project + // reading its own pre-migration path back would be told the wrong story. + expect(issues[0]?.message).not.toContain("this project's own pre-migration cache"); + expect(issues[0]?.message).toContain("another project's pre-migration cache"); + expect(issues[0]?.fix).toContain("aidd sync"); + }); + + it("says nothing when the host already follows this exact shared version", async () => { + const hostReader = new FakeHostMarketplaceRegistryReader({ + location: REGISTRY_LOCATION, + entries: new Map([[NAME, sharedPath(CURRENT_VERSION)]]), + }); + + expect(await driftIssuesFor(hostReader)).toEqual([]); + }); + + it("says nothing when this project's own version is ahead of the host's — the host follows on the next sync", async () => { + const hostReader = new FakeHostMarketplaceRegistryReader({ + location: REGISTRY_LOCATION, + entries: new Map([[NAME, sharedPath("1.0.0")]]), + }); + + expect(await driftIssuesFor(hostReader)).toEqual([]); + }); +}); diff --git a/cli/tests/contexts/framework/application/doctor-native-registration.integration.test.ts b/cli/tests/contexts/framework/application/doctor-native-registration.integration.test.ts new file mode 100644 index 000000000..f9fcaee3e --- /dev/null +++ b/cli/tests/contexts/framework/application/doctor-native-registration.integration.test.ts @@ -0,0 +1,85 @@ +import { describe, expect, it } from "vitest"; +import { Manifest } from "../../../../src/contexts/framework/domain/manifest.js"; +import type { HostPluginRegistryReader } from "../../../../src/contexts/tools/domain/ports/host-plugin-registry-reader.js"; +import type { AiToolId } from "../../../../src/kernel/tool.js"; +import { buildDoctorUseCase, buildUnitDeps } from "../../../helpers/ports/build-unit-deps.js"; +import { FakeHostPluginRegistryReader } from "../../../helpers/ports/fake-host-plugin-registry-reader.js"; + +const PROJECT_ROOT = "/test-project"; +const REGISTRY_LOCATION = "/home/dev/.claude/plugins/installed_plugins.json"; +const CONTEXT_REF = "aidd-context@aidd-framework"; +const DEV_REF = "aidd-dev@aidd-framework"; + +async function manifestWithTwoNativePlugins(): Promise { + const manifest = Manifest.create(); + manifest.addTool("claude", "test", []); + manifest.setNativeRegistrations("claude", { + binary: "claude", + marketplaces: [{ alias: "aidd-framework", hostName: "aidd-framework" }], + pluginRefs: [CONTEXT_REF, DEV_REF], + }); + return manifest; +} + +/** The sandbox this suite runs in reaches no real `claude` binary, so the double stands in + * for exactly the registry file that binary would have written. */ +describe("doctor represents what claude's own registry answers", () => { + it("is unhealthy with one error per ref the registry does not carry, naming `aidd sync`", async () => { + const deps = await buildUnitDeps(PROJECT_ROOT); + await deps.manifestRepo.save(await manifestWithTwoNativePlugins()); + const hostRegistries = new Map([ + [ + "claude", + new FakeHostPluginRegistryReader({ location: REGISTRY_LOCATION, refs: new Map() }), + ], + ]); + + const useCase = buildDoctorUseCase(deps, undefined, hostRegistries); + const report = await useCase.execute({ projectRoot: PROJECT_ROOT }); + + expect(report.healthy).toBe(false); + const nativeIssues = report.issues.filter((i) => i.message.includes("registry")); + expect(nativeIssues).toHaveLength(2); + expect(nativeIssues.map((i) => i.severity)).toEqual(["error", "error"]); + expect(nativeIssues.map((i) => i.message).join("\n")).toContain(CONTEXT_REF); + expect(nativeIssues.map((i) => i.message).join("\n")).toContain(DEV_REF); + for (const issue of nativeIssues) expect(issue.fix).toContain("aidd sync"); + }); + + it("is healthy when the registry carries every expected ref, enabled", async () => { + const deps = await buildUnitDeps(PROJECT_ROOT); + await deps.manifestRepo.save(await manifestWithTwoNativePlugins()); + const hostRegistries = new Map([ + [ + "claude", + new FakeHostPluginRegistryReader({ + location: REGISTRY_LOCATION, + refs: new Map([ + [CONTEXT_REF, { enabled: true }], + [DEV_REF, { enabled: true }], + ]), + }), + ], + ]); + + const useCase = buildDoctorUseCase(deps, undefined, hostRegistries); + const report = await useCase.execute({ projectRoot: PROJECT_ROOT }); + + expect(report.healthy).toBe(true); + expect(report.issues).toHaveLength(0); + }); + + /** An `unanswerable` reading is the normal state on any machine that has never run + * `claude`, never a fault `doctor` should gate on. */ + it("stays healthy when nothing here can read the registry at all", async () => { + const deps = await buildUnitDeps(PROJECT_ROOT); + await deps.manifestRepo.save(await manifestWithTwoNativePlugins()); + + const useCase = buildDoctorUseCase(deps, undefined, new Map()); + const report = await useCase.execute({ projectRoot: PROJECT_ROOT }); + + expect(report.healthy).toBe(true); + const infoIssues = report.issues.filter((i) => i.severity === "info"); + expect(infoIssues.length).toBeGreaterThan(0); + }); +}); diff --git a/cli/tests/contexts/framework/application/doctor-plugin.unit.test.ts b/cli/tests/contexts/framework/application/doctor-plugin.unit.test.ts new file mode 100644 index 000000000..a8e0bc081 --- /dev/null +++ b/cli/tests/contexts/framework/application/doctor-plugin.unit.test.ts @@ -0,0 +1,213 @@ +import { homedir } from "node:os"; +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; +import "../../../../src/contexts/tools/domain/profiles/claude/profile.js"; +import "../../../../src/contexts/tools/domain/profiles/cursor/profile.js"; +import { DoctorLayoutUseCase } from "../../../../src/contexts/framework/application/doctor/doctor-layout-use-case.js"; +import { DoctorMergeFilesUseCase } from "../../../../src/contexts/framework/application/doctor/doctor-merge-files-use-case.js"; +import { DoctorPluginUseCase } from "../../../../src/contexts/framework/application/doctor/doctor-plugin-use-case.js"; +import { DoctorReferencesUseCase } from "../../../../src/contexts/framework/application/doctor/doctor-references-use-case.js"; +import { DoctorRegistrationUseCase } from "../../../../src/contexts/framework/application/doctor/doctor-registration-use-case.js"; +import { DoctorTrackedFilesUseCase } from "../../../../src/contexts/framework/application/doctor/doctor-tracked-files-use-case.js"; +import { DoctorUseCase } from "../../../../src/contexts/framework/application/doctor/doctor-use-case.js"; +import { DetectPluginDriftUseCase } from "../../../../src/contexts/framework/application/shared/detect-plugin-drift-use-case.js"; +import { Manifest } from "../../../../src/contexts/framework/domain/manifest.js"; +import { InstalledPlugin } from "../../../../src/contexts/framework/domain/plugins/installed-plugin.js"; +import type { ManifestRepository } from "../../../../src/contexts/framework/domain/ports/manifest-repository.js"; +import { FileHash } from "../../../../src/kernel/file.js"; +import type { FileReader } from "../../../../src/kernel/ports/file-reader.js"; +import type { Hasher } from "../../../../src/kernel/ports/hasher.js"; +import { InMemoryMarketplaceRegistry } from "../../../helpers/ports/in-memory-marketplace-registry.js"; + +const EXPECTED_HASH = "abc123abc123abc123abc123abc123ab"; +const DRIFTED_HASH = "def456def456def456def456def456de"; +const PLUGIN_FILE = ".claude/plugins/my-plugin/commands/cmd.md"; + +function makeManifest(pluginFileHash: string): Manifest { + const manifest = Manifest.create(); + manifest.addTool("claude", "1.0.0", []); + manifest.addPlugin( + "claude", + InstalledPlugin.fromJSON({ + name: "my-plugin", + source: { kind: "local", path: "/some/path" }, + version: "1.0.0", + strict: false, + files: { [PLUGIN_FILE]: pluginFileHash }, + scope: "project", + }) + ); + return manifest; +} + +function makeFs(fileExists: boolean, diskHash: string): FileReader { + return { + fileExists: async () => fileExists, + isExecutable: async () => false, + realpath: async (path: string) => path, + readFileHash: async () => new FileHash(diskHash), + readFile: async () => "", + listDirectory: async () => [], + listFilesRecursive: async () => [], + }; +} + +function makeManifestRepo(manifest: Manifest): ManifestRepository { + return { + path: "/proj/.aidd/manifest.json", + load: async () => manifest, + save: async () => {}, + delete: async () => {}, + }; +} + +const noopHasher: Hasher = { + hash: () => new FileHash("00000000000000000000000000000000"), +}; + +function makeDoctorUseCase(fs: FileReader, manifest: Manifest): DoctorUseCase { + return new DoctorUseCase( + makeManifestRepo(manifest), + new DoctorTrackedFilesUseCase(fs), + new DoctorMergeFilesUseCase(fs, noopHasher), + new DoctorPluginUseCase(new DetectPluginDriftUseCase(fs)), + new DoctorReferencesUseCase(fs), + new DoctorLayoutUseCase(fs), + new DoctorRegistrationUseCase( + fs, + new InMemoryMarketplaceRegistry(), + new Map(), + new Map(), + new Map(), + () => "/user-cache", + { get: () => "1.0.0" } + ) + ); +} + +describe("DoctorUseCase — plugin integrity", () => { + describe("when plugin file is missing", () => { + it("reports a missing plugin issue", async () => { + const manifest = makeManifest(EXPECTED_HASH); + const fs = makeFs(false, EXPECTED_HASH); + const useCase = makeDoctorUseCase(fs, manifest); + + const report = await useCase.execute({ projectRoot: "/proj" }); + + expect(report.pluginIssues).toHaveLength(1); + expect(report.pluginIssues[0].issue).toBe("missing"); + expect(report.pluginIssues[0].pluginName).toBe("my-plugin"); + expect(report.pluginIssues[0].filePath).toBe(PLUGIN_FILE); + expect(report.healthy).toBe(false); + }); + }); + + describe("when plugin file has hash mismatch", () => { + it("reports a hash-mismatch plugin issue", async () => { + const manifest = makeManifest(EXPECTED_HASH); + const fs = makeFs(true, DRIFTED_HASH); + const useCase = makeDoctorUseCase(fs, manifest); + + const report = await useCase.execute({ projectRoot: "/proj" }); + + expect(report.pluginIssues).toHaveLength(1); + expect(report.pluginIssues[0].issue).toBe("hash-mismatch"); + expect(report.pluginIssues[0].toolId).toBe("claude"); + }); + }); + + describe("when all plugin files are present and correct", () => { + it("returns empty pluginIssues", async () => { + const manifest = makeManifest(EXPECTED_HASH); + const fs = makeFs(true, EXPECTED_HASH); + const useCase = makeDoctorUseCase(fs, manifest); + + const report = await useCase.execute({ projectRoot: "/proj" }); + + expect(report.pluginIssues).toHaveLength(0); + }); + }); + + describe("when pluginName filter is set", () => { + it("only checks the specified plugin", async () => { + const manifest = makeManifest(EXPECTED_HASH); + const fs = makeFs(false, EXPECTED_HASH); + const useCase = makeDoctorUseCase(fs, manifest); + + const report = await useCase.execute({ projectRoot: "/proj", pluginName: "other-plugin" }); + + expect(report.pluginIssues).toHaveLength(0); + }); + }); + + describe("when a user-scope plugin was never installed on this machine", () => { + it("reports one not-installed-on-machine issue, not one 'missing' issue per file", async () => { + const manifest = Manifest.create(); + manifest.addTool("cursor", "1.0.0", []); + manifest.addPlugin( + "cursor", + InstalledPlugin.fromJSON({ + name: "aidd-test", + source: { kind: "local", path: "/some/path" }, + version: "1.0.0", + strict: false, + files: { "a/one.md": EXPECTED_HASH, "a/two.md": EXPECTED_HASH }, + scope: "user", + }) + ); + const fs = makeFs(false, EXPECTED_HASH); + const useCase = new DoctorPluginUseCase(new DetectPluginDriftUseCase(fs)); + + const issues = await useCase.execute({ manifest, projectRoot: "/proj", allowedIds: null }); + + expect(issues).toHaveLength(1); + expect(issues[0].issue).toBe("not-installed-on-machine"); + expect(issues[0].toolId).toBe("cursor"); + expect(issues.filter((i) => i.issue === "missing")).toHaveLength(0); + }); + }); + + describe("when plugin is installed under user-scope (Cursor Mode B)", () => { + it("checks files under the resolved user-scope base dir, not projectRoot", async () => { + const manifest = Manifest.create(); + manifest.addTool("cursor", "1.0.0", []); + const cursorBaseDir = join(homedir(), ".cursor", "plugins", "local"); + const userScopeRelPath = "aidd-context/skills/06-discovery/SKILL.md"; + manifest.addPlugin( + "cursor", + InstalledPlugin.fromJSON({ + name: "aidd-context", + source: { kind: "local", path: "/some/path" }, + version: "1.0.0", + strict: false, + files: { [userScopeRelPath]: EXPECTED_HASH }, + scope: "user", + }) + ); + const checkedPaths: string[] = []; + const fs: FileReader = { + fileExists: async (p: string) => { + checkedPaths.push(p); + return true; + }, + isExecutable: async () => false, + realpath: async (path: string) => path, + readFileHash: async () => new FileHash(EXPECTED_HASH), + readFile: async () => "", + listDirectory: async () => [], + listFilesRecursive: async () => [], + }; + const pluginUseCase = new DoctorPluginUseCase(new DetectPluginDriftUseCase(fs)); + + await pluginUseCase.execute({ + manifest, + projectRoot: "/proj", + allowedIds: null, + }); + + const expectedAbs = join(cursorBaseDir, userScopeRelPath); + expect(checkedPaths).toContain(expectedAbs); + expect(checkedPaths.every((p) => !p.startsWith("/proj/aidd-context"))).toBe(true); + }); + }); +}); diff --git a/cli/tests/contexts/framework/application/doctor-registration.unit.test.ts b/cli/tests/contexts/framework/application/doctor-registration.unit.test.ts new file mode 100644 index 000000000..d8bb2019a --- /dev/null +++ b/cli/tests/contexts/framework/application/doctor-registration.unit.test.ts @@ -0,0 +1,247 @@ +import { describe, expect, it } from "vitest"; +import { Marketplace } from "../../../../src/contexts/distribution/domain/marketplace.js"; +import { DoctorRegistrationUseCase } from "../../../../src/contexts/framework/application/doctor/doctor-registration-use-case.js"; +import { Manifest } from "../../../../src/contexts/framework/domain/manifest.js"; +import { InstalledPlugin } from "../../../../src/contexts/framework/domain/plugins/installed-plugin.js"; +import type { AiToolId, ToolId } from "../../../../src/kernel/tool.js"; +import "../../../../src/contexts/tools/domain/profiles/claude/profile.js"; +import "../../../../src/contexts/tools/domain/profiles/copilot/profile.js"; +import "../../../../src/contexts/tools/domain/profiles/cursor/profile.js"; +import type { + HostPluginRegistryReader, + HostPluginRegistryReading, +} from "../../../../src/contexts/tools/domain/ports/host-plugin-registry-reader.js"; +import { FakeHostPluginRegistryReader } from "../../../helpers/ports/fake-host-plugin-registry-reader.js"; +import { FakeNativePluginActivator } from "../../../helpers/ports/fake-native-plugin-activator.js"; +import { InMemoryFileAdapter } from "../../../helpers/ports/in-memory-file-adapter.js"; +import { InMemoryMarketplaceRegistry } from "../../../helpers/ports/in-memory-marketplace-registry.js"; + +const PROJECT_ROOT = "/project"; +const LOCAL_SETTINGS = `${PROJECT_ROOT}/.claude/settings.local.json`; + +async function issuesFor( + registered: string[] | null, + toolId: ToolId = "claude", + toolInstalled = true +) { + const fs = new InMemoryFileAdapter(); + if (registered !== null) { + const entries = Object.fromEntries(registered.map((name) => [name, { source: {} }])); + await fs.writeFile(LOCAL_SETTINGS, JSON.stringify({ extraKnownMarketplaces: entries })); + } + const registry = new InMemoryMarketplaceRegistry(); + await registry.save( + PROJECT_ROOT, + Marketplace.create({ + name: "aidd-framework", + source: { kind: "local", path: "/src" }, + scope: "project", + addedAt: "2026-01-01T00:00:00Z", + }) + ); + const manifest = Manifest.create(); + manifest.addTool(toolId, "test", []); + const activators = new Map([ + ["claude", new FakeNativePluginActivator({ available: toolInstalled, enablesPlugins: false })], + ]); + return new DoctorRegistrationUseCase( + fs, + registry, + activators, + new Map(), + new Map(), + () => "/user-cache", + { get: () => "1.0.0" } + ).execute({ + manifest, + projectRoot: PROJECT_ROOT, + allowedIds: null, + }); +} + +describe("DoctorRegistrationUseCase", () => { + it("says nothing when the tool still declares the marketplace", async () => { + expect(await issuesFor(["aidd-framework"])).toEqual([]); + }); + + it("reports the marketplace the file no longer declares", async () => { + const issues = await issuesFor([]); + expect(issues).toHaveLength(1); + expect(issues[0].message).toContain("aidd-framework"); + expect(issues[0].fix).toContain(".claude/settings.local.json"); + }); + + it("reports it when the whole file is gone — nothing else would notice", async () => { + const issues = await issuesFor(null); + expect(issues).toHaveLength(1); + expect(issues[0].severity).toBe("warning"); + }); + + // The registration is written by the tool itself, so it cannot exist while the tool does not: + // reporting it missing would be reporting that something uninstalled is unconfigured. + it("says nothing about a tool whose binary is out of reach", async () => { + expect(await issuesFor(null, "claude", false)).toEqual([]); + }); + + it("stays silent for a tool that keeps its registrations in a tracked file", async () => { + expect(await issuesFor(null, "cursor")).toEqual([]); + }); + + // Copilot declares no place at all rather than a path, and a guard rejecting only `undefined` + // lets `null` reach `join(root, null)`, which throws and takes `plugin doctor` down. + it("stays silent, and does not throw, for a tool that declares no place at all", async () => { + await expect(issuesFor(null, "copilot")).resolves.toEqual([]); + }); +}); + +const REF = "aidd-context@aidd-framework"; +const REGISTRY_LOCATION = "/home/dev/.claude/plugins/installed_plugins.json"; + +function manifestWithNativeRegistrations( + pluginRefs: string[], + marketplaces: { alias: string; hostName: string }[] = [ + { alias: "aidd-framework", hostName: "aidd-framework" }, + ] +): Manifest { + const manifest = Manifest.create(); + manifest.addTool("claude", "test", []); + manifest.setNativeRegistrations("claude", { binary: "claude", marketplaces, pluginRefs }); + return manifest; +} + +function manifestWithPlugin(marketplace?: string): Manifest { + const manifest = Manifest.create(); + manifest.addTool("claude", "test", []); + manifest.addPlugin( + "claude", + InstalledPlugin.fromMetadata( + marketplace === undefined ? "hand-copied" : "aidd-context", + "1.0.0", + { kind: "github", repo: "ai-driven-dev/framework" }, + true, + "user", + marketplace + ) + ); + return manifest; +} + +async function nativeIssuesFor( + manifest: Manifest, + reading: HostPluginRegistryReading | "unreachable" +) { + const fs = new InMemoryFileAdapter(); + const registry = new InMemoryMarketplaceRegistry(); + const activators = new Map([ + ["claude", new FakeNativePluginActivator({ available: true, enablesPlugins: true })], + ]); + const hostRegistries = new Map(); + if (reading !== "unreachable") { + hostRegistries.set("claude", new FakeHostPluginRegistryReader(reading)); + } + return new DoctorRegistrationUseCase( + fs, + registry, + activators, + hostRegistries, + new Map(), + () => "/user-cache", + { get: () => "1.0.0" } + ).execute({ + manifest, + projectRoot: PROJECT_ROOT, + allowedIds: null, + }); +} + +describe("DoctorRegistrationUseCase — native registrations against the host's own registry", () => { + it("says nothing when the registry carries the expected ref, enabled", async () => { + const issues = await nativeIssuesFor(manifestWithNativeRegistrations([REF]), { + location: REGISTRY_LOCATION, + refs: new Map([[REF, { enabled: true }]]), + }); + + expect(issues).toEqual([]); + }); + + it("reports an error naming the ref and `aidd sync` when the registry lacks it", async () => { + const issues = await nativeIssuesFor(manifestWithNativeRegistrations([REF]), { + location: REGISTRY_LOCATION, + refs: new Map(), + }); + + expect(issues).toHaveLength(1); + expect(issues[0].severity).toBe("error"); + expect(issues[0].message).toContain(REF); + expect(issues[0].message).toContain(REGISTRY_LOCATION); + expect(issues[0].fix).toContain("aidd sync"); + }); + + it("reports an error naming `aidd framework install --tool claude` when the registry disabled it", async () => { + const issues = await nativeIssuesFor(manifestWithNativeRegistrations([REF]), { + location: REGISTRY_LOCATION, + refs: new Map([[REF, { enabled: false }]]), + }); + + expect(issues).toHaveLength(1); + expect(issues[0].severity).toBe("error"); + expect(issues[0].message).toContain(REF); + expect(issues[0].message).toContain("disabled"); + expect(issues[0].fix).toContain("aidd framework install --tool claude"); + }); + + it("reports an info line, never an error, when nothing here can read the registry", async () => { + const issues = await nativeIssuesFor(manifestWithNativeRegistrations([REF]), "unreachable"); + + expect(issues).toHaveLength(1); + expect(issues[0].severity).toBe("info"); + }); + + it("reports an info line when the registry file exists but could not be read", async () => { + const issues = await nativeIssuesFor(manifestWithNativeRegistrations([REF]), { + location: REGISTRY_LOCATION, + unreadable: "ENOENT", + }); + + expect(issues).toHaveLength(1); + expect(issues[0].severity).toBe("info"); + expect(issues[0].message).toContain("ENOENT"); + }); + + // `nativeRegistrations.marketplaces` names a marketplace the host registered with no plugin + // behind it, and nothing here can be asked about one alone, so this stays silent. + it("says nothing for a registered marketplace with no plugin ref to check", async () => { + const issues = await nativeIssuesFor( + manifestWithNativeRegistrations( + [], + [{ alias: "aidd-framework", hostName: "aidd-framework" }] + ), + { + location: REGISTRY_LOCATION, + refs: new Map(), + } + ); + + expect(issues).toEqual([]); + }); + + it("falls back to the manifest's own plugins when nativeRegistrations was never recorded", async () => { + const issues = await nativeIssuesFor(manifestWithPlugin("aidd-framework"), { + location: REGISTRY_LOCATION, + refs: new Map(), + }); + + expect(issues).toHaveLength(1); + expect(issues[0].message).toContain(REF); + }); + + it("is unanswerable, not an error, for a fallback plugin recording no marketplace", async () => { + const issues = await nativeIssuesFor(manifestWithPlugin(undefined), { + location: REGISTRY_LOCATION, + refs: new Map(), + }); + + expect(issues).toHaveLength(1); + expect(issues[0].severity).toBe("info"); + }); +}); diff --git a/cli/tests/application/use-cases/doctor-use-case.unit.test.ts b/cli/tests/contexts/framework/application/doctor-use-case.unit.test.ts similarity index 92% rename from cli/tests/application/use-cases/doctor-use-case.unit.test.ts rename to cli/tests/contexts/framework/application/doctor-use-case.unit.test.ts index 28b24bed8..4820b39a1 100644 --- a/cli/tests/application/use-cases/doctor-use-case.unit.test.ts +++ b/cli/tests/contexts/framework/application/doctor-use-case.unit.test.ts @@ -3,13 +3,13 @@ import { describe, expect, it } from "vitest"; import { extractAtReferences, extractMarkdownLinkTargets, -} from "../../../src/application/use-cases/doctor/doctor-use-case.js"; -import type { ToolId } from "../../../src/domain/tools/registry.js"; +} from "../../../../src/contexts/framework/domain/formats/markdown-references.js"; +import type { ToolId } from "../../../../src/kernel/tool.js"; import { buildDoctorUseCase, buildUnitDeps, initAndInstall, -} from "../../helpers/ports/build-unit-deps.js"; +} from "../../../helpers/ports/build-unit-deps.js"; const PROJECT_ROOT = "/test-project"; @@ -97,10 +97,8 @@ describe("doctor", () => { const deps = await buildUnitDeps(PROJECT_ROOT); await initAndInstall(deps, PROJECT_ROOT, "claude" as ToolId); - // Corrupt manifest by replacing it in-memory with invalid JSON - // ManifestRepositoryAdapter uses load() — InMemoryManifestRepository just returns null or manifest - // We need to simulate a corrupt manifest. The doctor calls manifestRepo.load(), - // so we make it throw. + // The doctor reads through `manifestRepo.load()`, so a corrupt manifest is a load that + // throws — the in-memory double never does on its own. const corruptRepo = Object.create(deps.manifestRepo) as typeof deps.manifestRepo; corruptRepo.load = async () => { throw new Error("Manifest is corrupted"); @@ -122,7 +120,7 @@ describe("doctor", () => { const deps = await buildUnitDeps(PROJECT_ROOT); await initAndInstall(deps, PROJECT_ROOT, "claude" as ToolId); - // Create an orphaned .cursor/commands directory (cursor is not installed) + // Orphaned: cursor is not installed. await deps.fs.writeFile( join(PROJECT_ROOT, ".cursor", "commands", "plan.md"), "---\nname: aidd:03:plan\ndescription: Plan feature\n---\nContent here.\n" @@ -175,7 +173,6 @@ describe("doctor", () => { const deps = await buildUnitDeps(PROJECT_ROOT); await initAndInstall(deps, PROJECT_ROOT, "claude" as ToolId); - // Create a .github directory with non-aidd content await deps.fs.writeFile( join(PROJECT_ROOT, ".github", "workflows", "ci.yml"), "name: CI\non: push\njobs:\n build:\n runs-on: ubuntu-latest\n" @@ -194,7 +191,7 @@ describe("doctor", () => { const deps = await buildUnitDeps(PROJECT_ROOT); await initAndInstall(deps, PROJECT_ROOT, "claude" as ToolId); - // Create .github/prompts/ with an aidd-named prompt file (not tracked in manifest) + // An aidd-named prompt file the manifest does not track. await deps.fs.writeFile( join(PROJECT_ROOT, ".github", "prompts", "plan.prompt.md"), "---\nname: aidd:01:plan\ndescription: Plan feature\n---\nContent here.\n" diff --git a/cli/tests/contexts/framework/application/flows/marketplace-check-use-case.unit.test.ts b/cli/tests/contexts/framework/application/flows/marketplace-check-use-case.unit.test.ts new file mode 100644 index 000000000..30acf33bb --- /dev/null +++ b/cli/tests/contexts/framework/application/flows/marketplace-check-use-case.unit.test.ts @@ -0,0 +1,144 @@ +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; +import "../../../../../src/contexts/tools/domain/profiles/claude/profile.js"; +import { FetchMarketplaceSourceUseCase } from "../../../../../src/contexts/distribution/application/fetch-marketplace-source-use-case.js"; +import { ResolveMarketplaceUseCase } from "../../../../../src/contexts/distribution/application/resolve-marketplace-use-case.js"; +import { Marketplace } from "../../../../../src/contexts/distribution/domain/marketplace.js"; +import { PluginCatalogRepositoryAdapter } from "../../../../../src/contexts/distribution/infrastructure/plugin-catalog-repository-adapter.js"; +import { MarketplaceCheckUseCase } from "../../../../../src/contexts/framework/application/flows/marketplace-check-use-case.js"; +import { Manifest } from "../../../../../src/contexts/framework/domain/manifest.js"; +import { InstalledPlugin } from "../../../../../src/contexts/framework/domain/plugins/installed-plugin.js"; +import { DeterministicHasher } from "../../../../helpers/ports/deterministic-hasher.js"; +import { FixturePluginFetcher } from "../../../../helpers/ports/fixture-plugin-fetcher.js"; +import { InMemoryFileAdapter } from "../../../../helpers/ports/in-memory-file-adapter.js"; +import { InMemoryManifestRepository } from "../../../../helpers/ports/in-memory-manifest-repository.js"; +import { InMemoryMarketplaceRegistry } from "../../../../helpers/ports/in-memory-marketplace-registry.js"; +import { seedFromDirectory } from "../../../../helpers/ports/seed-from-directory.js"; + +const VALID_FIXTURE = join(process.cwd(), "tests/fixtures/framework/marketplace-sample"); +const PROJECT_ROOT = "/test-project"; + +async function buildUseCase() { + const hasher = new DeterministicHasher(); + const fs = new InMemoryFileAdapter({}, hasher); + await seedFromDirectory(fs, VALID_FIXTURE, { useAbsolutePaths: true }); + const registry = new InMemoryMarketplaceRegistry(); + const manifestRepo = new InMemoryManifestRepository(); + const fetchMarketplaceSource = new FetchMarketplaceSourceUseCase(new FixturePluginFetcher()); + const resolveMarketplace = new ResolveMarketplaceUseCase( + fetchMarketplaceSource, + new PluginCatalogRepositoryAdapter(fs) + ); + const useCase = new MarketplaceCheckUseCase(manifestRepo, registry, resolveMarketplace); + return { useCase, registry, manifestRepo }; +} + +describe("MarketplaceCheckUseCase", () => { + it("flags entries with no lastFetched as stale", async () => { + const { useCase, registry } = await buildUseCase(); + await registry.save( + PROJECT_ROOT, + Marketplace.create({ + name: "awesome", + source: { kind: "local", path: VALID_FIXTURE }, + scope: "project", + addedAt: "2026-04-29T10:00:00.000Z", + }) + ); + + const result = await useCase.execute({ projectRoot: PROJECT_ROOT }); + + expect(result.stale.map((m) => m.name)).toEqual(["awesome"]); + }); + + it("does not flag entries fetched within the window", async () => { + const { useCase, registry } = await buildUseCase(); + await registry.save( + PROJECT_ROOT, + Marketplace.create({ + name: "fresh", + source: { kind: "local", path: VALID_FIXTURE }, + scope: "project", + addedAt: "2026-04-29T10:00:00.000Z", + }) + ); + await registry.updateLastFetched(PROJECT_ROOT, "fresh", "project", new Date().toISOString()); + + const result = await useCase.execute({ projectRoot: PROJECT_ROOT }); + + expect(result.stale).toEqual([]); + }); + + it("reports upstream-removed plugins", async () => { + const { useCase, registry, manifestRepo } = await buildUseCase(); + const manifest = Manifest.create(); + manifest.addTool("claude", "1.0.0", []); + manifest.addPlugin( + "claude", + InstalledPlugin.fromJSON({ + name: "ghost-plugin", + source: { kind: "github", repo: "owner/ghost" }, + version: "1.0.0", + strict: false, + files: {}, + scope: "project", + marketplace: "awesome", + }) + ); + await manifestRepo.save(manifest); + await registry.save( + PROJECT_ROOT, + Marketplace.create({ + name: "awesome", + source: { kind: "local", path: VALID_FIXTURE }, + scope: "project", + addedAt: "2026-04-29T10:00:00.000Z", + }) + ); + + const result = await useCase.execute({ projectRoot: PROJECT_ROOT }); + + expect(result.upstreamRemoved).toContainEqual({ + marketplace: "awesome", + plugin: "ghost-plugin", + toolId: "claude", + }); + }); + + it("neither skips nor reports upstream-removed when the catalog is missing (no error)", async () => { + const { useCase, registry } = await buildUseCase(); + await registry.save( + PROJECT_ROOT, + Marketplace.create({ + name: "empty", + source: { kind: "local", path: "/nonexistent-marketplace-dir" }, + scope: "project", + addedAt: "2026-04-29T10:00:00.000Z", + }) + ); + + const result = await useCase.execute({ projectRoot: PROJECT_ROOT }); + + expect(result.skipped).toEqual([]); + expect(result.upstreamRemoved).toEqual([]); + }); + + it("reports the marketplace as skipped when the catalog fetch throws", async () => { + const { useCase, registry } = await buildUseCase(); + await registry.save( + PROJECT_ROOT, + Marketplace.create({ + name: "unreachable", + source: { kind: "github", repo: "nonexistent/repo-12345" }, + scope: "project", + addedAt: "2026-04-29T10:00:00.000Z", + }) + ); + + const result = await useCase.execute({ projectRoot: PROJECT_ROOT }); + + expect(result.skipped).toHaveLength(1); + expect(result.skipped[0]?.marketplace).toBe("unreachable"); + expect(result.skipped[0]?.error).toBeDefined(); + }); +}); diff --git a/cli/tests/contexts/framework/application/flows/marketplace-remove-use-case.unit.test.ts b/cli/tests/contexts/framework/application/flows/marketplace-remove-use-case.unit.test.ts new file mode 100644 index 000000000..90e66e960 --- /dev/null +++ b/cli/tests/contexts/framework/application/flows/marketplace-remove-use-case.unit.test.ts @@ -0,0 +1,228 @@ +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; +import "../../../../../src/contexts/tools/domain/profiles/claude/profile.js"; +import "../../../../../src/contexts/tools/domain/profiles/cursor/profile.js"; +import { + FRAMEWORK_MARKETPLACE_NAME, + Marketplace, +} from "../../../../../src/contexts/distribution/domain/marketplace.js"; +import { MarketplaceRemoveUseCase } from "../../../../../src/contexts/framework/application/flows/marketplace-remove-use-case.js"; +import { Manifest } from "../../../../../src/contexts/framework/domain/manifest.js"; +import { InstalledPlugin } from "../../../../../src/contexts/framework/domain/plugins/installed-plugin.js"; +import { + InvalidMarketplaceNameError, + MarketplaceNotFoundError, +} from "../../../../../src/kernel/errors.js"; +import { DeterministicHasher } from "../../../../helpers/ports/deterministic-hasher.js"; +import { InMemoryFileAdapter } from "../../../../helpers/ports/in-memory-file-adapter.js"; +import { InMemoryManifestRepository } from "../../../../helpers/ports/in-memory-manifest-repository.js"; +import { InMemoryMarketplaceRegistry } from "../../../../helpers/ports/in-memory-marketplace-registry.js"; +import { KeepPrompter } from "../../../../helpers/ports/scripted-prompter.js"; + +const PROJECT_ROOT = "/test-project"; + +/** Records every path `deleteFile` is called with, so a test can prove where a plugin's + * file actually got deleted from without inspecting private use-case state. */ +class RecordingFileAdapter extends InMemoryFileAdapter { + readonly deletedPaths: string[] = []; + + override async deleteFile(path: string): Promise { + this.deletedPaths.push(path); + return super.deleteFile(path); + } +} + +function buildUseCase() { + const hasher = new DeterministicHasher(); + const fs = new RecordingFileAdapter({}, hasher); + const manifestRepo = new InMemoryManifestRepository(); + const registry = new InMemoryMarketplaceRegistry(); + const useCase = new MarketplaceRemoveUseCase(fs, manifestRepo, registry, new KeepPrompter()); + return { useCase, registry, manifestRepo, fs }; +} + +describe("MarketplaceRemoveUseCase", () => { + it("throws MarketplaceNotFoundError when entry does not exist", async () => { + const { useCase } = buildUseCase(); + await expect( + useCase.execute({ name: "missing", projectRoot: PROJECT_ROOT, autoConfirm: true }) + ).rejects.toThrow(MarketplaceNotFoundError); + }); + + it("removes registry entry when no orphans tracked", async () => { + const { useCase, registry } = buildUseCase(); + await registry.save( + PROJECT_ROOT, + Marketplace.create({ + name: "awesome", + source: { kind: "local", path: "/tmp/whatever" }, + scope: "project", + addedAt: "2026-04-29T10:00:00.000Z", + }) + ); + + const result = await useCase.execute({ + name: "awesome", + projectRoot: PROJECT_ROOT, + autoConfirm: true, + }); + + expect(result.removedPluginCount).toBe(0); + expect(await registry.list(PROJECT_ROOT)).toEqual([]); + }); + + it("removes orphan plugins and their files when autoConfirm is true", async () => { + const { useCase, registry, manifestRepo, fs } = buildUseCase(); + const manifest = Manifest.create(); + manifest.addTool("claude", "1.0.0", []); + const plugin = InstalledPlugin.fromJSON({ + name: "sample", + source: { kind: "github", repo: "owner/sample" }, + version: "1.0.0", + strict: false, + files: { ".claude/plugins/sample/CLAUDE.md": "0123456789abcdef0123456789abcdef" }, + scope: "project", + marketplace: "awesome", + }); + manifest.addPlugin("claude", plugin); + await manifestRepo.save(manifest); + + const filePath = join(PROJECT_ROOT, ".claude/plugins/sample/CLAUDE.md"); + await fs.writeFile(filePath, "content"); + + await registry.save( + PROJECT_ROOT, + Marketplace.create({ + name: "awesome", + source: { kind: "github", repo: "owner/awesome" }, + scope: "project", + addedAt: "2026-04-29T10:00:00.000Z", + }) + ); + + const result = await useCase.execute({ + name: "awesome", + projectRoot: PROJECT_ROOT, + autoConfirm: true, + }); + + expect(result.removedPluginCount).toBe(1); + expect(fs.has(filePath)).toBe(false); + const reloaded = await manifestRepo.load(); + expect(reloaded?.getPlugins("claude")).toHaveLength(0); + }); + + it("removes a user-scope (cursor) orphan's file from its resolved home directory, not projectRoot", async () => { + const { registry, manifestRepo, fs } = buildUseCase(); + const useCase = new MarketplaceRemoveUseCase(fs, manifestRepo, registry, new KeepPrompter()); + const manifest = Manifest.create(); + manifest.addTool("cursor", "1.0.0", []); + const pluginKey = "aidd-context/commands/hello.md"; + manifest.addPlugin( + "cursor", + InstalledPlugin.fromJSON({ + name: "aidd-context", + source: { kind: "github", repo: "owner/aidd-context" }, + version: "1.0.0", + strict: false, + files: { [pluginKey]: "0123456789abcdef0123456789abcdef" }, + scope: "user", + marketplace: "awesome", + }) + ); + await manifestRepo.save(manifest); + + await registry.save( + PROJECT_ROOT, + Marketplace.create({ + name: "awesome", + source: { kind: "github", repo: "owner/awesome" }, + scope: "project", + addedAt: "2026-04-29T10:00:00.000Z", + }) + ); + + const result = await useCase.execute({ + name: "awesome", + projectRoot: PROJECT_ROOT, + autoConfirm: true, + }); + + expect(result.removedPluginCount).toBe(1); + expect( + fs.deletedPaths.some((p) => p.endsWith(join(".cursor", "plugins", "local", pluginKey))) + ).toBe(true); + expect(fs.deletedPaths).not.toContain(join(PROJECT_ROOT, pluginKey)); + }); + + it("removes a cursor orphan's file under projectRoot, not ~/.cursor/plugins/local, when the manifest says scope: project", async () => { + const { registry, manifestRepo, fs } = buildUseCase(); + const useCase = new MarketplaceRemoveUseCase(fs, manifestRepo, registry, new KeepPrompter()); + const manifest = Manifest.create(); + manifest.addTool("cursor", "1.0.0", []); + const pluginKey = "aidd-context/commands/hello.md"; + manifest.addPlugin( + "cursor", + InstalledPlugin.fromJSON({ + name: "aidd-context", + source: { kind: "github", repo: "owner/aidd-context" }, + version: "1.0.0", + strict: false, + files: { [pluginKey]: "0123456789abcdef0123456789abcdef" }, + // Disagrees with cursor's own profile, which declares installScope "user". + scope: "project", + marketplace: "awesome", + }) + ); + await manifestRepo.save(manifest); + + await registry.save( + PROJECT_ROOT, + Marketplace.create({ + name: "awesome", + source: { kind: "github", repo: "owner/awesome" }, + scope: "project", + addedAt: "2026-04-29T10:00:00.000Z", + }) + ); + + const result = await useCase.execute({ + name: "awesome", + projectRoot: PROJECT_ROOT, + autoConfirm: true, + }); + + expect(result.removedPluginCount).toBe(1); + expect(fs.deletedPaths).toContain(join(PROJECT_ROOT, pluginKey)); + expect(fs.deletedPaths.some((p) => p.includes(join(".cursor", "plugins", "local")))).toBe( + false + ); + }); + + // `aidd-framework` is machine-scope: removing it from one project would orphan the host's + // own registration for every other project. `marketplace add` already refuses that name. + it("refuses to remove the reserved aidd-framework marketplace, leaving the registry untouched", async () => { + const { useCase, registry } = buildUseCase(); + await registry.save( + PROJECT_ROOT, + Marketplace.create({ + name: FRAMEWORK_MARKETPLACE_NAME, + source: { kind: "local", path: "." }, + scope: "user", + addedAt: "2026-04-29T10:00:00.000Z", + }) + ); + + await expect( + useCase.execute({ + name: FRAMEWORK_MARKETPLACE_NAME, + projectRoot: PROJECT_ROOT, + autoConfirm: true, + }) + ).rejects.toThrow(InvalidMarketplaceNameError); + + const list = await registry.list(PROJECT_ROOT); + expect(list).toHaveLength(1); + expect(list[0]?.name).toBe(FRAMEWORK_MARKETPLACE_NAME); + }); +}); diff --git a/cli/tests/contexts/framework/application/flows/marketplace-source-conflict.integration.test.ts b/cli/tests/contexts/framework/application/flows/marketplace-source-conflict.integration.test.ts new file mode 100644 index 000000000..50b87b2d7 --- /dev/null +++ b/cli/tests/contexts/framework/application/flows/marketplace-source-conflict.integration.test.ts @@ -0,0 +1,252 @@ +import { join } from "node:path"; +import "../../../../../src/contexts/tools/domain/profiles/claude/profile.js"; +import "../../../../../src/contexts/tools/domain/profiles/codex/profile.js"; +import { describe, expect, it } from "vitest"; +import { Marketplace } from "../../../../../src/contexts/distribution/domain/marketplace.js"; +import { MarketplaceSyncSettingsUseCase } from "../../../../../src/contexts/framework/application/flows/marketplace-sync-settings-use-case.js"; +import { Manifest } from "../../../../../src/contexts/framework/domain/manifest.js"; +import type { AiToolId } from "../../../../../src/kernel/tool.js"; +import { CapturingLogger } from "../../../../helpers/ports/capturing-logger.js"; +import { DeterministicHasher } from "../../../../helpers/ports/deterministic-hasher.js"; +import { fakeEnsureBuiltMarketplace } from "../../../../helpers/ports/fake-ensure-built-marketplace.js"; +import { FakeHostMarketplaceRegistryReader } from "../../../../helpers/ports/fake-host-marketplace-registry-reader.js"; +import { FakeNativePluginActivator } from "../../../../helpers/ports/fake-native-plugin-activator.js"; +import { InMemoryFileAdapter } from "../../../../helpers/ports/in-memory-file-adapter.js"; +import { InMemoryManifestRepository } from "../../../../helpers/ports/in-memory-manifest-repository.js"; +import { InMemoryMarketplaceRegistry } from "../../../../helpers/ports/in-memory-marketplace-registry.js"; + +const PROJECT_ROOT = "/test-project"; +const REGISTRY_LOCATION = "/home/.claude/plugins/known_marketplaces.json"; + +interface RegisteredCatalog { + /** Where the host's registry resolves the name to — the directory a catalog would + * be read from, if anything was ever written there. */ + readonly path: string; + readonly name?: string; + readonly version?: string; + readonly pluginNames?: readonly string[]; +} + +interface Setup { + readonly toolId?: AiToolId; + /** `name` written into the built catalog's own marketplace.json — defaults to the + * aidd-side name so most scenarios agree by construction. */ + readonly catalogName?: string; + readonly aiddName?: string; + readonly requestedVersion?: string; + readonly requestedPluginNames?: readonly string[]; + readonly hostReader?: FakeHostMarketplaceRegistryReader; + /** Catalog content to pre-write at the path this test's `hostReader` names as registered; + * omitted, that path holds nothing readable, standing in for a directory that is gone. */ + readonly registeredCatalog?: RegisteredCatalog; + /** Skips writing at the built dir this project just "built" to, standing in for a build that + * reported success but left nothing readable where its own tool profile probes. */ + readonly omitRequestedCatalog?: boolean; +} + +async function sync(setup: Setup = {}) { + const toolId = setup.toolId ?? "claude"; + const aiddName = setup.aiddName ?? "probe-mkt"; + const catalogName = setup.catalogName ?? aiddName; + const fs = new InMemoryFileAdapter(); + const manifestRepo = new InMemoryManifestRepository(); + const registry = new InMemoryMarketplaceRegistry(); + const logger = new CapturingLogger(); + const manifest = Manifest.create(); + manifest.addTool(toolId, "test", []); + await manifestRepo.save(manifest); + await registry.save( + PROJECT_ROOT, + Marketplace.create({ + name: aiddName, + source: { kind: "local", path: `/source/${aiddName}` }, + scope: "project", + addedAt: "2026-01-01T00:00:00Z", + }) + ); + const builtDir = `/built/${toolId}`; + // Written at whichever relative path this tool's own `distributionProbes.marketplace` names, + // codex's differing from claude's, so the `marketplaceRegistry` gate is exercised for codex too. + const catalogRelative = + toolId === "codex" ? ".agents/plugins/marketplace.json" : ".claude-plugin/marketplace.json"; + if (!setup.omitRequestedCatalog) { + await fs.writeFile( + `${builtDir}/${catalogRelative}`, + JSON.stringify({ + name: catalogName, + version: setup.requestedVersion, + plugins: (setup.requestedPluginNames ?? []).map((name) => ({ name })), + }) + ); + } + if (setup.registeredCatalog !== undefined) { + const rc = setup.registeredCatalog; + await fs.writeFile( + `${rc.path}/${catalogRelative}`, + JSON.stringify({ + name: rc.name ?? catalogName, + version: rc.version, + plugins: (rc.pluginNames ?? []).map((name) => ({ name })), + }) + ); + } + const activator = new FakeNativePluginActivator({ available: true, enablesPlugins: false }); + const hostRegistries = new Map( + setup.hostReader === undefined + ? [] + : ([[toolId, setup.hostReader]] as [AiToolId, FakeHostMarketplaceRegistryReader][]) + ); + const useCase = new MarketplaceSyncSettingsUseCase( + fs, + manifestRepo, + registry, + new DeterministicHasher(), + logger, + new Map([[toolId === "claude" ? "claude" : "codex", activator]]), + fakeEnsureBuiltMarketplace((target) => `/built/${target}`), + hostRegistries + ); + const result = await useCase.execute({ projectRoot: PROJECT_ROOT }); + return { result, activator, manifestRepo, builtDir, catalogRelative }; +} + +describe("the sync guard against a marketplace name a host already holds", () => { + it("refuses when a different catalog is registered under the same name, and names both sources and the plugin difference", async () => { + const hostReader = new FakeHostMarketplaceRegistryReader({ + location: REGISTRY_LOCATION, + entries: new Map([["probe-mkt", "/other/src"]]), + }); + + const { result, activator } = await sync({ + hostReader, + requestedPluginNames: ["sample-plugin"], + registeredCatalog: { path: "/other/src", pluginNames: ["different-plugin"] }, + }); + + expect(activator.addedMarketplaces).toHaveLength(0); + expect(result.errors).toHaveLength(1); + expect(result.errors[0]?.message).toMatch(/different catalog/); + expect(result.errors[0]?.message).toMatch(/probe-mkt/); + expect(result.errors[0]?.message).toMatch(/\/other\/src/); + expect(result.errors[0]?.message).toMatch(/\+sample-plugin/); + expect(result.errors[0]?.message).toMatch(/-different-plugin/); + }); + + it("does not refuse when only the version differs under the same name and plugin set — the host repointing to a newer build it already knows, not a different marketplace", async () => { + const hostReader = new FakeHostMarketplaceRegistryReader({ + location: REGISTRY_LOCATION, + entries: new Map([["probe-mkt", "/other/src"]]), + }); + + const { result, activator } = await sync({ + hostReader, + requestedVersion: "2.0.0", + requestedPluginNames: ["sample-plugin"], + registeredCatalog: { path: "/other/src", version: "1.0.0", pluginNames: ["sample-plugin"] }, + }); + + expect(activator.addedMarketplaces).toEqual(["/built/claude"]); + expect(result.errors).toEqual([]); + }); + + it("does not refuse when the host's registry already holds the same resolved source", async () => { + const hostReader = new FakeHostMarketplaceRegistryReader({ + location: REGISTRY_LOCATION, + // InMemoryFileAdapter.realpath is identity absent a declared symlink, so this is exactly what + // the guard resolves `builtDir` to: both reads land on the same catalog. + entries: new Map([["probe-mkt", "/built/claude"]]), + }); + + const { result, activator } = await sync({ hostReader }); + + expect(activator.addedMarketplaces).toEqual(["/built/claude"]); + expect(result.errors).toEqual([]); + }); + + it("does not refuse when the same catalog is registered from a different, resolved path — two projects sharing one build", async () => { + // Two independent projects build the same framework fixture to their own cache and + // register under the same name: same version, same plugins, only a different directory. + const hostReader = new FakeHostMarketplaceRegistryReader({ + location: REGISTRY_LOCATION, + entries: new Map([["probe-mkt", "/other-project/built/claude"]]), + }); + + const { result, activator } = await sync({ + hostReader, + requestedVersion: "1.0.0", + requestedPluginNames: ["sample-plugin"], + registeredCatalog: { + path: "/other-project/built/claude", + version: "1.0.0", + pluginNames: ["sample-plugin"], + }, + }); + + expect(activator.addedMarketplaces).toEqual(["/built/claude"]); + expect(result.errors).toEqual([]); + }); + + it("does not refuse when the registered source no longer resolves to a readable catalog — a dead entry a re-add repairs", async () => { + const hostReader = new FakeHostMarketplaceRegistryReader({ + location: REGISTRY_LOCATION, + entries: new Map([["probe-mkt", "/gone"]]), + }); + + // No `registeredCatalog` — nothing is ever written at "/gone", so reading its + // catalog fails exactly as it would for a directory that no longer exists. + const { result, activator } = await sync({ hostReader }); + + expect(activator.addedMarketplaces).toEqual(["/built/claude"]); + expect(result.errors).toEqual([]); + }); + + it("registers freely when this project's own local alias diverges from its built catalog's own name — a supported capability, not a fault", async () => { + const hostReader = new FakeHostMarketplaceRegistryReader({ + location: REGISTRY_LOCATION, + entries: new Map(), + }); + + const { result, activator } = await sync({ + aiddName: "mon-nom", + catalogName: "upstream", + hostReader, + }); + + expect(activator.addedMarketplaces).toEqual(["/built/claude"]); + expect(result.errors).toEqual([]); + }); + + it("never reads a host registry for a tool whose profile declares none, and still registers it", async () => { + // Codex's own profile declares no `marketplaceRegistry` — this reader would refuse + // every request if it were ever consulted, which is exactly what proves it is not. + const hostReader = new FakeHostMarketplaceRegistryReader({ + location: "/wherever", + entries: new Map([["probe-mkt", "/anything-else"]]), + }); + + const { result, activator } = await sync({ toolId: "codex", hostReader }); + + expect(hostReader.reads).toBe(0); + expect(activator.addedMarketplaces).toEqual(["/built/codex"]); + expect(result.errors).toEqual([]); + }); +}); + +describe("when the catalog this project just built cannot be read back", () => { + it("registers nothing and reports an error naming the unreadable file, rather than falling back to this project's own local alias", async () => { + const { result, activator, manifestRepo, builtDir, catalogRelative } = await sync({ + aiddName: "mon-nom", + omitRequestedCatalog: true, + }); + + expect(activator.addedMarketplaces).toEqual([]); + expect(result.errors).toHaveLength(1); + // join(), matching `marketplaceCatalogProbePath`: on win32 it produces a backslash-joined path, + // which a forward-slash template literal would never find inside the thrown message. + expect(result.errors[0]?.message).toContain(join(builtDir, catalogRelative)); + // Never written as `hostName` — a manifest that guessed the alias would go on + // claiming a registration the host was never asked to hold. + const reloaded = await manifestRepo.load(); + expect(reloaded?.getNativeRegistrations("claude")).toBeUndefined(); + }); +}); diff --git a/cli/tests/contexts/framework/application/flows/marketplace-sync-narrowing.integration.test.ts b/cli/tests/contexts/framework/application/flows/marketplace-sync-narrowing.integration.test.ts new file mode 100644 index 000000000..1b09fdfba --- /dev/null +++ b/cli/tests/contexts/framework/application/flows/marketplace-sync-narrowing.integration.test.ts @@ -0,0 +1,187 @@ +/** + * A narrowed run's outcome carries only the marketplace it touched, so replacing + * `nativeRegistrations` wholesale would erase every other marketplace's record. + */ +import "../../../../../src/contexts/tools/domain/profiles/claude/profile.js"; +import { describe, expect, it } from "vitest"; +import { Marketplace } from "../../../../../src/contexts/distribution/domain/marketplace.js"; +import { MarketplaceSyncSettingsUseCase } from "../../../../../src/contexts/framework/application/flows/marketplace-sync-settings-use-case.js"; +import type { EnsureBuiltMarketplace } from "../../../../../src/contexts/framework/application/shared/ensure-built-marketplace-use-case.js"; +import { Manifest } from "../../../../../src/contexts/framework/domain/manifest.js"; +import { InstalledPlugin } from "../../../../../src/contexts/framework/domain/plugins/installed-plugin.js"; +import { CapturingLogger } from "../../../../helpers/ports/capturing-logger.js"; +import { DeterministicHasher } from "../../../../helpers/ports/deterministic-hasher.js"; +import { FakeNativePluginActivator } from "../../../../helpers/ports/fake-native-plugin-activator.js"; +import { InMemoryFileAdapter } from "../../../../helpers/ports/in-memory-file-adapter.js"; +import { InMemoryManifestRepository } from "../../../../helpers/ports/in-memory-manifest-repository.js"; +import { InMemoryMarketplaceRegistry } from "../../../../helpers/ports/in-memory-marketplace-registry.js"; + +const PROJECT_ROOT = "/test-project"; + +function marketplace(name: string): Marketplace { + return Marketplace.create({ + name, + source: { kind: "github", repo: `ai-driven-dev/${name}` }, + scope: "project", + addedAt: "2026-09-02T00:00:00Z", + }); +} + +/** Keyed by marketplace name rather than tool — the default fake keys by tool alone, + * which would resolve two marketplaces to the same built tree and the same catalog. */ +function ensureBuiltPerMarketplace(): EnsureBuiltMarketplace { + return { + execute: async (options) => ({ + builtDir: `/built/${options.marketplace.name}`, + version: "test", + rebuilt: true, + }), + }; +} + +function seededCatalogs(): InMemoryFileAdapter { + return new InMemoryFileAdapter({ + "/built/market-a/.claude-plugin/marketplace.json": JSON.stringify({ + name: "market-a", + version: "1.0.0", + plugins: [{ name: "plugin-a" }], + }), + "/built/market-b/.claude-plugin/marketplace.json": JSON.stringify({ + name: "market-b", + version: "1.0.0", + plugins: [{ name: "plugin-b" }], + }), + }); +} + +function manifestWithTwoPlugins(): Manifest { + const manifest = Manifest.create(); + manifest.addTool("claude", "test", []); + manifest.addPlugin( + "claude", + InstalledPlugin.fromMetadata( + "plugin-a", + "1.0.0", + { kind: "github", repo: "ai-driven-dev/A" }, + true, + "project", + "market-a" + ) + ); + manifest.addPlugin( + "claude", + InstalledPlugin.fromMetadata( + "plugin-b", + "1.0.0", + { kind: "github", repo: "ai-driven-dev/B" }, + true, + "project", + "market-b" + ) + ); + return manifest; +} + +function build(activator: FakeNativePluginActivator) { + const registry = new InMemoryMarketplaceRegistry(); + const fs = seededCatalogs(); + const manifest = manifestWithTwoPlugins(); + const manifestRepo = new InMemoryManifestRepository(manifest, PROJECT_ROOT); + const hasher = new DeterministicHasher(); + return { + registry, + fs, + manifest, + manifestRepo, + useCase: new MarketplaceSyncSettingsUseCase( + fs, + manifestRepo, + registry, + hasher, + new CapturingLogger(), + new Map([["claude", activator]]), + ensureBuiltPerMarketplace() + ), + }; +} + +describe("marketplaceNames narrows a sync run to the marketplaces named", () => { + it("registers and enables only the named marketplace's own plugin", async () => { + const activator = new FakeNativePluginActivator({ available: true }); + const { useCase, registry } = build(activator); + await registry.save(PROJECT_ROOT, marketplace("market-a")); + await registry.save(PROJECT_ROOT, marketplace("market-b")); + + await useCase.execute({ projectRoot: PROJECT_ROOT, marketplaceNames: ["market-b"] }); + + expect(activator.addedMarketplaces).toEqual(["/built/market-b"]); + expect(activator.enabledPlugins).toEqual(["plugin-b@market-b"]); + }); + + it("keeps the untouched marketplace's own nativeRegistrations entry, never replacing it", async () => { + const activator = new FakeNativePluginActivator({ available: true }); + const { useCase, registry, manifestRepo } = build(activator); + await registry.save(PROJECT_ROOT, marketplace("market-a")); + await registry.save(PROJECT_ROOT, marketplace("market-b")); + // A first full run seeds both marketplaces' own registrations. + await useCase.execute({ projectRoot: PROJECT_ROOT }); + + await useCase.execute({ projectRoot: PROJECT_ROOT, marketplaceNames: ["market-b"] }); + + const reloaded = await manifestRepo.load(); + const registrations = reloaded?.getNativeRegistrations("claude"); + expect(registrations?.marketplaces).toEqual( + expect.arrayContaining([{ alias: "market-a", hostName: "market-a" }]) + ); + expect(registrations?.pluginRefs).toEqual(expect.arrayContaining(["plugin-a@market-a"])); + }); + + it("drops a stale ref for the touched marketplace by its hostName suffix, keeping the untouched marketplace's own refs", async () => { + const activator = new FakeNativePluginActivator({ available: true }); + const { useCase, registry, manifest, manifestRepo } = build(activator); + manifest.setNativeRegistrations("claude", { + binary: "claude", + marketplaces: [ + { alias: "market-a", hostName: "market-a" }, + { alias: "market-b", hostName: "market-b" }, + ], + pluginRefs: ["plugin-a@market-a", "plugin-b-old@market-b"], + }); + await manifestRepo.save(manifest); + await registry.save(PROJECT_ROOT, marketplace("market-a")); + await registry.save(PROJECT_ROOT, marketplace("market-b")); + + await useCase.execute({ projectRoot: PROJECT_ROOT, marketplaceNames: ["market-b"] }); + + const reloaded = await manifestRepo.load(); + const registrations = reloaded?.getNativeRegistrations("claude"); + expect(registrations?.pluginRefs).toContain("plugin-a@market-a"); + expect(registrations?.pluginRefs).toContain("plugin-b@market-b"); + expect(registrations?.pluginRefs).not.toContain("plugin-b-old@market-b"); + }); + + it("returns the empty result and calls no activator at all for a name matching no registered marketplace", async () => { + const activator = new FakeNativePluginActivator({ available: true }); + const { useCase, registry } = build(activator); + await registry.save(PROJECT_ROOT, marketplace("market-a")); + await registry.save(PROJECT_ROOT, marketplace("market-b")); + + const result = await useCase.execute({ projectRoot: PROJECT_ROOT, marketplaceNames: ["nope"] }); + + expect(result).toEqual({ activated: [], binaryMissing: [], warnings: [], errors: [] }); + expect(activator.addedMarketplaces).toEqual([]); + expect(activator.enabledPlugins).toEqual([]); + }); + + it("activates every registered marketplace when marketplaceNames is not given at all", async () => { + const activator = new FakeNativePluginActivator({ available: true }); + const { useCase, registry } = build(activator); + await registry.save(PROJECT_ROOT, marketplace("market-a")); + await registry.save(PROJECT_ROOT, marketplace("market-b")); + + await useCase.execute({ projectRoot: PROJECT_ROOT }); + + expect(activator.addedMarketplaces.sort()).toEqual(["/built/market-a", "/built/market-b"]); + expect(activator.enabledPlugins.sort()).toEqual(["plugin-a@market-a", "plugin-b@market-b"]); + }); +}); diff --git a/cli/tests/contexts/framework/application/flows/marketplace-sync-native-activation.integration.test.ts b/cli/tests/contexts/framework/application/flows/marketplace-sync-native-activation.integration.test.ts new file mode 100644 index 000000000..585b23197 --- /dev/null +++ b/cli/tests/contexts/framework/application/flows/marketplace-sync-native-activation.integration.test.ts @@ -0,0 +1,634 @@ +import "../../../../../src/contexts/tools/domain/profiles/claude/profile.js"; +import { resolve } from "node:path"; +import { describe, expect, it } from "vitest"; +import { Marketplace } from "../../../../../src/contexts/distribution/domain/marketplace.js"; +import { DoctorRegistrationUseCase } from "../../../../../src/contexts/framework/application/doctor/doctor-registration-use-case.js"; +import { MarketplaceSyncSettingsUseCase } from "../../../../../src/contexts/framework/application/flows/marketplace-sync-settings-use-case.js"; +import type { EnsureBuiltMarketplace } from "../../../../../src/contexts/framework/application/shared/ensure-built-marketplace-use-case.js"; +import { Manifest } from "../../../../../src/contexts/framework/domain/manifest.js"; +import { InstalledPlugin } from "../../../../../src/contexts/framework/domain/plugins/installed-plugin.js"; +import { buildHostRegistration } from "../../../../../src/contexts/tools/domain/host-plugin-registration.js"; +import type { + HostPluginRegistryReader, + HostPluginRegistryReading, +} from "../../../../../src/contexts/tools/domain/ports/host-plugin-registry-reader.js"; +import { CapturingLogger } from "../../../../helpers/ports/capturing-logger.js"; +import { DeterministicHasher } from "../../../../helpers/ports/deterministic-hasher.js"; +import { fakeEnsureBuiltMarketplace } from "../../../../helpers/ports/fake-ensure-built-marketplace.js"; +import { FakeNativePluginActivator } from "../../../../helpers/ports/fake-native-plugin-activator.js"; +import { InMemoryFileAdapter } from "../../../../helpers/ports/in-memory-file-adapter.js"; +import { InMemoryManifestRepository } from "../../../../helpers/ports/in-memory-manifest-repository.js"; +import { InMemoryMarketplaceRegistry } from "../../../../helpers/ports/in-memory-marketplace-registry.js"; + +const PROJECT_ROOT = "/test-project"; +const MARKETPLACE = "aidd-framework"; +const PLUGIN = "aidd-telemetry"; +const REF = `${PLUGIN}@${MARKETPLACE}`; + +function marketplace(): Marketplace { + return Marketplace.create({ + name: MARKETPLACE, + source: { kind: "github", repo: "ai-driven-dev/framework" }, + scope: "project", + addedAt: "2026-09-02T00:00:00Z", + }); +} + +function manifestWithPlugin(marketplace: string = MARKETPLACE): InMemoryManifestRepository { + const manifest = Manifest.create(); + manifest.addTool("claude", "test", []); + manifest.addPlugin( + "claude", + InstalledPlugin.fromMetadata( + PLUGIN, + "1.0.0", + { kind: "github", repo: "ai-driven-dev/framework" }, + true, + "project", + marketplace + ) + ); + return new InMemoryManifestRepository(manifest); +} + +/** An unreadable built catalog is a hard failure (`UnreadableBuiltCatalogError`), so this + * fixture must leave a readable one where `fakeEnsureBuiltMarketplace()` resolves "claude". */ +function seededBuiltCatalog(): InMemoryFileAdapter { + return new InMemoryFileAdapter({ + "/built/claude/.claude-plugin/marketplace.json": JSON.stringify({ + name: MARKETPLACE, + version: "1.0.0", + plugins: [{ name: PLUGIN }], + }), + }); +} + +function buildSync(activator: FakeNativePluginActivator, pluginMarketplace?: string) { + const registry = new InMemoryMarketplaceRegistry(); + const fs = seededBuiltCatalog(); + const manifestRepo = manifestWithPlugin(pluginMarketplace); + const hasher = new DeterministicHasher(); + return { + registry, + fs, + manifestRepo, + hasher, + useCase: new MarketplaceSyncSettingsUseCase( + fs, + manifestRepo, + registry, + hasher, + new CapturingLogger(), + new Map([["claude", activator]]), + fakeEnsureBuiltMarketplace() + ), + }; +} + +const SETTINGS_PATH = ".claude/settings.json"; + +/** `resolve`, exactly as `syncMarketplacesFile` does: on Windows the production key is + * `C:\\test-project\\.claude\\settings.json`, which a `/`-joined literal never addresses. */ +function settingsPathIn(projectRoot: string): string { + return resolve(projectRoot, SETTINGS_PATH); +} + +/** The host's own CLI writes its `marketplace add` and `plugin enable` results into the very + * file `syncTool` just hashed. The fake shells out to nothing, so it stands in for that write. */ +class ActivatorThatWritesSettings extends FakeNativePluginActivator { + constructor( + private readonly fs: InMemoryFileAdapter, + private readonly settingsAbsolutePath: string + ) { + super({ available: true }); + } + + private readonly writes: Promise[] = []; + + async settled(): Promise { + await Promise.all(this.writes); + } + + private async appendHostState(): Promise { + const before = await this.fs.readFile(this.settingsAbsolutePath).catch(() => "{}"); + const json = JSON.parse(before) as Record; + // Not a key this code writes: the point is content only the host could have put there. + json.installedPluginsBookkeeping = { [REF]: { installedAt: "2026-09-05T00:00:00Z" } }; + await this.fs.writeFile(this.settingsAbsolutePath, JSON.stringify(json, null, 2)); + } + + override addMarketplace(source: string): void { + super.addMarketplace(source); + this.writes.push(this.appendHostState()); + } + + override enablePlugin(pluginRef: string): void { + super.enablePlugin(pluginRef); + this.writes.push(this.appendHostState()); + } +} + +describe("syncing settings registers the plugin with the host's own CLI", () => { + it("drives the host CLI with the same ref the diagnostic looks up", async () => { + const activator = new FakeNativePluginActivator({ available: true }); + const { useCase, registry } = buildSync(activator); + await registry.save(PROJECT_ROOT, marketplace()); + + await useCase.execute({ projectRoot: PROJECT_ROOT }); + + expect(activator.enabledPlugins).toContain(REF); + const asked = buildHostRegistration([ + { + tool: "claude", + plugins: [{ name: PLUGIN, marketplace: MARKETPLACE }], + reading: { location: "/registry", refs: new Map([[REF, { enabled: true }]]) }, + }, + ]); + expect(asked.entries[0]?.ref).toBe(activator.enabledPlugins[0]); + }); + + it("registers nothing when the host CLI is not available, and does not fail the sync", async () => { + const activator = new FakeNativePluginActivator({ available: false }); + const { useCase, registry } = buildSync(activator); + await registry.save(PROJECT_ROOT, marketplace()); + + await useCase.execute({ projectRoot: PROJECT_ROOT }); + + expect(activator.enabledPlugins).toEqual([]); + }); + + // `mergeEnabledPlugins` skips a plugin whose marketplace does not resolve with a bare + // `continue`, so it reaches neither a settings file nor the host CLI. + it("registers nothing for a plugin whose marketplace does not resolve, and says nothing about it", async () => { + const activator = new FakeNativePluginActivator({ available: true }); + const { useCase, registry } = buildSync(activator, "a-marketplace-nobody-added"); + await registry.save(PROJECT_ROOT, marketplace()); + + await useCase.execute({ projectRoot: PROJECT_ROOT }); + + expect(activator.enabledPlugins).toEqual([]); + const entry = buildHostRegistration([ + { + tool: "claude", + plugins: [{ name: PLUGIN, marketplace: "a-marketplace-nobody-added" }], + reading: { location: "/registry", refs: new Map() }, + }, + ]).entries[0]; + + expect(entry?.answer).toBe("not-registered"); + }); +}); + +describe("nativeRegistrations reflects what the host's own CLI was asked to register", () => { + it("records binary, marketplaces and pluginRefs after a successful activation", async () => { + const activator = new FakeNativePluginActivator({ available: true }); + const { useCase, registry, manifestRepo } = buildSync(activator); + await registry.save(PROJECT_ROOT, marketplace()); + + await useCase.execute({ projectRoot: PROJECT_ROOT }); + + const reloaded = await manifestRepo.load(); + expect(reloaded?.getNativeRegistrations("claude")).toEqual({ + binary: "claude", + marketplaces: [{ alias: MARKETPLACE, hostName: MARKETPLACE }], + pluginRefs: [REF], + }); + }); + + it("records nothing when the host CLI is not available", async () => { + const activator = new FakeNativePluginActivator({ available: false }); + const { useCase, registry, manifestRepo } = buildSync(activator); + await registry.save(PROJECT_ROOT, marketplace()); + + await useCase.execute({ projectRoot: PROJECT_ROOT }); + + const reloaded = await manifestRepo.load(); + expect(reloaded?.getNativeRegistrations("claude")).toBeUndefined(); + }); + + it("re-registers through the host CLI when the manifest's record has gone stale", async () => { + const activator = new FakeNativePluginActivator({ available: true }); + const { useCase, registry, manifestRepo } = buildSync(activator); + const staleManifest = await manifestRepo.load(); + staleManifest?.setNativeRegistrations("claude", { + binary: "claude", + marketplaces: [{ alias: MARKETPLACE, hostName: MARKETPLACE }], + pluginRefs: [REF], + }); + if (staleManifest) await manifestRepo.save(staleManifest); + await registry.save(PROJECT_ROOT, marketplace()); + + await useCase.execute({ projectRoot: PROJECT_ROOT }); + + expect(activator.enabledPlugins).toContain(REF); + const reloaded = await manifestRepo.load(); + expect(reloaded?.getNativeRegistrations("claude")).toEqual({ + binary: "claude", + marketplaces: [{ alias: MARKETPLACE, hostName: MARKETPLACE }], + pluginRefs: [REF], + }); + }); + + // This project's local alias for a marketplace is free to differ from the name its catalog + // declares, and `claude` only ever knows the marketplace by the catalog's own name. + it("drives the host CLI and records the catalog's own name when this project's local alias differs from it", async () => { + const CATALOG_NAME = "aidd-framework-catalog"; + const activator = new FakeNativePluginActivator({ available: true }); + const registry = new InMemoryMarketplaceRegistry(); + const fs = new InMemoryFileAdapter({ + "/built/claude/.claude-plugin/marketplace.json": JSON.stringify({ + name: CATALOG_NAME, + version: "1.0.0", + plugins: [], + }), + }); + const manifestRepo = manifestWithPlugin(); + const hasher = new DeterministicHasher(); + const useCase = new MarketplaceSyncSettingsUseCase( + fs, + manifestRepo, + registry, + hasher, + new CapturingLogger(), + new Map([["claude", activator]]), + fakeEnsureBuiltMarketplace() + ); + await registry.save(PROJECT_ROOT, marketplace()); + + await useCase.execute({ projectRoot: PROJECT_ROOT }); + + expect(activator.enabledPlugins).toEqual([`${PLUGIN}@${CATALOG_NAME}`]); + expect(activator.enabledPlugins).not.toContain(REF); + const reloaded = await manifestRepo.load(); + expect(reloaded?.getNativeRegistrations("claude")).toEqual({ + binary: "claude", + marketplaces: [{ alias: MARKETPLACE, hostName: CATALOG_NAME }], + pluginRefs: [`${PLUGIN}@${CATALOG_NAME}`], + }); + }); +}); + +// `buildSync` always installs a plugin, so a guard `if (refs.length === 0) return false;` at the +// top of `activateTool` would still pass every other test in this file. +describe("registering a marketplace does not wait for a plugin to point at it", () => { + it("registers every known marketplace even when the manifest declares no plugin", async () => { + const activator = new FakeNativePluginActivator({ available: true }); + const registry = new InMemoryMarketplaceRegistry(); + const fs = seededBuiltCatalog(); + const manifest = Manifest.create(); + manifest.addTool("claude", "test", []); + const manifestRepo = new InMemoryManifestRepository(manifest); + const hasher = new DeterministicHasher(); + const useCase = new MarketplaceSyncSettingsUseCase( + fs, + manifestRepo, + registry, + hasher, + new CapturingLogger(), + new Map([["claude", activator]]), + fakeEnsureBuiltMarketplace() + ); + await registry.save(PROJECT_ROOT, marketplace()); + + await useCase.execute({ projectRoot: PROJECT_ROOT }); + + expect(activator.addedMarketplaces).not.toEqual([]); + }); +}); + +// Claude Code declares one `settingsPath` for both marketplaces and enabled plugins, so the +// host's own CLI writes into the very file `syncTool` hashed just before activation ran. +describe("what native activation leaves behind is not reported as the user's drift", () => { + it("tracks a hash that still matches the settings file after the host CLI has written to it", async () => { + const registry = new InMemoryMarketplaceRegistry(); + const fs = seededBuiltCatalog(); + const manifestRepo = manifestWithPlugin(); + const hasher = new DeterministicHasher(); + const settingsAbsolutePath = settingsPathIn(PROJECT_ROOT); + const activator = new ActivatorThatWritesSettings(fs, settingsAbsolutePath); + const useCase = new MarketplaceSyncSettingsUseCase( + fs, + manifestRepo, + registry, + hasher, + new CapturingLogger(), + new Map([["claude", activator]]), + fakeEnsureBuiltMarketplace() + ); + await registry.save(PROJECT_ROOT, marketplace()); + + await useCase.execute({ projectRoot: PROJECT_ROOT }); + // The port is synchronous and the host CLI's write is not, so let the writes the + // activator queued actually land before reading the file back. + await activator.settled(); + + const onDisk = await fs.readFile(settingsAbsolutePath); + const manifest = await manifestRepo.load(); + const tracked = manifest?.getToolFiles("claude") ?? []; + const entry = tracked.find((file) => file.relativePath === SETTINGS_PATH); + + expect(entry, "the settings file is tracked at all").toBeDefined(); + expect(entry?.hash).toEqual(hasher.hash(onDisk)); + }); + // A tool whose CLI is not on the PATH wrote nothing, so a settings file differing from its + // tracked hash differs because a person changed it — re-hashing would bless that as ours. + it("leaves a hash alone for a tool whose own CLI never ran", async () => { + const registry = new InMemoryMarketplaceRegistry(); + const fs = new InMemoryFileAdapter(); + const manifestRepo = manifestWithPlugin(); + const hasher = new DeterministicHasher(); + const settingsAbsolutePath = settingsPathIn(PROJECT_ROOT); + const useCase = new MarketplaceSyncSettingsUseCase( + fs, + manifestRepo, + registry, + hasher, + new CapturingLogger(), + // Not available: the binary is not on the PATH, so nothing of the host's is written. + new Map([["claude", new FakeNativePluginActivator({ available: false })]]), + fakeEnsureBuiltMarketplace() + ); + await registry.save(PROJECT_ROOT, marketplace()); + await useCase.execute({ projectRoot: PROJECT_ROOT }); + const hashAfterSync = (await manifestRepo.load()) + ?.getToolFiles("claude") + .find((file) => file.relativePath === SETTINGS_PATH)?.hash; + + const edited = `${await fs.readFile(settingsAbsolutePath)}\n`; + await fs.writeFile(settingsAbsolutePath, edited); + await useCase.execute({ projectRoot: PROJECT_ROOT }); + + const tracked = (await manifestRepo.load()) + ?.getToolFiles("claude") + .find((file) => file.relativePath === SETTINGS_PATH); + expect(hashAfterSync, "the settings file is tracked at all").toBeDefined(); + expect(tracked?.hash).toEqual(hashAfterSync); + expect(tracked?.hash).not.toEqual(hasher.hash(edited)); + }); +}); + +// Asking about the local alias would answer "dead" for a registration the host holds live under +// the catalog's own name, and then force-remove a name the host never held. +describe("reclaiming a dead registration asks and acts on the host's own name", () => { + it("checks and removes the catalog's own name, not this project's local alias, before re-adding", async () => { + const CATALOG_NAME = "aidd-framework-catalog"; + const activator = new FakeNativePluginActivator({ + available: true, + conflictOnAdd: true, + registrationState: "dead", + }); + const registry = new InMemoryMarketplaceRegistry(); + const fs = new InMemoryFileAdapter({ + "/built/claude/.claude-plugin/marketplace.json": JSON.stringify({ + name: CATALOG_NAME, + version: "1.0.0", + plugins: [], + }), + }); + const manifestRepo = manifestWithPlugin(); + const hasher = new DeterministicHasher(); + const useCase = new MarketplaceSyncSettingsUseCase( + fs, + manifestRepo, + registry, + hasher, + new CapturingLogger(), + new Map([["claude", activator]]), + fakeEnsureBuiltMarketplace() + ); + await registry.save(PROJECT_ROOT, marketplace()); + + await useCase.execute({ projectRoot: PROJECT_ROOT }); + + expect(activator.removedMarketplaces).toEqual([CATALOG_NAME]); + expect(activator.removedMarketplaces).not.toContain(MARKETPLACE); + // The reclaim's second `addMarketplace` succeeded once `removedMarketplaces` was + // non-empty (the fake's own `conflictOnAdd` bypass), so activation still finished. + expect(activator.addedMarketplaces).not.toEqual([]); + }); +}); + +// One activator double stands in for the host CLI both `doctor` and `sync` look at, so health +// is read from the state sync was asked to write, not from two doubles agreeing by construction. +describe("what doctor tells a person to run becomes true once sync has run", () => { + /** Answers `read()` from the activator's own recorded state, so a registry reading + * always reflects exactly what the last `execute()` asked the host CLI to enable. */ + class RegistryBoundToActivator implements HostPluginRegistryReader { + constructor(private readonly activator: FakeNativePluginActivator) {} + + async read(): Promise { + return { + location: "/home/dev/.claude/plugins/installed_plugins.json", + refs: new Map(this.activator.enabledPlugins.map((ref) => [ref, { enabled: true }])), + }; + } + } + + it("goes from `aidd sync` to healthy after sync re-registers", async () => { + const activator = new FakeNativePluginActivator({ available: true }); + const { useCase, registry, manifestRepo, fs } = buildSync(activator); + await registry.save(PROJECT_ROOT, marketplace()); + const doctorRegistration = new DoctorRegistrationUseCase( + fs, + registry, + new Map([["claude", activator]]), + new Map([["claude", new RegistryBoundToActivator(activator)]]), + new Map(), + () => "/user-cache", + { get: () => "1.0.0" } + ); + // `MarketplaceSyncSettingsUseCase.execute` mutates the loaded manifest in place, so one + // load kept across both doctor calls sees the sync that runs in between. + const manifest = await manifestRepo.load(); + expect(manifest, "buildSync always seeds a manifest").not.toBeNull(); + if (manifest === null) throw new Error("unreachable — asserted above"); + + const before = await doctorRegistration.execute({ + manifest, + projectRoot: PROJECT_ROOT, + allowedIds: null, + }); + expect( + before.some((issue) => issue.severity === "error" && issue.fix.includes("aidd sync")) + ).toBe(true); + + await useCase.execute({ projectRoot: PROJECT_ROOT }); + + const after = await doctorRegistration.execute({ + manifest, + projectRoot: PROJECT_ROOT, + allowedIds: null, + }); + expect(after.filter((issue) => issue.severity === "error")).toEqual([]); + }); +}); + +// `recordNativeRegistrations`'s keyed merge applies only to a narrowed run: an unnarrowed one +// replaces the whole entry, or a ref the registry no longer carries is retained forever. +describe("an unnarrowed run replaces the whole recorded entry (lot 9 review C-B1)", () => { + const LIVE_MARKETPLACE = "market-live"; + const LIVE_PLUGIN = "plugin-live"; + + function liveMarketplace(): Marketplace { + return Marketplace.create({ + name: LIVE_MARKETPLACE, + source: { kind: "github", repo: "ai-driven-dev/framework" }, + scope: "project", + addedAt: "2026-09-02T00:00:00Z", + }); + } + + function buildLiveSync(activator: FakeNativePluginActivator) { + const registry = new InMemoryMarketplaceRegistry(); + const fs = new InMemoryFileAdapter({ + "/built/claude/.claude-plugin/marketplace.json": JSON.stringify({ + name: LIVE_MARKETPLACE, + version: "1.0.0", + plugins: [{ name: LIVE_PLUGIN }], + }), + }); + const manifest = Manifest.create(); + manifest.addTool("claude", "test", []); + manifest.addPlugin( + "claude", + InstalledPlugin.fromMetadata( + LIVE_PLUGIN, + "1.0.0", + { kind: "github", repo: "ai-driven-dev/framework" }, + true, + "project", + LIVE_MARKETPLACE + ) + ); + const manifestRepo = new InMemoryManifestRepository(manifest); + const hasher = new DeterministicHasher(); + const useCase = new MarketplaceSyncSettingsUseCase( + fs, + manifestRepo, + registry, + hasher, + new CapturingLogger(), + new Map([["claude", activator]]), + fakeEnsureBuiltMarketplace() + ); + return { useCase, registry, manifestRepo }; + } + + it("drops a recorded ref whose hostName the registry no longer carries", async () => { + const activator = new FakeNativePluginActivator({ available: true }); + const { useCase, registry, manifestRepo } = buildLiveSync(activator); + await registry.save(PROJECT_ROOT, liveMarketplace()); + const staleManifest = await manifestRepo.load(); + staleManifest?.setNativeRegistrations("claude", { + binary: "claude", + marketplaces: [{ alias: "market-dead", hostName: "host-dead" }], + pluginRefs: ["plugin-dead@host-dead"], + }); + if (staleManifest) await manifestRepo.save(staleManifest); + + // Unnarrowed — no `marketplaceNames`, the shape `sync` and `setup` both run. + await useCase.execute({ projectRoot: PROJECT_ROOT }); + + const recorded = (await manifestRepo.load())?.getNativeRegistrations("claude"); + expect(recorded?.pluginRefs).not.toContain("plugin-dead@host-dead"); + expect(recorded?.marketplaces).not.toContainEqual({ + alias: "market-dead", + hostName: "host-dead", + }); + }); +}); + +// Two of this project's local aliases can resolve to one `hostName`; `retainedMarketplaces` +// filters by alias, so refs must be retained by alias too, never by hostName alone. +describe("a narrowed run preserves another alias's refs at a shared hostName (lot 9 review C-B2)", () => { + const SHARED_HOST_NAME = "shared-catalog"; + const ALIAS_X = "alias-x"; + const ALIAS_Y = "alias-y"; + const PLUGIN_X = "plugin-x"; + const PLUGIN_Y = "plugin-y"; + + function ensureBuiltKeyedByMarketplace(): EnsureBuiltMarketplace { + return { + execute: async (options) => ({ + builtDir: `/built/by-alias/${options.marketplace.name}`, + version: "test", + rebuilt: true, + }), + }; + } + + function aliasMarketplace(alias: string): Marketplace { + return Marketplace.create({ + name: alias, + source: { kind: "github", repo: "ai-driven-dev/framework" }, + scope: "project", + addedAt: "2026-09-02T00:00:00Z", + }); + } + + it("keeps the retained alias's own refs after a run narrowed to the other one", async () => { + const activator = new FakeNativePluginActivator({ available: true }); + const registry = new InMemoryMarketplaceRegistry(); + const fs = new InMemoryFileAdapter({ + [`/built/by-alias/${ALIAS_X}/.claude-plugin/marketplace.json`]: JSON.stringify({ + name: SHARED_HOST_NAME, + version: "1.0.0", + plugins: [{ name: PLUGIN_X }], + }), + [`/built/by-alias/${ALIAS_Y}/.claude-plugin/marketplace.json`]: JSON.stringify({ + name: SHARED_HOST_NAME, + version: "1.0.0", + plugins: [{ name: PLUGIN_Y }], + }), + }); + const manifest = Manifest.create(); + manifest.addTool("claude", "test", []); + manifest.addPlugin( + "claude", + InstalledPlugin.fromMetadata( + PLUGIN_X, + "1.0.0", + { kind: "github", repo: "ai-driven-dev/framework" }, + true, + "project", + ALIAS_X + ) + ); + manifest.addPlugin( + "claude", + InstalledPlugin.fromMetadata( + PLUGIN_Y, + "1.0.0", + { kind: "github", repo: "ai-driven-dev/framework" }, + true, + "project", + ALIAS_Y + ) + ); + const manifestRepo = new InMemoryManifestRepository(manifest); + const hasher = new DeterministicHasher(); + const useCase = new MarketplaceSyncSettingsUseCase( + fs, + manifestRepo, + registry, + hasher, + new CapturingLogger(), + new Map([["claude", activator]]), + ensureBuiltKeyedByMarketplace() + ); + await registry.save(PROJECT_ROOT, aliasMarketplace(ALIAS_X)); + await registry.save(PROJECT_ROOT, aliasMarketplace(ALIAS_Y)); + + await useCase.execute({ projectRoot: PROJECT_ROOT }); + const beforeNarrow = (await manifestRepo.load())?.getNativeRegistrations("claude"); + expect(beforeNarrow?.pluginRefs, "both refs recorded before the narrowed run").toEqual( + expect.arrayContaining([`${PLUGIN_X}@${SHARED_HOST_NAME}`, `${PLUGIN_Y}@${SHARED_HOST_NAME}`]) + ); + + await useCase.execute({ projectRoot: PROJECT_ROOT, marketplaceNames: [ALIAS_X] }); + + const recorded = (await manifestRepo.load())?.getNativeRegistrations("claude"); + expect(recorded?.pluginRefs).toContain(`${PLUGIN_Y}@${SHARED_HOST_NAME}`); + expect(recorded?.marketplaces).toContainEqual({ alias: ALIAS_Y, hostName: SHARED_HOST_NAME }); + }); +}); diff --git a/cli/tests/contexts/framework/application/flows/marketplace-sync-rollback-refusal.integration.test.ts b/cli/tests/contexts/framework/application/flows/marketplace-sync-rollback-refusal.integration.test.ts new file mode 100644 index 000000000..8a8b4ac8b --- /dev/null +++ b/cli/tests/contexts/framework/application/flows/marketplace-sync-rollback-refusal.integration.test.ts @@ -0,0 +1,136 @@ +import "../../../../../src/contexts/tools/domain/profiles/claude/profile.js"; +import { describe, expect, it } from "vitest"; +import { Marketplace } from "../../../../../src/contexts/distribution/domain/marketplace.js"; +import { MarketplaceSyncSettingsUseCase } from "../../../../../src/contexts/framework/application/flows/marketplace-sync-settings-use-case.js"; +import { Manifest } from "../../../../../src/contexts/framework/domain/manifest.js"; +import { userBuiltMarketplaceDir } from "../../../../../src/kernel/paths.js"; +import type { MarketplaceScope } from "../../../../../src/kernel/scope.js"; +import { CapturingLogger } from "../../../../helpers/ports/capturing-logger.js"; +import { DeterministicHasher } from "../../../../helpers/ports/deterministic-hasher.js"; +import { fakeEnsureBuiltMarketplace } from "../../../../helpers/ports/fake-ensure-built-marketplace.js"; +import { FakeHostMarketplaceRegistryReader } from "../../../../helpers/ports/fake-host-marketplace-registry-reader.js"; +import { FakeNativePluginActivator } from "../../../../helpers/ports/fake-native-plugin-activator.js"; +import { InMemoryFileAdapter } from "../../../../helpers/ports/in-memory-file-adapter.js"; +import { InMemoryManifestRepository } from "../../../../helpers/ports/in-memory-manifest-repository.js"; +import { InMemoryMarketplaceRegistry } from "../../../../helpers/ports/in-memory-marketplace-registry.js"; + +const PROJECT_ROOT = "/test-project"; +const REGISTRY_LOCATION = "/home/.claude/plugins/known_marketplaces.json"; +const USER_CACHE_ROOT = "/user-cache"; +const MARKETPLACE_NAME = "probe-mkt"; + +function sharedPath(version: string): string { + return userBuiltMarketplaceDir(USER_CACHE_ROOT, version, MARKETPLACE_NAME, "claude"); +} + +async function sync(options: { + requestedVersion: string; + registeredPath?: string; + registeredVersion?: string; + scope?: MarketplaceScope; +}) { + const fs = new InMemoryFileAdapter(); + const manifestRepo = new InMemoryManifestRepository(); + const registry = new InMemoryMarketplaceRegistry(); + const logger = new CapturingLogger(); + const manifest = Manifest.create(); + manifest.addTool("claude", "test", []); + await manifestRepo.save(manifest); + await registry.save( + PROJECT_ROOT, + Marketplace.create({ + name: MARKETPLACE_NAME, + source: { kind: "local", path: "/source" }, + scope: options.scope ?? "user", + addedAt: "2026-01-01T00:00:00Z", + }) + ); + const builtDir = sharedPath(options.requestedVersion); + await fs.writeFile( + `${builtDir}/.claude-plugin/marketplace.json`, + JSON.stringify({ name: MARKETPLACE_NAME, version: options.requestedVersion, plugins: [] }) + ); + if (options.registeredPath !== undefined) { + await fs.writeFile( + `${options.registeredPath}/.claude-plugin/marketplace.json`, + JSON.stringify({ name: MARKETPLACE_NAME, version: options.registeredVersion, plugins: [] }) + ); + } + const activator = new FakeNativePluginActivator({ available: true, enablesPlugins: false }); + const hostReader = new FakeHostMarketplaceRegistryReader({ + location: REGISTRY_LOCATION, + entries: + options.registeredPath === undefined + ? new Map() + : new Map([[MARKETPLACE_NAME, options.registeredPath]]), + }); + const useCase = new MarketplaceSyncSettingsUseCase( + fs, + manifestRepo, + registry, + new DeterministicHasher(), + logger, + new Map([["claude", activator]]), + fakeEnsureBuiltMarketplace(() => builtDir), + new Map([["claude", hostReader]]), + () => USER_CACHE_ROOT + ); + const result = await useCase.execute({ projectRoot: PROJECT_ROOT }); + return { result, activator, logger }; +} + +describe("the sync write path refuses to roll a host back to an older aidd-framework build", () => { + // The host already follows a newer build of the shared source than this run would request, so + // writing anyway would silently repoint it backward. + it("writes nothing and warns naming both versions and `aidd update`, when the host already follows a newer shared build", async () => { + const { result, activator, logger } = await sync({ + requestedVersion: "1.0.0", + registeredPath: sharedPath("2.0.0"), + registeredVersion: "2.0.0", + }); + + expect(activator.addedMarketplaces).toEqual([]); + expect(result.errors).toEqual([]); + expect( + logger.warnMessages.some( + (m) => m.includes("2.0.0") && m.includes("1.0.0") && m.includes("aidd update") + ) + ).toBe(true); + expect(result.warnings.some((m) => m.includes("aidd update"))).toBe(true); + }); + + // The migration itself: the host still points at this project's own pre-migration + // cache, and this run's build is the newer, shared source — it must proceed. + it("proceeds when the host still points at this project's own pre-migration cache", async () => { + const { result, activator } = await sync({ + requestedVersion: "1.0.0", + registeredPath: `${PROJECT_ROOT}/.aidd/cache/built/${MARKETPLACE_NAME}/claude`, + }); + + expect(activator.addedMarketplaces).toEqual([sharedPath("1.0.0")]); + expect(result.errors).toEqual([]); + }); + + it("proceeds when the host already follows an older shared build — a legitimate update, not a rollback", async () => { + const { result, activator } = await sync({ + requestedVersion: "2.0.0", + registeredPath: sharedPath("1.0.0"), + registeredVersion: "1.0.0", + }); + + expect(activator.addedMarketplaces).toEqual([sharedPath("2.0.0")]); + expect(result.errors).toEqual([]); + }); + + it("does nothing extra when the host already follows this exact shared version", async () => { + const { result, activator } = await sync({ + requestedVersion: "1.0.0", + registeredPath: sharedPath("1.0.0"), + registeredVersion: "1.0.0", + }); + + expect(activator.addedMarketplaces).toEqual([sharedPath("1.0.0")]); + expect(result.errors).toEqual([]); + expect(result.warnings).toEqual([]); + }); +}); diff --git a/cli/tests/contexts/framework/application/flows/marketplace-sync-settings-migration.integration.test.ts b/cli/tests/contexts/framework/application/flows/marketplace-sync-settings-migration.integration.test.ts new file mode 100644 index 000000000..129465d40 --- /dev/null +++ b/cli/tests/contexts/framework/application/flows/marketplace-sync-settings-migration.integration.test.ts @@ -0,0 +1,790 @@ +import "../../../../../src/contexts/tools/domain/profiles/claude/profile.js"; +import "../../../../../src/contexts/tools/domain/profiles/codex/profile.js"; +import { join, resolve } from "node:path"; +import { describe, expect, it } from "vitest"; +import { MarketplaceRegisterFrameworkUseCase } from "../../../../../src/contexts/distribution/application/marketplace-register-framework-use-case.js"; +import { + FRAMEWORK_MARKETPLACE_NAME, + Marketplace, +} from "../../../../../src/contexts/distribution/domain/marketplace.js"; +import { DoctorRegistrationUseCase } from "../../../../../src/contexts/framework/application/doctor/doctor-registration-use-case.js"; +import { MarketplaceSyncSettingsUseCase } from "../../../../../src/contexts/framework/application/flows/marketplace-sync-settings-use-case.js"; +import { Manifest } from "../../../../../src/contexts/framework/domain/manifest.js"; +import type { UserSourceReferences } from "../../../../../src/contexts/framework/domain/ports/user-source-references.js"; +import type { NativePluginActivator } from "../../../../../src/contexts/tools/domain/ports/native-plugin-activator.js"; +import { + BUILT_CACHE_SUBDIR, + builtMarketplaceDir, + userBuiltMarketplaceDir, +} from "../../../../../src/kernel/paths.js"; +import type { VersionReader } from "../../../../../src/kernel/ports/version-reader.js"; +import { CapturingLogger } from "../../../../helpers/ports/capturing-logger.js"; +import { DeterministicHasher } from "../../../../helpers/ports/deterministic-hasher.js"; +import { fakeEnsureBuiltMarketplace } from "../../../../helpers/ports/fake-ensure-built-marketplace.js"; +import { FakeHostMarketplaceRegistryReader } from "../../../../helpers/ports/fake-host-marketplace-registry-reader.js"; +import { FakeNativePluginActivator } from "../../../../helpers/ports/fake-native-plugin-activator.js"; +import { InMemoryFileAdapter } from "../../../../helpers/ports/in-memory-file-adapter.js"; +import { InMemoryManifestRepository } from "../../../../helpers/ports/in-memory-manifest-repository.js"; +import { InMemoryMarketplaceRegistry } from "../../../../helpers/ports/in-memory-marketplace-registry.js"; + +const PROJECT_ROOT = "/project"; +const CLAUDE_BUILT_DIR = "/shared/built/claude"; +const CODEX_BUILT_DIR = "/shared/built/codex"; +const CATALOG_RELATIVE = ".claude-plugin/marketplace.json"; + +function fakeVersion(value: string): VersionReader { + return { get: () => value }; +} + +function catalogFixture(builtDir: string): Record { + return { + [`${builtDir}/${CATALOG_RELATIVE}`]: JSON.stringify({ + name: FRAMEWORK_MARKETPLACE_NAME, + version: "1.0.0", + plugins: [], + }), + }; +} + +function projectScopeEntry(source: Marketplace["source"]): Marketplace { + return Marketplace.create({ + name: FRAMEWORK_MARKETPLACE_NAME, + source, + scope: "project", + addedAt: "2026-01-01T00:00:00Z", + }); +} + +function noSourceReferences( + added: Array<{ version: string; projectRoot: string }> +): UserSourceReferences { + return { + addReference: async (version, projectRoot) => { + added.push({ version, projectRoot }); + }, + removeReference: async () => undefined, + listAllReferencingProjects: async () => [], + }; +} + +describe("MarketplaceSyncSettingsUseCase — sync migrates a project installed before the shared source", () => { + it("preserves the existing entry's own source when migrating it from project to user scope, never falling back to the local default", async () => { + const registry = new InMemoryMarketplaceRegistry(); + await registry.save( + PROJECT_ROOT, + projectScopeEntry({ kind: "github", repo: "ai-driven-dev/framework", ref: "v1" }) + ); + const manifest = Manifest.create(); + const useCase = new MarketplaceSyncSettingsUseCase( + new InMemoryFileAdapter(catalogFixture(CLAUDE_BUILT_DIR)), + new InMemoryManifestRepository(manifest), + registry, + new DeterministicHasher(), + new CapturingLogger(), + new Map(), + fakeEnsureBuiltMarketplace(() => CLAUDE_BUILT_DIR), + new Map(), + () => "", + new MarketplaceRegisterFrameworkUseCase(registry) + ); + + await useCase.execute({ projectRoot: PROJECT_ROOT, recreateFrameworkIfMissing: true }); + + const entries = await registry.list(PROJECT_ROOT); + expect(entries).toHaveLength(1); + expect(entries[0]?.scope).toBe("user"); + expect(entries[0]?.source).toEqual({ + kind: "github", + repo: "ai-driven-dev/framework", + ref: "v1", + }); + }); + + it("registers the framework at user scope even when the project-scope entry is the only one present — not only when the registry is empty", async () => { + const registry = new InMemoryMarketplaceRegistry(); + await registry.save(PROJECT_ROOT, projectScopeEntry({ kind: "local", path: "/some/path" })); + const manifest = Manifest.create(); + const useCase = new MarketplaceSyncSettingsUseCase( + new InMemoryFileAdapter(catalogFixture(CLAUDE_BUILT_DIR)), + new InMemoryManifestRepository(manifest), + registry, + new DeterministicHasher(), + new CapturingLogger(), + new Map(), + fakeEnsureBuiltMarketplace(() => CLAUDE_BUILT_DIR), + new Map(), + () => "", + new MarketplaceRegisterFrameworkUseCase(registry) + ); + + await useCase.execute({ projectRoot: PROJECT_ROOT, recreateFrameworkIfMissing: true }); + + const entries = await registry.list(PROJECT_ROOT); + expect(entries).toEqual([expect.objectContaining({ scope: "user" })]); + }); +}); + +describe("MarketplaceSyncSettingsUseCase — codex and copilot refuse the same name from a different source", () => { + function frameworkAtUserScope(): Marketplace { + return Marketplace.create({ + name: FRAMEWORK_MARKETPLACE_NAME, + source: { kind: "local", path: "." }, + scope: "user", + addedAt: "2026-01-01T00:00:00Z", + }); + } + + it("reclaims a codex registration that still names the pre-migration path, by removing then re-adding the shared source", async () => { + const registry = new InMemoryMarketplaceRegistry(); + await registry.save(PROJECT_ROOT, frameworkAtUserScope()); + const manifest = Manifest.create(); + manifest.addTool("codex", "test", []); + const activator = new FakeNativePluginActivator({ + available: true, + enablesPlugins: false, + conflictOnAdd: true, + }); + const fs = new InMemoryFileAdapter({ + [`${CODEX_BUILT_DIR}/.agents/plugins/marketplace.json`]: JSON.stringify({ + name: FRAMEWORK_MARKETPLACE_NAME, + version: "1.0.0", + plugins: [], + }), + }); + const useCase = new MarketplaceSyncSettingsUseCase( + fs, + new InMemoryManifestRepository(manifest), + registry, + new DeterministicHasher(), + new CapturingLogger(), + new Map([["codex", activator]]), + fakeEnsureBuiltMarketplace(() => CODEX_BUILT_DIR) + ); + + await useCase.execute({ projectRoot: PROJECT_ROOT }); + + expect(activator.removedMarketplaces).toEqual([FRAMEWORK_MARKETPLACE_NAME]); + expect(activator.addedMarketplaces).toEqual([CODEX_BUILT_DIR]); + }); + + it("never reclaims an arbitrary, non-reserved marketplace name this way — only the framework's own", async () => { + const registry = new InMemoryMarketplaceRegistry(); + await registry.save( + PROJECT_ROOT, + Marketplace.create({ + name: "someones-plugins", + source: { kind: "local", path: "." }, + scope: "user", + addedAt: "2026-01-01T00:00:00Z", + }) + ); + const manifest = Manifest.create(); + manifest.addTool("codex", "test", []); + const activator = new FakeNativePluginActivator({ + available: true, + enablesPlugins: false, + conflictOnAdd: true, + }); + const builtDir = "/shared/built/other/codex"; + const fs = new InMemoryFileAdapter({ + [`${builtDir}/.agents/plugins/marketplace.json`]: JSON.stringify({ + name: "someones-plugins", + version: "1.0.0", + plugins: [], + }), + }); + const useCase = new MarketplaceSyncSettingsUseCase( + fs, + new InMemoryManifestRepository(manifest), + registry, + new DeterministicHasher(), + new CapturingLogger(), + new Map([["codex", activator]]), + fakeEnsureBuiltMarketplace(() => builtDir) + ); + + await useCase.execute({ projectRoot: PROJECT_ROOT }); + + expect(activator.removedMarketplaces).toEqual([]); + expect(activator.addedMarketplaces).toEqual([]); + }); + + // `isUnguardedFrameworkMarketplace` excludes a tool declaring its own marketplace registry + // from the reclaim door: a registry conflict is a reported error, never a silent remove-add. + it("never reclaims the reserved framework name for a tool that declares its own marketplace registry — claude reports the conflict instead", async () => { + const registry = new InMemoryMarketplaceRegistry(); + await registry.save(PROJECT_ROOT, frameworkAtUserScope()); + const manifest = Manifest.create(); + manifest.addTool("claude", "test", []); + const activator = new FakeNativePluginActivator({ + available: true, + enablesPlugins: false, + conflictOnAdd: true, + }); + const fs = new InMemoryFileAdapter({ + [`${CLAUDE_BUILT_DIR}/${CATALOG_RELATIVE}`]: JSON.stringify({ + name: FRAMEWORK_MARKETPLACE_NAME, + version: "1.0.0", + plugins: [], + }), + }); + const useCase = new MarketplaceSyncSettingsUseCase( + fs, + new InMemoryManifestRepository(manifest), + registry, + new DeterministicHasher(), + new CapturingLogger(), + new Map([["claude", activator]]), + fakeEnsureBuiltMarketplace(() => CLAUDE_BUILT_DIR) + // No `hostMarketplaceRegistries` reader for claude: `guardAgainstConflict` returns + // "proceed" without reading one, so the exclusion is decided in `reclaimOrReport`. + ); + + const result = await useCase.execute({ projectRoot: PROJECT_ROOT }); + + expect(activator.removedMarketplaces).toEqual([]); + expect(activator.addedMarketplaces).toEqual([]); + expect(result.warnings.some((w) => w.includes("skipped:"))).toBe(true); + }); +}); + +describe("MarketplaceSyncSettingsUseCase — the host still tracks another, unmigrated project's cache", () => { + const USER_CACHE_ROOT = "/user-cache"; + const CURRENT_VERSION = "2.0.0"; + + function sharedBuiltDir(): string { + return userBuiltMarketplaceDir( + USER_CACHE_ROOT, + CURRENT_VERSION, + FRAMEWORK_MARKETPLACE_NAME, + "claude" + ); + } + + it("registers the shared source without breaking, and records a reference for both this project and the one the host used to point at", async () => { + const foreignProjectCache = builtMarketplaceDir( + "/other-project", + FRAMEWORK_MARKETPLACE_NAME, + "claude" + ); + const registry = new InMemoryMarketplaceRegistry(); + await registry.save( + PROJECT_ROOT, + Marketplace.create({ + name: FRAMEWORK_MARKETPLACE_NAME, + source: { kind: "local", path: "." }, + scope: "user", + addedAt: "2026-01-01T00:00:00Z", + }) + ); + const manifest = Manifest.create(); + manifest.addTool("claude", "test", []); + const activator = new FakeNativePluginActivator({ available: true, enablesPlugins: false }); + const fs = new InMemoryFileAdapter({ + [`${sharedBuiltDir()}/${CATALOG_RELATIVE}`]: JSON.stringify({ + name: FRAMEWORK_MARKETPLACE_NAME, + version: CURRENT_VERSION, + plugins: [], + }), + // The foreign project's own directory still exists, which a host registry pointing there + // implies, so its claim on the shared source is worth recording. + [`${foreignProjectCache}/${CATALOG_RELATIVE}`]: JSON.stringify({ + name: FRAMEWORK_MARKETPLACE_NAME, + version: "1.0.0", + plugins: [], + }), + }); + const hostReader = new FakeHostMarketplaceRegistryReader({ + location: "/home/.claude/plugins/known_marketplaces.json", + entries: new Map([[FRAMEWORK_MARKETPLACE_NAME, foreignProjectCache]]), + }); + const added: Array<{ version: string; projectRoot: string }> = []; + const useCase = new MarketplaceSyncSettingsUseCase( + fs, + new InMemoryManifestRepository(manifest), + registry, + new DeterministicHasher(), + new CapturingLogger(), + new Map([["claude", activator]]), + fakeEnsureBuiltMarketplace(() => sharedBuiltDir()), + new Map([["claude", hostReader]]), + () => USER_CACHE_ROOT, + undefined, + noSourceReferences(added), + fakeVersion(CURRENT_VERSION) + ); + + await useCase.execute({ projectRoot: PROJECT_ROOT }); + + // The host is repointed onto the shared build without throwing: same catalog, foreign path, + // which `guardAgainstConflict` treats as an ordinary migration. + expect(activator.addedMarketplaces).toEqual([sharedBuiltDir()]); + const roots = added.map((a) => a.projectRoot); + expect(roots).toContain(PROJECT_ROOT); + expect(roots).toContain("/other-project"); + }); + + it("never records a reference for the foreign project's own root once that root no longer exists", async () => { + const foreignProjectCache = builtMarketplaceDir( + "/gone-project", + FRAMEWORK_MARKETPLACE_NAME, + "claude" + ); + const registry = new InMemoryMarketplaceRegistry(); + await registry.save( + PROJECT_ROOT, + Marketplace.create({ + name: FRAMEWORK_MARKETPLACE_NAME, + source: { kind: "local", path: "." }, + scope: "user", + addedAt: "2026-01-01T00:00:00Z", + }) + ); + const manifest = Manifest.create(); + manifest.addTool("claude", "test", []); + const activator = new FakeNativePluginActivator({ available: true, enablesPlugins: false }); + // Nothing is seeded under `foreignProjectCache` at all — the directory a real + // `rm -rf` would have removed after the fact. + const fs = new InMemoryFileAdapter({ + [`${sharedBuiltDir()}/${CATALOG_RELATIVE}`]: JSON.stringify({ + name: FRAMEWORK_MARKETPLACE_NAME, + version: CURRENT_VERSION, + plugins: [], + }), + }); + const hostReader = new FakeHostMarketplaceRegistryReader({ + location: "/home/.claude/plugins/known_marketplaces.json", + entries: new Map([[FRAMEWORK_MARKETPLACE_NAME, foreignProjectCache]]), + }); + const added: Array<{ version: string; projectRoot: string }> = []; + const useCase = new MarketplaceSyncSettingsUseCase( + fs, + new InMemoryManifestRepository(manifest), + registry, + new DeterministicHasher(), + new CapturingLogger(), + new Map([["claude", activator]]), + fakeEnsureBuiltMarketplace(() => sharedBuiltDir()), + new Map([["claude", hostReader]]), + () => USER_CACHE_ROOT, + undefined, + noSourceReferences(added), + fakeVersion(CURRENT_VERSION) + ); + + await useCase.execute({ projectRoot: PROJECT_ROOT }); + + expect(activator.addedMarketplaces).toEqual([sharedBuiltDir()]); + const roots = added.map((a) => a.projectRoot); + expect(roots).toContain(PROJECT_ROOT); + expect(roots).not.toContain("/gone-project"); + }); +}); + +describe("MarketplaceSyncSettingsUseCase — purging this project's own pre-migration cache", () => { + const OLD_CACHE_DIR = join(PROJECT_ROOT, BUILT_CACHE_SUBDIR, FRAMEWORK_MARKETPLACE_NAME); + const OLD_CACHE_FILE = join(OLD_CACHE_DIR, "claude", "agents", "some-agent.md"); + + function projectWithMigratableEntry(): InMemoryMarketplaceRegistry { + const registry = new InMemoryMarketplaceRegistry(); + registry.save( + PROJECT_ROOT, + Marketplace.create({ + name: FRAMEWORK_MARKETPLACE_NAME, + source: { kind: "local", path: "." }, + scope: "project", + addedAt: "2026-01-01T00:00:00Z", + }) + ); + return registry; + } + + it("deletes the project's own stale built tree once the run completes without error", async () => { + const registry = new InMemoryMarketplaceRegistry(); + await registry.save( + PROJECT_ROOT, + Marketplace.create({ + name: FRAMEWORK_MARKETPLACE_NAME, + source: { kind: "local", path: "." }, + scope: "project", + addedAt: "2026-01-01T00:00:00Z", + }) + ); + const manifest = Manifest.create(); + manifest.addTool("claude", "test", []); + const activator = new FakeNativePluginActivator({ available: true, enablesPlugins: false }); + const fs = new InMemoryFileAdapter({ + [OLD_CACHE_FILE]: "stale content", + [`${CLAUDE_BUILT_DIR}/${CATALOG_RELATIVE}`]: JSON.stringify({ + name: FRAMEWORK_MARKETPLACE_NAME, + version: "1.0.0", + plugins: [], + }), + }); + const useCase = new MarketplaceSyncSettingsUseCase( + fs, + new InMemoryManifestRepository(manifest), + registry, + new DeterministicHasher(), + new CapturingLogger(), + new Map([["claude", activator]]), + fakeEnsureBuiltMarketplace(() => CLAUDE_BUILT_DIR), + new Map(), + () => "", + new MarketplaceRegisterFrameworkUseCase(registry) + ); + + await useCase.execute({ projectRoot: PROJECT_ROOT, recreateFrameworkIfMissing: true }); + + expect(fs.has(OLD_CACHE_FILE)).toBe(false); + }); + + // A tool whose binary is off `PATH` never reaches `activateTool`, so its host registration gets + // no chance to move off this project's pre-migration cache; purging it would leave it dangling. + it("keeps the stale built tree in place, and warns, when a requested tool's binary is off PATH", async () => { + const registry = new InMemoryMarketplaceRegistry(); + await registry.save( + PROJECT_ROOT, + Marketplace.create({ + name: FRAMEWORK_MARKETPLACE_NAME, + source: { kind: "local", path: "." }, + scope: "project", + addedAt: "2026-01-01T00:00:00Z", + }) + ); + const manifest = Manifest.create(); + manifest.addTool("claude", "test", []); + const activator = new FakeNativePluginActivator({ available: false }); + const fs = new InMemoryFileAdapter({ [OLD_CACHE_FILE]: "stale content" }); + const logger = new CapturingLogger(); + const useCase = new MarketplaceSyncSettingsUseCase( + fs, + new InMemoryManifestRepository(manifest), + registry, + new DeterministicHasher(), + logger, + new Map([["claude", activator]]), + fakeEnsureBuiltMarketplace(() => CLAUDE_BUILT_DIR), + new Map(), + () => "", + new MarketplaceRegisterFrameworkUseCase(registry) + ); + + const result = await useCase.execute({ + projectRoot: PROJECT_ROOT, + recreateFrameworkIfMissing: true, + }); + + expect(result.errors).toEqual([]); + expect(result.binaryMissing).toEqual([{ toolId: "claude", binary: "claude" }]); + expect(fs.has(OLD_CACHE_FILE)).toBe(true); + expect(logger.warnMessages.some((w) => w.includes("pre-migration framework cache kept"))).toBe( + true + ); + }); + + // A build that fails is warned about and skipped, never an error, so the host's registration is + // left where it was. Same hazard as a missing binary, same answer. + it("keeps the stale built tree in place, and warns, when a requested tool's build failed", async () => { + const registry = new InMemoryMarketplaceRegistry(); + await registry.save( + PROJECT_ROOT, + Marketplace.create({ + name: FRAMEWORK_MARKETPLACE_NAME, + source: { kind: "local", path: "." }, + scope: "project", + addedAt: "2026-01-01T00:00:00Z", + }) + ); + const manifest = Manifest.create(); + manifest.addTool("claude", "test", []); + const activator = new FakeNativePluginActivator({ available: true }); + const fs = new InMemoryFileAdapter({ [OLD_CACHE_FILE]: "stale content" }); + const logger = new CapturingLogger(); + const failingBuild = { + execute: async () => { + throw new Error("translator refused the source"); + }, + }; + const useCase = new MarketplaceSyncSettingsUseCase( + fs, + new InMemoryManifestRepository(manifest), + registry, + new DeterministicHasher(), + logger, + new Map([["claude", activator]]), + failingBuild, + new Map(), + () => "", + new MarketplaceRegisterFrameworkUseCase(registry) + ); + + const result = await useCase.execute({ + projectRoot: PROJECT_ROOT, + recreateFrameworkIfMissing: true, + }); + + expect(result.errors).toEqual([]); + expect(activator.addedMarketplaces).toEqual([]); + expect(fs.has(OLD_CACHE_FILE)).toBe(true); + expect(logger.warnMessages.some((w) => w.includes("pre-migration framework cache kept"))).toBe( + true + ); + }); + + it("leaves the stale built tree in place when this run reports an error", async () => { + const registry = projectWithMigratableEntry(); + const manifest = Manifest.create(); + manifest.addTool("claude", "test", []); + const activator = new FakeNativePluginActivator({ + available: true, + enablesPlugins: false, + crashOnAddMarketplace: false, + }); + // No catalog seeded at the built dir, so `registerMarketplace` throws + // `UnreadableBuiltCatalogError`, which `activateNativeTools` collects as a genuine error. + const fs = new InMemoryFileAdapter({ [OLD_CACHE_FILE]: "stale content" }); + const useCase = new MarketplaceSyncSettingsUseCase( + fs, + new InMemoryManifestRepository(manifest), + registry, + new DeterministicHasher(), + new CapturingLogger(), + new Map([["claude", activator]]), + fakeEnsureBuiltMarketplace(() => CLAUDE_BUILT_DIR), + new Map(), + () => "", + new MarketplaceRegisterFrameworkUseCase(registry) + ); + + const result = await useCase.execute({ + projectRoot: PROJECT_ROOT, + recreateFrameworkIfMissing: true, + }); + + expect(result.errors.length).toBeGreaterThan(0); + expect(fs.has(OLD_CACHE_FILE)).toBe(true); + }); + + it("never deletes a candidate a symlink resolves outside the project root", async () => { + const registry = projectWithMigratableEntry(); + const manifest = Manifest.create(); + manifest.addTool("claude", "test", []); + const activator = new FakeNativePluginActivator({ available: true, enablesPlugins: false }); + const escapedFile = "/etc/evil/still-here.txt"; + const fs = new InMemoryFileAdapter({ + [escapedFile]: "not aidd's to delete", + [`${CLAUDE_BUILT_DIR}/${CATALOG_RELATIVE}`]: JSON.stringify({ + name: FRAMEWORK_MARKETPLACE_NAME, + version: "1.0.0", + plugins: [], + }), + }); + fs.setSymlink(OLD_CACHE_DIR, "/etc/evil"); + const useCase = new MarketplaceSyncSettingsUseCase( + fs, + new InMemoryManifestRepository(manifest), + registry, + new DeterministicHasher(), + new CapturingLogger(), + new Map([["claude", activator]]), + fakeEnsureBuiltMarketplace(() => CLAUDE_BUILT_DIR), + new Map(), + () => "", + new MarketplaceRegisterFrameworkUseCase(registry) + ); + + await useCase.execute({ projectRoot: PROJECT_ROOT, recreateFrameworkIfMissing: true }); + + expect(fs.has(escapedFile)).toBe(true); + }); + + it("purges only after native activation has run against the still-present cache, never before", async () => { + class OrderObservingActivator implements NativePluginActivator { + readonly cacheStillPresentAtAdd: boolean[] = []; + constructor( + private readonly fs: InMemoryFileAdapter, + private readonly probe: string + ) {} + isAvailable(): boolean { + return true; + } + enablesPlugins(): boolean { + return false; + } + addMarketplace(): void { + this.cacheStillPresentAtAdd.push(this.fs.has(this.probe)); + } + removeMarketplace(): void {} + registrationState(): "live" | "dead" | "unknown" { + return "unknown"; + } + upgradeMarketplaces(): void {} + enablePlugin(): void {} + uninstallPlugin(): void {} + } + + const registry = projectWithMigratableEntry(); + const manifest = Manifest.create(); + manifest.addTool("claude", "test", []); + const fs = new InMemoryFileAdapter({ + [OLD_CACHE_FILE]: "stale content", + [`${CLAUDE_BUILT_DIR}/${CATALOG_RELATIVE}`]: JSON.stringify({ + name: FRAMEWORK_MARKETPLACE_NAME, + version: "1.0.0", + plugins: [], + }), + }); + const activator = new OrderObservingActivator(fs, OLD_CACHE_FILE); + const useCase = new MarketplaceSyncSettingsUseCase( + fs, + new InMemoryManifestRepository(manifest), + registry, + new DeterministicHasher(), + new CapturingLogger(), + new Map([["claude", activator]]), + fakeEnsureBuiltMarketplace(() => CLAUDE_BUILT_DIR), + new Map(), + () => "", + new MarketplaceRegisterFrameworkUseCase(registry) + ); + + await useCase.execute({ projectRoot: PROJECT_ROOT, recreateFrameworkIfMissing: true }); + + expect(activator.cacheStillPresentAtAdd).toEqual([true]); + expect(fs.has(OLD_CACHE_FILE)).toBe(false); + }); +}); + +describe("MarketplaceSyncSettingsUseCase + DoctorRegistrationUseCase — the full migration cycle", () => { + /** Reads live from a `Map` the test's own activator double mutates on `addMarketplace`, the + * one double that lets a `doctor` call made after a `sync` see what that `sync` actually did. */ + class LiveHostMarketplaceRegistryReader { + constructor( + private readonly entries: Map, + private readonly location: string + ) {} + async read() { + return { location: this.location, entries: new Map(this.entries) }; + } + } + + class RegisteringActivator implements NativePluginActivator { + constructor(private readonly hostEntries: Map) {} + isAvailable(): boolean { + return true; + } + enablesPlugins(): boolean { + return false; + } + addMarketplace(source: string): void { + this.hostEntries.set(FRAMEWORK_MARKETPLACE_NAME, source); + } + removeMarketplace(): void {} + registrationState(): "live" | "dead" | "unknown" { + return "unknown"; + } + upgradeMarketplaces(): void {} + enablePlugin(): void {} + uninstallPlugin(): void {} + } + + it("goes from doctor warning to doctor healthy across one sync, leaving another project's own marketplace untouched", async () => { + const PROJECT_A = "/project-a"; + const PROJECT_B = "/project-b"; + const REGISTRY_LOCATION = "/home/.claude/plugins/known_marketplaces.json"; + const CURRENT_VERSION = "2.0.0"; + // resolve(): doctor's `resolvedBuiltDir()` and the sync guard's `realpath(builtDir)` both + // compare against a resolved path, so a drive-less key is never looked up on win32. + const USER_CACHE_ROOT = resolve("/user-cache"); + const sharedBuiltDir = resolve( + userBuiltMarketplaceDir( + USER_CACHE_ROOT, + CURRENT_VERSION, + FRAMEWORK_MARKETPLACE_NAME, + "claude" + ) + ); + const preMigrationCache = resolve( + builtMarketplaceDir(PROJECT_A, FRAMEWORK_MARKETPLACE_NAME, "claude") + ); + + const registry = new InMemoryMarketplaceRegistry(); + await registry.save( + PROJECT_A, + Marketplace.create({ + name: FRAMEWORK_MARKETPLACE_NAME, + source: { kind: "local", path: "." }, + scope: "project", + addedAt: "2026-01-01T00:00:00Z", + }) + ); + await registry.save( + PROJECT_B, + Marketplace.create({ + name: "project-b-plugins", + source: { kind: "local", path: "/project-b/plugins" }, + scope: "user", + addedAt: "2026-01-01T00:00:00Z", + }) + ); + + const hostEntries = new Map([[FRAMEWORK_MARKETPLACE_NAME, preMigrationCache]]); + const hostReader = new LiveHostMarketplaceRegistryReader(hostEntries, REGISTRY_LOCATION); + const manifest = Manifest.create(); + manifest.addTool("claude", "test", []); + + const doctor = new DoctorRegistrationUseCase( + new InMemoryFileAdapter({ + [`${sharedBuiltDir}/${CATALOG_RELATIVE}`]: JSON.stringify({ + name: FRAMEWORK_MARKETPLACE_NAME, + plugins: [], + }), + }), + registry, + new Map(), + new Map(), + new Map([["claude", hostReader]]), + () => USER_CACHE_ROOT, + fakeVersion(CURRENT_VERSION) + ); + + const before = await doctor.execute({ manifest, projectRoot: PROJECT_A, allowedIds: null }); + expect(before.some((issue) => issue.fix.includes("aidd sync"))).toBe(true); + + const activator = new RegisteringActivator(hostEntries); + const syncFs = new InMemoryFileAdapter({ + [`${sharedBuiltDir}/${CATALOG_RELATIVE}`]: JSON.stringify({ + name: FRAMEWORK_MARKETPLACE_NAME, + version: CURRENT_VERSION, + plugins: [], + }), + }); + const sync = new MarketplaceSyncSettingsUseCase( + syncFs, + new InMemoryManifestRepository(manifest), + registry, + new DeterministicHasher(), + new CapturingLogger(), + new Map([["claude", activator]]), + fakeEnsureBuiltMarketplace(() => sharedBuiltDir), + new Map([["claude", hostReader]]), + () => USER_CACHE_ROOT, + new MarketplaceRegisterFrameworkUseCase(registry), + undefined, + fakeVersion(CURRENT_VERSION) + ); + + const syncResult = await sync.execute({ + projectRoot: PROJECT_A, + recreateFrameworkIfMissing: true, + }); + expect(syncResult.errors).toEqual([]); + + const after = await doctor.execute({ manifest, projectRoot: PROJECT_A, allowedIds: null }); + expect(after).toEqual([]); + + const projectBEntries = await registry.list(PROJECT_B); + expect(projectBEntries.map((m) => m.name)).toContain("project-b-plugins"); + }); +}); diff --git a/cli/tests/contexts/framework/application/flows/marketplace-sync-settings-scope.integration.test.ts b/cli/tests/contexts/framework/application/flows/marketplace-sync-settings-scope.integration.test.ts new file mode 100644 index 000000000..e715a0b63 --- /dev/null +++ b/cli/tests/contexts/framework/application/flows/marketplace-sync-settings-scope.integration.test.ts @@ -0,0 +1,276 @@ +import { resolve } from "node:path"; +import "../../../../../src/contexts/tools/domain/profiles/claude/profile.js"; +import { describe, expect, it } from "vitest"; +import { Marketplace } from "../../../../../src/contexts/distribution/domain/marketplace.js"; +import { MarketplaceSyncSettingsUseCase } from "../../../../../src/contexts/framework/application/flows/marketplace-sync-settings-use-case.js"; +import { Manifest } from "../../../../../src/contexts/framework/domain/manifest.js"; +import type { UserSourceReferences } from "../../../../../src/contexts/framework/domain/ports/user-source-references.js"; +import { CapturingLogger } from "../../../../helpers/ports/capturing-logger.js"; +import { DeterministicHasher } from "../../../../helpers/ports/deterministic-hasher.js"; +import { FakeCurrentVersion } from "../../../../helpers/ports/fake-current-version.js"; +import { fakeEnsureBuiltMarketplace } from "../../../../helpers/ports/fake-ensure-built-marketplace.js"; +import { FakeNativePluginActivator } from "../../../../helpers/ports/fake-native-plugin-activator.js"; +import { InMemoryFileAdapter } from "../../../../helpers/ports/in-memory-file-adapter.js"; +import { InMemoryManifestRepository } from "../../../../helpers/ports/in-memory-manifest-repository.js"; +import { InMemoryMarketplaceRegistry } from "../../../../helpers/ports/in-memory-marketplace-registry.js"; + +const PROJECT_ROOT = "/test-project"; +const MARKETPLACE = "aidd-framework"; + +function marketplace(): Marketplace { + return Marketplace.create({ + name: MARKETPLACE, + source: { kind: "github", repo: "ai-driven-dev/framework" }, + scope: "user", + addedAt: "2026-09-02T00:00:00Z", + }); +} + +function manifestWithTool(): InMemoryManifestRepository { + const manifest = Manifest.create(); + manifest.addTool("claude", "test", []); + return new InMemoryManifestRepository(manifest); +} + +function seededBuiltCatalog(): InMemoryFileAdapter { + return new InMemoryFileAdapter({ + "/built/claude/.claude-plugin/marketplace.json": JSON.stringify({ + name: MARKETPLACE, + version: "1.0.0", + plugins: [], + }), + }); +} + +describe("MarketplaceSyncSettingsUseCase — the activation scope a caller asks for", () => { + it("enables at project scope by default, never claude's own implicit default", async () => { + const activator = new FakeNativePluginActivator({ available: true, enablesPlugins: false }); + const registry = new InMemoryMarketplaceRegistry(); + await registry.save(PROJECT_ROOT, marketplace()); + const useCase = new MarketplaceSyncSettingsUseCase( + seededBuiltCatalog(), + manifestWithTool(), + registry, + new DeterministicHasher(), + new CapturingLogger(), + new Map([["claude", activator]]), + fakeEnsureBuiltMarketplace() + ); + + await useCase.execute({ projectRoot: PROJECT_ROOT }); + + // No plugin declared here, so nothing to enable: this asserts the marketplace + // registration path ran at all, and the next test declares one for enablePlugin. + expect(activator.addedMarketplaces).toHaveLength(1); + }); + + it("passes the user scope down to enablePlugin when the caller asks for it", async () => { + const activator = new FakeNativePluginActivator({ available: true }); + const registry = new InMemoryMarketplaceRegistry(); + await registry.save(PROJECT_ROOT, marketplace()); + const manifest = Manifest.create(); + manifest.addTool("claude", "test", []); + const { InstalledPlugin } = await import( + "../../../../../src/contexts/framework/domain/plugins/installed-plugin.js" + ); + manifest.addPlugin( + "claude", + InstalledPlugin.fromMetadata( + "aidd-telemetry", + "1.0.0", + { kind: "github", repo: "ai-driven-dev/framework" }, + true, + "project", + MARKETPLACE + ) + ); + const catalog = new InMemoryFileAdapter({ + "/built/claude/.claude-plugin/marketplace.json": JSON.stringify({ + name: MARKETPLACE, + version: "1.0.0", + plugins: [{ name: "aidd-telemetry" }], + }), + }); + const useCase = new MarketplaceSyncSettingsUseCase( + catalog, + new InMemoryManifestRepository(manifest), + registry, + new DeterministicHasher(), + new CapturingLogger(), + new Map([["claude", activator]]), + fakeEnsureBuiltMarketplace() + ); + + await useCase.execute({ projectRoot: PROJECT_ROOT, scope: "user" }); + + expect(activator.enabledPluginScopes).toEqual(["user"]); + }); + + // A manifest carrying a plugin whose marketplace resolves, so `syncEnabledPluginsFile` has + // a real entry to write: an empty, no-plugin manifest wrote nothing under either scope. + async function pluginFixture(): Promise<{ + fs: InMemoryFileAdapter; + manifestRepo: InMemoryManifestRepository; + }> { + const manifest = Manifest.create(); + manifest.addTool("claude", "test", []); + const { InstalledPlugin } = await import( + "../../../../../src/contexts/framework/domain/plugins/installed-plugin.js" + ); + manifest.addPlugin( + "claude", + InstalledPlugin.fromMetadata( + "aidd-telemetry", + "1.0.0", + { kind: "github", repo: "ai-driven-dev/framework" }, + true, + "project", + MARKETPLACE + ) + ); + const fs = new InMemoryFileAdapter({ + "/built/claude/.claude-plugin/marketplace.json": JSON.stringify({ + name: MARKETPLACE, + version: "1.0.0", + plugins: [{ name: "aidd-telemetry" }], + }), + }); + return { fs, manifestRepo: new InMemoryManifestRepository(manifest) }; + } + + it("writes no project file at all when the caller asks for user scope — the full project delta, not one named path", async () => { + const activator = new FakeNativePluginActivator({ available: true, enablesPlugins: false }); + const registry = new InMemoryMarketplaceRegistry(); + await registry.save(PROJECT_ROOT, marketplace()); + const { fs, manifestRepo } = await pluginFixture(); + const useCase = new MarketplaceSyncSettingsUseCase( + fs, + manifestRepo, + registry, + new DeterministicHasher(), + new CapturingLogger(), + new Map([["claude", activator]]), + fakeEnsureBuiltMarketplace() + ); + + await useCase.execute({ projectRoot: PROJECT_ROOT, scope: "user" }); + + // resolve(): a project-scope write lands at resolve(projectRoot, settingsPath), which + // carries a drive letter on win32, so the drive-less literal would prove nothing there. + expect(fs.listUnder(resolve(PROJECT_ROOT))).toEqual([]); + }); + + it("writes .claude/settings.json for that same fixture at project scope — proof the fixture is not inert", async () => { + const activator = new FakeNativePluginActivator({ available: true, enablesPlugins: false }); + const registry = new InMemoryMarketplaceRegistry(); + await registry.save(PROJECT_ROOT, marketplace()); + const { fs, manifestRepo } = await pluginFixture(); + const useCase = new MarketplaceSyncSettingsUseCase( + fs, + manifestRepo, + registry, + new DeterministicHasher(), + new CapturingLogger(), + new Map([["claude", activator]]), + fakeEnsureBuiltMarketplace() + ); + + await useCase.execute({ projectRoot: PROJECT_ROOT }); + + // resolve(): the write carries a drive letter on win32, which a forward-slash template + // literal never matches. + const written = await fs.readFile(resolve(PROJECT_ROOT, ".claude", "settings.json")); + expect(written).toContain("aidd-telemetry"); + }); + + it("loads and saves the manifest passed as an override, never the one it was constructed with", async () => { + const activator = new FakeNativePluginActivator({ available: true, enablesPlugins: false }); + const registry = new InMemoryMarketplaceRegistry(); + await registry.save(PROJECT_ROOT, marketplace()); + const constructedRepo = manifestWithTool(); + const overrideRepo = manifestWithTool(); + const useCase = new MarketplaceSyncSettingsUseCase( + seededBuiltCatalog(), + constructedRepo, + registry, + new DeterministicHasher(), + new CapturingLogger(), + new Map([["claude", activator]]), + fakeEnsureBuiltMarketplace() + ); + + const result = await useCase.execute({ + projectRoot: PROJECT_ROOT, + scope: "user", + manifestRepo: overrideRepo, + }); + + expect(result.activated).toEqual(["claude"]); + expect(overrideRepo.getCurrent()?.getNativeRegistrations("claude")).toBeDefined(); + expect(constructedRepo.getCurrent()?.getNativeRegistrations("claude")).toBeUndefined(); + }); + + it("records no shared-source reference at user scope — no project-scope manifest exists for a later clean to ever decrement it from", async () => { + const activator = new FakeNativePluginActivator({ available: true, enablesPlugins: false }); + const registry = new InMemoryMarketplaceRegistry(); + await registry.save(PROJECT_ROOT, marketplace()); + const added: Array<{ version: string; projectRoot: string }> = []; + const userSourceReferences: UserSourceReferences = { + addReference: async (version, projectRoot) => { + added.push({ version, projectRoot }); + }, + removeReference: async () => undefined, + listAllReferencingProjects: async () => [], + }; + const useCase = new MarketplaceSyncSettingsUseCase( + seededBuiltCatalog(), + manifestWithTool(), + registry, + new DeterministicHasher(), + new CapturingLogger(), + new Map([["claude", activator]]), + fakeEnsureBuiltMarketplace(), + new Map(), + () => "", + undefined, + userSourceReferences, + new FakeCurrentVersion() + ); + + await useCase.execute({ projectRoot: PROJECT_ROOT, scope: "user" }); + + expect(added).toEqual([]); + }); + + it("still records a shared-source reference at project scope, unaffected", async () => { + const activator = new FakeNativePluginActivator({ available: true, enablesPlugins: false }); + const registry = new InMemoryMarketplaceRegistry(); + await registry.save(PROJECT_ROOT, marketplace()); + const added: Array<{ version: string; projectRoot: string }> = []; + const userSourceReferences: UserSourceReferences = { + addReference: async (version, projectRoot) => { + added.push({ version, projectRoot }); + }, + removeReference: async () => undefined, + listAllReferencingProjects: async () => [], + }; + const useCase = new MarketplaceSyncSettingsUseCase( + seededBuiltCatalog(), + manifestWithTool(), + registry, + new DeterministicHasher(), + new CapturingLogger(), + new Map([["claude", activator]]), + fakeEnsureBuiltMarketplace(), + new Map(), + () => "", + undefined, + userSourceReferences, + new FakeCurrentVersion() + ); + + await useCase.execute({ projectRoot: PROJECT_ROOT }); + + expect(added).toHaveLength(1); + }); +}); diff --git a/cli/tests/contexts/framework/application/flows/marketplace-sync-settings.unit.test.ts b/cli/tests/contexts/framework/application/flows/marketplace-sync-settings.unit.test.ts new file mode 100644 index 000000000..44675f1f6 --- /dev/null +++ b/cli/tests/contexts/framework/application/flows/marketplace-sync-settings.unit.test.ts @@ -0,0 +1,335 @@ +import "../../../../../src/contexts/tools/domain/profiles/claude/profile.js"; +import "../../../../../src/contexts/tools/domain/profiles/codex/profile.js"; +import { resolve } from "node:path"; +import { describe, expect, it } from "vitest"; +import { Marketplace } from "../../../../../src/contexts/distribution/domain/marketplace.js"; +import { MarketplaceSyncSettingsUseCase } from "../../../../../src/contexts/framework/application/flows/marketplace-sync-settings-use-case.js"; +import { ModeAMarketplaceTranslator } from "../../../../../src/contexts/framework/application/framework/translator/mode-a-marketplace-translator.js"; +import type { EnsureBuiltMarketplace } from "../../../../../src/contexts/framework/application/shared/ensure-built-marketplace-use-case.js"; +import { Manifest } from "../../../../../src/contexts/framework/domain/manifest.js"; +import { PluginDistribution } from "../../../../../src/contexts/translate/domain/plugin-distribution.js"; +import { CapturingLogger } from "../../../../helpers/ports/capturing-logger.js"; +import { DeterministicHasher } from "../../../../helpers/ports/deterministic-hasher.js"; +import { fakeEnsureBuiltMarketplace } from "../../../../helpers/ports/fake-ensure-built-marketplace.js"; +import { FakeNativePluginActivator } from "../../../../helpers/ports/fake-native-plugin-activator.js"; +import { InMemoryFileAdapter } from "../../../../helpers/ports/in-memory-file-adapter.js"; +import { InMemoryManifestRepository } from "../../../../helpers/ports/in-memory-manifest-repository.js"; +import { InMemoryMarketplaceRegistry } from "../../../../helpers/ports/in-memory-marketplace-registry.js"; + +const PROJECT_ROOT = "/test-project"; +const SHARED_SETTINGS = resolve(PROJECT_ROOT, ".claude/settings.json"); + +function distribution(name: string): PluginDistribution { + const files = [{ relativePath: "commands/hello.md", content: "# Hello" }]; + return new PluginDistribution({ + manifest: { name, version: "1.0.0" }, + format: "claude", + files, + components: { commands: files, agents: [], rules: [], skills: [], hooks: [], mcp: [] }, + }); +} + +interface SyncSetup { + /** Content written to `.claude/settings.json` before the sync, when given. */ + readonly settings?: string; + /** Marketplaces to register; the first is the one plugins are attached to. */ + readonly marketplaceNames?: readonly string[]; + readonly ensureBuilt?: EnsureBuiltMarketplace; + /** Whether the tool's own CLI enables plugins, which decides what it registers. */ + readonly enablesPlugins?: boolean; + /** Whether claude's own CLI is on PATH. Defaults to available. */ + readonly available?: boolean; + /** Makes the activator crash on `addMarketplace` with a plain `Error`. */ + readonly crashOnAddMarketplace?: boolean; + /** Refs that fail to enable — a recoverable, best-effort `NativePluginCliError`. */ + readonly failOnPlugins?: readonly string[]; +} + +/** A real build always leaves a catalog where `fakeEnsureBuiltMarketplace()` resolves + * "claude", and an unreadable one is a hard failure, so the fixture must leave one too. */ +function seededBuiltCatalog(name = "aidd-framework"): InMemoryFileAdapter { + return new InMemoryFileAdapter({ + "/built/claude/.claude-plugin/marketplace.json": JSON.stringify({ + name, + version: "1.0.0", + plugins: [], + }), + }); +} + +async function sync(setup: SyncSetup = {}) { + const names = setup.marketplaceNames ?? ["aidd-framework"]; + const fs = seededBuiltCatalog(names[0]); + const manifestRepo = new InMemoryManifestRepository(); + const registry = new InMemoryMarketplaceRegistry(); + const logger = new CapturingLogger(); + const manifest = Manifest.create(); + manifest.addTool("claude", "test", []); + + await new ModeAMarketplaceTranslator().addPlugin( + distribution("aidd-context"), + "claude", + { kind: "local", path: "/plugin-source" }, + PROJECT_ROOT, + manifest, + names[0] + ); + await manifestRepo.save(manifest); + for (const name of names) { + await registry.save( + PROJECT_ROOT, + Marketplace.create({ + name, + source: { kind: "local", path: `/source/${name}` }, + scope: "project", + addedAt: "2026-01-01T00:00:00Z", + }) + ); + } + if (setup.settings !== undefined) await fs.writeFile(SHARED_SETTINGS, setup.settings); + + const activator = new FakeNativePluginActivator({ + available: setup.available ?? true, + enablesPlugins: setup.enablesPlugins ?? false, + crashOnAddMarketplace: setup.crashOnAddMarketplace ?? false, + failOnPlugins: setup.failOnPlugins ?? [], + }); + const useCase = new MarketplaceSyncSettingsUseCase( + fs, + manifestRepo, + registry, + new DeterministicHasher(), + logger, + new Map([["claude", activator]]), + setup.ensureBuilt ?? fakeEnsureBuiltMarketplace() + ); + const result = await useCase.execute({ projectRoot: PROJECT_ROOT }); + const written = (await fs.fileExists(SHARED_SETTINGS)) + ? (JSON.parse(await fs.readFile(SHARED_SETTINGS)) as Record) + : undefined; + return { written, logger, fs, activator, result }; +} + +/** This flow writes into a file it does not own: whatever a person put there survives, and a + * trailing comma they left behind must not take the whole sync down with it. */ +describe("the settings file a user also edits", () => { + it("writes the enabled plugin into a file that did not exist", async () => { + const { written } = await sync(); + + expect(written?.enabledPlugins).toEqual({ "aidd-context@aidd-framework": true }); + }); + + it("keeps entries it did not put there", async () => { + const { written } = await sync({ + settings: JSON.stringify({ + model: "opus", + enabledPlugins: { "someone-elses@their-marketplace": true }, + }), + }); + + expect(written?.model).toBe("opus"); + expect(written?.enabledPlugins).toEqual({ + "someone-elses@their-marketplace": true, + "aidd-context@aidd-framework": true, + }); + }); + + it("leaves a plugin somebody turned off turned off", async () => { + // The sync adds a key only when it is absent. Adding it unconditionally would + // silently re-enable a plugin on the next `aidd sync`. + const { written } = await sync({ + settings: JSON.stringify({ enabledPlugins: { "aidd-context@aidd-framework": false } }), + }); + + expect(written?.enabledPlugins).toEqual({ "aidd-context@aidd-framework": false }); + }); + + it("warns and carries on when the file is not valid JSON", async () => { + // A trailing comma in a hand-edited file must not fail `setup`, `sync` and `update`. + const { written, logger } = await sync({ + settings: '{ "enabledPlugins": { "a@b": true }, }', + }); + + expect(logger.warnMessages.some((w) => w.includes("malformed JSON"))).toBe(true); + expect(written?.enabledPlugins).toEqual({ "aidd-context@aidd-framework": true }); + }); + + it("treats a file holding an array as empty rather than merging into it", async () => { + const { written } = await sync({ settings: JSON.stringify(["not", "an", "object"]) }); + + expect(written?.enabledPlugins).toEqual({ "aidd-context@aidd-framework": true }); + }); + + it("treats a file holding null as empty", async () => { + const { written } = await sync({ settings: "null" }); + + expect(written?.enabledPlugins).toEqual({ "aidd-context@aidd-framework": true }); + }); + + it("treats a non-object under the key as empty rather than spreading it", async () => { + const { written } = await sync({ settings: JSON.stringify({ enabledPlugins: ["a", "b"] }) }); + + expect(written?.enabledPlugins).toEqual({ "aidd-context@aidd-framework": true }); + }); +}); + +/** The build runs for every tool, before the branch that decides who writes the registration + * down, so a build that fails is on the path of every sync. */ +describe("a marketplace that will not build", () => { + const failingBuild = (failFor: string): EnsureBuiltMarketplace => ({ + execute: async (options) => { + if (options.marketplace.name === failFor) throw new Error("no catalog at that source"); + return { builtDir: `/built/${options.target}`, version: "test", rebuilt: true }; + }, + }); + + it("says which marketplace and which tool were skipped", async () => { + const { logger } = await sync({ + marketplaceNames: ["aidd-framework", "broken"], + ensureBuilt: failingBuild("broken"), + }); + + expect( + logger.warnMessages.some((w) => w.includes("'broken'") && w.includes("claude")), + "the warning must name the marketplace and the tool, or it says nothing actionable" + ).toBe(true); + }); + + it("still syncs the tool, rather than letting one bad source stop the rest", async () => { + const { written } = await sync({ + marketplaceNames: ["aidd-framework", "broken"], + ensureBuilt: failingBuild("broken"), + }); + + expect(written?.enabledPlugins).toEqual({ "aidd-context@aidd-framework": true }); + }); +}); + +/** Every known marketplace is registered whether or not a plugin was installed from it, so + * one no plugin points at still needs its tree built or the registration points at nothing. */ +describe("a marketplace no plugin points at", () => { + it("is still built", async () => { + const built: string[] = []; + + const recordingBuild: EnsureBuiltMarketplace = { + execute: async (options) => { + built.push(options.marketplace.name); + return { builtDir: `/built/${options.target}`, version: "test", rebuilt: true }; + }, + }; + + await sync({ + marketplaceNames: ["aidd-framework", "unused"], + ensureBuilt: recordingBuild, + enablesPlugins: true, + }); + + expect(built).toContain("unused"); + }); + it("is registered anyway, whether or not the tool enables its own plugins", async () => { + // Declaring a marketplace and installing a plugin from it are two acts, and a person does + // the first alone all the time; measured, the real binary knew of neither registered one. + const { activator } = await sync({ + marketplaceNames: ["aidd-framework", "unused"], + enablesPlugins: true, + }); + + expect(activator.addedMarketplaces).toHaveLength(2); + }); + + it("is registered anyway when the tool does not enable plugins itself", async () => { + const { activator } = await sync({ + marketplaceNames: ["aidd-framework", "unused"], + enablesPlugins: false, + }); + + expect(activator.addedMarketplaces).toHaveLength(2); + }); +}); + +describe("what execute reports about activation", () => { + it("names the tool whose CLI actually ran, in `activated`", async () => { + const { result } = await sync(); + + expect(result.activated).toEqual(["claude"]); + }); + + it("names the tool and the binary in `binaryMissing` when the CLI is not on PATH", async () => { + const { result } = await sync({ available: false }); + + expect(result.activated).toEqual([]); + expect(result.binaryMissing).toEqual([{ toolId: "claude", binary: "claude" }]); + }); + + it("collects a best-effort failure in `warnings`, the same content the logger gets", async () => { + const { result, logger } = await sync({ + enablesPlugins: true, + failOnPlugins: ["aidd-context@aidd-framework"], + }); + + expect(result.warnings).toEqual(logger.warnMessages); + expect(result.warnings.some((w) => w.includes("aidd-context@aidd-framework"))).toBe(true); + }); + + // A failure shape a real adapter never produces: activation must not swallow it as + // best-effort, and the decision to throw belongs to whoever calls `execute`. + it("carries a hard, unexpected activator failure in `errors` rather than throwing", async () => { + const { result } = await sync({ crashOnAddMarketplace: true }); + + expect(result.errors).toEqual([ + { scope: "claude", message: "activator crashed adding a marketplace" }, + ]); + expect(result.activated).toEqual([]); + }); +}); + +/** `sync --tool ` must touch only that tool — otherwise a person fixing one tool's + * registration would silently re-drive every other tool's CLI too. */ +describe("toolIds narrows which tool's CLI is driven", () => { + it("never calls the activator of a tool not named in toolIds", async () => { + const fs = seededBuiltCatalog(); + const manifestRepo = new InMemoryManifestRepository(); + const registry = new InMemoryMarketplaceRegistry(); + const manifest = Manifest.create(); + manifest.addTool("claude", "test", []); + manifest.addTool("codex", "test", []); + await new ModeAMarketplaceTranslator().addPlugin( + distribution("aidd-context"), + "claude", + { kind: "local", path: "/plugin-source" }, + PROJECT_ROOT, + manifest, + "aidd-framework" + ); + await manifestRepo.save(manifest); + await registry.save( + PROJECT_ROOT, + Marketplace.create({ + name: "aidd-framework", + source: { kind: "local", path: "/source/aidd-framework" }, + scope: "project", + addedAt: "2026-01-01T00:00:00Z", + }) + ); + const claudeActivator = new FakeNativePluginActivator({ available: true }); + const codexActivator = new FakeNativePluginActivator({ available: true }); + const useCase = new MarketplaceSyncSettingsUseCase( + fs, + manifestRepo, + registry, + new DeterministicHasher(), + new CapturingLogger(), + new Map([ + ["claude", claudeActivator], + ["codex", codexActivator], + ]), + fakeEnsureBuiltMarketplace() + ); + + const result = await useCase.execute({ projectRoot: PROJECT_ROOT, toolIds: ["claude"] }); + + expect(claudeActivator.addedMarketplaces).not.toEqual([]); + expect(codexActivator.addedMarketplaces).toEqual([]); + expect(result.activated).toEqual(["claude"]); + }); +}); diff --git a/cli/tests/contexts/framework/application/flows/marketplace-sync-shared-source-reference.unit.test.ts b/cli/tests/contexts/framework/application/flows/marketplace-sync-shared-source-reference.unit.test.ts new file mode 100644 index 000000000..72c06224a --- /dev/null +++ b/cli/tests/contexts/framework/application/flows/marketplace-sync-shared-source-reference.unit.test.ts @@ -0,0 +1,249 @@ +import "../../../../../src/contexts/tools/domain/profiles/claude/profile.js"; +import { describe, expect, it } from "vitest"; +import type { + MarketplaceRegisterFramework, + MarketplaceRegisterFrameworkOptions, + MarketplaceRegisterFrameworkResult, +} from "../../../../../src/contexts/distribution/application/marketplace-register-framework-use-case.js"; +import { + FRAMEWORK_MARKETPLACE_NAME, + Marketplace, +} from "../../../../../src/contexts/distribution/domain/marketplace.js"; +import { MarketplaceSyncSettingsUseCase } from "../../../../../src/contexts/framework/application/flows/marketplace-sync-settings-use-case.js"; +import { Manifest } from "../../../../../src/contexts/framework/domain/manifest.js"; +import { UserSourceReferencesAdapter } from "../../../../../src/contexts/framework/infrastructure/user-source-references-adapter.js"; +import { CapturingLogger } from "../../../../helpers/ports/capturing-logger.js"; +import { DeterministicHasher } from "../../../../helpers/ports/deterministic-hasher.js"; +import { FakeCurrentVersion } from "../../../../helpers/ports/fake-current-version.js"; +import { fakeEnsureBuiltMarketplace } from "../../../../helpers/ports/fake-ensure-built-marketplace.js"; +import { InMemoryFileAdapter } from "../../../../helpers/ports/in-memory-file-adapter.js"; +import { InMemoryManifestRepository } from "../../../../helpers/ports/in-memory-manifest-repository.js"; +import { InMemoryMarketplaceRegistry } from "../../../../helpers/ports/in-memory-marketplace-registry.js"; + +const PROJECT_ROOT = "/test-project"; +const VERSION = "1.0.0"; + +/** A manifest with claude installed and no plugin — enough for `sync` to run its + * settings pass, without a registered activator to drive (no catalog fixture needed). */ +async function manifestRepoWithClaudeInstalled(): Promise { + const manifestRepo = new InMemoryManifestRepository(); + const manifest = Manifest.create(); + manifest.addTool("claude", "test", []); + await manifestRepo.save(manifest); + return manifestRepo; +} + +function frameworkMarketplace(): Marketplace { + return Marketplace.create({ + name: FRAMEWORK_MARKETPLACE_NAME, + source: { kind: "local", path: "/shared/built/path" }, + scope: "user", + addedAt: "2026-01-01T00:00:00.000Z", + }); +} + +describe("the shared source's own reference, recorded by sync", () => { + it("records this project's reference when the framework marketplace is already registered", async () => { + const fs = new InMemoryFileAdapter({}, new DeterministicHasher()); + fs.setFile(`${PROJECT_ROOT}/marker`, ""); + const manifestRepo = await manifestRepoWithClaudeInstalled(); + const registry = new InMemoryMarketplaceRegistry(); + await registry.save(PROJECT_ROOT, frameworkMarketplace()); + const userSourceReferences = new UserSourceReferencesAdapter( + fs, + () => "/fake-home/.config/aidd" + ); + + const useCase = new MarketplaceSyncSettingsUseCase( + fs, + manifestRepo, + registry, + new DeterministicHasher(), + new CapturingLogger(), + new Map(), // no activators: this run only proves the reference, not native activation + fakeEnsureBuiltMarketplace(), + new Map(), + () => "", + undefined, + userSourceReferences, + new FakeCurrentVersion(VERSION) + ); + + await useCase.execute({ projectRoot: PROJECT_ROOT }); + + expect(await userSourceReferences.listAllReferencingProjects()).toContain(PROJECT_ROOT); + }); + + // `references.json` is a help, not an authority: a corrupted copy must never block `sync`, + // which does not depend on it. + it("warns and still completes sync when references.json is corrupted", async () => { + const fs = new InMemoryFileAdapter({}, new DeterministicHasher()); + fs.setFile(`${PROJECT_ROOT}/marker`, ""); + fs.setFile("/fake-home/.config/aidd/references.json", "not json"); + const manifestRepo = await manifestRepoWithClaudeInstalled(); + const registry = new InMemoryMarketplaceRegistry(); + await registry.save(PROJECT_ROOT, frameworkMarketplace()); + const userSourceReferences = new UserSourceReferencesAdapter( + fs, + () => "/fake-home/.config/aidd" + ); + const logger = new CapturingLogger(); + + const useCase = new MarketplaceSyncSettingsUseCase( + fs, + manifestRepo, + registry, + new DeterministicHasher(), + logger, + new Map(), + fakeEnsureBuiltMarketplace(), + new Map(), + () => "", + undefined, + userSourceReferences, + new FakeCurrentVersion(VERSION) + ); + + const result = await useCase.execute({ projectRoot: PROJECT_ROOT }); + + expect(result.errors).toEqual([]); + expect(logger.warnMessages.some((m) => m.includes("references.json"))).toBe(true); + }); + + it("never records a reference to a marketplace registered at project scope", async () => { + const fs = new InMemoryFileAdapter({}, new DeterministicHasher()); + fs.setFile(`${PROJECT_ROOT}/marker`, ""); + const manifestRepo = await manifestRepoWithClaudeInstalled(); + const registry = new InMemoryMarketplaceRegistry(); + await registry.save( + PROJECT_ROOT, + Marketplace.create({ + name: FRAMEWORK_MARKETPLACE_NAME, + source: { kind: "local", path: "/project/built/path" }, + scope: "project", + addedAt: "2026-01-01T00:00:00.000Z", + }) + ); + const userSourceReferences = new UserSourceReferencesAdapter( + fs, + () => "/fake-home/.config/aidd" + ); + + const useCase = new MarketplaceSyncSettingsUseCase( + fs, + manifestRepo, + registry, + new DeterministicHasher(), + new CapturingLogger(), + new Map(), + fakeEnsureBuiltMarketplace(), + new Map(), + () => "", + undefined, + userSourceReferences, + new FakeCurrentVersion(VERSION) + ); + + await useCase.execute({ projectRoot: PROJECT_ROOT }); + + expect(await userSourceReferences.listAllReferencingProjects()).not.toContain(PROJECT_ROOT); + }); + + // A clone whose committed manifest predates this machine's own copy of the registry finds + // `marketplaces` empty, and nothing about a fresh clone ever populates that registry. + it("recreates the framework marketplace when this machine's registry holds nothing at all", async () => { + const fs = new InMemoryFileAdapter({}, new DeterministicHasher()); + fs.setFile(`${PROJECT_ROOT}/marker`, ""); + const manifestRepo = await manifestRepoWithClaudeInstalled(); + const registry = new InMemoryMarketplaceRegistry(); // empty: nothing registered yet + const userSourceReferences = new UserSourceReferencesAdapter( + fs, + () => "/fake-home/.config/aidd" + ); + const registerFramework: MarketplaceRegisterFramework = { + execute: async ( + options: MarketplaceRegisterFrameworkOptions + ): Promise => { + await registry.save(options.projectRoot, frameworkMarketplace()); + return { registered: true, scope: "user" as const }; + }, + }; + + const useCase = new MarketplaceSyncSettingsUseCase( + fs, + manifestRepo, + registry, + new DeterministicHasher(), + new CapturingLogger(), + new Map(), + fakeEnsureBuiltMarketplace(), + new Map(), + () => "", + registerFramework, + userSourceReferences, + new FakeCurrentVersion(VERSION) + ); + + await useCase.execute({ projectRoot: PROJECT_ROOT, recreateFrameworkIfMissing: true }); + + expect((await registry.list(PROJECT_ROOT)).map((m) => m.name)).toContain( + FRAMEWORK_MARKETPLACE_NAME + ); + expect(await userSourceReferences.listAllReferencingProjects()).toContain(PROJECT_ROOT); + }); + + it("stays a silent no-op when no recreate use case was wired in", async () => { + const fs = new InMemoryFileAdapter({}, new DeterministicHasher()); + const manifestRepo = await manifestRepoWithClaudeInstalled(); + const registry = new InMemoryMarketplaceRegistry(); + + const useCase = new MarketplaceSyncSettingsUseCase( + fs, + manifestRepo, + registry, + new DeterministicHasher(), + new CapturingLogger(), + new Map(), + fakeEnsureBuiltMarketplace() + ); + + const result = await useCase.execute({ projectRoot: PROJECT_ROOT }); + + expect(result).toEqual({ activated: [], binaryMissing: [], warnings: [], errors: [] }); + }); + + // Every marketplace and plugin command reaches this same `execute`, where an empty registry is + // a deliberate choice, never a fresh clone: recreating it would undo a `marketplace remove`. + it("does not recreate the framework marketplace unless the caller asks for it", async () => { + const fs = new InMemoryFileAdapter({}, new DeterministicHasher()); + fs.setFile(`${PROJECT_ROOT}/marker`, ""); + const manifestRepo = await manifestRepoWithClaudeInstalled(); + const registry = new InMemoryMarketplaceRegistry(); // empty, on purpose + const registerFramework: MarketplaceRegisterFramework = { + execute: async (options: MarketplaceRegisterFrameworkOptions) => { + await registry.save(options.projectRoot, frameworkMarketplace()); + return { registered: true, scope: "user" as const }; + }, + }; + + const useCase = new MarketplaceSyncSettingsUseCase( + fs, + manifestRepo, + registry, + new DeterministicHasher(), + new CapturingLogger(), + new Map(), + fakeEnsureBuiltMarketplace(), + new Map(), + () => "", + registerFramework, + new UserSourceReferencesAdapter(fs, () => "/fake-home/.config/aidd"), + new FakeCurrentVersion(VERSION) + ); + + // No `recreateFrameworkIfMissing` — the same call shape `syncNativeActivation` uses. + await useCase.execute({ projectRoot: PROJECT_ROOT }); + + expect(await registry.list(PROJECT_ROOT)).toEqual([]); + }); +}); diff --git a/cli/tests/contexts/framework/application/framework/plugin-add-hooks-trust-notice.integration.test.ts b/cli/tests/contexts/framework/application/framework/plugin-add-hooks-trust-notice.integration.test.ts new file mode 100644 index 000000000..56015deba --- /dev/null +++ b/cli/tests/contexts/framework/application/framework/plugin-add-hooks-trust-notice.integration.test.ts @@ -0,0 +1,59 @@ +/** A hook a native tool delivers is not a skip but a component with a precondition: Codex + * gates every hook behind a per-hook trust grant it can decline in silence. */ +import "../../../../../src/contexts/tools/domain/profiles/codex/profile.js"; +import "../../../../../src/contexts/tools/domain/profiles/claude/profile.js"; +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; +import { PluginAddUseCase } from "../../../../../src/contexts/framework/application/plugin/plugin-add-use-case.js"; +import { PluginDistributionReaderAdapter } from "../../../../../src/contexts/framework/infrastructure/plugin-distribution-reader-adapter.js"; +import { codex } from "../../../../../src/contexts/tools/domain/profiles/codex/profile.js"; +import { buildUnitDeps, initAndInstall } from "../../../../helpers/ports/build-unit-deps.js"; +import { CapturingLogger } from "../../../../helpers/ports/capturing-logger.js"; +import { fakeEnsureBuiltMarketplace } from "../../../../helpers/ports/fake-ensure-built-marketplace.js"; +import { InMemoryMarketplaceRegistry } from "../../../../helpers/ports/in-memory-marketplace-registry.js"; +import { seedFromDirectory } from "../../../../helpers/ports/seed-from-directory.js"; + +const PLUGIN_FIXTURE = join(process.cwd(), "tests/fixtures/plugins/claude-format/sample-plugin"); +const PROJECT_ROOT = "/test-project"; + +async function installWithLogger(toolId: "codex" | "claude") { + const deps = await buildUnitDeps(PROJECT_ROOT); + await initAndInstall(deps, PROJECT_ROOT, toolId); + await seedFromDirectory(deps.fs, PLUGIN_FIXTURE, { useAbsolutePaths: true }); + const logger = new CapturingLogger(); + const useCase = new PluginAddUseCase( + deps.fs, + deps.manifestRepo, + deps.pluginFetcher, + new PluginDistributionReaderAdapter(deps.fs), + deps.hasher, + logger, + new InMemoryMarketplaceRegistry(), + fakeEnsureBuiltMarketplace() + ); + await useCase.execute({ + source: { kind: "local", path: PLUGIN_FIXTURE }, + toolIds: [toolId], + projectRoot: PROJECT_ROOT, + interactive: false, + }); + return logger; +} + +describe("PluginAddUseCase hook trust notice", () => { + it("names what Codex still requires, on the info channel, when the plugin delivers hooks", async () => { + const logger = await installWithLogger("codex"); + + expect(logger.infoMessages).toHaveLength(1); + expect(logger.infoMessages[0]).toBe( + `Plugin "sample-plugin" (codex): ${codex.capabilities.plugins.hooksTrustNotice}` + ); + expect(logger.warnMessages).toEqual([]); + }); + + it("says nothing for a tool with no trust gate on its hooks", async () => { + const logger = await installWithLogger("claude"); + + expect(logger.infoMessages).toEqual([]); + }); +}); diff --git a/cli/tests/contexts/framework/application/framework/plugin-add-opencode-hooks-install.integration.test.ts b/cli/tests/contexts/framework/application/framework/plugin-add-opencode-hooks-install.integration.test.ts new file mode 100644 index 000000000..b9748eee7 --- /dev/null +++ b/cli/tests/contexts/framework/application/framework/plugin-add-opencode-hooks-install.integration.test.ts @@ -0,0 +1,81 @@ +/** Installing a plugin carrying hooks/ against OpenCode delivers the script, namespaced under + * .opencode/hooks// like .claude/ and .cursor/ already are. Never .opencode/plugin/, + * which OpenCode's own loader imports in-process, where a plain hook script kills the host. */ + +import { join, posix } from "node:path"; +import { describe, expect, it } from "vitest"; +import { PluginAddUseCase } from "../../../../../src/contexts/framework/application/plugin/plugin-add-use-case.js"; +import { PluginDistributionReaderAdapter } from "../../../../../src/contexts/framework/infrastructure/plugin-distribution-reader-adapter.js"; +import { buildUnitDeps, initAndInstall } from "../../../../helpers/ports/build-unit-deps.js"; +import { CapturingLogger } from "../../../../helpers/ports/capturing-logger.js"; +import { fakeEnsureBuiltMarketplace } from "../../../../helpers/ports/fake-ensure-built-marketplace.js"; +import { InMemoryMarketplaceRegistry } from "../../../../helpers/ports/in-memory-marketplace-registry.js"; +import { seedFromDirectory } from "../../../../helpers/ports/seed-from-directory.js"; + +const PLUGIN_FIXTURE = join(process.cwd(), "tests/fixtures/plugins/claude-format/sample-plugin"); +const PROJECT_ROOT = "/test-project"; + +async function installSamplePlugin() { + const deps = await buildUnitDeps(PROJECT_ROOT); + await initAndInstall(deps, PROJECT_ROOT, "opencode"); + await seedFromDirectory(deps.fs, PLUGIN_FIXTURE, { useAbsolutePaths: true }); + const capturingLogger = new CapturingLogger(); + const registry = new InMemoryMarketplaceRegistry(); + const useCase = new PluginAddUseCase( + deps.fs, + deps.manifestRepo, + deps.pluginFetcher, + new PluginDistributionReaderAdapter(deps.fs), + deps.hasher, + capturingLogger, + registry, + fakeEnsureBuiltMarketplace() + ); + await useCase.execute({ + source: { kind: "local", path: PLUGIN_FIXTURE }, + toolIds: ["opencode"], + projectRoot: PROJECT_ROOT, + interactive: false, + }); + return { deps, capturingLogger }; +} + +describe("PluginAddUseCase OpenCode hooks install (Phase 7)", () => { + it("writes every hooks/ script but the manifest under .opencode/hooks//", async () => { + const { deps } = await installSamplePlugin(); + + // deps.fs is the in-memory adapter, whose listUnder() returns "/"-normalised keys whatever the + // platform joined with, so a native `join` would answer "\\" on win32 and match none of them. + const writtenPaths = deps.fs.listUnder(PROJECT_ROOT); + expect(writtenPaths).toContain( + posix.join(PROJECT_ROOT, ".opencode", "hooks", "sample-plugin", "update_memory.js") + ); + expect(writtenPaths).not.toContain( + posix.join(PROJECT_ROOT, ".opencode", "hooks", "sample-plugin", "hooks.json") + ); + // Never under .opencode/plugin/ either: that is the directory OpenCode's own + // loader imports in-process, and a plain hook script there kills the host. + expect(writtenPaths).not.toContain( + posix.join(PROJECT_ROOT, ".opencode", "plugin", "update_memory.js") + ); + }); + + it("emits no logger.warn — hooks are delivered, not skipped", async () => { + const { capturingLogger } = await installSamplePlugin(); + + expect(capturingLogger.warnMessages).toEqual([]); + }); +}); + +// `plugin install` and `setup` reach OpenCode through ContentTranslator, never through +// FlatBuildStrategy, so without flatHooksBridge there the namespaced script triggers nothing. +describe("PluginAddUseCase OpenCode event bridge (Lot B)", () => { + it("generates .opencode/plugin/-hooks.js for a plugin's mapped hook", async () => { + const { deps } = await installSamplePlugin(); + + const writtenPaths = deps.fs.listUnder(PROJECT_ROOT); + expect(writtenPaths).toContain( + posix.join(PROJECT_ROOT, ".opencode", "plugin", "sample-plugin-hooks.js") + ); + }); +}); diff --git a/cli/tests/application/use-cases/plugin/translator/built-tree-cursor-materialization.integration.test.ts b/cli/tests/contexts/framework/application/framework/translator/built-tree-cursor-materialization.integration.test.ts similarity index 76% rename from cli/tests/application/use-cases/plugin/translator/built-tree-cursor-materialization.integration.test.ts rename to cli/tests/contexts/framework/application/framework/translator/built-tree-cursor-materialization.integration.test.ts index 5447e3de7..07f3c3a29 100644 --- a/cli/tests/application/use-cases/plugin/translator/built-tree-cursor-materialization.integration.test.ts +++ b/cli/tests/contexts/framework/application/framework/translator/built-tree-cursor-materialization.integration.test.ts @@ -1,13 +1,13 @@ -import "../../../../../src/domain/tools/ai/cursor.js"; +import "../../../../../../src/contexts/tools/domain/profiles/cursor/profile.js"; import { describe, expect, it } from "vitest"; -import { BuiltTreeMaterializationTranslator } from "../../../../../src/application/use-cases/plugin/translator/built-tree-materialization-translator.js"; -import { Manifest } from "../../../../../src/domain/models/manifest.js"; -import { Marketplace } from "../../../../../src/domain/models/marketplace.js"; -import { PluginDistribution } from "../../../../../src/domain/models/plugin-distribution.js"; -import { DeterministicHasher } from "../../../../helpers/ports/deterministic-hasher.js"; -import { fakeEnsureBuiltMarketplace } from "../../../../helpers/ports/fake-ensure-built-marketplace.js"; -import { InMemoryFileAdapter } from "../../../../helpers/ports/in-memory-file-adapter.js"; -import { InMemoryMarketplaceRegistry } from "../../../../helpers/ports/in-memory-marketplace-registry.js"; +import { Marketplace } from "../../../../../../src/contexts/distribution/domain/marketplace.js"; +import { BuiltTreeMaterializationTranslator } from "../../../../../../src/contexts/framework/application/framework/translator/built-tree-materialization-translator.js"; +import { Manifest } from "../../../../../../src/contexts/framework/domain/manifest.js"; +import { PluginDistribution } from "../../../../../../src/contexts/translate/domain/plugin-distribution.js"; +import { DeterministicHasher } from "../../../../../helpers/ports/deterministic-hasher.js"; +import { fakeEnsureBuiltMarketplace } from "../../../../../helpers/ports/fake-ensure-built-marketplace.js"; +import { InMemoryFileAdapter } from "../../../../../helpers/ports/in-memory-file-adapter.js"; +import { InMemoryMarketplaceRegistry } from "../../../../../helpers/ports/in-memory-marketplace-registry.js"; const PROJECT_ROOT = "/proj"; const HOME = "/home/u"; @@ -65,8 +65,7 @@ describe("BuiltTreeMaterializationTranslator — cursor (integration)", () => { { kind: "local", path: "/plugin-source" }, PROJECT_ROOT, manifest, - "aidd-framework", - "docs" + "aidd-framework" ); const base = `${HOME}/.cursor/plugins/local/sample-plugin`; @@ -75,7 +74,6 @@ describe("BuiltTreeMaterializationTranslator — cursor (integration)", () => { expect(fs.getFile(`${base}/rules/r.mdc`)).toBe("rule body"); // .mcp.json keeps its dotted name (came from build, not remapped to mcp.json). expect(fs.getFile(`${base}/.mcp.json`)).toBe("{}"); - // Manifest tracks the installed files for remove/restore. const installed = manifest.getPlugins("cursor").find((p) => p.name === "sample-plugin"); expect(installed?.files.size).toBe(4); }); @@ -91,15 +89,13 @@ describe("BuiltTreeMaterializationTranslator — cursor (integration)", () => { fakeEnsureBuiltMarketplace(), await makeRegistry() ); - // No marketplace → fallback path; empty dist → no files, no throw. const result = await translator.addPlugin( dist(), "cursor", { kind: "local", path: "/plugin-source" }, PROJECT_ROOT, manifest, - undefined, - "docs" + undefined ); expect(result.skipped).toEqual([]); }); diff --git a/cli/tests/contexts/framework/application/framework/translator/built-tree-opencode-materialization.integration.test.ts b/cli/tests/contexts/framework/application/framework/translator/built-tree-opencode-materialization.integration.test.ts new file mode 100644 index 000000000..dddf5d135 --- /dev/null +++ b/cli/tests/contexts/framework/application/framework/translator/built-tree-opencode-materialization.integration.test.ts @@ -0,0 +1,135 @@ +import "../../../../../../src/contexts/tools/domain/profiles/opencode/profile.js"; +import { describe, expect, it } from "vitest"; +import { Marketplace } from "../../../../../../src/contexts/distribution/domain/marketplace.js"; +import { BuiltTreeMaterializationTranslator } from "../../../../../../src/contexts/framework/application/framework/translator/built-tree-materialization-translator.js"; +import { Manifest } from "../../../../../../src/contexts/framework/domain/manifest.js"; +import { PluginDistribution } from "../../../../../../src/contexts/translate/domain/plugin-distribution.js"; +import { DeterministicHasher } from "../../../../../helpers/ports/deterministic-hasher.js"; +import { fakeEnsureBuiltMarketplace } from "../../../../../helpers/ports/fake-ensure-built-marketplace.js"; +import { InMemoryFileAdapter } from "../../../../../helpers/ports/in-memory-file-adapter.js"; +import { InMemoryMarketplaceRegistry } from "../../../../../helpers/ports/in-memory-marketplace-registry.js"; + +const PROJECT_ROOT = "/proj"; +const BUILT = "/built/opencode"; + +function dist(): PluginDistribution { + return new PluginDistribution({ + manifest: { name: "aidd-vcs", version: "1.0.0" }, + format: "claude", + files: [], + components: { commands: [], agents: [], rules: [], skills: [], hooks: [], mcp: [] }, + }); +} + +function distWithHooks(): PluginDistribution { + return new PluginDistribution({ + manifest: { name: "aidd-vcs", version: "1.0.0" }, + format: "claude", + files: [], + components: { + commands: [], + agents: [], + rules: [], + skills: [], + mcp: [], + hooks: [ + { relativePath: "hooks/hooks.json", content: "{}" }, + { relativePath: "hooks/journal.cjs", content: "// journal" }, + { relativePath: "hooks/lib/host.cjs", content: "// host" }, + { relativePath: "hooks/opencode-plugin.js", content: "export const plugin = 1;" }, + ], + }, + }); +} + +async function makeRegistry(): Promise { + const registry = new InMemoryMarketplaceRegistry(); + await registry.save( + PROJECT_ROOT, + Marketplace.create({ + name: "aidd-framework", + source: { kind: "local", path: "/src/framework" }, + scope: "project", + addedAt: "2026-01-01T00:00:00Z", + }) + ); + return registry; +} + +describe("BuiltTreeMaterializationTranslator — opencode (integration)", () => { + it("copies only this plugin's flat files into the project, byte-for-byte", async () => { + const fs = new InMemoryFileAdapter(); + const skill = "Load [assets/x.md](../assets/x.md)"; + // This plugin's skills nest under its own segment (aidd-vcs/...); agents stay + // hyphen-prefixed (aidd-vcs-helper.md). Another plugin's files must be ignored. + fs.setFile(`${BUILT}/.opencode/skills/aidd-vcs/01-commit/SKILL.md`, skill); + fs.setFile(`${BUILT}/.opencode/agents/aidd-vcs-helper.md`, "agent body"); + fs.setFile(`${BUILT}/.opencode/skills/aidd-dev/00-sdlc/SKILL.md`, "OTHER PLUGIN"); + fs.setFile(`${BUILT}/.build-version`, "5.0.0:1.0.0"); + + const manifest = Manifest.create(); + manifest.addTool("opencode", "test", []); + const translator = new BuiltTreeMaterializationTranslator( + fs, + new DeterministicHasher(), + () => "/home/u", + fakeEnsureBuiltMarketplace(), + await makeRegistry() + ); + + await translator.addPlugin( + dist(), + "opencode", + { kind: "local", path: "/plugin-source" }, + PROJECT_ROOT, + manifest, + "aidd-framework" + ); + + expect(fs.getFile(`${PROJECT_ROOT}/.opencode/skills/aidd-vcs/01-commit/SKILL.md`)).toBe(skill); + expect(fs.getFile(`${PROJECT_ROOT}/.opencode/agents/aidd-vcs-helper.md`)).toBe("agent body"); + expect(fs.has(`${PROJECT_ROOT}/.opencode/skills/aidd-dev/00-sdlc/SKILL.md`)).toBe(false); + expect(fs.has(`${PROJECT_ROOT}/.build-version`)).toBe(false); + const installed = manifest.getPlugins("opencode").find((p) => p.name === "aidd-vcs"); + expect(installed?.files.size).toBe(2); + }); + + // Hook paths follow none of the "-" naming `belongsToPlugin` reads, so they are + // computed from the plugin's own distribution and matched by path. + it("copies this plugin's flat hooks by their computed paths, not by naming convention", async () => { + const fs = new InMemoryFileAdapter(); + fs.setFile(`${BUILT}/.opencode/hooks/aidd-vcs/journal.cjs`, "// journal"); + fs.setFile(`${BUILT}/.opencode/hooks/aidd-vcs/lib/host.cjs`, "// host"); + fs.setFile(`${BUILT}/.opencode/plugin/aidd-vcs.js`, "export const plugin = 1;"); + fs.setFile(`${BUILT}/.opencode/hooks/other-plugin/hook.js`, "OTHER PLUGIN"); + fs.setFile(`${BUILT}/.opencode/plugin/other-plugin.js`, "OTHER PLUGIN"); + + const manifest = Manifest.create(); + manifest.addTool("opencode", "test", []); + const translator = new BuiltTreeMaterializationTranslator( + fs, + new DeterministicHasher(), + () => "/home/u", + fakeEnsureBuiltMarketplace(), + await makeRegistry() + ); + + await translator.addPlugin( + distWithHooks(), + "opencode", + { kind: "local", path: "/plugin-source" }, + PROJECT_ROOT, + manifest, + "aidd-framework" + ); + + expect(fs.getFile(`${PROJECT_ROOT}/.opencode/hooks/aidd-vcs/journal.cjs`)).toBe("// journal"); + expect(fs.getFile(`${PROJECT_ROOT}/.opencode/hooks/aidd-vcs/lib/host.cjs`)).toBe("// host"); + expect(fs.getFile(`${PROJECT_ROOT}/.opencode/plugin/aidd-vcs.js`)).toBe( + "export const plugin = 1;" + ); + expect(fs.has(`${PROJECT_ROOT}/.opencode/hooks/aidd-vcs/hooks.json`)).toBe(false); + expect(fs.has(`${PROJECT_ROOT}/.opencode/hooks/other-plugin/hook.js`)).toBe(false); + expect(fs.has(`${PROJECT_ROOT}/.opencode/plugin/other-plugin.js`)).toBe(false); + }); +}); diff --git a/cli/tests/contexts/framework/application/framework/translator/built-tree-vs-modeb-skills-agree.unit.test.ts b/cli/tests/contexts/framework/application/framework/translator/built-tree-vs-modeb-skills-agree.unit.test.ts new file mode 100644 index 000000000..593f9544d --- /dev/null +++ b/cli/tests/contexts/framework/application/framework/translator/built-tree-vs-modeb-skills-agree.unit.test.ts @@ -0,0 +1,94 @@ +import { existsSync, readdirSync } from "node:fs"; +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; +import { buildOpencodeFlatContract } from "../../../../../../src/contexts/tools/domain/profiles/opencode/build.js"; +import { opencode } from "../../../../../../src/contexts/tools/domain/profiles/opencode/profile.js"; +import { PluginContentTranslator } from "../../../../../../src/contexts/translate/domain/content-translator.js"; +import { + type PluginComponentFile, + PluginDistribution, +} from "../../../../../../src/contexts/translate/domain/plugin-distribution.js"; +import { FileHash } from "../../../../../../src/kernel/file.js"; +import { REPOSITORY_ROOT } from "../../../../../helpers/repository-root.js"; + +const PLUGINS_DIR = join(REPOSITORY_ROOT, "plugins"); + +/** + * Two independent paths compute an OpenCode-flat skill path for the same plugin content. + * The fixture's non-skill children under skills/ are what makes a drift between them visible. + */ + +const PLUGIN_NAME = "aidd-telemetry"; +const stubHasher = { hash: (_content: string) => new FileHash("a".repeat(32)) }; + +function skillFile(relativePath: string, content = "// stub"): PluginComponentFile { + return { relativePath: `skills/${relativePath}`, content }; +} + +function makeTelemetryLikeDist(): PluginDistribution { + const skills = [ + skillFile("shared/attribution.cjs"), + skillFile("package.json", `{ "type": "commonjs" }`), + skillFile("01-cost/SKILL.md", `---\nname: 01-cost\ndescription: Cost skill\n---\n\nBody.\n`), + skillFile("01-cost/scripts/telemetry-report.cjs", `require("../../shared/attribution.cjs");`), + ]; + return new PluginDistribution({ + manifest: { name: PLUGIN_NAME, version: "1.0.0" }, + format: "claude", + files: skills, + components: { commands: [], agents: [], rules: [], skills, hooks: [], mcp: [] }, + }); +} + +function modeBSkillPaths(dist: PluginDistribution): string[] { + return new PluginContentTranslator(stubHasher) + .translate(dist, opencode) + .map((f) => f.relativePath) + .filter((p) => p.startsWith(".opencode/skills/")) + .sort(); +} + +function builtTreeSkillPaths(dist: PluginDistribution): string[] { + const skillsArtifact = buildOpencodeFlatContract().artifacts.skills; + if (!skillsArtifact.supported) throw new Error("opencode flat skills artifact unsupported"); + return dist.components.skills.map((f) => skillsArtifact.path(PLUGIN_NAME, f.relativePath)).sort(); +} + +describe("opencode flat skills — built-tree route agrees with mode-B install route", () => { + it("produce the identical set of relative output paths for a plugin with non-skill children", () => { + const dist = makeTelemetryLikeDist(); + + const modeB = modeBSkillPaths(dist); + const builtTree = builtTreeSkillPaths(dist); + + expect(builtTree).toEqual(modeB); + expect(modeB).toEqual([ + ".opencode/skills/aidd-telemetry/01-cost/SKILL.md", + ".opencode/skills/aidd-telemetry/01-cost/scripts/telemetry-report.cjs", + ".opencode/skills/aidd-telemetry/package.json", + ".opencode/skills/aidd-telemetry/shared/attribution.cjs", + ]); + }); +}); + +// Nesting a skill under its plugin's own directory shortens its `name` to the leaf, so +// nothing stops two plugins shipping the same leaf any more. This does. +describe("skill names across plugins, now that the plugin prefix is a directory", () => { + it("no two plugins ship a skill with the same leaf name", () => { + const byLeaf = new Map(); + for (const plugin of readdirSync(PLUGINS_DIR, { withFileTypes: true })) { + if (!plugin.isDirectory()) continue; + const skillsDir = join(PLUGINS_DIR, plugin.name, "skills"); + if (!existsSync(skillsDir)) continue; + for (const skill of readdirSync(skillsDir, { withFileTypes: true })) { + if (!skill.isDirectory()) continue; + if (!existsSync(join(skillsDir, skill.name, "SKILL.md"))) continue; + byLeaf.set(skill.name, [...(byLeaf.get(skill.name) ?? []), plugin.name]); + } + } + + const shared = [...byLeaf.entries()].filter(([, plugins]) => plugins.length > 1); + expect(byLeaf.size).toBeGreaterThan(10); + expect(shared.map(([leaf, plugins]) => `${leaf}: ${plugins.join(", ")}`)).toEqual([]); + }); +}); diff --git a/cli/tests/contexts/framework/application/framework/translator/install-plugin-claude-mode-a.integration.test.ts b/cli/tests/contexts/framework/application/framework/translator/install-plugin-claude-mode-a.integration.test.ts new file mode 100644 index 000000000..5161e01fa --- /dev/null +++ b/cli/tests/contexts/framework/application/framework/translator/install-plugin-claude-mode-a.integration.test.ts @@ -0,0 +1,188 @@ +import "../../../../../../src/contexts/tools/domain/profiles/claude/profile.js"; +import { resolve } from "node:path"; +import { describe, expect, it } from "vitest"; +import { Marketplace } from "../../../../../../src/contexts/distribution/domain/marketplace.js"; +import { MarketplaceSyncSettingsUseCase } from "../../../../../../src/contexts/framework/application/flows/marketplace-sync-settings-use-case.js"; +import { ModeAMarketplaceTranslator } from "../../../../../../src/contexts/framework/application/framework/translator/mode-a-marketplace-translator.js"; +import { Manifest } from "../../../../../../src/contexts/framework/domain/manifest.js"; +import { PluginDistribution } from "../../../../../../src/contexts/translate/domain/plugin-distribution.js"; +import { CapturingLogger } from "../../../../../helpers/ports/capturing-logger.js"; +import { DeterministicHasher } from "../../../../../helpers/ports/deterministic-hasher.js"; +import { fakeEnsureBuiltMarketplace } from "../../../../../helpers/ports/fake-ensure-built-marketplace.js"; +import { FakeNativePluginActivator } from "../../../../../helpers/ports/fake-native-plugin-activator.js"; +import { InMemoryFileAdapter } from "../../../../../helpers/ports/in-memory-file-adapter.js"; +import { InMemoryManifestRepository } from "../../../../../helpers/ports/in-memory-manifest-repository.js"; +import { InMemoryMarketplaceRegistry } from "../../../../../helpers/ports/in-memory-marketplace-registry.js"; + +const PROJECT_ROOT = "/test-project"; +const MARKETPLACE_NAME = "aidd-framework"; + +/** A readable catalog at the path the default `fakeEnsureBuiltMarketplace()` resolves + * "claude" to — a real build always leaves one there, and an unreadable one now fails hard. */ +async function seedBuiltCatalog(fs: InMemoryFileAdapter, name = MARKETPLACE_NAME): Promise { + await fs.writeFile( + "/built/claude/.claude-plugin/marketplace.json", + JSON.stringify({ name, version: "1.0.0", plugins: [] }) + ); +} + +function buildDist(name = "aidd-context"): PluginDistribution { + return new PluginDistribution({ + manifest: { name, version: "1.0.0" }, + format: "claude", + files: [{ relativePath: "commands/hello.md", content: "# Hello" }], + components: { + commands: [{ relativePath: "commands/hello.md", content: "# Hello" }], + agents: [], + rules: [], + skills: [], + hooks: [], + mcp: [], + }, + }); +} + +describe("install claude plugin via Mode A (integration)", () => { + it("leaves the registration to claude and keeps only what it owns", async () => { + const fs = new InMemoryFileAdapter(); + await seedBuiltCatalog(fs); + const hasher = new DeterministicHasher(); + const manifestRepo = new InMemoryManifestRepository(); + const registry = new InMemoryMarketplaceRegistry(); + // Claude drives its own registration; it does not enable plugins that way. + const activator = new FakeNativePluginActivator({ available: true, enablesPlugins: false }); + const manifest = Manifest.create(); + manifest.addTool("claude", "test", []); + + await new ModeAMarketplaceTranslator().addPlugin( + buildDist(), + "claude", + { kind: "local", path: "/plugin-source" }, + PROJECT_ROOT, + manifest, + MARKETPLACE_NAME + ); + await manifestRepo.save(manifest); + await registry.save( + PROJECT_ROOT, + Marketplace.create({ + name: MARKETPLACE_NAME, + source: { kind: "local", path: "/marketplace-source" }, + scope: "project", + addedAt: "2026-01-01T00:00:00Z", + }) + ); + + const useCase = new MarketplaceSyncSettingsUseCase( + fs, + manifestRepo, + registry, + hasher, + new CapturingLogger(), + new Map([["claude", activator]]), + fakeEnsureBuiltMarketplace() + ); + await useCase.execute({ projectRoot: PROJECT_ROOT }); + + const shared = JSON.parse( + await fs.readFile(resolve(PROJECT_ROOT, ".claude/settings.json")) + ) as Record; + + // Claude registers its own marketplaces through its own command, so this CLI + // writes no registration anywhere — not in the shared file, not beside it. + expect(shared.extraKnownMarketplaces).toBeUndefined(); + expect(await fs.fileExists(resolve(PROJECT_ROOT, ".claude/settings.local.json"))).toBe(false); + expect(activator.addedMarketplaces).toEqual(["/built/claude"]); + + // Enabled plugins stay here: `claude plugin install --scope project` writes this + // very object, so driving it would be a second way of doing the same thing. + expect( + (shared.enabledPlugins as Record)[`aidd-context@${MARKETPLACE_NAME}`] + ).toBe(true); + expect(activator.enabledPlugins).toEqual([]); + }); + + it("does not materialize plugin files on disk for Mode A", async () => { + const fs = new InMemoryFileAdapter(); + const manifest = Manifest.create(); + manifest.addTool("claude", "test", []); + await new ModeAMarketplaceTranslator().addPlugin( + buildDist(), + "claude", + { kind: "local", path: "/plugin-source" }, + PROJECT_ROOT, + manifest, + MARKETPLACE_NAME + ); + const pluginFiles = fs.listAll().filter((p) => p.includes(".claude/plugins/")); + expect(pluginFiles).toEqual([]); + const installed = manifest.getPlugins("claude").find((p) => p.name === "aidd-context"); + expect(installed?.files.size).toBe(0); + }); + + it("takes a registration left in the shared file by an older install out of it", async () => { + const fs = new InMemoryFileAdapter(); + await seedBuiltCatalog(fs); + const hasher = new DeterministicHasher(); + const manifestRepo = new InMemoryManifestRepository(); + const registry = new InMemoryMarketplaceRegistry(); + // Claude drives its own registration; it does not enable plugins that way. + const activator = new FakeNativePluginActivator({ available: true, enablesPlugins: false }); + const manifest = Manifest.create(); + manifest.addTool("claude", "test", []); + + await new ModeAMarketplaceTranslator().addPlugin( + buildDist(), + "claude", + { kind: "local", path: "/plugin-source" }, + PROJECT_ROOT, + manifest, + MARKETPLACE_NAME + ); + await manifestRepo.save(manifest); + await registry.save( + PROJECT_ROOT, + Marketplace.create({ + name: MARKETPLACE_NAME, + source: { kind: "local", path: "/marketplace-source" }, + scope: "project", + addedAt: "2026-01-01T00:00:00Z", + }) + ); + + const useCase = new MarketplaceSyncSettingsUseCase( + fs, + manifestRepo, + registry, + hasher, + new CapturingLogger(), + new Map([["claude", activator]]), + fakeEnsureBuiltMarketplace() + ); + + // What a project installed before the split looks like: the registration sitting in + // the committed file, naming a path that belongs to whoever ran the install. + await fs.writeFile( + resolve(PROJECT_ROOT, ".claude/settings.json"), + JSON.stringify({ + extraKnownMarketplaces: { + [MARKETPLACE_NAME]: { source: { source: "directory", path: "/someone/elses/machine" } }, + }, + }) + ); + + await useCase.execute({ projectRoot: PROJECT_ROOT }); + + const shared = JSON.parse( + await fs.readFile(resolve(PROJECT_ROOT, ".claude/settings.json")) + ) as Record; + + expect(shared.extraKnownMarketplaces).toBeUndefined(); + expect(activator.addedMarketplaces).toContain("/built/claude"); + // Both branches wrote the shared file in this one call — the eviction, then the enabled- + // plugins merge; the plugin landing proves the second did not carry the evicted key back. + expect( + (shared.enabledPlugins as Record)[`aidd-context@${MARKETPLACE_NAME}`] + ).toBe(true); + }); +}); diff --git a/cli/tests/contexts/framework/application/framework/translator/install-plugin-codex-mode-a.integration.test.ts b/cli/tests/contexts/framework/application/framework/translator/install-plugin-codex-mode-a.integration.test.ts new file mode 100644 index 000000000..b917af5df --- /dev/null +++ b/cli/tests/contexts/framework/application/framework/translator/install-plugin-codex-mode-a.integration.test.ts @@ -0,0 +1,209 @@ +// Codex enables plugins through its own CLI, which writes the user-global +// `~/.codex/config.toml` and plugin cache, so a project-local settings file is inert. +import "../../../../../../src/contexts/tools/domain/profiles/codex/profile.js"; +import { resolve } from "node:path"; +import { describe, expect, it } from "vitest"; +import { Marketplace } from "../../../../../../src/contexts/distribution/domain/marketplace.js"; +import { MarketplaceSyncSettingsUseCase } from "../../../../../../src/contexts/framework/application/flows/marketplace-sync-settings-use-case.js"; +import { ModeAMarketplaceTranslator } from "../../../../../../src/contexts/framework/application/framework/translator/mode-a-marketplace-translator.js"; +import { Manifest } from "../../../../../../src/contexts/framework/domain/manifest.js"; +import { PluginDistribution } from "../../../../../../src/contexts/translate/domain/plugin-distribution.js"; +import type { PluginSource } from "../../../../../../src/kernel/source.js"; +import { CapturingLogger } from "../../../../../helpers/ports/capturing-logger.js"; +import { DeterministicHasher } from "../../../../../helpers/ports/deterministic-hasher.js"; +import { fakeEnsureBuiltMarketplace } from "../../../../../helpers/ports/fake-ensure-built-marketplace.js"; +import { FakeNativePluginActivator } from "../../../../../helpers/ports/fake-native-plugin-activator.js"; +import { InMemoryFileAdapter } from "../../../../../helpers/ports/in-memory-file-adapter.js"; +import { InMemoryManifestRepository } from "../../../../../helpers/ports/in-memory-manifest-repository.js"; +import { InMemoryMarketplaceRegistry } from "../../../../../helpers/ports/in-memory-marketplace-registry.js"; + +const PROJECT_ROOT = "/test-project"; +const MARKETPLACE_NAME = "aidd-framework"; + +/** A readable catalog at the path `fakeEnsureBuiltMarketplace()` resolves "codex" to, at its + * own `distributionProbes.marketplace` relative path: a real build always leaves one there, and + * an unreadable catalog is now `UnreadableBuiltCatalogError` rather than a fall back. */ +async function seedBuiltCatalog(fs: InMemoryFileAdapter, name = MARKETPLACE_NAME): Promise { + await fs.writeFile( + "/built/codex/.agents/plugins/marketplace.json", + JSON.stringify({ name, version: "1.0.0", plugins: [] }) + ); +} + +function buildDist(name = "aidd-context"): PluginDistribution { + return new PluginDistribution({ + manifest: { name, version: "1.0.0" }, + format: "claude", + files: [{ relativePath: "commands/hello.md", content: "# Hello" }], + components: { + commands: [{ relativePath: "commands/hello.md", content: "# Hello" }], + agents: [], + rules: [], + skills: [], + hooks: [], + mcp: [], + }, + }); +} + +async function seedCodexPlugin( + manifestRepo: InMemoryManifestRepository, + registry: InMemoryMarketplaceRegistry, + source: PluginSource = { kind: "local", path: "/marketplace-source" } +): Promise { + const manifest = Manifest.create(); + manifest.addTool("codex", "test", []); + await new ModeAMarketplaceTranslator().addPlugin( + buildDist(), + "codex", + { kind: "local", path: "/plugin-source" }, + PROJECT_ROOT, + manifest, + MARKETPLACE_NAME + ); + await manifestRepo.save(manifest); + await registry.save( + PROJECT_ROOT, + Marketplace.create({ + name: MARKETPLACE_NAME, + source, + scope: "project", + addedAt: "2026-01-01T00:00:00Z", + }) + ); +} + +async function seedTwoCodexPlugins( + manifestRepo: InMemoryManifestRepository, + registry: InMemoryMarketplaceRegistry +): Promise { + const manifest = Manifest.create(); + manifest.addTool("codex", "test", []); + const translator = new ModeAMarketplaceTranslator(); + for (const name of ["aidd-context", "aidd-vcs"]) { + await translator.addPlugin( + buildDist(name), + "codex", + { kind: "local", path: "/plugin-source" }, + PROJECT_ROOT, + manifest, + MARKETPLACE_NAME + ); + } + await manifestRepo.save(manifest); + await registry.save( + PROJECT_ROOT, + Marketplace.create({ + name: MARKETPLACE_NAME, + source: { kind: "local", path: "/marketplace-source" }, + scope: "project", + addedAt: "2026-01-01T00:00:00Z", + }) + ); +} + +describe("install codex plugin via Mode A (integration)", () => { + it("drives the codex CLI and writes no project-local config.json", async () => { + const fs = new InMemoryFileAdapter(); + await seedBuiltCatalog(fs); + const hasher = new DeterministicHasher(); + const manifestRepo = new InMemoryManifestRepository(); + const registry = new InMemoryMarketplaceRegistry(); + const activator = new FakeNativePluginActivator({ available: true }); + await seedCodexPlugin(manifestRepo, registry); + + const useCase = new MarketplaceSyncSettingsUseCase( + fs, + manifestRepo, + registry, + hasher, + new CapturingLogger(), + new Map([["codex", activator]]), + fakeEnsureBuiltMarketplace() + ); + await useCase.execute({ projectRoot: PROJECT_ROOT }); + + // Registers the BUILT (transformed) tree, not the raw source. A fresh add + // succeeds outright — no pre-emptive remove on a clean install. + expect(activator.removedMarketplaces).toEqual([]); + expect(activator.addedMarketplaces).toEqual(["/built/codex"]); + expect(activator.upgradeCount).toBe(1); + expect(activator.enabledPlugins).toEqual([`aidd-context@${MARKETPLACE_NAME}`]); + expect(await fs.fileExists(resolve(PROJECT_ROOT, ".codex/config.json"))).toBe(false); + }); + + it("builds a github marketplace locally and registers the built tree", async () => { + const fs = new InMemoryFileAdapter(); + await seedBuiltCatalog(fs); + const manifestRepo = new InMemoryManifestRepository(); + const registry = new InMemoryMarketplaceRegistry(); + const activator = new FakeNativePluginActivator({ available: true }); + await seedCodexPlugin(manifestRepo, registry, { + kind: "github", + repo: "ai-driven-dev/framework", + }); + + const useCase = new MarketplaceSyncSettingsUseCase( + fs, + manifestRepo, + registry, + new DeterministicHasher(), + new CapturingLogger(), + new Map([["codex", activator]]), + fakeEnsureBuiltMarketplace() + ); + await useCase.execute({ projectRoot: PROJECT_ROOT }); + + expect(activator.addedMarketplaces).toEqual(["/built/codex"]); + expect(activator.enabledPlugins).toEqual([`aidd-context@${MARKETPLACE_NAME}`]); + }); + + it("enables the remaining plugins when one plugin fails (per-plugin best-effort)", async () => { + const fs = new InMemoryFileAdapter(); + await seedBuiltCatalog(fs); + const manifestRepo = new InMemoryManifestRepository(); + const registry = new InMemoryMarketplaceRegistry(); + const logger = new CapturingLogger(); + const activator = new FakeNativePluginActivator({ + available: true, + failOnPlugins: [`aidd-context@${MARKETPLACE_NAME}`], + }); + await seedTwoCodexPlugins(manifestRepo, registry); + + const useCase = new MarketplaceSyncSettingsUseCase( + fs, + manifestRepo, + registry, + new DeterministicHasher(), + logger, + new Map([["codex", activator]]), + fakeEnsureBuiltMarketplace() + ); + await useCase.execute({ projectRoot: PROJECT_ROOT }); + + expect(activator.enabledPlugins).toEqual([`aidd-vcs@${MARKETPLACE_NAME}`]); + expect(logger.warnMessages.some((m) => m.includes("aidd-context@aidd-framework"))).toBe(true); + }); + + it("skips activation when the codex CLI is unavailable", async () => { + const fs = new InMemoryFileAdapter(); + const manifestRepo = new InMemoryManifestRepository(); + const registry = new InMemoryMarketplaceRegistry(); + const activator = new FakeNativePluginActivator({ available: false }); + await seedCodexPlugin(manifestRepo, registry); + + const useCase = new MarketplaceSyncSettingsUseCase( + fs, + manifestRepo, + registry, + new DeterministicHasher(), + new CapturingLogger(), + new Map([["codex", activator]]), + fakeEnsureBuiltMarketplace() + ); + await useCase.execute({ projectRoot: PROJECT_ROOT }); + + expect(activator.addedMarketplaces).toEqual([]); + expect(activator.enabledPlugins).toEqual([]); + }); +}); diff --git a/cli/tests/contexts/framework/application/framework/translator/install-plugin-copilot-mode-a.integration.test.ts b/cli/tests/contexts/framework/application/framework/translator/install-plugin-copilot-mode-a.integration.test.ts new file mode 100644 index 000000000..38202cfdc --- /dev/null +++ b/cli/tests/contexts/framework/application/framework/translator/install-plugin-copilot-mode-a.integration.test.ts @@ -0,0 +1,221 @@ +import "../../../../../../src/contexts/tools/domain/profiles/copilot/profile.js"; +import { resolve } from "node:path"; +import { describe, expect, it } from "vitest"; +import { Marketplace } from "../../../../../../src/contexts/distribution/domain/marketplace.js"; +import { MarketplaceSyncSettingsUseCase } from "../../../../../../src/contexts/framework/application/flows/marketplace-sync-settings-use-case.js"; +import { ModeAMarketplaceTranslator } from "../../../../../../src/contexts/framework/application/framework/translator/mode-a-marketplace-translator.js"; +import { Manifest } from "../../../../../../src/contexts/framework/domain/manifest.js"; +import { PluginDistribution } from "../../../../../../src/contexts/translate/domain/plugin-distribution.js"; +import { CapturingLogger } from "../../../../../helpers/ports/capturing-logger.js"; +import { DeterministicHasher } from "../../../../../helpers/ports/deterministic-hasher.js"; +import { fakeEnsureBuiltMarketplace } from "../../../../../helpers/ports/fake-ensure-built-marketplace.js"; +import { FakeNativePluginActivator } from "../../../../../helpers/ports/fake-native-plugin-activator.js"; +import { InMemoryFileAdapter } from "../../../../../helpers/ports/in-memory-file-adapter.js"; +import { InMemoryManifestRepository } from "../../../../../helpers/ports/in-memory-manifest-repository.js"; +import { InMemoryMarketplaceRegistry } from "../../../../../helpers/ports/in-memory-marketplace-registry.js"; + +const PROJECT_ROOT = "/test-project"; +const MARKETPLACE_NAME = "aidd-framework"; + +/** A real build always leaves a catalog at copilot's own `distributionProbes.marketplace` + * path, and an unreadable one is a hard failure, so the fixture must leave one too. */ +async function seedBuiltCatalog(fs: InMemoryFileAdapter, name = MARKETPLACE_NAME): Promise { + await fs.writeFile( + "/built/copilot/.plugin/marketplace.json", + JSON.stringify({ name, version: "1.0.0", plugins: [] }) + ); +} + +async function seedCopilotPlugin( + manifestRepo: InMemoryManifestRepository, + registry: InMemoryMarketplaceRegistry +): Promise { + const manifest = Manifest.create(); + manifest.addTool("copilot", "test", []); + await new ModeAMarketplaceTranslator().addPlugin( + buildDist(), + "copilot", + { kind: "github", repo: "ai-driven-dev/framework" }, + PROJECT_ROOT, + manifest, + MARKETPLACE_NAME + ); + await manifestRepo.save(manifest); + await registry.save( + PROJECT_ROOT, + Marketplace.create({ + name: MARKETPLACE_NAME, + source: { kind: "github", repo: "ai-driven-dev/framework" }, + scope: "project", + addedAt: "2026-01-01T00:00:00Z", + }) + ); +} + +function buildDist(name = "aidd-context"): PluginDistribution { + return new PluginDistribution({ + manifest: { name, version: "1.0.0" }, + format: "claude", + files: [{ relativePath: "commands/hello.md", content: "# Hello" }], + components: { + commands: [{ relativePath: "commands/hello.md", content: "# Hello" }], + agents: [], + rules: [], + skills: [], + hooks: [], + mcp: [], + }, + }); +} + +describe("install copilot plugin via Mode A (integration)", () => { + it("recommends plugins in the shared file and puts no path in it", async () => { + const fs = new InMemoryFileAdapter(); + const hasher = new DeterministicHasher(); + const manifestRepo = new InMemoryManifestRepository(); + const registry = new InMemoryMarketplaceRegistry(); + await seedCopilotPlugin(manifestRepo, registry); + + const useCase = new MarketplaceSyncSettingsUseCase( + fs, + manifestRepo, + registry, + hasher, + new CapturingLogger(), + new Map(), + fakeEnsureBuiltMarketplace() + ); + await useCase.execute({ projectRoot: PROJECT_ROOT }); + + const settingsPath = resolve(PROJECT_ROOT, ".github/copilot/settings.json"); + const settings = JSON.parse(await fs.readFile(settingsPath)) as Record; + + // VS Code reads this file to recommend plugins to teammates, so it carries names. + expect(settings.enabledPlugins).toBeDefined(); + + // No marketplace registration: that names the built tree by absolute path, which belongs + // to whoever ran the install; copilot learns its marketplaces from its own CLI instead. + expect(settings.extraKnownMarketplaces).toBeUndefined(); + expect(JSON.stringify(settings)).not.toContain("/built/copilot"); + }); + + it("drives the copilot CLI activator and still writes the settings file", async () => { + const fs = new InMemoryFileAdapter(); + await seedBuiltCatalog(fs); + const manifestRepo = new InMemoryManifestRepository(); + const registry = new InMemoryMarketplaceRegistry(); + const activator = new FakeNativePluginActivator({ available: true }); + await seedCopilotPlugin(manifestRepo, registry); + + const useCase = new MarketplaceSyncSettingsUseCase( + fs, + manifestRepo, + registry, + new DeterministicHasher(), + new CapturingLogger(), + new Map([["copilot", activator]]), + fakeEnsureBuiltMarketplace() + ); + await useCase.execute({ projectRoot: PROJECT_ROOT }); + + // Registers the BUILT copilot tree (not the raw github source). A fresh add + // succeeds outright — no pre-emptive remove. + expect(activator.removedMarketplaces).toEqual([]); + expect(activator.addedMarketplaces).toEqual(["/built/copilot"]); + expect(activator.enabledPlugins).toEqual([`aidd-context@${MARKETPLACE_NAME}`]); + expect(await fs.fileExists(resolve(PROJECT_ROOT, ".github/copilot/settings.json"))).toBe(true); + }); + + it("takes the name back when whoever held it is gone", async () => { + const fs = new InMemoryFileAdapter(); + await seedBuiltCatalog(fs); + const manifestRepo = new InMemoryManifestRepository(); + const registry = new InMemoryMarketplaceRegistry(); + // The name is held, and the tool reports its source no longer resolves: nobody + // alive is behind it, so taking it back breaks nothing. + const activator = new FakeNativePluginActivator({ + available: true, + conflictOnAdd: true, + registrationState: "dead", + }); + await seedCopilotPlugin(manifestRepo, registry); + + const useCase = new MarketplaceSyncSettingsUseCase( + fs, + manifestRepo, + registry, + new DeterministicHasher(), + new CapturingLogger(), + new Map([["copilot", activator]]), + fakeEnsureBuiltMarketplace() + ); + await useCase.execute({ projectRoot: PROJECT_ROOT }); + + expect(activator.removedMarketplaces).toEqual([MARKETPLACE_NAME]); + // Forced, because a marketplace with plugins installed refuses a plain removal. + expect(activator.forcedRemovals).toEqual([true]); + expect(activator.addedMarketplaces).toEqual(["/built/copilot"]); + expect(activator.enabledPlugins).toEqual([`aidd-context@${MARKETPLACE_NAME}`]); + }); + + it("leaves a name alone while it still resolves, whoever holds it", async () => { + const fs = new InMemoryFileAdapter(); + await seedBuiltCatalog(fs); + const manifestRepo = new InMemoryManifestRepository(); + const registry = new InMemoryMarketplaceRegistry(); + // Held, and the source resolves: another project is alive behind it. Taking the + // name would break that project, and both would then steal it back on every sync. + const activator = new FakeNativePluginActivator({ + available: true, + conflictOnAdd: true, + registrationState: "live", + }); + const logger = new CapturingLogger(); + await seedCopilotPlugin(manifestRepo, registry); + + await new MarketplaceSyncSettingsUseCase( + fs, + manifestRepo, + registry, + new DeterministicHasher(), + logger, + new Map([["copilot", activator]]), + fakeEnsureBuiltMarketplace() + ).execute({ projectRoot: PROJECT_ROOT }); + + expect(activator.removedMarketplaces).toEqual([]); + expect(logger.warnMessages.some((m) => m.includes("register marketplace"))).toBe(true); + }); + + it("says nothing about taking a name back when it cannot tell who holds it", async () => { + const fs = new InMemoryFileAdapter(); + await seedBuiltCatalog(fs); + const manifestRepo = new InMemoryManifestRepository(); + const registry = new InMemoryMarketplaceRegistry(); + // The tool offers no way to tell a dead registration from a live one, which must + // read as "leave it alone" rather than as permission. + const activator = new FakeNativePluginActivator({ + available: true, + conflictOnAdd: true, + registrationState: "unknown", + }); + const logger = new CapturingLogger(); + await seedCopilotPlugin(manifestRepo, registry); + + const useCase = new MarketplaceSyncSettingsUseCase( + fs, + manifestRepo, + registry, + new DeterministicHasher(), + logger, + new Map([["copilot", activator]]), + fakeEnsureBuiltMarketplace() + ); + await useCase.execute({ projectRoot: PROJECT_ROOT }); + + expect(activator.removedMarketplaces).toEqual([]); + expect(logger.warnMessages.some((m) => m.includes("no longer exists"))).toBe(false); + // The failure itself is still surfaced, not swallowed. + expect(logger.warnMessages.some((m) => m.includes("register marketplace"))).toBe(true); + }); +}); diff --git a/cli/tests/application/use-cases/plugin/translator/install-plugin-cursor-hooks-mcp.integration.test.ts b/cli/tests/contexts/framework/application/framework/translator/install-plugin-cursor-hooks-mcp.integration.test.ts similarity index 81% rename from cli/tests/application/use-cases/plugin/translator/install-plugin-cursor-hooks-mcp.integration.test.ts rename to cli/tests/contexts/framework/application/framework/translator/install-plugin-cursor-hooks-mcp.integration.test.ts index f296428f9..931cd67a8 100644 --- a/cli/tests/application/use-cases/plugin/translator/install-plugin-cursor-hooks-mcp.integration.test.ts +++ b/cli/tests/contexts/framework/application/framework/translator/install-plugin-cursor-hooks-mcp.integration.test.ts @@ -1,25 +1,13 @@ -/** - * Phase 6 — Cursor flat (native user-scope) hooks + mcp parity. - * Plugin-scope hooks were measured to never fire (three probes, see - * aidd_docs/tasks/2026_08/2026_08_22_telemetry-every-tool/measurements.md, Phase 4), - * so hooksDestination:"project" in cursor.ts now routes hooks/hooks.json to the - * project's own .cursor/hooks.json instead — the destination measured to fire. - * Asserts that: - * - hooks/hooks.json is merged into the project's .cursor/hooks.json (camelCase - * events, ${CLAUDE_PLUGIN_ROOT}/ → ./.cursor/hooks//) - * - .mcp.json is still passed through as mcp.json at the plugin root, unchanged - * - hooks.json is NOT tracked in Plugin.files (it isn't under the plugin's own - * baseDir, so `plugin remove`'s baseDir-relative deletePluginFiles must not try) - * - No skip warnings are emitted - */ -import "../../../../../src/domain/tools/ai/cursor.js"; +// Plugin-scope Cursor hooks were measured never to fire, so `hooksDestination: "project"` routes +// hooks/hooks.json into the project's own .cursor/hooks.json — the destination that does fire. +import "../../../../../../src/contexts/tools/domain/profiles/cursor/profile.js"; import { join } from "node:path"; import { describe, expect, it } from "vitest"; -import { ModeBFlatMaterializationTranslator } from "../../../../../src/application/use-cases/plugin/translator/mode-b-flat-materialization-translator.js"; -import { Manifest } from "../../../../../src/domain/models/manifest.js"; -import { PluginDistribution } from "../../../../../src/domain/models/plugin-distribution.js"; -import { DeterministicHasher } from "../../../../helpers/ports/deterministic-hasher.js"; -import { InMemoryFileAdapter } from "../../../../helpers/ports/in-memory-file-adapter.js"; +import { ModeBFlatMaterializationTranslator } from "../../../../../../src/contexts/framework/application/framework/translator/mode-b-flat-materialization-translator.js"; +import { Manifest } from "../../../../../../src/contexts/framework/domain/manifest.js"; +import { PluginDistribution } from "../../../../../../src/contexts/translate/domain/plugin-distribution.js"; +import { DeterministicHasher } from "../../../../../helpers/ports/deterministic-hasher.js"; +import { InMemoryFileAdapter } from "../../../../../helpers/ports/in-memory-file-adapter.js"; const STUB_HOME = "/tmp/test-home"; const PROJECT_ROOT = "/test-project"; @@ -107,8 +95,7 @@ describe("install cursor plugin with hooks and mcp (Phase 6)", () => { { kind: "local", path: "/plugin-source" }, PROJECT_ROOT, manifest, - undefined, - "docs" + undefined ); const hooksPath = join(PROJECT_ROOT, ".cursor", "hooks.json"); @@ -136,8 +123,7 @@ describe("install cursor plugin with hooks and mcp (Phase 6)", () => { { kind: "local", path: "/plugin-source" }, PROJECT_ROOT, manifest, - undefined, - "docs" + undefined ); const hooksPath = join(PROJECT_ROOT, ".cursor", "hooks.json"); @@ -160,8 +146,7 @@ describe("install cursor plugin with hooks and mcp (Phase 6)", () => { { kind: "local", path: "/plugin-source" }, PROJECT_ROOT, manifest, - undefined, - "docs" + undefined ); const mcpPath = join(EXPECTED_BASE, PLUGIN_NAME, "mcp.json"); @@ -185,8 +170,7 @@ describe("install cursor plugin with hooks and mcp (Phase 6)", () => { { kind: "local", path: "/plugin-source" }, PROJECT_ROOT, manifest, - undefined, - "docs" + undefined ); const plugins = manifest.getPlugins("cursor"); @@ -210,8 +194,7 @@ describe("install cursor plugin with hooks and mcp (Phase 6)", () => { { kind: "local", path: "/plugin-source" }, PROJECT_ROOT, manifest, - undefined, - "docs" + undefined ); expect(skipped).toHaveLength(0); diff --git a/cli/tests/contexts/framework/application/framework/translator/install-plugin-cursor-marketplace-hooks.integration.test.ts b/cli/tests/contexts/framework/application/framework/translator/install-plugin-cursor-marketplace-hooks.integration.test.ts new file mode 100644 index 000000000..4aca98011 --- /dev/null +++ b/cli/tests/contexts/framework/application/framework/translator/install-plugin-cursor-marketplace-hooks.integration.test.ts @@ -0,0 +1,196 @@ +/** A marketplace-sourced Cursor install must deliver hooks where a local-source one does: + * a built tree's plugin-scoped `hooks/hooks.json` lands in a directory Cursor never reads. */ +import "../../../../../../src/contexts/tools/domain/profiles/cursor/profile.js"; +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; +import { Marketplace } from "../../../../../../src/contexts/distribution/domain/marketplace.js"; +import { BuiltTreeMaterializationTranslator } from "../../../../../../src/contexts/framework/application/framework/translator/built-tree-materialization-translator.js"; +import { ModeBFlatMaterializationTranslator } from "../../../../../../src/contexts/framework/application/framework/translator/mode-b-flat-materialization-translator.js"; +import type { PluginTranslator } from "../../../../../../src/contexts/framework/application/framework/translator/plugin-translator.js"; +import { Manifest } from "../../../../../../src/contexts/framework/domain/manifest.js"; +import { getToolConfig, isAiTool } from "../../../../../../src/contexts/tools/domain/registry.js"; +import { PluginDistribution } from "../../../../../../src/contexts/translate/domain/plugin-distribution.js"; +import type { AiToolId } from "../../../../../../src/kernel/tool.js"; +import { DeterministicHasher } from "../../../../../helpers/ports/deterministic-hasher.js"; +import { fakeEnsureBuiltMarketplace } from "../../../../../helpers/ports/fake-ensure-built-marketplace.js"; +import { InMemoryFileAdapter } from "../../../../../helpers/ports/in-memory-file-adapter.js"; +import { InMemoryMarketplaceRegistry } from "../../../../../helpers/ports/in-memory-marketplace-registry.js"; + +const PROJECT_ROOT = "/proj"; +const HOME = "/home/u"; +const BUILT = "/built/cursor"; +const PLUGIN_NAME = "sample-plugin"; + +// biome-ignore lint/suspicious/noTemplateCurlyInString: intentionally testing Claude hook placeholder substitution +const PLUGIN_ROOT_VAR = "${CLAUDE_PLUGIN_ROOT}"; + +const HOOKS_CONTENT = JSON.stringify({ + hooks: { + PostToolUse: [ + { hooks: [{ type: "command", command: `node ${PLUGIN_ROOT_VAR}/hooks/post.js` }] }, + ], + }, +}); + +function dist(): PluginDistribution { + return new PluginDistribution({ + manifest: { name: PLUGIN_NAME, version: "1.0.0" }, + format: "claude", + files: [{ relativePath: "hooks/hooks.json", content: HOOKS_CONTENT }], + components: { + commands: [], + agents: [], + rules: [], + skills: [], + hooks: [ + { relativePath: "hooks/hooks.json", content: HOOKS_CONTENT }, + { relativePath: "hooks/post.js", content: "module.exports = () => {};" }, + ], + mcp: [], + }, + }); +} + +async function makeRegistry(): Promise { + const registry = new InMemoryMarketplaceRegistry(); + await registry.save( + PROJECT_ROOT, + Marketplace.create({ + name: "aidd-framework", + source: { kind: "local", path: "/src/framework" }, + scope: "project", + addedAt: "2026-01-01T00:00:00Z", + }) + ); + return registry; +} + +describe("BuiltTreeMaterializationTranslator — cursor marketplace hooks (Phase 7)", () => { + it("merges hooks into the project's .cursor/hooks.json, not the plugin-scoped built tree", async () => { + const fs = new InMemoryFileAdapter(); + fs.setFile(`${BUILT}/plugins/${PLUGIN_NAME}/hooks/hooks.json`, HOOKS_CONTENT); + fs.setFile(`${BUILT}/plugins/${PLUGIN_NAME}/hooks/post.js`, "module.exports = () => {};"); + const manifest = Manifest.create(); + manifest.addTool("cursor", "test", []); + const translator = new BuiltTreeMaterializationTranslator( + fs, + new DeterministicHasher(), + () => HOME, + fakeEnsureBuiltMarketplace(), + await makeRegistry() + ); + + const { skipped } = await translator.addPlugin( + dist(), + "cursor", + { kind: "local", path: "/plugin-source" }, + PROJECT_ROOT, + manifest, + "aidd-framework" + ); + + const hooksPath = join(PROJECT_ROOT, ".cursor", "hooks.json"); + expect(fs.has(hooksPath)).toBe(true); + const parsed = JSON.parse(fs.getFile(hooksPath) ?? "{}") as { hooks: Record }; + expect(parsed.hooks).toHaveProperty("postToolUse"); + expect(skipped).toEqual([]); + }); + + it("writes no hooks/ path under the plugin-scoped built-tree destination", async () => { + const fs = new InMemoryFileAdapter(); + fs.setFile(`${BUILT}/plugins/${PLUGIN_NAME}/hooks/hooks.json`, HOOKS_CONTENT); + fs.setFile(`${BUILT}/plugins/${PLUGIN_NAME}/hooks/post.js`, "module.exports = () => {};"); + const manifest = Manifest.create(); + manifest.addTool("cursor", "test", []); + const translator = new BuiltTreeMaterializationTranslator( + fs, + new DeterministicHasher(), + () => HOME, + fakeEnsureBuiltMarketplace(), + await makeRegistry() + ); + + await translator.addPlugin( + dist(), + "cursor", + { kind: "local", path: "/plugin-source" }, + PROJECT_ROOT, + manifest, + "aidd-framework" + ); + + const base = `${HOME}/.cursor/plugins/local/${PLUGIN_NAME}`; + const written = fs.listUnder(base); + expect(written.some((p) => p.includes("hooks"))).toBe(false); + }); +}); + +describe("Cursor's two install routes agree on hooks destination (Phase 7, Task 2)", () => { + async function installViaLocal(): Promise { + const fs = new InMemoryFileAdapter(); + const manifest = Manifest.create(); + manifest.addTool("cursor", "test", []); + const translator: PluginTranslator = new ModeBFlatMaterializationTranslator( + fs, + new DeterministicHasher(), + () => HOME + ); + await translator.addPlugin( + dist(), + "cursor", + { kind: "local", path: "/plugin-source" }, + PROJECT_ROOT, + manifest, + undefined + ); + return fs; + } + + async function installViaMarketplace(): Promise { + const fs = new InMemoryFileAdapter(); + fs.setFile(`${BUILT}/plugins/${PLUGIN_NAME}/hooks/hooks.json`, HOOKS_CONTENT); + fs.setFile(`${BUILT}/plugins/${PLUGIN_NAME}/hooks/post.js`, "module.exports = () => {};"); + const manifest = Manifest.create(); + manifest.addTool("cursor", "test", []); + const translator: PluginTranslator = new BuiltTreeMaterializationTranslator( + fs, + new DeterministicHasher(), + () => HOME, + fakeEnsureBuiltMarketplace(), + await makeRegistry() + ); + await translator.addPlugin( + dist(), + "cursor", + { kind: "local", path: "/plugin-source" }, + PROJECT_ROOT, + manifest, + "aidd-framework" + ); + return fs; + } + + // Read from cursor.ts's own declaration, never hard-coded: both translators regressing to + // plugin scope together would still pass a route-against-route comparison. + function declaredHooksDestination(toolId: AiToolId): "plugin" | "project" { + const toolConfig = getToolConfig(toolId); + if (!isAiTool(toolConfig)) throw new Error(`${toolId} is not an AI tool`); + const caps = toolConfig.capabilities as Record; + return (caps.plugins as { hooksDestination: "plugin" | "project" }).hooksDestination; + } + + it("both routes write to the destination cursor.ts declares — a plugin.hooksDestination change breaks this", async () => { + expect(declaredHooksDestination("cursor")).toBe("project"); + + const viaLocal = await installViaLocal(); + const viaMarketplace = await installViaMarketplace(); + + const projectHooksPath = join(PROJECT_ROOT, ".cursor", "hooks.json"); + expect(viaLocal.has(projectHooksPath)).toBe(true); + expect(viaMarketplace.has(projectHooksPath)).toBe(true); + + const pluginScopedBase = `${HOME}/.cursor/plugins/local/${PLUGIN_NAME}`; + expect(viaLocal.listUnder(pluginScopedBase).some((p) => p.includes("hooks"))).toBe(false); + expect(viaMarketplace.listUnder(pluginScopedBase).some((p) => p.includes("hooks"))).toBe(false); + }); +}); diff --git a/cli/tests/application/use-cases/plugin/translator/install-plugin-cursor-mode-b.integration.test.ts b/cli/tests/contexts/framework/application/framework/translator/install-plugin-cursor-mode-b.integration.test.ts similarity index 82% rename from cli/tests/application/use-cases/plugin/translator/install-plugin-cursor-mode-b.integration.test.ts rename to cli/tests/contexts/framework/application/framework/translator/install-plugin-cursor-mode-b.integration.test.ts index 14d656828..677d68622 100644 --- a/cli/tests/application/use-cases/plugin/translator/install-plugin-cursor-mode-b.integration.test.ts +++ b/cli/tests/contexts/framework/application/framework/translator/install-plugin-cursor-mode-b.integration.test.ts @@ -1,11 +1,11 @@ -import "../../../../../src/domain/tools/ai/cursor.js"; +import "../../../../../../src/contexts/tools/domain/profiles/cursor/profile.js"; import { join, posix } from "node:path"; import { describe, expect, it } from "vitest"; -import { ModeBFlatMaterializationTranslator } from "../../../../../src/application/use-cases/plugin/translator/mode-b-flat-materialization-translator.js"; -import { Manifest } from "../../../../../src/domain/models/manifest.js"; -import { PluginDistribution } from "../../../../../src/domain/models/plugin-distribution.js"; -import { DeterministicHasher } from "../../../../helpers/ports/deterministic-hasher.js"; -import { InMemoryFileAdapter } from "../../../../helpers/ports/in-memory-file-adapter.js"; +import { ModeBFlatMaterializationTranslator } from "../../../../../../src/contexts/framework/application/framework/translator/mode-b-flat-materialization-translator.js"; +import { Manifest } from "../../../../../../src/contexts/framework/domain/manifest.js"; +import { PluginDistribution } from "../../../../../../src/contexts/translate/domain/plugin-distribution.js"; +import { DeterministicHasher } from "../../../../../helpers/ports/deterministic-hasher.js"; +import { InMemoryFileAdapter } from "../../../../../helpers/ports/in-memory-file-adapter.js"; const STUB_HOME = "/tmp/test-home"; const PROJECT_ROOT = "/test-project"; @@ -44,13 +44,11 @@ describe("install cursor plugin via Mode B (integration)", () => { { kind: "local", path: "/plugin-source" }, PROJECT_ROOT, manifest, - undefined, - "docs" + undefined ); - // fs.listAll() returns the adapter's own "/"-normalised keys (in-memory-file-adapter.ts's - // `norm`) - a raw `.startsWith` needs the same convention, unlike `fs.has()` below which - // normalises internally. + // `fs.listAll()` returns the adapter's own "/"-normalised keys, so a raw `.startsWith` + // needs the same convention, unlike `fs.has()` below which normalises internally. const expectedBase = posix.join(STUB_HOME, ".cursor", "plugins", "local"); const writtenPaths = fs.listAll(); expect(writtenPaths.some((p) => p.startsWith(expectedBase))).toBe(true); @@ -69,8 +67,7 @@ describe("install cursor plugin via Mode B (integration)", () => { { kind: "local", path: "/plugin-source" }, PROJECT_ROOT, manifest, - undefined, - "docs" + undefined ); expect(fs.listAll().every((p) => !p.startsWith(PROJECT_ROOT))).toBe(true); @@ -89,8 +86,7 @@ describe("install cursor plugin via Mode B (integration)", () => { { kind: "local", path: "/plugin-source" }, PROJECT_ROOT, manifest, - undefined, - "docs" + undefined ); const plugins = manifest.getPlugins("cursor"); @@ -116,8 +112,7 @@ describe("install cursor plugin via Mode B (integration)", () => { { kind: "local", path: "/plugin-source" }, PROJECT_ROOT, manifest, - undefined, - "docs" + undefined ); const plugins = manifest.getPlugins("cursor"); diff --git a/cli/tests/application/use-cases/plugin/translator/install-plugin-opencode-mcp.integration.test.ts b/cli/tests/contexts/framework/application/framework/translator/install-plugin-opencode-mcp.integration.test.ts similarity index 86% rename from cli/tests/application/use-cases/plugin/translator/install-plugin-opencode-mcp.integration.test.ts rename to cli/tests/contexts/framework/application/framework/translator/install-plugin-opencode-mcp.integration.test.ts index 8fce916de..ce78378c7 100644 --- a/cli/tests/application/use-cases/plugin/translator/install-plugin-opencode-mcp.integration.test.ts +++ b/cli/tests/contexts/framework/application/framework/translator/install-plugin-opencode-mcp.integration.test.ts @@ -1,22 +1,12 @@ -/** - * Phase 4b — OpenCode MCP merge integration. - * Asserts that installing a plugin with .mcp.json against OpenCode: - * - merges servers into opencode.json under the mcp section - * - preserves disabled state (enabled: false) from source - * - populates Plugin.mcpEntries in the manifest - * - does not affect Claude (Mode A) installs - * - is idempotent: a second add with same version produces byte-equal opencode.json - * - replace path: v1→v2 drops orphaned servers, adds new ones - */ -import "../../../../../src/domain/tools/ai/opencode.js"; -import "../../../../../src/domain/tools/ai/claude.js"; +import "../../../../../../src/contexts/tools/domain/profiles/opencode/profile.js"; +import "../../../../../../src/contexts/tools/domain/profiles/claude/profile.js"; import { join } from "node:path"; import { describe, expect, it } from "vitest"; -import { ModeBFlatMaterializationTranslator } from "../../../../../src/application/use-cases/plugin/translator/mode-b-flat-materialization-translator.js"; -import { Manifest } from "../../../../../src/domain/models/manifest.js"; -import { PluginDistribution } from "../../../../../src/domain/models/plugin-distribution.js"; -import { DeterministicHasher } from "../../../../helpers/ports/deterministic-hasher.js"; -import { InMemoryFileAdapter } from "../../../../helpers/ports/in-memory-file-adapter.js"; +import { ModeBFlatMaterializationTranslator } from "../../../../../../src/contexts/framework/application/framework/translator/mode-b-flat-materialization-translator.js"; +import { Manifest } from "../../../../../../src/contexts/framework/domain/manifest.js"; +import { PluginDistribution } from "../../../../../../src/contexts/translate/domain/plugin-distribution.js"; +import { DeterministicHasher } from "../../../../../helpers/ports/deterministic-hasher.js"; +import { InMemoryFileAdapter } from "../../../../../helpers/ports/in-memory-file-adapter.js"; const PROJECT_ROOT = "/test-project"; const STUB_HOME = "/tmp/test-home"; @@ -69,8 +59,7 @@ describe("install opencode plugin with MCP (Phase 4b integration)", () => { { kind: "local", path: "/plugin-source" }, PROJECT_ROOT, manifest, - undefined, - "docs" + undefined ); expect(fs.has(OPENCODE_JSON)).toBe(true); @@ -92,8 +81,7 @@ describe("install opencode plugin with MCP (Phase 4b integration)", () => { { kind: "local", path: "/plugin-source" }, PROJECT_ROOT, manifest, - undefined, - "docs" + undefined ); const parsed = JSON.parse(await fs.readFile(OPENCODE_JSON)) as { @@ -115,8 +103,7 @@ describe("install opencode plugin with MCP (Phase 4b integration)", () => { { kind: "local", path: "/plugin-source" }, PROJECT_ROOT, manifest, - undefined, - "docs" + undefined ); const installed = manifest.getPlugins("opencode").find((p) => p.name === PLUGIN_NAME); @@ -138,14 +125,13 @@ describe("install opencode plugin with MCP (Phase 4b integration)", () => { { kind: "local", path: "/plugin-source" }, PROJECT_ROOT, manifest, - undefined, - "docs" + undefined ); const firstContent = await fs.readFile(OPENCODE_JSON); const firstPlugin = manifest.getPlugins("opencode").find((p) => p.name === PLUGIN_NAME); const firstMcpEntries = firstPlugin?.mcpEntries ?? new Map(); - // Simulate re-install (replace=true path: previous removed, then re-added) + // The replace path: the previous install is removed, then re-added. manifest.removePlugin("opencode", PLUGIN_NAME); await adapter.addPlugin( buildDist(), @@ -154,7 +140,6 @@ describe("install opencode plugin with MCP (Phase 4b integration)", () => { PROJECT_ROOT, manifest, undefined, - "docs", firstMcpEntries ); const secondContent = await fs.readFile(OPENCODE_JSON); @@ -179,8 +164,7 @@ describe("install opencode plugin with MCP (Phase 4b integration)", () => { { kind: "local", path: "/plugin-source" }, PROJECT_ROOT, manifest, - undefined, - "docs" + undefined ); const v1Plugin = manifest.getPlugins("opencode").find((p) => p.name === PLUGIN_NAME); @@ -200,7 +184,6 @@ describe("install opencode plugin with MCP (Phase 4b integration)", () => { PROJECT_ROOT, manifest, undefined, - "docs", v1McpEntries ); @@ -231,8 +214,7 @@ describe("install opencode plugin with MCP (Phase 4b integration)", () => { { kind: "local", path: "/plugin-source" }, PROJECT_ROOT, manifest, - undefined, - "docs" + undefined ); const parsed = JSON.parse(await fs.readFile(OPENCODE_JSON)) as { @@ -254,8 +236,7 @@ describe("install opencode plugin with MCP (Phase 4b integration)", () => { { kind: "local", path: "/plugin-source" }, PROJECT_ROOT, manifest, - undefined, - "docs" + undefined ); expect(fs.has(OPENCODE_JSON)).toBe(false); @@ -279,8 +260,7 @@ describe("install opencode plugin with MCP (Phase 4b integration)", () => { { kind: "local", path: "/plugin-source" }, PROJECT_ROOT, manifest, - undefined, - "docs" + undefined ); const parsed = JSON.parse(await fs.readFile(OPENCODE_JSON)) as { @@ -305,8 +285,7 @@ describe("install opencode plugin with MCP (Phase 4b integration)", () => { { kind: "local", path: "/plugin-source" }, PROJECT_ROOT, manifest, - undefined, - "docs" + undefined ); // "local-tool" is user-owned — must be skipped, not overwritten @@ -315,16 +294,13 @@ describe("install opencode plugin with MCP (Phase 4b integration)", () => { expect(mcpSkip?.reason).toContain("local-tool"); expect(mcpSkip?.pluginName).toBe(PLUGIN_NAME); - // User server must remain untouched const parsed = JSON.parse(await fs.readFile(OPENCODE_JSON)) as { mcp: Record; }; expect(parsed.mcp["local-tool"]).toEqual(userServer); - // Plugin must NOT claim local-tool in mcpEntries const installed = manifest.getPlugins("opencode").find((p) => p.name === PLUGIN_NAME); expect(installed?.mcpEntries.has("local-tool")).toBe(false); - // Non-colliding servers are still installed expect(installed?.mcpEntries.has("remote-tool")).toBe(true); }); }); diff --git a/cli/tests/application/use-cases/plugin/translator/install-plugin-opencode-mode-b.integration.test.ts b/cli/tests/contexts/framework/application/framework/translator/install-plugin-opencode-mode-b.integration.test.ts similarity index 78% rename from cli/tests/application/use-cases/plugin/translator/install-plugin-opencode-mode-b.integration.test.ts rename to cli/tests/contexts/framework/application/framework/translator/install-plugin-opencode-mode-b.integration.test.ts index d6e82a36b..f89233b76 100644 --- a/cli/tests/application/use-cases/plugin/translator/install-plugin-opencode-mode-b.integration.test.ts +++ b/cli/tests/contexts/framework/application/framework/translator/install-plugin-opencode-mode-b.integration.test.ts @@ -1,13 +1,12 @@ -// OpenCode uses Mode B with `mode: "flat"` and project scope. The translator routes through -// `translateFlat` which writes files at `.opencode/
//` under projectRoot -// (not under a single `.opencode/plugins//` root — that shape is exclusive to native mode). -import "../../../../../src/domain/tools/ai/opencode.js"; +// OpenCode uses Mode B with `mode: "flat"` and project scope, so `translateFlat` writes at +// `.opencode/
//`, never under one `.opencode/plugins//` root. +import "../../../../../../src/contexts/tools/domain/profiles/opencode/profile.js"; import { describe, expect, it } from "vitest"; -import { ModeBFlatMaterializationTranslator } from "../../../../../src/application/use-cases/plugin/translator/mode-b-flat-materialization-translator.js"; -import { Manifest } from "../../../../../src/domain/models/manifest.js"; -import { PluginDistribution } from "../../../../../src/domain/models/plugin-distribution.js"; -import { DeterministicHasher } from "../../../../helpers/ports/deterministic-hasher.js"; -import { InMemoryFileAdapter } from "../../../../helpers/ports/in-memory-file-adapter.js"; +import { ModeBFlatMaterializationTranslator } from "../../../../../../src/contexts/framework/application/framework/translator/mode-b-flat-materialization-translator.js"; +import { Manifest } from "../../../../../../src/contexts/framework/domain/manifest.js"; +import { PluginDistribution } from "../../../../../../src/contexts/translate/domain/plugin-distribution.js"; +import { DeterministicHasher } from "../../../../../helpers/ports/deterministic-hasher.js"; +import { InMemoryFileAdapter } from "../../../../../helpers/ports/in-memory-file-adapter.js"; const PROJECT_ROOT = "/test-project"; const STUB_HOME = "/tmp/test-home"; @@ -53,8 +52,7 @@ describe("install opencode plugin via Mode B (integration)", () => { { kind: "local", path: "/plugin-source" }, PROJECT_ROOT, manifest, - undefined, - "docs" + undefined ); const written = fs.listAll(); @@ -79,8 +77,7 @@ describe("install opencode plugin via Mode B (integration)", () => { { kind: "local", path: "/plugin-source" }, PROJECT_ROOT, manifest, - undefined, - "docs" + undefined ); expect(fs.listAll().every((p) => !p.startsWith(STUB_HOME))).toBe(true); @@ -99,8 +96,7 @@ describe("install opencode plugin via Mode B (integration)", () => { { kind: "local", path: "/plugin-source" }, PROJECT_ROOT, manifest, - undefined, - "docs" + undefined ); const installed = manifest.getPlugins("opencode").find((p) => p.name === "aidd-context"); diff --git a/cli/tests/application/use-cases/plugin/translator/mode-a-marketplace-adapter.unit.test.ts b/cli/tests/contexts/framework/application/framework/translator/mode-a-marketplace-adapter.unit.test.ts similarity index 75% rename from cli/tests/application/use-cases/plugin/translator/mode-a-marketplace-adapter.unit.test.ts rename to cli/tests/contexts/framework/application/framework/translator/mode-a-marketplace-adapter.unit.test.ts index 15c481011..5bee02c23 100644 --- a/cli/tests/application/use-cases/plugin/translator/mode-a-marketplace-adapter.unit.test.ts +++ b/cli/tests/contexts/framework/application/framework/translator/mode-a-marketplace-adapter.unit.test.ts @@ -1,13 +1,11 @@ -// Note: idempotency (already-present marketplace) and empty-marketplace-list scenarios are -// NOT covered here. Those behaviors live on MarketplaceSyncSettingsUseCase, which owns the -// marketplace registration logic. ModeAMarketplaceTranslator is a thin translator adapter that -// only registers the plugin reference in the manifest with empty files. -import "../../../../../src/domain/tools/ai/claude.js"; +// Idempotency and the empty-marketplace-list case live on MarketplaceSyncSettingsUseCase; this +// translator only registers the plugin reference in the manifest, with empty files. +import "../../../../../../src/contexts/tools/domain/profiles/claude/profile.js"; import { describe, expect, it } from "vitest"; -import { ModeAMarketplaceTranslator } from "../../../../../src/application/use-cases/plugin/translator/mode-a-marketplace-translator.js"; -import { Manifest } from "../../../../../src/domain/models/manifest.js"; -import { PluginDistribution } from "../../../../../src/domain/models/plugin-distribution.js"; -import { InMemoryFileAdapter } from "../../../../helpers/ports/in-memory-file-adapter.js"; +import { ModeAMarketplaceTranslator } from "../../../../../../src/contexts/framework/application/framework/translator/mode-a-marketplace-translator.js"; +import { Manifest } from "../../../../../../src/contexts/framework/domain/manifest.js"; +import { PluginDistribution } from "../../../../../../src/contexts/translate/domain/plugin-distribution.js"; +import { InMemoryFileAdapter } from "../../../../../helpers/ports/in-memory-file-adapter.js"; function buildDist(name = "test-plugin"): PluginDistribution { return new PluginDistribution({ @@ -43,8 +41,7 @@ describe("ModeAMarketplaceTranslator", () => { { kind: "local", path: "/plugin-source" }, "/project", manifest, - "aidd-framework", - "docs" + "aidd-framework" ); const plugins = manifest.getPlugins("claude"); const installed = plugins.find((p) => p.name === "aidd-context"); @@ -66,8 +63,7 @@ describe("ModeAMarketplaceTranslator", () => { { kind: "local", path: "/plugin-source" }, "/project", manifest, - undefined, - "docs" + undefined ); const plugins = manifest.getPlugins("claude"); const installed = plugins.find((p) => p.name === "test-plugin"); @@ -89,8 +85,7 @@ describe("ModeAMarketplaceTranslator", () => { { kind: "local", path: "/plugin-source" }, "/project", manifest, - "aidd-framework", - "docs" + "aidd-framework" ); expect(fs.has("/project/.claude/plugins/test-plugin/commands/hello.md")).toBe(false); }); diff --git a/cli/tests/application/use-cases/plugin/translator/mode-b-flat-materialization-adapter.unit.test.ts b/cli/tests/contexts/framework/application/framework/translator/mode-b-flat-materialization-adapter.unit.test.ts similarity index 86% rename from cli/tests/application/use-cases/plugin/translator/mode-b-flat-materialization-adapter.unit.test.ts rename to cli/tests/contexts/framework/application/framework/translator/mode-b-flat-materialization-adapter.unit.test.ts index 5bca9751c..736f6eb3a 100644 --- a/cli/tests/application/use-cases/plugin/translator/mode-b-flat-materialization-adapter.unit.test.ts +++ b/cli/tests/contexts/framework/application/framework/translator/mode-b-flat-materialization-adapter.unit.test.ts @@ -1,13 +1,13 @@ -import "../../../../../src/domain/tools/ai/claude.js"; -import "../../../../../src/domain/tools/ai/opencode.js"; +import "../../../../../../src/contexts/tools/domain/profiles/claude/profile.js"; +import "../../../../../../src/contexts/tools/domain/profiles/opencode/profile.js"; import { join } from "node:path"; import { describe, expect, it } from "vitest"; -import { ModeBFlatMaterializationTranslator } from "../../../../../src/application/use-cases/plugin/translator/mode-b-flat-materialization-translator.js"; -import { CursorProjectScopeUnsupportedError } from "../../../../../src/domain/errors.js"; -import { Manifest } from "../../../../../src/domain/models/manifest.js"; -import { PluginDistribution } from "../../../../../src/domain/models/plugin-distribution.js"; -import { DeterministicHasher } from "../../../../helpers/ports/deterministic-hasher.js"; -import { InMemoryFileAdapter } from "../../../../helpers/ports/in-memory-file-adapter.js"; +import { ModeBFlatMaterializationTranslator } from "../../../../../../src/contexts/framework/application/framework/translator/mode-b-flat-materialization-translator.js"; +import { Manifest } from "../../../../../../src/contexts/framework/domain/manifest.js"; +import { PluginDistribution } from "../../../../../../src/contexts/translate/domain/plugin-distribution.js"; +import { CursorProjectScopeUnsupportedError } from "../../../../../../src/kernel/errors.js"; +import { DeterministicHasher } from "../../../../../helpers/ports/deterministic-hasher.js"; +import { InMemoryFileAdapter } from "../../../../../helpers/ports/in-memory-file-adapter.js"; const PROJECT_ROOT = "/test-project"; @@ -57,8 +57,7 @@ describe("ModeBFlatMaterializationTranslator", () => { { kind: "local", path: "/plugin-source" }, PROJECT_ROOT, manifest, - undefined, - "docs" + undefined ); const expectedPath = join(PROJECT_ROOT, ".opencode/commands/test-plugin/hello.md"); expect(fs.has(expectedPath)).toBe(true); @@ -75,8 +74,7 @@ describe("ModeBFlatMaterializationTranslator", () => { { kind: "local", path: "/plugin-source" }, PROJECT_ROOT, manifest, - undefined, - "docs" + undefined ); const plugins = manifest.getPlugins("opencode"); const installed = plugins.find((p) => p.name === "test-plugin"); @@ -102,8 +100,7 @@ describe("ModeBFlatMaterializationTranslator", () => { { kind: "local", path: "/plugin-source" }, PROJECT_ROOT, manifest, - undefined, - "docs" + undefined ); expect(fs.listAll().length).toBe(0); const plugins = manifest.getPlugins("opencode"); @@ -124,8 +121,7 @@ describe("ModeBFlatMaterializationTranslator", () => { { kind: "local", path: "/plugin-source" }, PROJECT_ROOT, manifest, - undefined, - "docs" + undefined ) ).rejects.toThrow(CursorProjectScopeUnsupportedError); }); @@ -156,8 +152,7 @@ describe("ModeBFlatMaterializationTranslator", () => { { kind: "local", path: "/plugin-source" }, PROJECT_ROOT, manifest, - undefined, - "docs" + undefined ); expect(fs.listAll().length).toBe(0); const plugins = manifest.getPlugins("opencode"); diff --git a/cli/tests/application/use-cases/plugin/translator/plugin-translation-adapter-factory.unit.test.ts b/cli/tests/contexts/framework/application/framework/translator/plugin-translation-adapter-factory.unit.test.ts similarity index 76% rename from cli/tests/application/use-cases/plugin/translator/plugin-translation-adapter-factory.unit.test.ts rename to cli/tests/contexts/framework/application/framework/translator/plugin-translation-adapter-factory.unit.test.ts index 67ec5f379..6516c8b5d 100644 --- a/cli/tests/application/use-cases/plugin/translator/plugin-translation-adapter-factory.unit.test.ts +++ b/cli/tests/contexts/framework/application/framework/translator/plugin-translation-adapter-factory.unit.test.ts @@ -1,12 +1,12 @@ import { describe, expect, it } from "vitest"; -import { BuiltTreeMaterializationTranslator } from "../../../../../src/application/use-cases/plugin/translator/built-tree-materialization-translator.js"; -import { ModeAMarketplaceTranslator } from "../../../../../src/application/use-cases/plugin/translator/mode-a-marketplace-translator.js"; -import { resolveTranslator } from "../../../../../src/application/use-cases/plugin/translator/plugin-translator-factory.js"; -import { PluginsCapability } from "../../../../../src/domain/capabilities/plugins-capability.js"; -import { DeterministicHasher } from "../../../../helpers/ports/deterministic-hasher.js"; -import { fakeEnsureBuiltMarketplace } from "../../../../helpers/ports/fake-ensure-built-marketplace.js"; -import { InMemoryFileAdapter } from "../../../../helpers/ports/in-memory-file-adapter.js"; -import { InMemoryMarketplaceRegistry } from "../../../../helpers/ports/in-memory-marketplace-registry.js"; +import { BuiltTreeMaterializationTranslator } from "../../../../../../src/contexts/framework/application/framework/translator/built-tree-materialization-translator.js"; +import { ModeAMarketplaceTranslator } from "../../../../../../src/contexts/framework/application/framework/translator/mode-a-marketplace-translator.js"; +import { resolveTranslator } from "../../../../../../src/contexts/framework/application/framework/translator/plugin-translator-factory.js"; +import { PluginsCapability } from "../../../../../../src/contexts/tools/domain/capabilities/plugins-capability.js"; +import { DeterministicHasher } from "../../../../../helpers/ports/deterministic-hasher.js"; +import { fakeEnsureBuiltMarketplace } from "../../../../../helpers/ports/fake-ensure-built-marketplace.js"; +import { InMemoryFileAdapter } from "../../../../../helpers/ports/in-memory-file-adapter.js"; +import { InMemoryMarketplaceRegistry } from "../../../../../helpers/ports/in-memory-marketplace-registry.js"; function buildDeps(homedir = "/stub-home") { const fs = new InMemoryFileAdapter(); @@ -23,7 +23,8 @@ function buildDeps(homedir = "/stub-home") { const MARKETPLACE_SETTINGS = { settingsPath: ".claude/settings.json", settingsKey: "extraKnownMarketplaces", - toEntry: () => null, + toEntryKey: () => null, + marketplacesSettingsPath: null, }; describe("resolveTranslator", () => { diff --git a/cli/tests/contexts/framework/application/framework/translator/remove-plugin-cursor-hooks-mcp.integration.test.ts b/cli/tests/contexts/framework/application/framework/translator/remove-plugin-cursor-hooks-mcp.integration.test.ts new file mode 100644 index 000000000..f1ce48bcd --- /dev/null +++ b/cli/tests/contexts/framework/application/framework/translator/remove-plugin-cursor-hooks-mcp.integration.test.ts @@ -0,0 +1,157 @@ +/** + * hooks.json is not tracked in Plugin.files: hooksDestination:"project" merges its entries + * into the project's own .cursor/hooks.json, which baseDir-relative deletion cannot find. + */ +import "../../../../../../src/contexts/tools/domain/profiles/cursor/profile.js"; +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; +import { ModeBFlatMaterializationTranslator } from "../../../../../../src/contexts/framework/application/framework/translator/mode-b-flat-materialization-translator.js"; +import { PluginRemoveUseCase } from "../../../../../../src/contexts/framework/application/plugin/plugin-remove-use-case.js"; +import { Manifest } from "../../../../../../src/contexts/framework/domain/manifest.js"; +import { PluginDistribution } from "../../../../../../src/contexts/translate/domain/plugin-distribution.js"; +import { CLIOutput } from "../../../../../../src/presentation/output.js"; +import { DeterministicHasher } from "../../../../../helpers/ports/deterministic-hasher.js"; +import { InMemoryFileAdapter } from "../../../../../helpers/ports/in-memory-file-adapter.js"; +import { InMemoryManifestRepository } from "../../../../../helpers/ports/in-memory-manifest-repository.js"; + +const STUB_HOME = "/tmp/test-home"; +const PROJECT_ROOT = "/test-project"; +const PLUGIN_NAME = "aidd-context"; +const OTHER_PLUGIN_NAME = "aidd-context-two"; +const RESOLVED_BASE = join(STUB_HOME, ".cursor", "plugins", "local"); +const HOOKS_PATH = join(PROJECT_ROOT, ".cursor", "hooks.json"); +const SCRIPT_PATH = join(PROJECT_ROOT, ".cursor", "hooks", PLUGIN_NAME, "pre.js"); +const OTHER_SCRIPT_PATH = join(PROJECT_ROOT, ".cursor", "hooks", OTHER_PLUGIN_NAME, "pre.js"); + +// biome-ignore lint/suspicious/noTemplateCurlyInString: intentionally testing Claude hook placeholder substitution +const PLUGIN_ROOT_VAR = "${CLAUDE_PLUGIN_ROOT}"; + +const HOOKS_CONTENT = JSON.stringify({ + hooks: { + PreToolUse: [ + { + hooks: [{ type: "command", command: `node ${PLUGIN_ROOT_VAR}/hooks/pre.js` }], + }, + ], + }, +}); + +const MCP_CONTENT = JSON.stringify({ + mcpServers: { + "local-tool": { command: "node", args: ["./mcp-server.js"] }, + }, +}); + +function buildDist(name: string): PluginDistribution { + return new PluginDistribution({ + manifest: { name, version: "1.0.0" }, + format: "claude", + files: [ + { relativePath: "hooks/hooks.json", content: HOOKS_CONTENT }, + { relativePath: "hooks/pre.js", content: "module.exports = () => {};" }, + { relativePath: ".mcp.json", content: MCP_CONTENT }, + ], + components: { + commands: [], + agents: [], + rules: [], + skills: [], + hooks: [ + { relativePath: "hooks/hooks.json", content: HOOKS_CONTENT }, + { relativePath: "hooks/pre.js", content: "module.exports = () => {};" }, + ], + mcp: [{ relativePath: ".mcp.json", content: MCP_CONTENT }], + }, + }); +} + +async function installPlugin(fs: InMemoryFileAdapter, manifest: Manifest, name: string) { + const adapter = new ModeBFlatMaterializationTranslator( + fs, + new DeterministicHasher(), + () => STUB_HOME + ); + await adapter.addPlugin( + buildDist(name), + "cursor", + { kind: "local", path: "/plugin-source" }, + PROJECT_ROOT, + manifest, + undefined + ); +} + +describe("Cursor plugin.files tracking enables uninstall of mcp.json; hooks.json is out-of-band (Phase 6)", () => { + it("Plugin.files keys join to the exact written absolute paths (uninstall can find the files)", async () => { + const fs = new InMemoryFileAdapter(); + const manifest = Manifest.create(); + manifest.addTool("cursor", "test", []); + + await installPlugin(fs, manifest, PLUGIN_NAME); + + const plugins = manifest.getPlugins("cursor"); + const installed = plugins.find((p) => p.name === PLUGIN_NAME); + expect(installed).toBeDefined(); + const keys = [...(installed?.files.keys() ?? [])]; + expect(keys.some((k) => k.endsWith("hooks.json"))).toBe(false); + expect(keys.some((k) => k.endsWith("mcp.json"))).toBe(true); + // Every tracked key, when joined with resolvedBase, must match a written file + for (const key of keys) { + const absPath = join(RESOLVED_BASE, key); + expect(fs.has(absPath)).toBe(true); + } + // hooks.json was still written - just not tracked in Plugin.files, and not here + expect(fs.has(HOOKS_PATH)).toBe(true); + }); +}); + +describe("plugin remove unmerges Cursor project hooks (Phase 7, Task 3)", () => { + it("removes what an install merged and copied, leaving every other plugin's entries untouched", async () => { + const fs = new InMemoryFileAdapter(); + const manifest = Manifest.create(); + manifest.addTool("cursor", "test", []); + await installPlugin(fs, manifest, PLUGIN_NAME); + await installPlugin(fs, manifest, OTHER_PLUGIN_NAME); + const manifestRepo = new InMemoryManifestRepository(manifest); + await manifestRepo.save(manifest); + expect(fs.has(SCRIPT_PATH)).toBe(true); + expect(fs.has(OTHER_SCRIPT_PATH)).toBe(true); + + const removeUseCase = new PluginRemoveUseCase( + fs, + manifestRepo, + new CLIOutput(false), + new Map() + ); + await removeUseCase.execute({ + pluginName: PLUGIN_NAME, + toolIds: ["cursor"], + projectRoot: PROJECT_ROOT, + }); + + const parsed = JSON.parse(await fs.readFile(HOOKS_PATH)) as { + hooks: Record>; + }; + const commands = parsed.hooks.preToolUse.map((e) => e.command); + expect(commands.some((c) => c.includes(`/${PLUGIN_NAME}/`))).toBe(false); + expect(commands.some((c) => c.includes(`/${OTHER_PLUGIN_NAME}/`))).toBe(true); + expect(fs.has(SCRIPT_PATH)).toBe(false); + expect(fs.has(OTHER_SCRIPT_PATH)).toBe(true); + }); + + it("installing the same plugin twice leaves one copy in .cursor/hooks.json", async () => { + // Mirrors `aidd plugin install --replace`: the manifest entry is dropped before re-adding, + // but the .cursor/hooks.json this plugin already merged into is untouched by that drop. + const fs = new InMemoryFileAdapter(); + const manifest = Manifest.create(); + manifest.addTool("cursor", "test", []); + await installPlugin(fs, manifest, PLUGIN_NAME); + manifest.removePlugin("cursor", PLUGIN_NAME); + await installPlugin(fs, manifest, PLUGIN_NAME); + + const parsed = JSON.parse(await fs.readFile(HOOKS_PATH)) as { + hooks: Record>; + }; + expect(parsed.hooks.preToolUse).toHaveLength(1); + }); +}); diff --git a/cli/tests/application/use-cases/plugin/translator/remove-plugin-opencode-mcp.integration.test.ts b/cli/tests/contexts/framework/application/framework/translator/remove-plugin-opencode-mcp.integration.test.ts similarity index 78% rename from cli/tests/application/use-cases/plugin/translator/remove-plugin-opencode-mcp.integration.test.ts rename to cli/tests/contexts/framework/application/framework/translator/remove-plugin-opencode-mcp.integration.test.ts index bfb55bfe9..da426f411 100644 --- a/cli/tests/application/use-cases/plugin/translator/remove-plugin-opencode-mcp.integration.test.ts +++ b/cli/tests/contexts/framework/application/framework/translator/remove-plugin-opencode-mcp.integration.test.ts @@ -1,21 +1,14 @@ -/** - * Phase 5 — OpenCode plugin remove: unmerge MCP entries. - * Asserts that removing a plugin installed with mcpEntries: - * - strips only plugin-contributed servers from opencode.json - * - preserves user-added servers - * - removes the plugin from the manifest - */ -import "../../../../../src/domain/tools/ai/opencode.js"; +import "../../../../../../src/contexts/tools/domain/profiles/opencode/profile.js"; import { join } from "node:path"; import { describe, expect, it } from "vitest"; -import { CLIOutput } from "../../../../../src/application/output.js"; -import { PluginRemoveUseCase } from "../../../../../src/application/use-cases/plugin/plugin-remove-use-case.js"; -import { ModeBFlatMaterializationTranslator } from "../../../../../src/application/use-cases/plugin/translator/mode-b-flat-materialization-translator.js"; -import { Manifest } from "../../../../../src/domain/models/manifest.js"; -import { PluginDistribution } from "../../../../../src/domain/models/plugin-distribution.js"; -import { DeterministicHasher } from "../../../../helpers/ports/deterministic-hasher.js"; -import { InMemoryFileAdapter } from "../../../../helpers/ports/in-memory-file-adapter.js"; -import { InMemoryManifestRepository } from "../../../../helpers/ports/in-memory-manifest-repository.js"; +import { ModeBFlatMaterializationTranslator } from "../../../../../../src/contexts/framework/application/framework/translator/mode-b-flat-materialization-translator.js"; +import { PluginRemoveUseCase } from "../../../../../../src/contexts/framework/application/plugin/plugin-remove-use-case.js"; +import { Manifest } from "../../../../../../src/contexts/framework/domain/manifest.js"; +import { PluginDistribution } from "../../../../../../src/contexts/translate/domain/plugin-distribution.js"; +import { CLIOutput } from "../../../../../../src/presentation/output.js"; +import { DeterministicHasher } from "../../../../../helpers/ports/deterministic-hasher.js"; +import { InMemoryFileAdapter } from "../../../../../helpers/ports/in-memory-file-adapter.js"; +import { InMemoryManifestRepository } from "../../../../../helpers/ports/in-memory-manifest-repository.js"; const PROJECT_ROOT = "/test-project"; const STUB_HOME = "/tmp/test-home"; @@ -63,8 +56,7 @@ describe("remove opencode plugin: unmerge MCP entries (Phase 5)", () => { { kind: "local", path: "/plugin-source" }, PROJECT_ROOT, manifest, - undefined, - "docs" + undefined ); await manifestRepo.save(manifest); @@ -102,8 +94,7 @@ describe("remove opencode plugin: unmerge MCP entries (Phase 5)", () => { { kind: "local", path: "/plugin-source" }, PROJECT_ROOT, manifest, - undefined, - "docs" + undefined ); await manifestRepo.save(manifest); @@ -138,10 +129,8 @@ describe("remove opencode plugin: unmerge MCP entries (Phase 5)", () => { { kind: "local", path: "/plugin-source" }, PROJECT_ROOT, manifest, - undefined, - "docs" + undefined ); - // Simulate opencode.json not existing at remove time await fs.deleteFile(OPENCODE_JSON); await manifestRepo.save(manifest); diff --git a/cli/tests/contexts/framework/application/global/doctor-all-use-case.unit.test.ts b/cli/tests/contexts/framework/application/global/doctor-all-use-case.unit.test.ts new file mode 100644 index 000000000..1683ccf87 --- /dev/null +++ b/cli/tests/contexts/framework/application/global/doctor-all-use-case.unit.test.ts @@ -0,0 +1,20 @@ +import { describe, expect, it } from "vitest"; +import { DoctorAllUseCase } from "../../../../../src/contexts/framework/application/global/doctor-all-use-case.js"; +import { buildDoctorUseCase, buildUnitDeps } from "../../../../helpers/ports/build-unit-deps.js"; + +const PROJECT_ROOT = "/test-project"; + +describe("DoctorAllUseCase", () => { + it("is not healthy when every scope errored (no manifest found)", async () => { + const deps = await buildUnitDeps(PROJECT_ROOT); + const doctorUseCase = buildDoctorUseCase(deps); + const useCase = new DoctorAllUseCase(doctorUseCase); + + const result = await useCase.execute(PROJECT_ROOT); + + expect(result.ai).toBeNull(); + expect(result.ide).toBeNull(); + expect(result.errors.length).toBeGreaterThan(0); + expect(result.healthy).toBe(false); + }); +}); diff --git a/cli/tests/application/use-cases/shared/resolve-update-decision.unit.test.ts b/cli/tests/contexts/framework/application/global/resolve-update-decision.unit.test.ts similarity index 95% rename from cli/tests/application/use-cases/shared/resolve-update-decision.unit.test.ts rename to cli/tests/contexts/framework/application/global/resolve-update-decision.unit.test.ts index 287ca5546..47437e11f 100644 --- a/cli/tests/application/use-cases/shared/resolve-update-decision.unit.test.ts +++ b/cli/tests/contexts/framework/application/global/resolve-update-decision.unit.test.ts @@ -1,10 +1,10 @@ import { describe, expect, it, vi } from "vitest"; -import { InputRequiredError } from "../../../../src/application/errors.js"; import { BulkConflictState, ResolveUpdateDecisionUseCase, -} from "../../../../src/application/use-cases/shared/resolve-update-decision-use-case.js"; -import type { Prompter } from "../../../../src/domain/ports/prompter.js"; +} from "../../../../../src/contexts/framework/application/global/resolve-update-decision-use-case.js"; +import { InputRequiredError } from "../../../../../src/kernel/errors.js"; +import type { Prompter } from "../../../../../src/kernel/ports/prompter.js"; function buildFakePrompter( resolveConflictBulkReturn: "keep" | "overwrite" | "overwrite-all" | "skip-all" diff --git a/cli/tests/application/use-cases/global/update-ai-tools-use-case.unit.test.ts b/cli/tests/contexts/framework/application/global/update-ai-tools-use-case.unit.test.ts similarity index 86% rename from cli/tests/application/use-cases/global/update-ai-tools-use-case.unit.test.ts rename to cli/tests/contexts/framework/application/global/update-ai-tools-use-case.unit.test.ts index e86c006e7..271a8a6a5 100644 --- a/cli/tests/application/use-cases/global/update-ai-tools-use-case.unit.test.ts +++ b/cli/tests/contexts/framework/application/global/update-ai-tools-use-case.unit.test.ts @@ -1,15 +1,15 @@ import { describe, expect, it, vi } from "vitest"; -import { UpdateAiToolsUseCase } from "../../../../src/application/use-cases/global/update-ai-tools-use-case.js"; -import { ResolveUpdateDecisionUseCase } from "../../../../src/application/use-cases/shared/resolve-update-decision-use-case.js"; -import { UpdateOneToolUseCase } from "../../../../src/application/use-cases/shared/update-one-tool-use-case.js"; -import { SyncConflictResolverUseCase } from "../../../../src/application/use-cases/sync/sync-conflict-resolver-use-case.js"; -import type { Prompter } from "../../../../src/domain/ports/prompter.js"; +import { ResolveUpdateDecisionUseCase } from "../../../../../src/contexts/framework/application/global/resolve-update-decision-use-case.js"; +import { UpdateAiToolsUseCase } from "../../../../../src/contexts/framework/application/global/update-ai-tools-use-case.js"; +import { UpdateOneToolUseCase } from "../../../../../src/contexts/framework/application/global/update-one-tool-use-case.js"; +import type { Prompter } from "../../../../../src/kernel/ports/prompter.js"; +import { SyncConflictResolverUseCase } from "../../../../../src/presentation/prompts/sync-conflict-resolver-use-case.js"; import { buildUnitDeps, buildUpdateOneToolUseCase, initProject, installTool, -} from "../../../helpers/ports/build-unit-deps.js"; +} from "../../../../helpers/ports/build-unit-deps.js"; const PROJECT_ROOT = "/test-project"; @@ -120,7 +120,6 @@ describe("UpdateAiToolsUseCase", () => { updateOneTool ); - // Modify the first tracked file of each tool to trigger conflict const loadedManifest = await deps.manifestRepo.load(); if (!loadedManifest) throw new Error("Manifest not found"); for (const toolId of ["claude", "cursor"] as const) { @@ -132,7 +131,6 @@ describe("UpdateAiToolsUseCase", () => { await useCase.execute({ projectRoot: PROJECT_ROOT, userForce: false, interactive: true }); - // Bulk prompt called exactly once — second tool reuses the bulk state expect(resolveConflictBulkMock).toHaveBeenCalledTimes(1); }); }); diff --git a/cli/tests/application/use-cases/global/update-ide-tools-use-case.unit.test.ts b/cli/tests/contexts/framework/application/global/update-ide-tools-use-case.unit.test.ts similarity index 89% rename from cli/tests/application/use-cases/global/update-ide-tools-use-case.unit.test.ts rename to cli/tests/contexts/framework/application/global/update-ide-tools-use-case.unit.test.ts index 556b61d51..64565bbee 100644 --- a/cli/tests/application/use-cases/global/update-ide-tools-use-case.unit.test.ts +++ b/cli/tests/contexts/framework/application/global/update-ide-tools-use-case.unit.test.ts @@ -1,12 +1,12 @@ import { describe, expect, it, vi } from "vitest"; -import { UpdateIdeToolsUseCase } from "../../../../src/application/use-cases/global/update-ide-tools-use-case.js"; -import type { UpdateOneToolUseCase } from "../../../../src/application/use-cases/shared/update-one-tool-use-case.js"; +import { UpdateIdeToolsUseCase } from "../../../../../src/contexts/framework/application/global/update-ide-tools-use-case.js"; +import type { UpdateOneToolUseCase } from "../../../../../src/contexts/framework/application/global/update-one-tool-use-case.js"; import { buildUnitDeps, buildUpdateOneToolUseCase, initProject, installTool, -} from "../../../helpers/ports/build-unit-deps.js"; +} from "../../../../helpers/ports/build-unit-deps.js"; const PROJECT_ROOT = "/test-project"; diff --git a/cli/tests/application/use-cases/shared/update-one-tool-use-case.integration.test.ts b/cli/tests/contexts/framework/application/global/update-one-tool-use-case.integration.test.ts similarity index 93% rename from cli/tests/application/use-cases/shared/update-one-tool-use-case.integration.test.ts rename to cli/tests/contexts/framework/application/global/update-one-tool-use-case.integration.test.ts index 00285dedd..a1ed1a27a 100644 --- a/cli/tests/application/use-cases/shared/update-one-tool-use-case.integration.test.ts +++ b/cli/tests/contexts/framework/application/global/update-one-tool-use-case.integration.test.ts @@ -1,20 +1,20 @@ import { join } from "node:path"; import { describe, expect, it, vi } from "vitest"; -import { InputRequiredError } from "../../../../src/application/errors.js"; import { BulkConflictState, ResolveUpdateDecisionUseCase, -} from "../../../../src/application/use-cases/shared/resolve-update-decision-use-case.js"; -import { UpdateOneToolUseCase } from "../../../../src/application/use-cases/shared/update-one-tool-use-case.js"; -import { SyncConflictResolverUseCase } from "../../../../src/application/use-cases/sync/sync-conflict-resolver-use-case.js"; -import type { Manifest } from "../../../../src/domain/models/manifest.js"; -import type { Prompter } from "../../../../src/domain/ports/prompter.js"; +} from "../../../../../src/contexts/framework/application/global/resolve-update-decision-use-case.js"; +import { UpdateOneToolUseCase } from "../../../../../src/contexts/framework/application/global/update-one-tool-use-case.js"; +import type { Manifest } from "../../../../../src/contexts/framework/domain/manifest.js"; +import { InputRequiredError } from "../../../../../src/kernel/errors.js"; +import type { Prompter } from "../../../../../src/kernel/ports/prompter.js"; +import { SyncConflictResolverUseCase } from "../../../../../src/presentation/prompts/sync-conflict-resolver-use-case.js"; import { buildUnitDeps, initAndInstall, initProject, installTool, -} from "../../../helpers/ports/build-unit-deps.js"; +} from "../../../../helpers/ports/build-unit-deps.js"; const PROJECT_ROOT = "/test-project"; diff --git a/cli/tests/contexts/framework/application/helpers.ts b/cli/tests/contexts/framework/application/helpers.ts new file mode 100644 index 000000000..97fb4ff6b --- /dev/null +++ b/cli/tests/contexts/framework/application/helpers.ts @@ -0,0 +1,313 @@ +import { mkdir, mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import "../../../../src/contexts/tools/domain/profiles/claude/profile.js"; +import "../../../../src/contexts/tools/domain/profiles/codex/profile.js"; +import "../../../../src/contexts/tools/domain/profiles/copilot/profile.js"; +import "../../../../src/contexts/tools/domain/profiles/cursor/profile.js"; +import "../../../../src/contexts/tools/domain/profiles/opencode/profile.js"; +import "../../../../src/contexts/tools/domain/profiles/vscode/profile.js"; +import { PluginCatalogRepositoryAdapter } from "../../../../src/contexts/distribution/infrastructure/plugin-catalog-repository-adapter.js"; +import { PluginFetcherAdapter } from "../../../../src/contexts/distribution/infrastructure/plugin-fetcher-adapter.js"; +import { GitignoreUseCase } from "../../../../src/contexts/framework/application/gitignore-use-case.js"; +import { InitUseCase } from "../../../../src/contexts/framework/application/init-use-case.js"; +import { InstallIdeConfigUseCase } from "../../../../src/contexts/framework/application/install/install-ide-config-use-case.js"; +import { InstallRuntimeConfigUseCase } from "../../../../src/contexts/framework/application/install/install-runtime-config-use-case.js"; +import { PostInstallPipelineUseCase } from "../../../../src/contexts/framework/application/install/post-install-pipeline-use-case.js"; +import { Manifest } from "../../../../src/contexts/framework/domain/manifest.js"; +import { ManifestRepositoryAdapter } from "../../../../src/contexts/framework/infrastructure/manifest-repository-adapter.js"; +import { PluginDistributionReaderAdapter } from "../../../../src/contexts/framework/infrastructure/plugin-distribution-reader-adapter.js"; +import type { VersionControl } from "../../../../src/contexts/telemetry/domain/ports/version-control.js"; +import { isIdeToolId } from "../../../../src/contexts/tools/domain/registry.js"; +import type { Prompter } from "../../../../src/kernel/ports/prompter.js"; +import type { VersionReader } from "../../../../src/kernel/ports/version-reader.js"; +import type { ToolId } from "../../../../src/kernel/tool.js"; +import { CLIOutput } from "../../../../src/presentation/output.js"; +import { BundledAssetProviderAdapter } from "../../../../src/runtime/assets/asset-loader.js"; +import { FileAdapter } from "../../../../src/runtime/filesystem/file-adapter.js"; +import { HasherAdapter } from "../../../../src/runtime/filesystem/hasher-adapter.js"; +import type { Platform } from "../../../../src/runtime/platform/platform.js"; +import { SilentPrompterAdapter } from "../../../../src/runtime/prompter/prompter-adapter.js"; +import { CurrentVersionAdapter } from "../../../../src/runtime/self-update/current-version-adapter.js"; +export const linuxPlatform: Platform = { current: () => "linux" }; +export const win32Platform: Platform = { current: () => "win32" }; +export const noGit: VersionControl = { + installCommitMessageDelegate: async () => ({ lineAdded: false }), + removeCommitMessageDelegate: async () => ({ removed: false }), + listTrackedFiles: async () => [], + isRepository: async () => false, + hasHistoryFor: async () => false, + readCommitTrailerSetup: async () => ({ + delegate: "absent", + callSite: "no-hook-file", + hookHasOtherContent: false, + }), +}; + +export const OverwritePrompter = SilentPrompterAdapter; + +export class KeepPrompter implements Prompter { + async resolveConflict( + _relativePath: string, + _reason: "deleted" | "modified" + ): Promise<"keep" | "overwrite"> { + return "keep"; + } + + async resolveConflictBulk( + _relativePath: string, + _reason: "deleted" | "modified" + ): Promise<"keep" | "overwrite" | "overwrite-all" | "skip-all"> { + return "keep"; + } + + async confirm(_message: string): Promise { + return true; + } + + async input(_message: string, defaultValue?: string): Promise { + return defaultValue ?? ""; + } + + async select( + _message: string, + choices: Array<{ name: string; value: T; disabled?: boolean }> + ): Promise { + const first = choices.find((c) => !c.disabled); + if (first === undefined) { + throw new Error("No enabled choices available"); + } + return first.value; + } + + async checkbox( + _message: string, + choices: Array<{ name: string; value: T; checked?: boolean; disabled?: boolean | string }> + ): Promise { + return choices.filter((c) => c.checked === true && !c.disabled).map((c) => c.value); + } +} + +abstract class QueuedSelectPrompter implements Prompter { + private readonly selectQueue: string[]; + private selectIdx = 0; + + constructor(selectQueue: string[]) { + this.selectQueue = selectQueue; + } + + abstract resolveConflict( + relativePath: string, + reason: "deleted" | "modified" + ): Promise<"keep" | "overwrite">; + + async resolveConflictBulk( + _relativePath: string, + _reason: "deleted" | "modified" + ): Promise<"keep" | "overwrite" | "overwrite-all" | "skip-all"> { + return "overwrite"; + } + + async confirm(_message: string): Promise { + return true; + } + + async input(_message: string, defaultValue?: string): Promise { + return defaultValue ?? ""; + } + + async select( + _message: string, + choices: Array<{ name: string; value: T; disabled?: boolean }> + ): Promise { + const response = + this.selectQueue[this.selectIdx] ?? this.selectQueue[this.selectQueue.length - 1]; + this.selectIdx++; + const match = choices.find((c) => !c.disabled && String(c.value) === response); + if (match === undefined) + throw new Error(`${this.constructor.name}: no match for "${response}" in choices`); + return match.value; + } + + async checkbox( + _message: string, + choices: Array<{ name: string; value: T; checked?: boolean; disabled?: boolean | string }> + ): Promise { + return choices.filter((c) => c.checked === true && !c.disabled).map((c) => c.value); + } +} + +export class SkipPrompter extends QueuedSelectPrompter { + constructor() { + super(["global", "skip all"]); + } + + async resolveConflict( + _relativePath: string, + _reason: "deleted" | "modified" + ): Promise<"keep" | "overwrite"> { + return "keep"; + } +} + +export class BackupPrompter extends QueuedSelectPrompter { + constructor() { + super(["global", "backup all"]); + } + + async resolveConflict( + _relativePath: string, + _reason: "deleted" | "modified" + ): Promise<"keep" | "overwrite"> { + return "overwrite"; + } +} + +export class RecordingPrompter implements Prompter { + readonly calls: Array<{ relativePath: string; reason: "deleted" | "modified" }> = []; + private readonly response: "keep" | "overwrite"; + + constructor(response: "keep" | "overwrite" = "overwrite") { + this.response = response; + } + + async resolveConflict( + relativePath: string, + reason: "deleted" | "modified" + ): Promise<"keep" | "overwrite"> { + this.calls.push({ relativePath, reason }); + return this.response; + } + + async resolveConflictBulk( + _relativePath: string, + _reason: "deleted" | "modified" + ): Promise<"keep" | "overwrite" | "overwrite-all" | "skip-all"> { + return this.response; + } + + async confirm(_message: string): Promise { + return true; + } + + async input(_message: string, defaultValue?: string): Promise { + return defaultValue ?? ""; + } + + async select( + _message: string, + choices: Array<{ name: string; value: T; disabled?: boolean }> + ): Promise { + const first = choices.find((c) => !c.disabled); + if (first === undefined) { + throw new Error("No enabled choices available"); + } + return first.value; + } + + async checkbox( + _message: string, + choices: Array<{ name: string; value: T; checked?: boolean; disabled?: boolean | string }> + ): Promise { + return choices.filter((c) => c.checked === true && !c.disabled).map((c) => c.value); + } +} + +export const FIXTURE_DIR = join(process.cwd(), "tests/fixtures/framework"); +export const FIXTURE_DIR_V2 = join(process.cwd(), "tests/fixtures/framework-v2"); + +export function buildDeps(projectRoot: string) { + const hasher = new HasherAdapter(); + const fs = new FileAdapter(hasher); + const manifestRepo = new ManifestRepositoryAdapter(projectRoot); + const logger = new CLIOutput(false); + const assetProvider = new BundledAssetProviderAdapter(); + const pluginFetcher = new PluginFetcherAdapter(fs); + const pluginDistributionReader = new PluginDistributionReaderAdapter(fs); + const pluginCatalogRepository = new PluginCatalogRepositoryAdapter(fs); + const gitignoreUseCase = new GitignoreUseCase(fs); + const postInstallPipelineUseCase = new PostInstallPipelineUseCase(manifestRepo, gitignoreUseCase); + const installRuntimeConfigUseCase = new InstallRuntimeConfigUseCase( + fs, + hasher, + logger, + assetProvider, + postInstallPipelineUseCase + ); + const installIdeConfigUseCase = new InstallIdeConfigUseCase( + fs, + hasher, + logger, + assetProvider, + postInstallPipelineUseCase + ); + const currentVersionProvider: VersionReader = new CurrentVersionAdapter(); + return { + hasher, + fs, + manifestRepo, + logger, + assetProvider, + pluginFetcher, + pluginDistributionReader, + pluginCatalogRepository, + installRuntimeConfigUseCase, + installIdeConfigUseCase, + currentVersionProvider, + }; +} + +export async function createTempProject(): Promise<{ tempDir: string; projectRoot: string }> { + const tempDir = await mkdtemp(join(tmpdir(), "aidd-test-")); + const projectRoot = join(tempDir, "project"); + await mkdir(projectRoot, { recursive: true }); + return { tempDir, projectRoot }; +} + +export async function cleanupTempProject(tempDir: string): Promise { + await rm(tempDir, { recursive: true, force: true }); +} + +export async function initProject( + deps: ReturnType, + projectRoot: string +): Promise { + const initUseCase = new InitUseCase(deps.fs, deps.manifestRepo); + await initUseCase.execute({ + projectRoot, + }); +} + +export async function installTool( + deps: ReturnType, + projectRoot: string, + toolId: ToolId +) { + const manifest = (await deps.manifestRepo.load()) ?? Manifest.create(); + const version = "test"; + if (isIdeToolId(toolId)) { + return deps.installIdeConfigUseCase.execute({ + toolId, + projectRoot, + manifest, + force: false, + version, + }); + } + return deps.installRuntimeConfigUseCase.execute({ + toolId, + projectRoot, + manifest, + force: false, + version, + }); +} + +export async function initAndInstall( + deps: ReturnType, + projectRoot: string, + toolId: ToolId +) { + await initProject(deps, projectRoot); + return installTool(deps, projectRoot, toolId); +} diff --git a/cli/tests/application/use-cases/init-use-case.unit.test.ts b/cli/tests/contexts/framework/application/init-use-case.unit.test.ts similarity index 75% rename from cli/tests/application/use-cases/init-use-case.unit.test.ts rename to cli/tests/contexts/framework/application/init-use-case.unit.test.ts index 67ffb5fec..90237cb9c 100644 --- a/cli/tests/application/use-cases/init-use-case.unit.test.ts +++ b/cli/tests/contexts/framework/application/init-use-case.unit.test.ts @@ -1,14 +1,14 @@ import { join } from "node:path"; import { describe, expect, it } from "vitest"; -import "../../../src/domain/tools/ai/claude.js"; -import "../../../src/domain/tools/ai/codex.js"; -import "../../../src/domain/tools/ai/copilot.js"; -import "../../../src/domain/tools/ai/cursor.js"; -import "../../../src/domain/tools/ai/opencode.js"; -import "../../../src/domain/tools/ide/vscode.js"; -import { InitUseCase } from "../../../src/application/use-cases/init-use-case.js"; -import type { ToolId } from "../../../src/domain/tools/registry.js"; -import { buildUnitDeps, initProject, installTool } from "../../helpers/ports/build-unit-deps.js"; +import "../../../../src/contexts/tools/domain/profiles/claude/profile.js"; +import "../../../../src/contexts/tools/domain/profiles/codex/profile.js"; +import "../../../../src/contexts/tools/domain/profiles/copilot/profile.js"; +import "../../../../src/contexts/tools/domain/profiles/cursor/profile.js"; +import "../../../../src/contexts/tools/domain/profiles/opencode/profile.js"; +import "../../../../src/contexts/tools/domain/profiles/vscode/profile.js"; +import { InitUseCase } from "../../../../src/contexts/framework/application/init-use-case.js"; +import type { ToolId } from "../../../../src/kernel/tool.js"; +import { buildUnitDeps, initProject, installTool } from "../../../helpers/ports/build-unit-deps.js"; const PROJECT_ROOT = "/test-project"; @@ -93,6 +93,23 @@ describe("init", () => { ).rejects.toThrow(/Already initialized/); }); + // `aidd init` is not a command this CLI exposes (see the surface in cli.md) — telling + // a user to run `aidd init --force` points them at something that does not exist. + it("names a real recovery path, not the nonexistent `aidd init --force`", async () => { + const deps = await buildUnitDeps(PROJECT_ROOT); + await new InitUseCase(deps.fs, deps.manifestRepo).execute({ projectRoot: PROJECT_ROOT }); + await expect( + new InitUseCase(deps.fs, deps.manifestRepo).checkPreconditions({ + projectRoot: PROJECT_ROOT, + }) + ).rejects.toThrow(/aidd clean --force/); + await expect( + new InitUseCase(deps.fs, deps.manifestRepo).checkPreconditions({ + projectRoot: PROJECT_ROOT, + }) + ).rejects.not.toThrow(/aidd init --force/); + }); + it("--force: fails with guidance when no manifest exists", async () => { const deps = await buildUnitDeps(PROJECT_ROOT); await expect( diff --git a/cli/tests/application/use-cases/install/install-agents-use-case.unit.test.ts b/cli/tests/contexts/framework/application/install/content/install-agents-use-case.unit.test.ts similarity index 87% rename from cli/tests/application/use-cases/install/install-agents-use-case.unit.test.ts rename to cli/tests/contexts/framework/application/install/content/install-agents-use-case.unit.test.ts index 2d5068714..e2acfa81f 100644 --- a/cli/tests/application/use-cases/install/install-agents-use-case.unit.test.ts +++ b/cli/tests/contexts/framework/application/install/content/install-agents-use-case.unit.test.ts @@ -1,15 +1,15 @@ // Register the claude and copilot tools so their capabilities are accessible -import "../../../../src/domain/tools/ai/claude.js"; -import "../../../../src/domain/tools/ai/copilot.js"; +import "../../../../../../src/contexts/tools/domain/profiles/claude/profile.js"; +import "../../../../../../src/contexts/tools/domain/profiles/copilot/profile.js"; import { describe, expect, it } from "vitest"; -import { InstallAgentsUseCase } from "../../../../src/application/use-cases/install/install-agents-use-case.js"; -import type { ContentSection } from "../../../../src/domain/models/framework.js"; -import { GITKEEP_FILE } from "../../../../src/domain/models/framework.js"; -import { claude } from "../../../../src/domain/tools/ai/claude.js"; -import { copilot } from "../../../../src/domain/tools/ai/copilot.js"; -import { DeterministicHasher } from "../../../helpers/ports/deterministic-hasher.js"; +import { InstallAgentsUseCase } from "../../../../../../src/contexts/framework/application/install/content/install-agents-use-case.js"; +import { claude } from "../../../../../../src/contexts/tools/domain/profiles/claude/profile.js"; +import { copilot } from "../../../../../../src/contexts/tools/domain/profiles/copilot/profile.js"; +import type { ContentSection } from "../../../../../../src/contexts/translate/domain/canon.js"; +import { GITKEEP_FILE } from "../../../../../../src/kernel/file.js"; +import { DeterministicHasher } from "../../../../../helpers/ports/deterministic-hasher.js"; -const DOCS_DIR = "aidd_docs"; +const _DOCS_DIR = "aidd_docs"; const agentsSection: ContentSection = { name: "agents", @@ -41,7 +41,6 @@ describe("InstallAgentsUseCase", () => { toolConfig: claude, section: agentsSection, contentFiles, - docsDir: DOCS_DIR, }); expect(files).toHaveLength(1); @@ -58,7 +57,6 @@ describe("InstallAgentsUseCase", () => { toolConfig: claude, section: agentsSection, contentFiles: new Map(), - docsDir: DOCS_DIR, }); expect(files).toHaveLength(0); @@ -75,7 +73,6 @@ describe("InstallAgentsUseCase", () => { toolConfig: claude, section: agentsSection, contentFiles, - docsDir: DOCS_DIR, }); expect(files).toHaveLength(0); @@ -92,7 +89,6 @@ describe("InstallAgentsUseCase", () => { toolConfig: claude, section: agentsSection, contentFiles, - docsDir: DOCS_DIR, }); expect(files).toHaveLength(1); @@ -108,7 +104,6 @@ describe("InstallAgentsUseCase", () => { toolConfig: claude, section: agentsSection, contentFiles, - docsDir: DOCS_DIR, }); expect(files).toHaveLength(1); @@ -126,7 +121,6 @@ describe("InstallAgentsUseCase", () => { toolConfig: claude, section: agentsSection, contentFiles, - docsDir: DOCS_DIR, }); expect(files).toHaveLength(1); @@ -145,7 +139,6 @@ describe("InstallAgentsUseCase", () => { toolConfig: claude, section: agentsSection, contentFiles, - docsDir: DOCS_DIR, }); expect(files).toHaveLength(2); @@ -167,7 +160,6 @@ describe("InstallAgentsUseCase", () => { toolConfig: claude, section: agentsSectionWithEntry, contentFiles, - docsDir: DOCS_DIR, }); expect(files).toHaveLength(1); @@ -184,7 +176,6 @@ describe("InstallAgentsUseCase", () => { toolConfig: claude, section: agentsSectionWithEntry, contentFiles, - docsDir: DOCS_DIR, }); expect(files).toHaveLength(0); @@ -201,7 +192,6 @@ describe("InstallAgentsUseCase", () => { toolConfig: copilot, section: agentsSection, contentFiles, - docsDir: DOCS_DIR, }); expect(files).toHaveLength(0); diff --git a/cli/tests/application/use-cases/install/install-commands-use-case.unit.test.ts b/cli/tests/contexts/framework/application/install/content/install-commands-use-case.unit.test.ts similarity index 80% rename from cli/tests/application/use-cases/install/install-commands-use-case.unit.test.ts rename to cli/tests/contexts/framework/application/install/content/install-commands-use-case.unit.test.ts index 3d5f3420d..72177a5c9 100644 --- a/cli/tests/application/use-cases/install/install-commands-use-case.unit.test.ts +++ b/cli/tests/contexts/framework/application/install/content/install-commands-use-case.unit.test.ts @@ -1,15 +1,15 @@ // Register the claude and copilot tools so their capabilities are accessible -import "../../../../src/domain/tools/ai/claude.js"; -import "../../../../src/domain/tools/ai/copilot.js"; +import "../../../../../../src/contexts/tools/domain/profiles/claude/profile.js"; +import "../../../../../../src/contexts/tools/domain/profiles/copilot/profile.js"; import { describe, expect, it } from "vitest"; -import { InstallCommandsUseCase } from "../../../../src/application/use-cases/install/install-commands-use-case.js"; -import type { ContentSection } from "../../../../src/domain/models/framework.js"; -import { GITKEEP_FILE } from "../../../../src/domain/models/framework.js"; -import { claude } from "../../../../src/domain/tools/ai/claude.js"; -import { copilot } from "../../../../src/domain/tools/ai/copilot.js"; -import { DeterministicHasher } from "../../../helpers/ports/deterministic-hasher.js"; +import { InstallCommandsUseCase } from "../../../../../../src/contexts/framework/application/install/content/install-commands-use-case.js"; +import { claude } from "../../../../../../src/contexts/tools/domain/profiles/claude/profile.js"; +import { copilot } from "../../../../../../src/contexts/tools/domain/profiles/copilot/profile.js"; +import type { ContentSection } from "../../../../../../src/contexts/translate/domain/canon.js"; +import { GITKEEP_FILE } from "../../../../../../src/kernel/file.js"; +import { DeterministicHasher } from "../../../../../helpers/ports/deterministic-hasher.js"; -const DOCS_DIR = "aidd_docs"; +const _DOCS_DIR = "aidd_docs"; const commandsSection: ContentSection = { name: "commands", @@ -35,7 +35,6 @@ describe("InstallCommandsUseCase", () => { toolConfig: claude, section: commandsSection, contentFiles, - docsDir: DOCS_DIR, }); expect(files).toHaveLength(1); @@ -52,7 +51,6 @@ describe("InstallCommandsUseCase", () => { toolConfig: claude, section: commandsSection, contentFiles: new Map(), - docsDir: DOCS_DIR, }); expect(files).toHaveLength(0); @@ -69,7 +67,6 @@ describe("InstallCommandsUseCase", () => { toolConfig: claude, section: commandsSection, contentFiles, - docsDir: DOCS_DIR, }); expect(files).toHaveLength(0); @@ -86,10 +83,8 @@ describe("InstallCommandsUseCase", () => { toolConfig: claude, section: commandsSection, contentFiles, - docsDir: DOCS_DIR, }); - // Only claude's file passes; cursor's is rejected by acceptsFileName expect(files).toHaveLength(1); expect(files[0].frameworkPath).toBe("commands/04_code/implement.claude.md"); }); @@ -103,7 +98,6 @@ describe("InstallCommandsUseCase", () => { toolConfig: claude, section: commandsSection, contentFiles, - docsDir: DOCS_DIR, }); expect(files).toHaveLength(1); @@ -113,7 +107,6 @@ describe("InstallCommandsUseCase", () => { it("converts frontmatter via the capability's convertFrontmatter", () => { const { useCase } = buildUseCase(); - // Claude commands capability converts frontmatter (name, description, argument-hint, model) const rawContent = "---\nname: test-cmd\ndescription: test description\nargument-hint: $ARG\n---\n# body\n"; const contentFiles = new Map([["commands/04_code/test-cmd.claude.md", rawContent]]); @@ -122,7 +115,6 @@ describe("InstallCommandsUseCase", () => { toolConfig: claude, section: commandsSection, contentFiles, - docsDir: DOCS_DIR, }); expect(files).toHaveLength(1); @@ -145,11 +137,8 @@ describe("InstallCommandsUseCase", () => { toolConfig: claude, section: sectionWithEntry, contentFiles, - docsDir: DOCS_DIR, }); - // Only SKILL.md (matches entryFile) — but SKILL.md has no tool suffix, still accepted - // other.claude.md is filtered by entryFile check const paths = files.map((f) => f.frameworkPath); expect(paths).not.toContain("commands/other.claude.md"); }); @@ -163,7 +152,6 @@ describe("InstallCommandsUseCase", () => { toolConfig: copilot, section: commandsSection, contentFiles, - docsDir: DOCS_DIR, }); expect(files).toHaveLength(0); diff --git a/cli/tests/application/use-cases/install/install-rules-use-case.unit.test.ts b/cli/tests/contexts/framework/application/install/content/install-rules-use-case.unit.test.ts similarity index 85% rename from cli/tests/application/use-cases/install/install-rules-use-case.unit.test.ts rename to cli/tests/contexts/framework/application/install/content/install-rules-use-case.unit.test.ts index d0dc7e504..e81ecde53 100644 --- a/cli/tests/application/use-cases/install/install-rules-use-case.unit.test.ts +++ b/cli/tests/contexts/framework/application/install/content/install-rules-use-case.unit.test.ts @@ -1,15 +1,14 @@ -// Register the claude and copilot tools so their capabilities are accessible -import "../../../../src/domain/tools/ai/claude.js"; -import "../../../../src/domain/tools/ai/copilot.js"; +import "../../../../../../src/contexts/tools/domain/profiles/claude/profile.js"; +import "../../../../../../src/contexts/tools/domain/profiles/copilot/profile.js"; import { describe, expect, it } from "vitest"; -import { InstallRulesUseCase } from "../../../../src/application/use-cases/install/install-rules-use-case.js"; -import type { ContentSection } from "../../../../src/domain/models/framework.js"; -import { GITKEEP_FILE } from "../../../../src/domain/models/framework.js"; -import { claude } from "../../../../src/domain/tools/ai/claude.js"; -import { copilot } from "../../../../src/domain/tools/ai/copilot.js"; -import { DeterministicHasher } from "../../../helpers/ports/deterministic-hasher.js"; +import { InstallRulesUseCase } from "../../../../../../src/contexts/framework/application/install/content/install-rules-use-case.js"; +import { claude } from "../../../../../../src/contexts/tools/domain/profiles/claude/profile.js"; +import { copilot } from "../../../../../../src/contexts/tools/domain/profiles/copilot/profile.js"; +import type { ContentSection } from "../../../../../../src/contexts/translate/domain/canon.js"; +import { GITKEEP_FILE } from "../../../../../../src/kernel/file.js"; +import { DeterministicHasher } from "../../../../../helpers/ports/deterministic-hasher.js"; -const DOCS_DIR = "aidd_docs"; +const _DOCS_DIR = "aidd_docs"; const rulesSection: ContentSection = { name: "rules", @@ -40,7 +39,6 @@ describe("InstallRulesUseCase", () => { toolConfig: claude, section: rulesSection, contentFiles, - docsDir: DOCS_DIR, }); expect(files).toHaveLength(1); @@ -57,7 +55,6 @@ describe("InstallRulesUseCase", () => { toolConfig: claude, section: rulesSection, contentFiles: new Map(), - docsDir: DOCS_DIR, }); expect(files).toHaveLength(0); @@ -74,7 +71,6 @@ describe("InstallRulesUseCase", () => { toolConfig: claude, section: rulesSection, contentFiles, - docsDir: DOCS_DIR, }); expect(files).toHaveLength(0); @@ -91,7 +87,6 @@ describe("InstallRulesUseCase", () => { toolConfig: claude, section: rulesSection, contentFiles, - docsDir: DOCS_DIR, }); expect(files).toHaveLength(1); @@ -107,7 +102,6 @@ describe("InstallRulesUseCase", () => { toolConfig: claude, section: rulesSection, contentFiles, - docsDir: DOCS_DIR, }); expect(files).toHaveLength(1); @@ -124,7 +118,6 @@ describe("InstallRulesUseCase", () => { toolConfig: claude, section: rulesSection, contentFiles, - docsDir: DOCS_DIR, }); expect(files).toHaveLength(1); @@ -142,11 +135,9 @@ describe("InstallRulesUseCase", () => { toolConfig: claude, section: rulesSection, contentFiles, - docsDir: DOCS_DIR, }); expect(files).toHaveLength(1); - // serialized without frontmatter (empty object → no --- block) expect(files[0].content).not.toContain("paths:"); }); @@ -161,7 +152,6 @@ describe("InstallRulesUseCase", () => { toolConfig: claude, section: rulesSection, contentFiles, - docsDir: DOCS_DIR, }); expect(files).toHaveLength(2); @@ -183,7 +173,6 @@ describe("InstallRulesUseCase", () => { toolConfig: claude, section: rulesSectionWithEntry, contentFiles, - docsDir: DOCS_DIR, }); expect(files).toHaveLength(1); @@ -200,7 +189,6 @@ describe("InstallRulesUseCase", () => { toolConfig: claude, section: rulesSectionWithEntry, contentFiles, - docsDir: DOCS_DIR, }); expect(files).toHaveLength(0); @@ -217,7 +205,6 @@ describe("InstallRulesUseCase", () => { toolConfig: copilot, section: rulesSection, contentFiles, - docsDir: DOCS_DIR, }); expect(files).toHaveLength(0); diff --git a/cli/tests/application/use-cases/install/install-skills-use-case.unit.test.ts b/cli/tests/contexts/framework/application/install/content/install-skills-use-case.unit.test.ts similarity index 83% rename from cli/tests/application/use-cases/install/install-skills-use-case.unit.test.ts rename to cli/tests/contexts/framework/application/install/content/install-skills-use-case.unit.test.ts index d9b5f44be..8e860bafc 100644 --- a/cli/tests/application/use-cases/install/install-skills-use-case.unit.test.ts +++ b/cli/tests/contexts/framework/application/install/content/install-skills-use-case.unit.test.ts @@ -1,24 +1,22 @@ // Register the claude and copilot tools so their capabilities are accessible -import "../../../../src/domain/tools/ai/claude.js"; -import "../../../../src/domain/tools/ai/copilot.js"; +import "../../../../../../src/contexts/tools/domain/profiles/claude/profile.js"; +import "../../../../../../src/contexts/tools/domain/profiles/copilot/profile.js"; import { describe, expect, it } from "vitest"; -import { InstallSkillsUseCase } from "../../../../src/application/use-cases/install/install-skills-use-case.js"; -import type { ContentSection } from "../../../../src/domain/models/framework.js"; -import { GITKEEP_FILE } from "../../../../src/domain/models/framework.js"; -import { claude } from "../../../../src/domain/tools/ai/claude.js"; -import { copilot } from "../../../../src/domain/tools/ai/copilot.js"; -import { DeterministicHasher } from "../../../helpers/ports/deterministic-hasher.js"; +import { InstallSkillsUseCase } from "../../../../../../src/contexts/framework/application/install/content/install-skills-use-case.js"; +import { claude } from "../../../../../../src/contexts/tools/domain/profiles/claude/profile.js"; +import { copilot } from "../../../../../../src/contexts/tools/domain/profiles/copilot/profile.js"; +import type { ContentSection } from "../../../../../../src/contexts/translate/domain/canon.js"; +import { GITKEEP_FILE } from "../../../../../../src/kernel/file.js"; +import { DeterministicHasher } from "../../../../../helpers/ports/deterministic-hasher.js"; -const DOCS_DIR = "aidd_docs"; +const _DOCS_DIR = "aidd_docs"; -// Skills section without entryFile filter (flat mode) const skillsSectionFlat: ContentSection = { name: "skills", directory: "skills", entryFile: null, }; -// Skills section with entryFile: "SKILL.md" (plugin/subdirectory mode) const skillsSectionWithEntry: ContentSection = { name: "skills", directory: "skills", @@ -42,7 +40,6 @@ describe("InstallSkillsUseCase", () => { toolConfig: claude, section: skillsSectionFlat, contentFiles, - docsDir: DOCS_DIR, }); expect(files).toHaveLength(1); @@ -59,7 +56,6 @@ describe("InstallSkillsUseCase", () => { toolConfig: claude, section: skillsSectionFlat, contentFiles: new Map(), - docsDir: DOCS_DIR, }); expect(files).toHaveLength(0); @@ -76,7 +72,6 @@ describe("InstallSkillsUseCase", () => { toolConfig: claude, section: skillsSectionFlat, contentFiles, - docsDir: DOCS_DIR, }); expect(files).toHaveLength(0); @@ -93,7 +88,6 @@ describe("InstallSkillsUseCase", () => { toolConfig: claude, section: skillsSectionFlat, contentFiles, - docsDir: DOCS_DIR, }); expect(files).toHaveLength(1); @@ -109,7 +103,6 @@ describe("InstallSkillsUseCase", () => { toolConfig: claude, section: skillsSectionFlat, contentFiles, - docsDir: DOCS_DIR, }); expect(files).toHaveLength(1); @@ -128,7 +121,6 @@ describe("InstallSkillsUseCase", () => { toolConfig: claude, section: skillsSectionFlat, contentFiles, - docsDir: DOCS_DIR, }); expect(files).toHaveLength(2); @@ -150,10 +142,8 @@ describe("InstallSkillsUseCase", () => { toolConfig: claude, section: skillsSectionWithEntry, contentFiles, - docsDir: DOCS_DIR, }); - // Only SKILL.md passes the entryFile filter expect(files).toHaveLength(1); expect(files[0].frameworkPath).toBe("skills/my-skill/SKILL.md"); }); @@ -168,7 +158,6 @@ describe("InstallSkillsUseCase", () => { toolConfig: claude, section: skillsSectionWithEntry, contentFiles, - docsDir: DOCS_DIR, }); expect(files).toHaveLength(0); @@ -185,7 +174,6 @@ describe("InstallSkillsUseCase", () => { toolConfig: copilot, section: skillsSectionFlat, contentFiles, - docsDir: DOCS_DIR, }); expect(files).toHaveLength(0); diff --git a/cli/tests/application/use-cases/install-ai-tool-use-case.unit.test.ts b/cli/tests/contexts/framework/application/install/install-ai-tool-use-case.unit.test.ts similarity index 82% rename from cli/tests/application/use-cases/install-ai-tool-use-case.unit.test.ts rename to cli/tests/contexts/framework/application/install/install-ai-tool-use-case.unit.test.ts index c2f929b30..86974bedb 100644 --- a/cli/tests/application/use-cases/install-ai-tool-use-case.unit.test.ts +++ b/cli/tests/contexts/framework/application/install/install-ai-tool-use-case.unit.test.ts @@ -1,32 +1,38 @@ import { describe, expect, it, vi } from "vitest"; -import { InstallAiToolUseCase } from "../../../src/application/use-cases/install/install-ai-tool-use-case.js"; -import type { MarketplaceSyncSettings } from "../../../src/application/use-cases/marketplace/marketplace-sync-settings-use-case.js"; -import type { PluginInstallFromMarketplace } from "../../../src/application/use-cases/plugin/plugin-install-from-marketplace-use-case.js"; -import { Manifest } from "../../../src/domain/models/manifest.js"; -import { Plugin } from "../../../src/domain/models/plugin.js"; -import { buildUnitDeps, initAndInstall, installTool } from "../../helpers/ports/build-unit-deps.js"; +import type { MarketplaceSyncSettings } from "../../../../../src/contexts/framework/application/flows/marketplace-sync-settings-use-case.js"; +import { InstallAiToolUseCase } from "../../../../../src/contexts/framework/application/install/install-ai-tool-use-case.js"; +import type { PluginInstallFromMarketplace } from "../../../../../src/contexts/framework/application/plugin/plugin-install-from-marketplace-use-case.js"; +import { Manifest } from "../../../../../src/contexts/framework/domain/manifest.js"; +import { InstalledPlugin } from "../../../../../src/contexts/framework/domain/plugins/installed-plugin.js"; +import { + buildUnitDeps, + initAndInstall, + installTool, +} from "../../../../helpers/ports/build-unit-deps.js"; const PROJECT_ROOT = "/test-project"; const VERSION = "1.0.0"; -function makeMockPlugin(name: string, marketplace = "aidd"): Plugin { - return Plugin.fromJSON({ +function makeMockPlugin(name: string, marketplace = "aidd"): InstalledPlugin { + return InstalledPlugin.fromJSON({ name, source: { kind: "github", repo: "acme/plugins", ref: "main" }, version: "1.0.0", strict: false, files: {}, + scope: "project", marketplace, }); } -function makeMockOrphanPlugin(name: string): Plugin { - return Plugin.fromJSON({ +function makeMockOrphanPlugin(name: string): InstalledPlugin { + return InstalledPlugin.fromJSON({ name, source: { kind: "github", repo: "acme/plugins", ref: "main" }, version: "1.0.0", strict: false, files: {}, + scope: "project", }); } @@ -59,7 +65,7 @@ function buildUseCase( async function addPlugin( deps: Awaited>, toolId: string, - plugin: Plugin + plugin: InstalledPlugin ): Promise { const manifest = (await deps.manifestRepo.load()) ?? Manifest.create(); manifest.addPlugin(toolId as Parameters[0], plugin); @@ -132,6 +138,36 @@ describe("InstallAiToolUseCase", () => { expect(syncSettingsMock.execute).toHaveBeenCalledWith({ projectRoot: PROJECT_ROOT }); }); + it("surfaces the sync's own errors in its result rather than discarding them", async () => { + const deps = await buildUnitDeps(PROJECT_ROOT); + await initAndInstall(deps, PROJECT_ROOT, "claude"); + await addPlugin(deps, "claude", makeMockPlugin("my-plugin")); + + const conflictError = { scope: "opencode", message: "different catalog" }; + const { useCase } = buildUseCase( + deps, + {}, + { + execute: vi.fn().mockResolvedValue({ + activated: [], + binaryMissing: [], + warnings: [], + errors: [conflictError], + }), + } + ); + + const result = await useCase.execute({ + toolId: "opencode", + projectRoot: PROJECT_ROOT, + force: false, + version: VERSION, + propagatePlugins: true, + }); + + expect(result.activation?.errors).toEqual([conflictError]); + }); + it("skips propagation and sync when --no-plugins flag is set", async () => { const deps = await buildUnitDeps(PROJECT_ROOT); await initAndInstall(deps, PROJECT_ROOT, "claude"); diff --git a/cli/tests/application/use-cases/install-config-use-case.integration.test.ts b/cli/tests/contexts/framework/application/install/install-config-use-case.integration.test.ts similarity index 80% rename from cli/tests/application/use-cases/install-config-use-case.integration.test.ts rename to cli/tests/contexts/framework/application/install/install-config-use-case.integration.test.ts index 803e105ae..c242ad637 100644 --- a/cli/tests/application/use-cases/install-config-use-case.integration.test.ts +++ b/cli/tests/contexts/framework/application/install/install-config-use-case.integration.test.ts @@ -1,13 +1,13 @@ import { describe, expect, it } from "vitest"; -import { InstallConfigUseCase } from "../../../src/application/use-cases/install/install-config-use-case.js"; -import { SettingsCapability } from "../../../src/domain/capabilities/settings-capability.js"; -import { extractConfigCapabilities } from "../../../src/domain/models/config-capability.js"; -import { FrameworkDescriptor } from "../../../src/domain/models/framework.js"; -import { copilot } from "../../../src/domain/tools/ai/copilot.js"; -import { BundledAssetProviderAdapter } from "../../../src/infrastructure/assets/asset-loader.js"; -import { DeterministicHasher } from "../../helpers/ports/deterministic-hasher.js"; -import { InMemoryFileAdapter } from "../../helpers/ports/in-memory-file-adapter.js"; -import { linuxPlatform } from "./helpers.js"; +import { InstallConfigUseCase } from "../../../../../src/contexts/framework/application/install/install-config-use-case.js"; +import { extractConfigCapabilities } from "../../../../../src/contexts/framework/domain/config-capability.js"; +import { SettingsCapability } from "../../../../../src/contexts/tools/domain/capabilities/settings-capability.js"; +import { copilot } from "../../../../../src/contexts/tools/domain/profiles/copilot/profile.js"; +import { FrameworkDescriptor } from "../../../../../src/contexts/translate/domain/canon.js"; +import { BundledAssetProviderAdapter } from "../../../../../src/runtime/assets/asset-loader.js"; +import { DeterministicHasher } from "../../../../helpers/ports/deterministic-hasher.js"; +import { InMemoryFileAdapter } from "../../../../helpers/ports/in-memory-file-adapter.js"; +import { linuxPlatform } from "../helpers.js"; const PROJECT_ROOT = "/test-project"; diff --git a/cli/tests/application/use-cases/install-ide-config-use-case.unit.test.ts b/cli/tests/contexts/framework/application/install/install-ide-config-use-case.unit.test.ts similarity index 90% rename from cli/tests/application/use-cases/install-ide-config-use-case.unit.test.ts rename to cli/tests/contexts/framework/application/install/install-ide-config-use-case.unit.test.ts index 4bf3b3cee..c86f0ae72 100644 --- a/cli/tests/application/use-cases/install-ide-config-use-case.unit.test.ts +++ b/cli/tests/contexts/framework/application/install/install-ide-config-use-case.unit.test.ts @@ -1,8 +1,8 @@ import { join } from "node:path"; import { describe, expect, it, vi } from "vitest"; -import { InstallIdeConfigUseCase } from "../../../src/application/use-cases/install/install-ide-config-use-case.js"; -import { Manifest } from "../../../src/domain/models/manifest.js"; -import { buildUnitDeps, initProject } from "../../helpers/ports/build-unit-deps.js"; +import { InstallIdeConfigUseCase } from "../../../../../src/contexts/framework/application/install/install-ide-config-use-case.js"; +import { Manifest } from "../../../../../src/contexts/framework/domain/manifest.js"; +import { buildUnitDeps, initProject } from "../../../../helpers/ports/build-unit-deps.js"; const PROJECT_ROOT = "/test-project"; @@ -91,9 +91,7 @@ describe("InstallIdeConfigUseCase", () => { expect(result.skipped).toBe(false); const content = deps.fs.getFile(settingsPath) ?? ""; - // user-prime strategy preserves user modifications on force reinstall expect(content).toContain('"modified"'); - // framework keys are also present expect(content).toContain('"editor.formatOnSave"'); }); diff --git a/cli/tests/application/use-cases/install-ide-tool-use-case.unit.test.ts b/cli/tests/contexts/framework/application/install/install-ide-tool-use-case.unit.test.ts similarity index 87% rename from cli/tests/application/use-cases/install-ide-tool-use-case.unit.test.ts rename to cli/tests/contexts/framework/application/install/install-ide-tool-use-case.unit.test.ts index bfa17ba4a..ec7787629 100644 --- a/cli/tests/application/use-cases/install-ide-tool-use-case.unit.test.ts +++ b/cli/tests/contexts/framework/application/install/install-ide-tool-use-case.unit.test.ts @@ -1,13 +1,13 @@ import { join } from "node:path"; import { describe, expect, it } from "vitest"; -import { InstallIdeToolUseCase } from "../../../src/application/use-cases/install/install-ide-tool-use-case.js"; -import { Manifest } from "../../../src/domain/models/manifest.js"; +import { InstallIdeToolUseCase } from "../../../../../src/contexts/framework/application/install/install-ide-tool-use-case.js"; +import { Manifest } from "../../../../../src/contexts/framework/domain/manifest.js"; import { buildUnitDeps, initAndInstall, initProject, installTool, -} from "../../helpers/ports/build-unit-deps.js"; +} from "../../../../helpers/ports/build-unit-deps.js"; const PROJECT_ROOT = "/test-project"; const VERSION = "1.0.0"; @@ -41,9 +41,7 @@ describe("InstallIdeToolUseCase", () => { expect(result.skipped).toBe(false); const settingsPath = join(PROJECT_ROOT, ".vscode/settings.json"); const content = deps.fs.getFile(settingsPath) ?? ""; - // copilot static keys must be present expect(content).toContain('"github.copilot.enable"'); - // vscode framework keys must also be present expect(content).toContain('"editor.formatOnSave"'); }); @@ -70,7 +68,6 @@ describe("InstallIdeToolUseCase", () => { describe("no AI tool depends on the installing IDE", () => { it("performs only the IDE install with no extra mergeJsonFile calls for copilot settings", async () => { const deps = await buildUnitDeps(PROJECT_ROOT); - // Install an AI tool that has NO vscode dependency (claude does not have requiresTool: vscode) await initAndInstall(deps, PROJECT_ROOT, "claude"); const manifest = (await deps.manifestRepo.load()) ?? Manifest.create(); @@ -83,11 +80,10 @@ describe("InstallIdeToolUseCase", () => { }); expect(result.skipped).toBe(false); - // Claude has no static settings with requiresTool: vscode, so copilot keys absent + // Claude declares no static settings with `requiresTool: vscode`. const settingsPath = join(PROJECT_ROOT, ".vscode/settings.json"); const content = deps.fs.getFile(settingsPath) ?? ""; expect(content).not.toContain('"github.copilot.enable"'); - // But vscode framework keys are present expect(content).toContain('"editor.formatOnSave"'); }); }); @@ -128,10 +124,8 @@ describe("InstallIdeToolUseCase", () => { const settingsPath = join(PROJECT_ROOT, ".vscode/settings.json"); const content = deps.fs.getFile(settingsPath) ?? ""; - // Copilot AI-specific keys expect(content).toContain('"github.copilot.enable"'); expect(content).toContain('"github.copilot.nextEditSuggestions.enabled"'); - // VSCode framework keys expect(content).toContain('"editor.formatOnSave"'); }); }); diff --git a/cli/tests/application/use-cases/install-runtime-config-use-case.unit.test.ts b/cli/tests/contexts/framework/application/install/install-runtime-config-use-case.unit.test.ts similarity index 93% rename from cli/tests/application/use-cases/install-runtime-config-use-case.unit.test.ts rename to cli/tests/contexts/framework/application/install/install-runtime-config-use-case.unit.test.ts index 5b7897e6e..eae45c091 100644 --- a/cli/tests/application/use-cases/install-runtime-config-use-case.unit.test.ts +++ b/cli/tests/contexts/framework/application/install/install-runtime-config-use-case.unit.test.ts @@ -1,8 +1,12 @@ import { join } from "node:path"; import { describe, expect, it, vi } from "vitest"; -import { InstallRuntimeConfigUseCase } from "../../../src/application/use-cases/install/install-runtime-config-use-case.js"; -import { Manifest } from "../../../src/domain/models/manifest.js"; -import { buildUnitDeps, initProject, installTool } from "../../helpers/ports/build-unit-deps.js"; +import { InstallRuntimeConfigUseCase } from "../../../../../src/contexts/framework/application/install/install-runtime-config-use-case.js"; +import { Manifest } from "../../../../../src/contexts/framework/domain/manifest.js"; +import { + buildUnitDeps, + initProject, + installTool, +} from "../../../../helpers/ports/build-unit-deps.js"; const PROJECT_ROOT = "/test-project"; diff --git a/cli/tests/contexts/framework/application/install/post-install-pipeline-use-case.unit.test.ts b/cli/tests/contexts/framework/application/install/post-install-pipeline-use-case.unit.test.ts new file mode 100644 index 000000000..3d95eeb3f --- /dev/null +++ b/cli/tests/contexts/framework/application/install/post-install-pipeline-use-case.unit.test.ts @@ -0,0 +1,76 @@ +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; +import { GitignoreUseCase } from "../../../../../src/contexts/framework/application/gitignore-use-case.js"; +import { PostInstallPipelineUseCase } from "../../../../../src/contexts/framework/application/install/post-install-pipeline-use-case.js"; +import { buildUnitDeps, initAndInstall } from "../../../../helpers/ports/build-unit-deps.js"; + +const PROJECT_ROOT = "/test-project"; + +/** Records every `execute` call so a test can assert the pipeline batches its gitignore + * entries into one call instead of writing the same file twice. */ +class RecordingGitignoreUseCase extends GitignoreUseCase { + readonly calls: string[][] = []; + + override async execute(projectRoot: string, entries: string[]): Promise { + this.calls.push(entries); + return super.execute(projectRoot, entries); + } +} + +describe("post-install pipeline", () => { + it("saves manifest and updates gitignore after file write", async () => { + const deps = await buildUnitDeps(PROJECT_ROOT); + await initAndInstall(deps, PROJECT_ROOT, "claude"); + + const manifest = await deps.manifestRepo.load(); + if (manifest === null) throw new Error("manifest not found"); + + await new PostInstallPipelineUseCase(deps.manifestRepo, deps.gitignoreUseCase).execute({ + projectRoot: PROJECT_ROOT, + manifest, + }); + + const reloaded = await deps.manifestRepo.load(); + expect(reloaded).not.toBeNull(); + + const gitignorePath = join(PROJECT_ROOT, ".gitignore"); + expect(deps.fs.has(gitignorePath)).toBe(true); + const gitignoreContent = deps.fs.getFile(gitignorePath) ?? ""; + expect(gitignoreContent).toContain(".aidd/cache/"); + }); + + it("calls gitignore exactly once, with all three families of entries", async () => { + const deps = await buildUnitDeps(PROJECT_ROOT); + await initAndInstall(deps, PROJECT_ROOT, "claude"); + const manifest = await deps.manifestRepo.load(); + if (manifest === null) throw new Error("manifest not found"); + + const recordingGitignore = new RecordingGitignoreUseCase(deps.fs); + await new PostInstallPipelineUseCase(deps.manifestRepo, recordingGitignore).execute({ + projectRoot: PROJECT_ROOT, + manifest, + }); + + expect(recordingGitignore.calls).toHaveLength(1); + expect(recordingGitignore.calls[0]).toEqual( + expect.arrayContaining([".aidd/cache/", "aidd_docs/runs/", ".claude/settings.local.json"]) + ); + }); + + it("ignores the run journal, and nothing wider", async () => { + const deps = await buildUnitDeps(PROJECT_ROOT); + await initAndInstall(deps, PROJECT_ROOT, "claude"); + const manifest = await deps.manifestRepo.load(); + if (manifest === null) throw new Error("manifest not found"); + + await new PostInstallPipelineUseCase(deps.manifestRepo, deps.gitignoreUseCase).execute({ + projectRoot: PROJECT_ROOT, + manifest, + }); + + const gitignoreContent = deps.fs.getFile(join(PROJECT_ROOT, ".gitignore")) ?? ""; + expect(gitignoreContent).toContain("aidd_docs/runs/"); + expect(gitignoreContent).not.toContain("aidd_docs/*"); + expect(gitignoreContent).not.toMatch(/^aidd_docs\/$/mu); + }); +}); diff --git a/cli/tests/contexts/framework/application/list-installed-rules-use-case.unit.test.ts b/cli/tests/contexts/framework/application/list-installed-rules-use-case.unit.test.ts new file mode 100644 index 000000000..c4755378a --- /dev/null +++ b/cli/tests/contexts/framework/application/list-installed-rules-use-case.unit.test.ts @@ -0,0 +1,107 @@ +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; +// Side-effect imports: this use case asks the registry which tools have rules at all, so a +// tool that never registered is a tool it silently cannot see. +import "../../../../src/contexts/tools/domain/profiles/claude/profile.js"; +import "../../../../src/contexts/tools/domain/profiles/codex/profile.js"; +import "../../../../src/contexts/tools/domain/profiles/copilot/profile.js"; +import "../../../../src/contexts/tools/domain/profiles/cursor/profile.js"; +import "../../../../src/contexts/tools/domain/profiles/opencode/profile.js"; +import { ListInstalledRulesUseCase } from "../../../../src/contexts/framework/application/list-installed-rules-use-case.js"; +import type { FileReader } from "../../../../src/kernel/ports/file-reader.js"; + +const ROOT = "/project"; + +/** The four members this use case never calls reject rather than answer a placeholder, which + * would let it start reading through the wrong member and still look green. */ +function readerOf(files: Readonly>): FileReader { + const unused = (member: string) => (): never => { + throw new Error(`this use case does not call ${member}`); + }; + return { + listFilesRecursive: async (dir: string) => + Object.keys(files).filter((path) => path.startsWith(dir.replaceAll("\\", "/"))), + readFile: async (path: string) => files[path.replaceAll("\\", "/")] ?? "", + listDirectory: unused("listDirectory"), + fileExists: unused("fileExists"), + readFileHash: unused("readFileHash"), + isExecutable: unused("isExecutable"), + realpath: unused("realpath"), + }; +} + +const at = (relative: string) => join(ROOT, relative).replaceAll("\\", "/"); + +describe("ListInstalledRulesUseCase — every tool's installed rules, in one answer", () => { + it("finds a rule under each tool's own installed directory", async () => { + const useCase = new ListInstalledRulesUseCase( + readerOf({ + [at(".claude/rules/01-standards/1-naming.md")]: "---\ndescription: Names\n---\n", + [at(".cursor/rules/1-naming.mdc")]: "---\n---\n", + [at(".github/instructions/01-naming.instructions.md")]: "---\n---\n", + [at(".codex/rules/1-naming.md")]: "---\n---\n", + [at(".opencode/rules/1-naming.md")]: "---\n---\n", + }) + ); + + const { rules } = await useCase.execute({ projectRoot: ROOT }); + + expect(rules.map((rule) => rule.tool).sort()).toEqual([ + "claude", + "codex", + "copilot", + "cursor", + "opencode", + ]); + }); + + // `content-translator.ts` installs a plugin's `rules/` into every tool whose capability + // accepts them, Codex included, so a Codex project holding rules must never be told none. + it("answers for Codex, which the script it replaces skipped outright", async () => { + const useCase = new ListInstalledRulesUseCase( + readerOf({ [at(".codex/rules/1-naming.md")]: "---\ndescription: Names\n---\n" }) + ); + + const { rules } = await useCase.execute({ projectRoot: ROOT }); + + expect(rules).toEqual([ + { + tool: "codex", + path: ".codex/rules/1-naming.md", + name: "1-naming", + description: "Names", + }, + ]); + }); + + it("reports a path relative to the project, never the machine it ran on", async () => { + const useCase = new ListInstalledRulesUseCase( + readerOf({ [at(".claude/rules/deep/nested/1-naming.md")]: "---\n---\n" }) + ); + + const { rules } = await useCase.execute({ projectRoot: ROOT }); + + expect(rules[0]?.path).toBe(".claude/rules/deep/nested/1-naming.md"); + }); + + // The extension is the only thing separating a rule from a stray file beside it, and it + // comes from the installer, never from a list written here. + it("passes over a file whose extension is not the one that tool installs", async () => { + const useCase = new ListInstalledRulesUseCase( + readerOf({ + [at(".cursor/rules/1-naming.mdc")]: "---\n---\n", + [at(".cursor/rules/README.md")]: "---\n---\n", + }) + ); + + const { rules } = await useCase.execute({ projectRoot: ROOT }); + + expect(rules.map((rule) => rule.path)).toEqual([".cursor/rules/1-naming.mdc"]); + }); + + it("answers an empty list, never an error, for a project holding no rule at all", async () => { + const useCase = new ListInstalledRulesUseCase(readerOf({})); + + await expect(useCase.execute({ projectRoot: ROOT })).resolves.toEqual({ rules: [] }); + }); +}); diff --git a/cli/tests/contexts/framework/application/plugin/plugin-add-skip-warn.integration.test.ts b/cli/tests/contexts/framework/application/plugin/plugin-add-skip-warn.integration.test.ts new file mode 100644 index 000000000..59afb8ba1 --- /dev/null +++ b/cli/tests/contexts/framework/application/plugin/plugin-add-skip-warn.integration.test.ts @@ -0,0 +1,68 @@ +/** Every registered tool now runs what a plugin's `hooks/` ships, so no live fixture reaches + * `collectHooksSkips`'s non-empty branch; the warn format is pinned tool-agnostically below. */ +import "../../../../../src/contexts/tools/domain/profiles/opencode/profile.js"; +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; +import { PluginAddUseCase } from "../../../../../src/contexts/framework/application/plugin/plugin-add-use-case.js"; +import { PluginDistributionReaderAdapter } from "../../../../../src/contexts/framework/infrastructure/plugin-distribution-reader-adapter.js"; +import type { ReadonlySkipList } from "../../../../../src/contexts/translate/domain/plugin-translation-skip.js"; +import { buildUnitDeps, initAndInstall } from "../../../../helpers/ports/build-unit-deps.js"; +import { CapturingLogger } from "../../../../helpers/ports/capturing-logger.js"; +import { fakeEnsureBuiltMarketplace } from "../../../../helpers/ports/fake-ensure-built-marketplace.js"; +import { InMemoryMarketplaceRegistry } from "../../../../helpers/ports/in-memory-marketplace-registry.js"; +import { seedFromDirectory } from "../../../../helpers/ports/seed-from-directory.js"; + +const PLUGIN_FIXTURE = join(process.cwd(), "tests/fixtures/plugins/claude-format/sample-plugin"); +const PROJECT_ROOT = "/test-project"; + +describe("PluginAddUseCase skip warnings", () => { + describe("when a plugin's hooks are now accepted (no skip entry)", () => { + it("emits no logger.warn — OpenCode delivers sample-plugin's hooks instead of skipping them", async () => { + const deps = await buildUnitDeps(PROJECT_ROOT); + await initAndInstall(deps, PROJECT_ROOT, "opencode"); + await seedFromDirectory(deps.fs, PLUGIN_FIXTURE, { useAbsolutePaths: true }); + const capturingLogger = new CapturingLogger(); + const registry = new InMemoryMarketplaceRegistry(); + const useCase = new PluginAddUseCase( + deps.fs, + deps.manifestRepo, + deps.pluginFetcher, + new PluginDistributionReaderAdapter(deps.fs), + deps.hasher, + capturingLogger, + registry, + fakeEnsureBuiltMarketplace() + ); + await useCase.execute({ + source: { kind: "local", path: PLUGIN_FIXTURE }, + toolIds: ["opencode"], + projectRoot: PROJECT_ROOT, + interactive: false, + }); + expect(capturingLogger.warnMessages).toEqual([]); + }); + }); + + describe("warn message format", () => { + it("formats skip warnings as Plugin : skipped for ", () => { + const logger = new CapturingLogger(); + const skipped: ReadonlySkipList = [ + { + pluginName: "aidd-pm", + component: "hooks", + toolId: "opencode", + reason: "OpenCode plugin runtime is JS modules; declarative hooks.json is not supported.", + }, + ]; + for (const entry of skipped) { + logger.warn( + `Plugin "${entry.pluginName}": ${entry.component} skipped for ${entry.toolId} — ${entry.reason}` + ); + } + expect(logger.warnMessages).toHaveLength(1); + expect(logger.warnMessages[0]).toBe( + 'Plugin "aidd-pm": hooks skipped for opencode — OpenCode plugin runtime is JS modules; declarative hooks.json is not supported.' + ); + }); + }); +}); diff --git a/cli/tests/application/use-cases/plugin/plugin-add-use-case.unit.test.ts b/cli/tests/contexts/framework/application/plugin/plugin-add-use-case.unit.test.ts similarity index 87% rename from cli/tests/application/use-cases/plugin/plugin-add-use-case.unit.test.ts rename to cli/tests/contexts/framework/application/plugin/plugin-add-use-case.unit.test.ts index 41e47bc9b..610d3e316 100644 --- a/cli/tests/application/use-cases/plugin/plugin-add-use-case.unit.test.ts +++ b/cli/tests/contexts/framework/application/plugin/plugin-add-use-case.unit.test.ts @@ -1,15 +1,18 @@ import { join } from "node:path"; import { describe, expect, it, vi } from "vitest"; -import { PluginAddUseCase } from "../../../../src/application/use-cases/plugin/plugin-add-use-case.js"; -import { DuplicatePluginError, MissingPluginMetadataError } from "../../../../src/domain/errors.js"; -import { Marketplace } from "../../../../src/domain/models/marketplace.js"; -import { PluginDistribution } from "../../../../src/domain/models/plugin-distribution.js"; -import type { PluginDistributionReader } from "../../../../src/domain/ports/plugin-distribution-reader.js"; -import { PluginDistributionReaderAdapter } from "../../../../src/infrastructure/adapters/plugin-distribution-reader-adapter.js"; -import { buildUnitDeps, initAndInstall } from "../../../helpers/ports/build-unit-deps.js"; -import { fakeEnsureBuiltMarketplace } from "../../../helpers/ports/fake-ensure-built-marketplace.js"; -import { InMemoryMarketplaceRegistry } from "../../../helpers/ports/in-memory-marketplace-registry.js"; -import { seedFromDirectory } from "../../../helpers/ports/seed-from-directory.js"; +import { Marketplace } from "../../../../../src/contexts/distribution/domain/marketplace.js"; +import { PluginAddUseCase } from "../../../../../src/contexts/framework/application/plugin/plugin-add-use-case.js"; +import type { PluginDistributionReader } from "../../../../../src/contexts/framework/domain/ports/plugin-distribution-reader.js"; +import { PluginDistributionReaderAdapter } from "../../../../../src/contexts/framework/infrastructure/plugin-distribution-reader-adapter.js"; +import { PluginDistribution } from "../../../../../src/contexts/translate/domain/plugin-distribution.js"; +import { + DuplicatePluginError, + MissingPluginMetadataError, +} from "../../../../../src/kernel/errors.js"; +import { buildUnitDeps, initAndInstall } from "../../../../helpers/ports/build-unit-deps.js"; +import { fakeEnsureBuiltMarketplace } from "../../../../helpers/ports/fake-ensure-built-marketplace.js"; +import { InMemoryMarketplaceRegistry } from "../../../../helpers/ports/in-memory-marketplace-registry.js"; +import { seedFromDirectory } from "../../../../helpers/ports/seed-from-directory.js"; const PLUGIN_FIXTURE = join(process.cwd(), "tests/fixtures/plugins/claude-format/sample-plugin"); const PROJECT_ROOT = "/test-project"; @@ -216,11 +219,45 @@ describe("PluginAddUseCase", () => { const PLUGIN_METADATA = { name: "sample-plugin", version: "1.0.0", strict: false }; + describe("the catalog's strict", () => { + it("lands on every installed entry, whatever the plugin's own manifest says", async () => { + const deps = await buildUnitDeps(PROJECT_ROOT); + await initAndInstall(deps, PROJECT_ROOT, "opencode"); + deps.fs.setFile( + "/built/opencode/.opencode/skills/sample-plugin/demo/SKILL.md", + "# Demo skill" + ); + await seedFromDirectory(deps.fs, PLUGIN_FIXTURE, { useAbsolutePaths: true }); + deps.pluginFetcher.register(GIT_SUBDIR_SOURCE, PLUGIN_FIXTURE); + const useCase = new PluginAddUseCase( + deps.fs, + deps.manifestRepo, + deps.pluginFetcher, + new PluginDistributionReaderAdapter(deps.fs), + deps.hasher, + deps.logger, + await makeGithubRegistry(PROJECT_ROOT), + fakeEnsureBuiltMarketplace() + ); + await useCase.execute({ + source: GIT_SUBDIR_SOURCE, + toolIds: ["opencode"], + projectRoot: PROJECT_ROOT, + marketplace: "aidd-framework", + interactive: false, + pluginMetadata: { ...PLUGIN_METADATA, strict: true }, + }); + const manifest = await deps.manifestRepo.load(); + const installed = manifest?.getPlugins("opencode").find((p) => p.name === "sample-plugin"); + expect(installed?.strict).toBe(true); + }); + }); + describe("opencode", () => { it("fetches and materializes flat files", async () => { const deps = await buildUnitDeps(PROJECT_ROOT); await initAndInstall(deps, PROJECT_ROOT, "opencode"); - // OpenCode now copies its per-target flat BUILT tree (skills nested under + // OpenCode copies its per-target flat BUILT tree (skills nested under // /, agents namespaced -). deps.fs.setFile( "/built/opencode/.opencode/skills/sample-plugin/demo/SKILL.md", @@ -265,7 +302,7 @@ describe("PluginAddUseCase", () => { deps.pluginFetcher.register(GIT_SUBDIR_SOURCE, PLUGIN_FIXTURE); const registry = await makeGithubRegistry(PROJECT_ROOT); const fetchSpy = vi.spyOn(deps.pluginFetcher, "fetch"); - // Cursor now copies the per-target BUILT tree verbatim; seed it. + // Cursor copies the per-target BUILT tree verbatim; seed it. deps.fs.setFile("/built/cursor/plugins/sample-plugin/skills/demo/SKILL.md", "# Demo skill"); const useCase = new PluginAddUseCase( deps.fs, @@ -466,7 +503,7 @@ describe("PluginAddUseCase", () => { it("materializes flat files even when source is local marketplace", async () => { const deps = await buildUnitDeps(PROJECT_ROOT); await initAndInstall(deps, PROJECT_ROOT, "opencode"); - // OpenCode now copies its per-target flat BUILT tree (skills nested under + // OpenCode copies its per-target flat BUILT tree (skills nested under // /, agents namespaced -). deps.fs.setFile( "/built/opencode/.opencode/skills/sample-plugin/demo/SKILL.md", @@ -513,10 +550,8 @@ describe("PluginAddUseCase", () => { describe("zero-files guard regression (Blocker 2)", () => { it("native tool + local source + marketplace + zero-translation distribution → manifest entry NOT added", async () => { - // Regression: on main, if translateWithComponentPaths yields zero files the plugin is - // NOT added to the manifest. Before this fix, ModeAMarketplaceTranslator bypassed the guard. - // A distribution with no recognized manifest path produces zero translated files for - // any native tool (findSourceManifestContent returns null, no component files → files=[]). + // A distribution with no recognized manifest path produces zero translated files for a + // native tool, and a plugin with zero files is never added to the manifest. const deps = await buildUnitDeps(PROJECT_ROOT); await initAndInstall(deps, PROJECT_ROOT, "claude"); const zeroFilesReader: PluginDistributionReader = { diff --git a/cli/tests/application/use-cases/plugin/plugin-install-from-marketplace-use-case.unit.test.ts b/cli/tests/contexts/framework/application/plugin/plugin-install-from-marketplace-use-case.unit.test.ts similarity index 89% rename from cli/tests/application/use-cases/plugin/plugin-install-from-marketplace-use-case.unit.test.ts rename to cli/tests/contexts/framework/application/plugin/plugin-install-from-marketplace-use-case.unit.test.ts index b051d285c..f451f7be5 100644 --- a/cli/tests/application/use-cases/plugin/plugin-install-from-marketplace-use-case.unit.test.ts +++ b/cli/tests/contexts/framework/application/plugin/plugin-install-from-marketplace-use-case.unit.test.ts @@ -1,23 +1,23 @@ import { join } from "node:path"; import { describe, expect, it, vi } from "vitest"; -import { PluginAddUseCase } from "../../../../src/application/use-cases/plugin/plugin-add-use-case.js"; -import { PluginInstallFromMarketplaceUseCase } from "../../../../src/application/use-cases/plugin/plugin-install-from-marketplace-use-case.js"; -import { FetchMarketplaceSourceUseCase } from "../../../../src/application/use-cases/shared/fetch-marketplace-source-use-case.js"; -import { ResolveMarketplaceUseCase } from "../../../../src/application/use-cases/shared/resolve-marketplace-use-case.js"; +import { FetchMarketplaceSourceUseCase } from "../../../../../src/contexts/distribution/application/fetch-marketplace-source-use-case.js"; +import { ResolveMarketplaceUseCase } from "../../../../../src/contexts/distribution/application/resolve-marketplace-use-case.js"; +import { Marketplace } from "../../../../../src/contexts/distribution/domain/marketplace.js"; +import { PluginCatalogRepositoryAdapter } from "../../../../../src/contexts/distribution/infrastructure/plugin-catalog-repository-adapter.js"; +import { PluginAddUseCase } from "../../../../../src/contexts/framework/application/plugin/plugin-add-use-case.js"; +import { PluginInstallFromMarketplaceUseCase } from "../../../../../src/contexts/framework/application/plugin/plugin-install-from-marketplace-use-case.js"; +import { PluginDistributionReaderAdapter } from "../../../../../src/contexts/framework/infrastructure/plugin-distribution-reader-adapter.js"; import { AmbiguousPluginMatchError, PluginNotInMarketplaceError, VersionMismatchError, -} from "../../../../src/domain/errors.js"; -import { Marketplace } from "../../../../src/domain/models/marketplace.js"; -import { PluginCatalogRepositoryAdapter } from "../../../../src/infrastructure/adapters/plugin-catalog-repository-adapter.js"; -import { PluginDistributionReaderAdapter } from "../../../../src/infrastructure/adapters/plugin-distribution-reader-adapter.js"; -import { buildUnitDeps, initAndInstall } from "../../../helpers/ports/build-unit-deps.js"; -import { fakeEnsureBuiltMarketplace } from "../../../helpers/ports/fake-ensure-built-marketplace.js"; -import type { InMemoryFileAdapter } from "../../../helpers/ports/in-memory-file-adapter.js"; -import { InMemoryMarketplaceRegistry } from "../../../helpers/ports/in-memory-marketplace-registry.js"; -import { KeepPrompter } from "../../../helpers/ports/scripted-prompter.js"; -import { seedFromDirectory } from "../../../helpers/ports/seed-from-directory.js"; +} from "../../../../../src/kernel/errors.js"; +import { buildUnitDeps, initAndInstall } from "../../../../helpers/ports/build-unit-deps.js"; +import { fakeEnsureBuiltMarketplace } from "../../../../helpers/ports/fake-ensure-built-marketplace.js"; +import type { InMemoryFileAdapter } from "../../../../helpers/ports/in-memory-file-adapter.js"; +import { InMemoryMarketplaceRegistry } from "../../../../helpers/ports/in-memory-marketplace-registry.js"; +import { KeepPrompter } from "../../../../helpers/ports/scripted-prompter.js"; +import { seedFromDirectory } from "../../../../helpers/ports/seed-from-directory.js"; const PLUGIN_FIXTURE = join(process.cwd(), "tests/fixtures/plugins/claude-format/sample-plugin"); const PROJECT_ROOT = "/test-project"; @@ -251,7 +251,6 @@ describe("PluginInstallFromMarketplaceUseCase", () => { plugins: [{ name: "sample-plugin", source: { kind: "local", path: "sample-plugin" } }], }) ); - // github marketplace source → local catalog dir; resolved git-subdir plugin → on-disk fixture deps.pluginFetcher.register(githubSource, GH_MKT_DIR); deps.pluginFetcher.register( { diff --git a/cli/tests/application/use-cases/plugin/plugin-install-use-case.unit.test.ts b/cli/tests/contexts/framework/application/plugin/plugin-install-use-case.unit.test.ts similarity index 77% rename from cli/tests/application/use-cases/plugin/plugin-install-use-case.unit.test.ts rename to cli/tests/contexts/framework/application/plugin/plugin-install-use-case.unit.test.ts index 55c9031d4..9d2e72355 100644 --- a/cli/tests/application/use-cases/plugin/plugin-install-use-case.unit.test.ts +++ b/cli/tests/contexts/framework/application/plugin/plugin-install-use-case.unit.test.ts @@ -1,19 +1,20 @@ -import "../../../../src/domain/tools/ai/claude.js"; -import "../../../../src/domain/tools/ai/cursor.js"; +import "../../../../../src/contexts/tools/domain/profiles/claude/profile.js"; +import "../../../../../src/contexts/tools/domain/profiles/cursor/profile.js"; import { join } from "node:path"; import { describe, expect, it, vi } from "vitest"; -import type { PluginAdd } from "../../../../src/application/use-cases/plugin/plugin-add-use-case.js"; -import type { PluginInstallFromMarketplace } from "../../../../src/application/use-cases/plugin/plugin-install-from-marketplace-use-case.js"; -import { PluginInstallUseCase } from "../../../../src/application/use-cases/plugin/plugin-install-use-case.js"; -import type { PluginPick } from "../../../../src/application/use-cases/plugin/plugin-pick-use-case.js"; +import type { MarketplaceTrustStore } from "../../../../../src/contexts/distribution/domain/ports/marketplace-trust-store.js"; +import type { PluginAdd } from "../../../../../src/contexts/framework/application/plugin/plugin-add-use-case.js"; +import type { PluginInstallFromMarketplace } from "../../../../../src/contexts/framework/application/plugin/plugin-install-from-marketplace-use-case.js"; +import { PluginInstallUseCase } from "../../../../../src/contexts/framework/application/plugin/plugin-install-use-case.js"; import { InteractiveOnlyError, InvalidPluginScopeError, TrustDeniedError, -} from "../../../../src/domain/errors.js"; -import type { MarketplaceTrustStore } from "../../../../src/domain/ports/marketplace-trust-store.js"; -import type { Prompter } from "../../../../src/domain/ports/prompter.js"; -import { InMemoryManifestRepository } from "../../../helpers/ports/in-memory-manifest-repository.js"; +} from "../../../../../src/kernel/errors.js"; +import type { Prompter } from "../../../../../src/kernel/ports/prompter.js"; +import type { PluginPick } from "../../../../../src/presentation/prompts/plugin-pick-use-case.js"; +import { InMemoryEnvironment } from "../../../../helpers/ports/in-memory-environment.js"; +import { InMemoryManifestRepository } from "../../../../helpers/ports/in-memory-manifest-repository.js"; const PLUGIN_FIXTURE = join(process.cwd(), "tests/fixtures/plugins/claude-format/sample-plugin"); const PROJECT_ROOT = "/test-project"; @@ -42,6 +43,7 @@ function makeUseCases(overrides?: { marketplaceExecute?: ReturnType; trustStore?: MarketplaceTrustStore; prompter?: Prompter; + environment?: InMemoryEnvironment; }) { const pickExecute = overrides?.pickExecute ?? vi.fn(); const addExecute = overrides?.addExecute ?? vi.fn(); @@ -54,6 +56,7 @@ function makeUseCases(overrides?: { const manifestRepo = new InMemoryManifestRepository(); const trustStore = overrides?.trustStore ?? makeAlwaysTrustStore(); const prompter = overrides?.prompter ?? makeSilentPrompter(); + const environment = overrides?.environment ?? new InMemoryEnvironment(); return { pluginPickUseCase, pluginAddUseCase, @@ -61,6 +64,7 @@ function makeUseCases(overrides?: { manifestRepo, trustStore, prompter, + environment, pickExecute, addExecute, marketplaceExecute, @@ -75,6 +79,7 @@ function makeUseCase(overrides?: Parameters[0]): PluginInst manifestRepo, trustStore, prompter, + environment, } = makeUseCases(overrides); return new PluginInstallUseCase( pluginPickUseCase, @@ -82,7 +87,8 @@ function makeUseCase(overrides?: Parameters[0]): PluginInst pluginInstallFromMarketplaceUseCase, manifestRepo, trustStore, - prompter + prompter, + environment ); } @@ -182,7 +188,7 @@ describe("PluginInstallUseCase", () => { expect(result.kind).toBe("local"); }); - it("delegates to PluginInstallFromMarketplaceUseCase when arg is a plugin name", async () => { + it("delegates to PluginInstallFromMarketplace when arg is a plugin name", async () => { const marketplaceExecute = vi.fn().mockResolvedValue({ entry: { name: "my-plugin" } }); const result = await makeUseCase({ marketplaceExecute }).execute({ @@ -278,4 +284,35 @@ describe("PluginInstallUseCase", () => { expect(trustStore.isTrusted).not.toHaveBeenCalled(); }); }); + + describe("token publication", () => { + it("publishes --token through the environment, for a fetcher built before the flag arrived", async () => { + const environment = new InMemoryEnvironment(); + const marketplaceExecute = vi.fn().mockResolvedValue({ entry: { name: "my-plugin" } }); + + await makeUseCase({ marketplaceExecute, environment }).execute({ + pluginArg: "my-plugin", + toolIds: "all", + projectRoot: PROJECT_ROOT, + interactive: false, + token: "ghp_from_flag", + }); + + expect(environment.get("AIDD_TOKEN")).toBe("ghp_from_flag"); + }); + + it("publishes nothing when no token is passed", async () => { + const environment = new InMemoryEnvironment(); + const marketplaceExecute = vi.fn().mockResolvedValue({ entry: { name: "my-plugin" } }); + + await makeUseCase({ marketplaceExecute, environment }).execute({ + pluginArg: "my-plugin", + toolIds: "all", + projectRoot: PROJECT_ROOT, + interactive: false, + }); + + expect(environment.get("AIDD_TOKEN")).toBeUndefined(); + }); + }); }); diff --git a/cli/tests/contexts/framework/application/plugin/plugin-list-use-case.unit.test.ts b/cli/tests/contexts/framework/application/plugin/plugin-list-use-case.unit.test.ts new file mode 100644 index 000000000..c7e3f8fe3 --- /dev/null +++ b/cli/tests/contexts/framework/application/plugin/plugin-list-use-case.unit.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, it } from "vitest"; +import "../../../../../src/contexts/tools/domain/profiles/claude/profile.js"; +import { PluginListUseCase } from "../../../../../src/contexts/framework/application/plugin/plugin-list-use-case.js"; +import { Manifest } from "../../../../../src/contexts/framework/domain/manifest.js"; +import { InstalledPlugin } from "../../../../../src/contexts/framework/domain/plugins/installed-plugin.js"; +import type { ManifestRepository } from "../../../../../src/contexts/framework/domain/ports/manifest-repository.js"; + +function makeManifestWithPlugin(): Manifest { + const manifest = Manifest.create(); + manifest.addTool("claude", "1.0.0", []); + const plugin = InstalledPlugin.fromJSON({ + name: "sample-plugin", + source: { kind: "local", path: "./sample" }, + version: "1.0.0", + strict: false, + files: {}, + scope: "project", + }); + manifest.addPlugin("claude", plugin); + return manifest; +} + +function makeManifestRepository(manifest: Manifest): ManifestRepository { + return { + path: "/test-project/.aidd/manifest.json", + load: async () => manifest, + save: async () => {}, + delete: async () => {}, + }; +} + +describe("PluginListUseCase", () => { + describe("list plugins for installed tool", () => { + it("returns map with installed plugins for requested tool", async () => { + const manifest = makeManifestWithPlugin(); + const repo = makeManifestRepository(manifest); + const useCase = new PluginListUseCase(repo); + const result = await useCase.execute({ toolIds: ["claude"] }); + expect(result.has("claude")).toBe(true); + const plugins = result.get("claude") ?? []; + expect(plugins).toHaveLength(1); + expect(plugins[0].name).toBe("sample-plugin"); + expect(plugins[0].version).toBe("1.0.0"); + }); + + it("returns empty list for tool with no plugins", async () => { + const manifest = Manifest.create(); + manifest.addTool("claude", "1.0.0", []); + const repo = makeManifestRepository(manifest); + const useCase = new PluginListUseCase(repo); + const result = await useCase.execute({ toolIds: ["claude"] }); + expect(result.get("claude")).toHaveLength(0); + }); + }); +}); diff --git a/cli/tests/contexts/framework/application/plugin/plugin-remove-cache.integration.test.ts b/cli/tests/contexts/framework/application/plugin/plugin-remove-cache.integration.test.ts new file mode 100644 index 000000000..dddbf2bf6 --- /dev/null +++ b/cli/tests/contexts/framework/application/plugin/plugin-remove-cache.integration.test.ts @@ -0,0 +1,177 @@ +/** + * Unlike `clean`'s codex path, this purge is never gated on emptiness: the plugin's cache is + * what the removal asks the host to forget, so it goes only once the host confirms. + */ +import { homedir } from "node:os"; +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; +import "../../../../../src/contexts/tools/domain/profiles/claude/profile.js"; +import { ModeAMarketplaceTranslator } from "../../../../../src/contexts/framework/application/framework/translator/mode-a-marketplace-translator.js"; +import { PluginRemoveUseCase } from "../../../../../src/contexts/framework/application/plugin/plugin-remove-use-case.js"; +import { Manifest } from "../../../../../src/contexts/framework/domain/manifest.js"; +import { PluginDistribution } from "../../../../../src/contexts/translate/domain/plugin-distribution.js"; +import { CapturingLogger } from "../../../../helpers/ports/capturing-logger.js"; +import { FakeNativePluginActivator } from "../../../../helpers/ports/fake-native-plugin-activator.js"; +import { InMemoryFileAdapter } from "../../../../helpers/ports/in-memory-file-adapter.js"; +import { InMemoryManifestRepository } from "../../../../helpers/ports/in-memory-manifest-repository.js"; + +const PROJECT_ROOT = "/test-project"; +const ALIAS = "my-local-alias"; +const HOST_NAME = "upstream-catalog-name"; +const PLUGIN_NAME = "aidd-telemetry"; +// The host is addressed by `hostName`, never by `ALIAS`, this project's own local key, +// which a host never learns. +const REF = `${PLUGIN_NAME}@${HOST_NAME}`; +const CLAUDE_CACHE_ROOT = join(homedir(), ".claude", "plugins", "cache"); + +function buildDist(): PluginDistribution { + return new PluginDistribution({ + manifest: { name: PLUGIN_NAME, version: "1.0.0" }, + format: "claude", + files: [{ relativePath: "commands/hello.md", content: "# Hello" }], + components: { + commands: [{ relativePath: "commands/hello.md", content: "# Hello" }], + agents: [], + rules: [], + skills: [], + hooks: [], + mcp: [], + }, + }); +} + +async function seedManifest(): Promise { + const manifest = Manifest.create(); + manifest.addTool("claude", "test", []); + await new ModeAMarketplaceTranslator().addPlugin( + buildDist(), + "claude", + { kind: "local", path: "/plugin-source" }, + PROJECT_ROOT, + manifest, + ALIAS + ); + // hostName differs from this project's own local alias — the same divergence + // `clean`'s own marketplace cache purge already addresses by hostName, never alias. + manifest.setNativeRegistrations("claude", { + binary: "claude", + marketplaces: [{ alias: ALIAS, hostName: HOST_NAME }], + pluginRefs: [REF], + }); + return manifest; +} + +describe("PluginRemoveUseCase purges the plugin's own cache subtree", () => { + it("purges cache/// once the host confirms it uninstalled, even with content still inside", async () => { + const fs = new InMemoryFileAdapter(); + const cacheEntry = join(CLAUDE_CACHE_ROOT, HOST_NAME, PLUGIN_NAME, "1.0.0", "plugin.json"); + await fs.writeFile(cacheEntry, "{}"); + const manifestRepo = new InMemoryManifestRepository(await seedManifest(), PROJECT_ROOT); + const activator = new FakeNativePluginActivator({ available: true }); + const logger = new CapturingLogger(); + const removeUseCase = new PluginRemoveUseCase( + fs, + manifestRepo, + logger, + new Map([["claude", activator]]) + ); + + await removeUseCase.execute({ + pluginName: PLUGIN_NAME, + toolIds: ["claude"], + projectRoot: PROJECT_ROOT, + }); + + expect(activator.uninstalledPlugins).toEqual([REF]); + expect(await fs.fileExists(cacheEntry)).toBe(false); + }); + + it("leaves the plugin's cache in place, and names it, when the host CLI is not on PATH", async () => { + const fs = new InMemoryFileAdapter(); + const cacheEntry = join(CLAUDE_CACHE_ROOT, HOST_NAME, PLUGIN_NAME, "1.0.0", "plugin.json"); + await fs.writeFile(cacheEntry, "{}"); + const manifestRepo = new InMemoryManifestRepository(await seedManifest(), PROJECT_ROOT); + const activator = new FakeNativePluginActivator({ available: false }); + const logger = new CapturingLogger(); + const removeUseCase = new PluginRemoveUseCase( + fs, + manifestRepo, + logger, + new Map([["claude", activator]]) + ); + + await removeUseCase.execute({ + pluginName: PLUGIN_NAME, + toolIds: ["claude"], + projectRoot: PROJECT_ROOT, + }); + + expect(await fs.fileExists(cacheEntry)).toBe(true); + expect(logger.warnMessages.some((m) => m.includes("its own removal was not confirmed"))).toBe( + true + ); + }); + + it("leaves the plugin's cache in place when the host CLI reports the plugin already absent", async () => { + const fs = new InMemoryFileAdapter(); + const cacheEntry = join(CLAUDE_CACHE_ROOT, HOST_NAME, PLUGIN_NAME, "1.0.0", "plugin.json"); + await fs.writeFile(cacheEntry, "{}"); + const manifestRepo = new InMemoryManifestRepository(await seedManifest(), PROJECT_ROOT); + const activator = new FakeNativePluginActivator({ available: true, failOnUninstall: [REF] }); + const logger = new CapturingLogger(); + const removeUseCase = new PluginRemoveUseCase( + fs, + manifestRepo, + logger, + new Map([["claude", activator]]) + ); + + await removeUseCase.execute({ + pluginName: PLUGIN_NAME, + toolIds: ["claude"], + projectRoot: PROJECT_ROOT, + }); + + expect(await fs.fileExists(cacheEntry)).toBe(true); + }); + + it("refuses a '..' segment in a manifest's own hostName, never purging outside the declared cache root", async () => { + const evilHostName = "../../../evil"; + const fs = new InMemoryFileAdapter(); + const witness = join(CLAUDE_CACHE_ROOT, evilHostName, PLUGIN_NAME, "keep-me.txt"); + await fs.writeFile(witness, "still here"); + const manifest = Manifest.create(); + manifest.addTool("claude", "test", []); + await new ModeAMarketplaceTranslator().addPlugin( + buildDist(), + "claude", + { kind: "local", path: "/plugin-source" }, + PROJECT_ROOT, + manifest, + ALIAS + ); + manifest.setNativeRegistrations("claude", { + binary: "claude", + marketplaces: [{ alias: ALIAS, hostName: evilHostName }], + pluginRefs: [`${PLUGIN_NAME}@${ALIAS}`], + }); + const manifestRepo = new InMemoryManifestRepository(manifest, PROJECT_ROOT); + const activator = new FakeNativePluginActivator({ available: true }); + const logger = new CapturingLogger(); + const removeUseCase = new PluginRemoveUseCase( + fs, + manifestRepo, + logger, + new Map([["claude", activator]]) + ); + + await removeUseCase.execute({ + pluginName: PLUGIN_NAME, + toolIds: ["claude"], + projectRoot: PROJECT_ROOT, + }); + + expect(await fs.fileExists(witness)).toBe(true); + expect(logger.warnMessages.some((m) => m.includes("does not resolve inside"))).toBe(true); + }); +}); diff --git a/cli/tests/contexts/framework/application/plugin/plugin-remove-native-activation.integration.test.ts b/cli/tests/contexts/framework/application/plugin/plugin-remove-native-activation.integration.test.ts new file mode 100644 index 000000000..2468e8f25 --- /dev/null +++ b/cli/tests/contexts/framework/application/plugin/plugin-remove-native-activation.integration.test.ts @@ -0,0 +1,353 @@ +// `claude`, `codex` and `copilot` only load a plugin once their own CLI has registered it, so +// removal must drive `uninstallPlugin` with the same `@` ref install used. +import "../../../../../src/contexts/tools/domain/profiles/claude/profile.js"; +import { describe, expect, it, vi } from "vitest"; +import { ModeAMarketplaceTranslator } from "../../../../../src/contexts/framework/application/framework/translator/mode-a-marketplace-translator.js"; +import { PluginRemoveUseCase } from "../../../../../src/contexts/framework/application/plugin/plugin-remove-use-case.js"; +import { Manifest } from "../../../../../src/contexts/framework/domain/manifest.js"; +import { PluginDistribution } from "../../../../../src/contexts/translate/domain/plugin-distribution.js"; +import { CapturingLogger } from "../../../../helpers/ports/capturing-logger.js"; +import { FakeHostPluginRegistryReader } from "../../../../helpers/ports/fake-host-plugin-registry-reader.js"; +import { FakeNativePluginActivator } from "../../../../helpers/ports/fake-native-plugin-activator.js"; +import { InMemoryFileAdapter } from "../../../../helpers/ports/in-memory-file-adapter.js"; +import { InMemoryManifestRepository } from "../../../../helpers/ports/in-memory-manifest-repository.js"; + +const PROJECT_ROOT = "/test-project"; +const MARKETPLACE_NAME = "aidd-framework"; +const PLUGIN_NAME = "aidd-telemetry"; +const REF = `${PLUGIN_NAME}@${MARKETPLACE_NAME}`; + +function buildDist(): PluginDistribution { + return new PluginDistribution({ + manifest: { name: PLUGIN_NAME, version: "1.0.0" }, + format: "claude", + files: [{ relativePath: "commands/hello.md", content: "# Hello" }], + components: { + commands: [{ relativePath: "commands/hello.md", content: "# Hello" }], + agents: [], + rules: [], + skills: [], + hooks: [], + mcp: [], + }, + }); +} + +async function installViaModeA(manifest: Manifest): Promise { + await new ModeAMarketplaceTranslator().addPlugin( + buildDist(), + "claude", + { kind: "local", path: "/plugin-source" }, + PROJECT_ROOT, + manifest, + MARKETPLACE_NAME + ); +} + +function buildRemoveUseCase( + activator: FakeNativePluginActivator, + logger: CapturingLogger +): { removeUseCase: PluginRemoveUseCase; manifestRepo: InMemoryManifestRepository } { + const fs = new InMemoryFileAdapter(); + const manifestRepo = new InMemoryManifestRepository(); + const removeUseCase = new PluginRemoveUseCase( + fs, + manifestRepo, + logger, + new Map([["claude", activator]]) + ); + return { removeUseCase, manifestRepo }; +} + +describe("PluginRemoveUseCase undoes native activation", () => { + it("uninstalls via the host CLI using the same @ ref install used", async () => { + const activator = new FakeNativePluginActivator({ available: true }); + const logger = new CapturingLogger(); + const { removeUseCase, manifestRepo } = buildRemoveUseCase(activator, logger); + const manifest = Manifest.create(); + manifest.addTool("claude", "test", []); + await installViaModeA(manifest); + await manifestRepo.save(manifest); + + await removeUseCase.execute({ + pluginName: PLUGIN_NAME, + toolIds: ["claude"], + projectRoot: PROJECT_ROOT, + }); + + expect(activator.uninstalledPlugins).toEqual([REF]); + expect(logger.warnMessages).toEqual([]); + }); + + it("warns naming the host and leaves the removal complete when the CLI is not on PATH", async () => { + const activator = new FakeNativePluginActivator({ available: false }); + const logger = new CapturingLogger(); + const { removeUseCase, manifestRepo } = buildRemoveUseCase(activator, logger); + const manifest = Manifest.create(); + manifest.addTool("claude", "test", []); + await installViaModeA(manifest); + await manifestRepo.save(manifest); + + await removeUseCase.execute({ + pluginName: PLUGIN_NAME, + toolIds: ["claude"], + projectRoot: PROJECT_ROOT, + }); + + expect(activator.uninstalledPlugins).toEqual([]); + expect(logger.warnMessages).toHaveLength(1); + expect(logger.warnMessages[0]).toContain("claude"); + expect(logger.warnMessages[0]).toContain(REF); + const loaded = await manifestRepo.load(); + expect(loaded?.getPlugins("claude").some((p) => p.name === PLUGIN_NAME)).toBe(false); + }); + + it("warns naming the host and message when the host CLI reports the plugin already absent", async () => { + const activator = new FakeNativePluginActivator({ + available: true, + failOnUninstall: [REF], + }); + const logger = new CapturingLogger(); + const { removeUseCase, manifestRepo } = buildRemoveUseCase(activator, logger); + const manifest = Manifest.create(); + manifest.addTool("claude", "test", []); + await installViaModeA(manifest); + await manifestRepo.save(manifest); + + await expect( + removeUseCase.execute({ + pluginName: PLUGIN_NAME, + toolIds: ["claude"], + projectRoot: PROJECT_ROOT, + }) + ).resolves.not.toThrow(); + + expect(logger.warnMessages).toHaveLength(1); + expect(logger.warnMessages[0]).toContain("claude"); + expect(logger.warnMessages[0]).toContain(REF); + }); + + it("never calls the host CLI for a plugin installed without a recorded marketplace", async () => { + const activator = new FakeNativePluginActivator({ available: true }); + const logger = new CapturingLogger(); + const { removeUseCase, manifestRepo } = buildRemoveUseCase(activator, logger); + const manifest = Manifest.create(); + manifest.addTool("claude", "test", []); + await new ModeAMarketplaceTranslator().addPlugin( + buildDist(), + "claude", + { kind: "local", path: "/plugin-source" }, + PROJECT_ROOT, + manifest, + undefined + ); + await manifestRepo.save(manifest); + + await removeUseCase.execute({ + pluginName: PLUGIN_NAME, + toolIds: ["claude"], + projectRoot: PROJECT_ROOT, + }); + + expect(activator.uninstalledPlugins).toEqual([]); + expect(logger.warnMessages).toEqual([]); + }); + + // A real `claude` binary registers at its own implicit `"user"` default whatever scope the + // manifest records for the plugin's files, and refuses an uninstall aimed at another scope. + it("falls back to the other scope when the manifest's own scope does not match what was actually registered", async () => { + const activator = new FakeNativePluginActivator({ + available: true, + installedAtScope: new Map([[REF, "user"]]), + }); + const logger = new CapturingLogger(); + const { removeUseCase, manifestRepo } = buildRemoveUseCase(activator, logger); + const manifest = Manifest.create(); + manifest.addTool("claude", "test", []); + await installViaModeA(manifest); + await manifestRepo.save(manifest); + + await removeUseCase.execute({ + pluginName: PLUGIN_NAME, + toolIds: ["claude"], + projectRoot: PROJECT_ROOT, + }); + + expect(activator.uninstalledPlugins).toEqual([REF]); + expect(activator.uninstalledPluginScopes).toEqual(["project", "user"]); + expect(logger.warnMessages).toEqual([]); + }); + + // The host call must address the host by `hostName`, read from this tool's own + // `nativeRegistrations` — never by `plugin.marketplace`, a local alias a host never learns. + it("uses the host's own name for the marketplace, read from this tool's own native registrations", async () => { + const activator = new FakeNativePluginActivator({ available: true }); + const logger = new CapturingLogger(); + const { removeUseCase, manifestRepo } = buildRemoveUseCase(activator, logger); + const manifest = Manifest.create(); + manifest.addTool("claude", "test", []); + await new ModeAMarketplaceTranslator().addPlugin( + buildDist(), + "claude", + { kind: "local", path: "/plugin-source" }, + PROJECT_ROOT, + manifest, + "local" + ); + manifest.setNativeRegistrations("claude", { + binary: "claude", + marketplaces: [{ alias: "local", hostName: "upstream" }], + pluginRefs: [], + }); + await manifestRepo.save(manifest); + + await removeUseCase.execute({ + pluginName: PLUGIN_NAME, + toolIds: ["claude"], + projectRoot: PROJECT_ROOT, + }); + + expect(activator.uninstalledPlugins).toEqual([`${PLUGIN_NAME}@upstream`]); + }); + + it("resolves the host's own registry by the hostName-keyed ref, not the alias", async () => { + const hostRef = `${PLUGIN_NAME}@upstream`; + const activator = new FakeNativePluginActivator({ + available: true, + installedAtScope: new Map([[hostRef, "user"]]), + }); + const logger = new CapturingLogger(); + const fs = new InMemoryFileAdapter(); + const manifestRepo = new InMemoryManifestRepository(); + const removeUseCase = new PluginRemoveUseCase( + fs, + manifestRepo, + logger, + new Map([["claude", activator]]), + new Map([ + [ + "claude", + new FakeHostPluginRegistryReader({ + location: "/registry", + refs: new Map([[hostRef, { enabled: true, scope: "user" }]]), + }), + ], + ]) + ); + const manifest = Manifest.create(); + manifest.addTool("claude", "test", []); + await new ModeAMarketplaceTranslator().addPlugin( + buildDist(), + "claude", + { kind: "local", path: "/plugin-source" }, + PROJECT_ROOT, + manifest, + "local" + ); + manifest.setNativeRegistrations("claude", { + binary: "claude", + marketplaces: [{ alias: "local", hostName: "upstream" }], + pluginRefs: [], + }); + await manifestRepo.save(manifest); + + await removeUseCase.execute({ + pluginName: PLUGIN_NAME, + toolIds: ["claude"], + projectRoot: PROJECT_ROOT, + }); + + expect(activator.uninstalledPlugins).toEqual([hostRef]); + expect(activator.uninstalledPluginScopes).toEqual(["user"]); + }); + + it("warns naming the alias when this tool's own native registrations exist but name no entry for it", async () => { + const activator = new FakeNativePluginActivator({ available: true }); + const logger = new CapturingLogger(); + const { removeUseCase, manifestRepo } = buildRemoveUseCase(activator, logger); + const manifest = Manifest.create(); + manifest.addTool("claude", "test", []); + await installViaModeA(manifest); + manifest.setNativeRegistrations("claude", { + binary: "claude", + marketplaces: [{ alias: "some-other-marketplace", hostName: "other" }], + pluginRefs: [], + }); + await manifestRepo.save(manifest); + + await removeUseCase.execute({ + pluginName: PLUGIN_NAME, + toolIds: ["claude"], + projectRoot: PROJECT_ROOT, + }); + + expect(activator.uninstalledPlugins).toEqual([REF]); + expect(logger.warnMessages.some((m) => m.includes(MARKETPLACE_NAME))).toBe(true); + }); + + // Two reads, not three: `removeNativeActivation` resolves the host name and the warn gate + // from one read, and `purgeCachedPlugin`'s own read is a separate decision made after it. + it("reads this tool's own native registrations once per decision, not twice for the warn gate", async () => { + const activator = new FakeNativePluginActivator({ available: true }); + const logger = new CapturingLogger(); + const { removeUseCase, manifestRepo } = buildRemoveUseCase(activator, logger); + const manifest = Manifest.create(); + manifest.addTool("claude", "test", []); + await installViaModeA(manifest); + manifest.setNativeRegistrations("claude", { + binary: "claude", + marketplaces: [{ alias: "some-other-marketplace", hostName: "other" }], + pluginRefs: [], + }); + await manifestRepo.save(manifest); + const stored = await manifestRepo.load(); + if (stored === null) throw new Error("unreachable — just saved"); + const spy = vi.spyOn(stored, "getNativeRegistrations"); + + await removeUseCase.execute({ + pluginName: PLUGIN_NAME, + toolIds: ["claude"], + projectRoot: PROJECT_ROOT, + }); + + expect(spy).toHaveBeenCalledTimes(2); + }); + + it("uninstalls at the scope the host's own registry names directly, one attempt", async () => { + const activator = new FakeNativePluginActivator({ + available: true, + installedAtScope: new Map([[REF, "user"]]), + }); + const logger = new CapturingLogger(); + const fs = new InMemoryFileAdapter(); + const manifestRepo = new InMemoryManifestRepository(); + const removeUseCase = new PluginRemoveUseCase( + fs, + manifestRepo, + logger, + new Map([["claude", activator]]), + new Map([ + [ + "claude", + new FakeHostPluginRegistryReader({ + location: "/registry", + refs: new Map([[REF, { enabled: true, scope: "user" }]]), + }), + ], + ]) + ); + const manifest = Manifest.create(); + manifest.addTool("claude", "test", []); + await installViaModeA(manifest); + await manifestRepo.save(manifest); + + await removeUseCase.execute({ + pluginName: PLUGIN_NAME, + toolIds: ["claude"], + projectRoot: PROJECT_ROOT, + }); + + expect(activator.uninstalledPlugins).toEqual([REF]); + expect(activator.uninstalledPluginScopes).toEqual(["user"]); + }); +}); diff --git a/cli/tests/contexts/framework/application/plugin/plugin-remove-shared-ref-guard.integration.test.ts b/cli/tests/contexts/framework/application/plugin/plugin-remove-shared-ref-guard.integration.test.ts new file mode 100644 index 000000000..31fdf29e8 --- /dev/null +++ b/cli/tests/contexts/framework/application/plugin/plugin-remove-shared-ref-guard.integration.test.ts @@ -0,0 +1,179 @@ +/** At a host that enables a plugin machine-wide (no `NativeActivation.scopeArgs`), removing + * it in one project must not disable it for another still referencing the shared source. */ +import "../../../../../src/contexts/tools/domain/profiles/codex/profile.js"; +import { describe, expect, it } from "vitest"; +import { + FRAMEWORK_MARKETPLACE_NAME, + Marketplace, +} from "../../../../../src/contexts/distribution/domain/marketplace.js"; +import { PluginRemoveUseCase } from "../../../../../src/contexts/framework/application/plugin/plugin-remove-use-case.js"; +import { Manifest } from "../../../../../src/contexts/framework/domain/manifest.js"; +import { InstalledPlugin } from "../../../../../src/contexts/framework/domain/plugins/installed-plugin.js"; +import { UserSourceReferencesAdapter } from "../../../../../src/contexts/framework/infrastructure/user-source-references-adapter.js"; +import { CapturingLogger } from "../../../../helpers/ports/capturing-logger.js"; +import { DeterministicHasher } from "../../../../helpers/ports/deterministic-hasher.js"; +import { FakeNativePluginActivator } from "../../../../helpers/ports/fake-native-plugin-activator.js"; +import { InMemoryFileAdapter } from "../../../../helpers/ports/in-memory-file-adapter.js"; +import { InMemoryManifestRepository } from "../../../../helpers/ports/in-memory-manifest-repository.js"; +import { InMemoryMarketplaceRegistry } from "../../../../helpers/ports/in-memory-marketplace-registry.js"; + +const PROJECT_ROOT = "/test-project"; +const OTHER_PROJECT = "/other-project"; +const USER_CONFIG_DIR = "/fake-home/.config/aidd"; +const PLUGIN_NAME = "aidd-vcs"; +const REF = `${PLUGIN_NAME}@${FRAMEWORK_MARKETPLACE_NAME}`; + +function seedManifest(marketplaceAlias: string = FRAMEWORK_MARKETPLACE_NAME): Manifest { + const manifest = Manifest.create(); + manifest.addTool("codex", "1.0.0", []); + manifest.addPlugin( + "codex", + InstalledPlugin.fromJSON({ + name: PLUGIN_NAME, + source: { kind: "local", path: "/plugin-source" }, + version: "1.0.0", + strict: true, + files: {}, + scope: "project", + marketplace: marketplaceAlias, + }) + ); + return manifest; +} + +function seedSharedMarketplaceRegistry( + marketplaceAlias: string = FRAMEWORK_MARKETPLACE_NAME +): InMemoryMarketplaceRegistry { + const registry = new InMemoryMarketplaceRegistry(); + registry.save( + PROJECT_ROOT, + Marketplace.create({ + name: marketplaceAlias, + source: { kind: "local", path: "/some/built/path" }, + scope: "user", + addedAt: "2026-01-01T00:00:00.000Z", + }) + ); + return registry; +} + +function seedReferences(fs: InMemoryFileAdapter, roots: readonly string[]): void { + fs.setFile( + `${USER_CONFIG_DIR}/references.json`, + JSON.stringify({ "1.0.0": [PROJECT_ROOT, ...roots] }) + ); + // This project's own directory exists too, exactly like `clean`'s own guard test — + // `listAllReferencingProjects` filters by `fs.fileExists`. + fs.setFile(`${PROJECT_ROOT}/marker`, ""); + for (const root of roots) fs.setFile(`${root}/marker`, ""); +} + +function buildUseCase( + fs: InMemoryFileAdapter, + activator: FakeNativePluginActivator, + logger: CapturingLogger, + manifest: Manifest = seedManifest(), + marketplaceRegistry: InMemoryMarketplaceRegistry = seedSharedMarketplaceRegistry() +): { + removeUseCase: PluginRemoveUseCase; + manifestRepo: InMemoryManifestRepository; +} { + const manifestRepo = new InMemoryManifestRepository(manifest, PROJECT_ROOT); + const userSourceReferences = new UserSourceReferencesAdapter(fs, () => USER_CONFIG_DIR); + const removeUseCase = new PluginRemoveUseCase( + fs, + manifestRepo, + logger, + new Map([["codex", activator]]), + new Map(), + userSourceReferences, + marketplaceRegistry + ); + return { removeUseCase, manifestRepo }; +} + +describe("plugin remove guards a ref another project on this machine still needs", () => { + it("leaves codex's ref enabled and names the other project still referencing the shared source", async () => { + const fs = new InMemoryFileAdapter({}, new DeterministicHasher()); + seedReferences(fs, [OTHER_PROJECT]); + const activator = new FakeNativePluginActivator({ available: true }); + const logger = new CapturingLogger(); + const { removeUseCase } = buildUseCase(fs, activator, logger); + + await removeUseCase.execute({ + pluginName: PLUGIN_NAME, + toolIds: ["codex"], + projectRoot: PROJECT_ROOT, + }); + + expect(activator.uninstalledPlugins).not.toContain(REF); + expect( + logger.warnMessages.some((m) => m.includes("left enabled") && m.includes(OTHER_PROJECT)) + ).toBe(true); + }); + + it("still uninstalls codex's ref when no other project references the shared source", async () => { + // `plugin remove` never drops its own claim, so a project's own root must be subtracted + // from `listAllReferencingProjects` or it reads itself back as another project. + const fs = new InMemoryFileAdapter({}, new DeterministicHasher()); + seedReferences(fs, []); + const activator = new FakeNativePluginActivator({ available: true }); + const { removeUseCase } = buildUseCase(fs, activator, new CapturingLogger()); + + await removeUseCase.execute({ + pluginName: PLUGIN_NAME, + toolIds: ["codex"], + projectRoot: PROJECT_ROOT, + }); + + expect(activator.uninstalledPlugins).toContain(REF); + }); + + // `refAnotherProjectStillNeeds` matches the ref's suffix against `sharedSourceHostName`, so + // a ref moved to `hostName` while that stays the alias silently stops guarding anything. + it("guards by the host's own ref when this project's alias diverges from the catalog's declared name", async () => { + // The alias is always the reserved `FRAMEWORK_MARKETPLACE_NAME`, which gates the guard; + // the divergence under test is between it and what the catalog declares as `hostName`. + const HOST_NAME = "upstream"; + const HOST_REF = `${PLUGIN_NAME}@${HOST_NAME}`; + const fs = new InMemoryFileAdapter({}, new DeterministicHasher()); + seedReferences(fs, [OTHER_PROJECT]); + const activator = new FakeNativePluginActivator({ available: true }); + const logger = new CapturingLogger(); + const manifest = seedManifest(); + manifest.setNativeRegistrations("codex", { + binary: "codex", + marketplaces: [{ alias: FRAMEWORK_MARKETPLACE_NAME, hostName: HOST_NAME }], + pluginRefs: [], + }); + const { removeUseCase } = buildUseCase(fs, activator, logger, manifest); + + await removeUseCase.execute({ + pluginName: PLUGIN_NAME, + toolIds: ["codex"], + projectRoot: PROJECT_ROOT, + }); + + expect(activator.uninstalledPlugins).not.toContain(HOST_REF); + expect( + logger.warnMessages.some((m) => m.includes("left enabled") && m.includes(HOST_REF)) + ).toBe(true); + }); + + it("uninstalls by the alias ref and logs no warning when this tool has no native registrations at all", async () => { + const fs = new InMemoryFileAdapter({}, new DeterministicHasher()); + seedReferences(fs, []); + const activator = new FakeNativePluginActivator({ available: true }); + const logger = new CapturingLogger(); + const { removeUseCase } = buildUseCase(fs, activator, logger); + + await removeUseCase.execute({ + pluginName: PLUGIN_NAME, + toolIds: ["codex"], + projectRoot: PROJECT_ROOT, + }); + + expect(activator.uninstalledPlugins).toContain(REF); + expect(logger.warnMessages).toEqual([]); + }); +}); diff --git a/cli/tests/contexts/framework/application/plugin/plugin-remove-use-case.unit.test.ts b/cli/tests/contexts/framework/application/plugin/plugin-remove-use-case.unit.test.ts new file mode 100644 index 000000000..e87ef7c19 --- /dev/null +++ b/cli/tests/contexts/framework/application/plugin/plugin-remove-use-case.unit.test.ts @@ -0,0 +1,138 @@ +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; +import "../../../../../src/contexts/tools/domain/profiles/cursor/profile.js"; +import { PluginAddUseCase } from "../../../../../src/contexts/framework/application/plugin/plugin-add-use-case.js"; +import { PluginRemoveUseCase } from "../../../../../src/contexts/framework/application/plugin/plugin-remove-use-case.js"; +import { Manifest } from "../../../../../src/contexts/framework/domain/manifest.js"; +import { InstalledPlugin } from "../../../../../src/contexts/framework/domain/plugins/installed-plugin.js"; +import { PluginDistributionReaderAdapter } from "../../../../../src/contexts/framework/infrastructure/plugin-distribution-reader-adapter.js"; +import { PluginNotFoundError } from "../../../../../src/kernel/errors.js"; +import { buildUnitDeps, initAndInstall } from "../../../../helpers/ports/build-unit-deps.js"; +import { CapturingLogger } from "../../../../helpers/ports/capturing-logger.js"; +import { fakeEnsureBuiltMarketplace } from "../../../../helpers/ports/fake-ensure-built-marketplace.js"; +import { InMemoryFileAdapter } from "../../../../helpers/ports/in-memory-file-adapter.js"; +import { InMemoryManifestRepository } from "../../../../helpers/ports/in-memory-manifest-repository.js"; +import { seedFromDirectory } from "../../../../helpers/ports/seed-from-directory.js"; + +/** Records every path `deleteFile` is called with, so a test can prove where a plugin's + * file actually got deleted from without inspecting private use-case state. */ +class RecordingFileAdapter extends InMemoryFileAdapter { + readonly deletedPaths: string[] = []; + + override async deleteFile(path: string): Promise { + this.deletedPaths.push(path); + return super.deleteFile(path); + } +} + +const PLUGIN_FIXTURE = join(process.cwd(), "tests/fixtures/plugins/claude-format/sample-plugin"); +const PROJECT_ROOT = "/test-project"; + +async function installPlugin(deps: Awaited>): Promise { + await seedFromDirectory(deps.fs, PLUGIN_FIXTURE, { useAbsolutePaths: true }); + const addUseCase = new PluginAddUseCase( + deps.fs, + deps.manifestRepo, + deps.pluginFetcher, + new PluginDistributionReaderAdapter(deps.fs), + deps.hasher, + deps.logger, + deps.marketplaceRegistry, + fakeEnsureBuiltMarketplace() + ); + await addUseCase.execute({ + source: { kind: "local", path: PLUGIN_FIXTURE }, + toolIds: ["claude"], + projectRoot: PROJECT_ROOT, + interactive: false, + }); +} + +describe("PluginRemoveUseCase", () => { + describe("remove installed plugin", () => { + it("deletes plugin files and updates manifest", async () => { + const deps = await buildUnitDeps(PROJECT_ROOT); + await initAndInstall(deps, PROJECT_ROOT, "claude"); + await installPlugin(deps); + + const removeUseCase = new PluginRemoveUseCase( + deps.fs, + deps.manifestRepo, + deps.logger, + deps.nativePluginActivators + ); + await removeUseCase.execute({ + pluginName: "sample-plugin", + toolIds: ["claude"], + projectRoot: PROJECT_ROOT, + }); + + expect( + deps.fs.has(join(PROJECT_ROOT, ".claude/plugins/sample-plugin/commands/greet.md")) + ).toBe(false); + const manifest = await deps.manifestRepo.load(); + const plugins = manifest?.getPlugins("claude") ?? []; + expect(plugins.some((p) => p.name === "sample-plugin")).toBe(false); + }); + }); + + describe("remove missing plugin", () => { + it("throws PluginNotFoundError", async () => { + const deps = await buildUnitDeps(PROJECT_ROOT); + await initAndInstall(deps, PROJECT_ROOT, "claude"); + + const removeUseCase = new PluginRemoveUseCase( + deps.fs, + deps.manifestRepo, + deps.logger, + deps.nativePluginActivators + ); + await expect( + removeUseCase.execute({ + pluginName: "nonexistent-plugin", + toolIds: ["claude"], + projectRoot: PROJECT_ROOT, + }) + ).rejects.toThrow(PluginNotFoundError); + }); + }); + + describe("scope from the manifest wins over the tool's current profile", () => { + it("deletes a cursor plugin's files under projectRoot when the manifest says scope: project, never under ~/.cursor/plugins/local", async () => { + const manifest = Manifest.create(); + manifest.addTool("cursor", "1.0.0", []); + const pluginKey = "aidd-context/commands/hello.md"; + manifest.addPlugin( + "cursor", + InstalledPlugin.fromJSON({ + name: "aidd-context", + source: { kind: "local", path: "/some/path" }, + version: "1.0.0", + strict: false, + files: { [pluginKey]: "abc123abc123abc123abc123abc123ab" }, + // Disagrees with cursor's own profile, which declares installScope "user". + scope: "project", + }) + ); + const fs = new RecordingFileAdapter(); + const manifestRepo = new InMemoryManifestRepository(manifest, PROJECT_ROOT); + const removeUseCase = new PluginRemoveUseCase( + fs, + manifestRepo, + new CapturingLogger(), + new Map() + ); + + await removeUseCase.execute({ + pluginName: "aidd-context", + toolIds: ["cursor"], + projectRoot: PROJECT_ROOT, + }); + + expect(fs.deletedPaths).toContain(join(PROJECT_ROOT, pluginKey)); + expect(fs.deletedPaths.some((p) => p.includes(join(".cursor", "plugins", "local")))).toBe( + false + ); + }); + }); +}); diff --git a/cli/tests/application/use-cases/plugin/plugin-search-use-case.unit.test.ts b/cli/tests/contexts/framework/application/plugin/plugin-search-use-case.unit.test.ts similarity index 82% rename from cli/tests/application/use-cases/plugin/plugin-search-use-case.unit.test.ts rename to cli/tests/contexts/framework/application/plugin/plugin-search-use-case.unit.test.ts index 65c7219f8..17e17fdd8 100644 --- a/cli/tests/application/use-cases/plugin/plugin-search-use-case.unit.test.ts +++ b/cli/tests/contexts/framework/application/plugin/plugin-search-use-case.unit.test.ts @@ -1,13 +1,13 @@ import { join } from "node:path"; import { describe, expect, it } from "vitest"; -import { PluginSearchUseCase } from "../../../../src/application/use-cases/plugin/plugin-search-use-case.js"; -import { FetchMarketplaceSourceUseCase } from "../../../../src/application/use-cases/shared/fetch-marketplace-source-use-case.js"; -import { ResolveMarketplaceUseCase } from "../../../../src/application/use-cases/shared/resolve-marketplace-use-case.js"; -import { Marketplace } from "../../../../src/domain/models/marketplace.js"; -import { PluginCatalogRepositoryAdapter } from "../../../../src/infrastructure/adapters/plugin-catalog-repository-adapter.js"; -import { FixturePluginFetcher } from "../../../helpers/ports/fixture-plugin-fetcher.js"; -import { InMemoryFileAdapter } from "../../../helpers/ports/in-memory-file-adapter.js"; -import { InMemoryMarketplaceRegistry } from "../../../helpers/ports/in-memory-marketplace-registry.js"; +import { FetchMarketplaceSourceUseCase } from "../../../../../src/contexts/distribution/application/fetch-marketplace-source-use-case.js"; +import { ResolveMarketplaceUseCase } from "../../../../../src/contexts/distribution/application/resolve-marketplace-use-case.js"; +import { Marketplace } from "../../../../../src/contexts/distribution/domain/marketplace.js"; +import { PluginCatalogRepositoryAdapter } from "../../../../../src/contexts/distribution/infrastructure/plugin-catalog-repository-adapter.js"; +import { PluginSearchUseCase } from "../../../../../src/contexts/framework/application/plugin/plugin-search-use-case.js"; +import { FixturePluginFetcher } from "../../../../helpers/ports/fixture-plugin-fetcher.js"; +import { InMemoryFileAdapter } from "../../../../helpers/ports/in-memory-file-adapter.js"; +import { InMemoryMarketplaceRegistry } from "../../../../helpers/ports/in-memory-marketplace-registry.js"; const PROJECT_ROOT = "/test-project"; const MKT1_PATH = "/mkt1"; diff --git a/cli/tests/contexts/framework/application/plugin/plugin-target-resolution.unit.test.ts b/cli/tests/contexts/framework/application/plugin/plugin-target-resolution.unit.test.ts new file mode 100644 index 000000000..90f2bd75b --- /dev/null +++ b/cli/tests/contexts/framework/application/plugin/plugin-target-resolution.unit.test.ts @@ -0,0 +1,42 @@ +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; +import "../../../../../src/contexts/tools/domain/profiles/claude/profile.js"; +import "../../../../../src/contexts/tools/domain/profiles/cursor/profile.js"; +import { + resolveBaseDirFromRecord, + resolveScopeForInstall, +} from "../../../../../src/contexts/framework/application/plugin/plugin-target-resolution.js"; +import { UnresolvableUserScopeError } from "../../../../../src/kernel/errors.js"; + +const HOME = "/home/u"; +const homedir = () => HOME; + +describe("resolveScopeForInstall()", () => { + it("reads the scope a fresh install writes from the tool's own profile", () => { + expect(resolveScopeForInstall("cursor")).toBe("user"); + expect(resolveScopeForInstall("claude")).toBe("project"); + }); +}); + +describe("resolveBaseDirFromRecord() — the manifest's recorded scope, not the profile", () => { + it("resolves project scope to projectRoot regardless of what the tool's profile says", () => { + // cursor's own profile declares installScope "user", so a manifest entry recorded + // scope: "project" must still resolve under projectRoot. + const baseDir = resolveBaseDirFromRecord("project", "cursor", "/proj", homedir); + expect(baseDir).toBe("/proj"); + expect(baseDir).not.toContain(join(".cursor", "plugins", "local")); + }); + + it("resolves user scope to the tool's user-scope plugins dir", () => { + const baseDir = resolveBaseDirFromRecord("user", "cursor", "/proj", homedir); + expect(baseDir).toBe(join(HOME, ".cursor", "plugins", "local")); + }); + + // A "user" scope the tool's current profile cannot explain must refuse to guess rather + // than quietly resolve under projectRoot; claude declares no user-scope directory at all. + it("throws, rather than falling back to projectRoot, when the tool declares no user-scope directory", () => { + expect(() => resolveBaseDirFromRecord("user", "claude", "/proj", homedir)).toThrow( + UnresolvableUserScopeError + ); + }); +}); diff --git a/cli/tests/application/use-cases/plugin/plugin-update-built-tree.unit.test.ts b/cli/tests/contexts/framework/application/plugin/plugin-update-built-tree.unit.test.ts similarity index 83% rename from cli/tests/application/use-cases/plugin/plugin-update-built-tree.unit.test.ts rename to cli/tests/contexts/framework/application/plugin/plugin-update-built-tree.unit.test.ts index ed310f9e9..b43bbb6d8 100644 --- a/cli/tests/application/use-cases/plugin/plugin-update-built-tree.unit.test.ts +++ b/cli/tests/contexts/framework/application/plugin/plugin-update-built-tree.unit.test.ts @@ -1,13 +1,13 @@ import { join } from "node:path"; import { describe, expect, it } from "vitest"; -import { PluginAddUseCase } from "../../../../src/application/use-cases/plugin/plugin-add-use-case.js"; -import { PluginUpdateUseCase } from "../../../../src/application/use-cases/plugin/plugin-update-use-case.js"; -import { Marketplace } from "../../../../src/domain/models/marketplace.js"; -import { PluginDistributionReaderAdapter } from "../../../../src/infrastructure/adapters/plugin-distribution-reader-adapter.js"; -import { buildUnitDeps, initAndInstall } from "../../../helpers/ports/build-unit-deps.js"; -import { fakeEnsureBuiltMarketplace } from "../../../helpers/ports/fake-ensure-built-marketplace.js"; -import { InMemoryMarketplaceRegistry } from "../../../helpers/ports/in-memory-marketplace-registry.js"; -import { seedFromDirectory } from "../../../helpers/ports/seed-from-directory.js"; +import { Marketplace } from "../../../../../src/contexts/distribution/domain/marketplace.js"; +import { PluginAddUseCase } from "../../../../../src/contexts/framework/application/plugin/plugin-add-use-case.js"; +import { PluginUpdateUseCase } from "../../../../../src/contexts/framework/application/plugin/plugin-update-use-case.js"; +import { PluginDistributionReaderAdapter } from "../../../../../src/contexts/framework/infrastructure/plugin-distribution-reader-adapter.js"; +import { buildUnitDeps, initAndInstall } from "../../../../helpers/ports/build-unit-deps.js"; +import { fakeEnsureBuiltMarketplace } from "../../../../helpers/ports/fake-ensure-built-marketplace.js"; +import { InMemoryMarketplaceRegistry } from "../../../../helpers/ports/in-memory-marketplace-registry.js"; +import { seedFromDirectory } from "../../../../helpers/ports/seed-from-directory.js"; const PLUGIN_FIXTURE = join(process.cwd(), "tests/fixtures/plugins/claude-format/sample-plugin"); const PROJECT_ROOT = "/test-project"; diff --git a/cli/tests/application/use-cases/plugin/plugin-update-mode-a-marketplace.unit.test.ts b/cli/tests/contexts/framework/application/plugin/plugin-update-mode-a-marketplace.unit.test.ts similarity index 87% rename from cli/tests/application/use-cases/plugin/plugin-update-mode-a-marketplace.unit.test.ts rename to cli/tests/contexts/framework/application/plugin/plugin-update-mode-a-marketplace.unit.test.ts index 02b55cfa7..ddf998fce 100644 --- a/cli/tests/application/use-cases/plugin/plugin-update-mode-a-marketplace.unit.test.ts +++ b/cli/tests/contexts/framework/application/plugin/plugin-update-mode-a-marketplace.unit.test.ts @@ -1,13 +1,13 @@ import { join } from "node:path"; import { describe, expect, it } from "vitest"; -import { PluginAddUseCase } from "../../../../src/application/use-cases/plugin/plugin-add-use-case.js"; -import { PluginUpdateUseCase } from "../../../../src/application/use-cases/plugin/plugin-update-use-case.js"; -import { Marketplace } from "../../../../src/domain/models/marketplace.js"; -import { PluginDistributionReaderAdapter } from "../../../../src/infrastructure/adapters/plugin-distribution-reader-adapter.js"; -import { buildUnitDeps, initAndInstall } from "../../../helpers/ports/build-unit-deps.js"; -import { fakeEnsureBuiltMarketplace } from "../../../helpers/ports/fake-ensure-built-marketplace.js"; -import { InMemoryMarketplaceRegistry } from "../../../helpers/ports/in-memory-marketplace-registry.js"; -import { seedFromDirectory } from "../../../helpers/ports/seed-from-directory.js"; +import { Marketplace } from "../../../../../src/contexts/distribution/domain/marketplace.js"; +import { PluginAddUseCase } from "../../../../../src/contexts/framework/application/plugin/plugin-add-use-case.js"; +import { PluginUpdateUseCase } from "../../../../../src/contexts/framework/application/plugin/plugin-update-use-case.js"; +import { PluginDistributionReaderAdapter } from "../../../../../src/contexts/framework/infrastructure/plugin-distribution-reader-adapter.js"; +import { buildUnitDeps, initAndInstall } from "../../../../helpers/ports/build-unit-deps.js"; +import { fakeEnsureBuiltMarketplace } from "../../../../helpers/ports/fake-ensure-built-marketplace.js"; +import { InMemoryMarketplaceRegistry } from "../../../../helpers/ports/in-memory-marketplace-registry.js"; +import { seedFromDirectory } from "../../../../helpers/ports/seed-from-directory.js"; const PLUGIN_FIXTURE = join(process.cwd(), "tests/fixtures/plugins/claude-format/sample-plugin"); const PROJECT_ROOT = "/test-project"; diff --git a/cli/tests/application/use-cases/plugin/plugin-update-use-case.unit.test.ts b/cli/tests/contexts/framework/application/plugin/plugin-update-use-case.unit.test.ts similarity index 81% rename from cli/tests/application/use-cases/plugin/plugin-update-use-case.unit.test.ts rename to cli/tests/contexts/framework/application/plugin/plugin-update-use-case.unit.test.ts index fca921bb1..968dde656 100644 --- a/cli/tests/application/use-cases/plugin/plugin-update-use-case.unit.test.ts +++ b/cli/tests/contexts/framework/application/plugin/plugin-update-use-case.unit.test.ts @@ -1,11 +1,11 @@ import { join } from "node:path"; import { describe, expect, it } from "vitest"; -import { PluginAddUseCase } from "../../../../src/application/use-cases/plugin/plugin-add-use-case.js"; -import { PluginUpdateUseCase } from "../../../../src/application/use-cases/plugin/plugin-update-use-case.js"; -import { PluginDistributionReaderAdapter } from "../../../../src/infrastructure/adapters/plugin-distribution-reader-adapter.js"; -import { buildUnitDeps, initAndInstall } from "../../../helpers/ports/build-unit-deps.js"; -import { fakeEnsureBuiltMarketplace } from "../../../helpers/ports/fake-ensure-built-marketplace.js"; -import { seedFromDirectory } from "../../../helpers/ports/seed-from-directory.js"; +import { PluginAddUseCase } from "../../../../../src/contexts/framework/application/plugin/plugin-add-use-case.js"; +import { PluginUpdateUseCase } from "../../../../../src/contexts/framework/application/plugin/plugin-update-use-case.js"; +import { PluginDistributionReaderAdapter } from "../../../../../src/contexts/framework/infrastructure/plugin-distribution-reader-adapter.js"; +import { buildUnitDeps, initAndInstall } from "../../../../helpers/ports/build-unit-deps.js"; +import { fakeEnsureBuiltMarketplace } from "../../../../helpers/ports/fake-ensure-built-marketplace.js"; +import { seedFromDirectory } from "../../../../helpers/ports/seed-from-directory.js"; const PLUGIN_FIXTURE = join(process.cwd(), "tests/fixtures/plugins/claude-format/sample-plugin"); const PROJECT_ROOT = "/test-project"; diff --git a/cli/tests/contexts/framework/application/restore-all-plugins-use-case.unit.test.ts b/cli/tests/contexts/framework/application/restore-all-plugins-use-case.unit.test.ts new file mode 100644 index 000000000..84d9787b8 --- /dev/null +++ b/cli/tests/contexts/framework/application/restore-all-plugins-use-case.unit.test.ts @@ -0,0 +1,126 @@ +import { describe, expect, it } from "vitest"; +import "../../../../src/contexts/tools/domain/profiles/claude/profile.js"; +import "../../../../src/contexts/tools/domain/profiles/cursor/profile.js"; +import type { PluginFetcher } from "../../../../src/contexts/distribution/domain/ports/plugin-fetcher.js"; +import { RestoreAllPluginsUseCase } from "../../../../src/contexts/framework/application/restore/restore-all-plugins-use-case.js"; +import { Manifest } from "../../../../src/contexts/framework/domain/manifest.js"; +import { InstalledPlugin } from "../../../../src/contexts/framework/domain/plugins/installed-plugin.js"; +import type { PluginDistributionReader } from "../../../../src/contexts/framework/domain/ports/plugin-distribution-reader.js"; +import { PluginDistribution } from "../../../../src/contexts/translate/domain/plugin-distribution.js"; +import { FileHash } from "../../../../src/kernel/file.js"; +import type { FileReader } from "../../../../src/kernel/ports/file-reader.js"; +import type { FileWriter } from "../../../../src/kernel/ports/file-writer.js"; +import type { Hasher } from "../../../../src/kernel/ports/hasher.js"; + +const noopFs: FileReader & FileWriter = { + fileExists: async () => false, + isExecutable: async () => false, + realpath: async (path: string) => path, + readFileHash: async () => new FileHash("00000000000000000000000000000000"), + readFile: async () => "", + listDirectory: async () => [], + listFilesRecursive: async () => [], + writeFile: async () => {}, + deleteFile: async () => {}, + createDirectory: async () => {}, + deleteEmptyDirectories: async () => {}, + deleteDirectory: async () => {}, + chmodExecutable: async () => {}, +}; + +const noopHasher: Hasher = { + hash: () => new FileHash("00000000000000000000000000000000"), +}; + +const stubFetcher: PluginFetcher = { + fetch: async () => "/cache/local", +}; + +const emptyDistributionReader: PluginDistributionReader = { + read: async () => + new PluginDistribution({ + manifest: { name: "aidd-test", version: "1.0.0" }, + format: "claude", + files: [], + components: { skills: [], commands: [], agents: [], rules: [], hooks: [], mcp: [] }, + }), +}; + +function nativePlugin( + files: Record = {}, + scope: "project" | "user" = "project" +): InstalledPlugin { + return InstalledPlugin.fromJSON({ + name: "aidd-test", + source: { kind: "local", path: "/some/path" }, + version: "1.0.0", + strict: false, + files, + scope, + }); +} + +describe("RestoreAllPluginsUseCase — native-activation tools", () => { + it("names claude in nativeOnlyToolIds when its only installed plugin tracks zero files", async () => { + const manifest = Manifest.create(); + manifest.addTool("claude", "1.0.0", []); + manifest.addPlugin("claude", nativePlugin({})); + const useCase = new RestoreAllPluginsUseCase( + noopFs, + noopHasher, + stubFetcher, + emptyDistributionReader + ); + + const result = await useCase.execute({ + projectRoot: "/proj", + manifest, + fileFilter: null, + }); + + expect(result.nativeOnlyToolIds).toEqual(["claude"]); + expect(result.totalFiles).toBe(0); + }); + + it("does not name a tool whose plugin files are actually tracked", async () => { + const manifest = Manifest.create(); + manifest.addTool("cursor", "1.0.0", []); + manifest.addPlugin( + "cursor", + nativePlugin({ "a/one.md": "00000000000000000000000000000000" }, "user") + ); + const useCase = new RestoreAllPluginsUseCase( + noopFs, + noopHasher, + stubFetcher, + emptyDistributionReader + ); + + const result = await useCase.execute({ + projectRoot: "/proj", + manifest, + fileFilter: null, + }); + + expect(result.nativeOnlyToolIds).toEqual([]); + }); + + it("does not name a tool that has no plugin installed at all", async () => { + const manifest = Manifest.create(); + manifest.addTool("claude", "1.0.0", []); + const useCase = new RestoreAllPluginsUseCase( + noopFs, + noopHasher, + stubFetcher, + emptyDistributionReader + ); + + const result = await useCase.execute({ + projectRoot: "/proj", + manifest, + fileFilter: null, + }); + + expect(result.nativeOnlyToolIds).toEqual([]); + }); +}); diff --git a/cli/tests/contexts/framework/application/restore-all-use-case.unit.test.ts b/cli/tests/contexts/framework/application/restore-all-use-case.unit.test.ts new file mode 100644 index 000000000..56bdbe5cf --- /dev/null +++ b/cli/tests/contexts/framework/application/restore-all-use-case.unit.test.ts @@ -0,0 +1,389 @@ +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; +import { RestoreAllUseCase } from "../../../../src/contexts/framework/application/global/restore-all-use-case.js"; +import { PluginAddUseCase } from "../../../../src/contexts/framework/application/plugin/plugin-add-use-case.js"; +import { RestoreUseCase } from "../../../../src/contexts/framework/application/restore/restore-use-case.js"; +import { DetectPluginDriftUseCase } from "../../../../src/contexts/framework/application/shared/detect-plugin-drift-use-case.js"; +import { StatusUseCase } from "../../../../src/contexts/framework/application/status-use-case.js"; +import { PluginDistributionReaderAdapter } from "../../../../src/contexts/framework/infrastructure/plugin-distribution-reader-adapter.js"; +import { + buildUnitDeps, + initAndInstall, + installTool, +} from "../../../helpers/ports/build-unit-deps.js"; +import { fakeEnsureBuiltMarketplace } from "../../../helpers/ports/fake-ensure-built-marketplace.js"; +import { FakePlatform } from "../../../helpers/ports/fake-platform.js"; +import { OverwritePrompter, ScriptedPrompter } from "../../../helpers/ports/scripted-prompter.js"; +import { seedFromDirectory } from "../../../helpers/ports/seed-from-directory.js"; + +const PROJECT_ROOT = "/test-project"; +const PLUGIN_FIXTURE = join(process.cwd(), "tests/fixtures/plugins/claude-format/sample-plugin"); + +type Deps = Awaited>; + +function builtDeps(deps: Deps) { + return { + ensureBuilt: fakeEnsureBuiltMarketplace(), + marketplaceRegistry: deps.marketplaceRegistry, + homedir: () => "/home/test", + }; +} + +async function installPlugin( + deps: Deps, + toolId: "claude" | "cursor", + pluginReader: PluginDistributionReaderAdapter +): Promise { + await new PluginAddUseCase( + deps.fs, + deps.manifestRepo, + deps.pluginFetcher, + pluginReader, + deps.hasher, + deps.logger, + deps.marketplaceRegistry, + fakeEnsureBuiltMarketplace() + ).execute({ + source: { kind: "local", path: PLUGIN_FIXTURE }, + toolIds: [toolId], + projectRoot: PROJECT_ROOT, + interactive: false, + }); +} + +function makeRestoreAllUseCase( + deps: Deps, + pluginReader: PluginDistributionReaderAdapter, + prompter: OverwritePrompter | ScriptedPrompter = new OverwritePrompter(), + withBuiltDeps = false +): RestoreAllUseCase { + const statusUseCase = new StatusUseCase( + deps.fs, + deps.manifestRepo, + deps.hasher, + new DetectPluginDriftUseCase(deps.fs) + ); + const restoreUseCase = new RestoreUseCase( + deps.fs, + deps.manifestRepo, + deps.hasher, + deps.logger, + new FakePlatform("linux"), + prompter, + deps.pluginFetcher, + pluginReader, + deps.assetProvider, + withBuiltDeps ? builtDeps(deps) : undefined + ); + return new RestoreAllUseCase(deps.manifestRepo, prompter, statusUseCase, restoreUseCase); +} + +function countingReader(fs: Deps["fs"]): { + reader: PluginDistributionReaderAdapter; + count: () => number; +} { + const reader = new PluginDistributionReaderAdapter(fs); + let calls = 0; + const original = reader.read.bind(reader); + reader.read = async (...args: Parameters) => { + calls++; + return original(...args); + }; + return { reader, count: () => calls }; +} + +describe("RestoreAllUseCase — the --force flag", () => { + /** + * `--force` has to reach the use case: folded into `interactive`, a non-TTY run decided + * with `force: false` and reported "all files are unmodified" over a modified file. + */ + async function setupWithModifiedTrackedFile(): Promise<{ + deps: Deps; + reader: PluginDistributionReaderAdapter; + trackedPath: string; + }> { + const deps = await buildUnitDeps(PROJECT_ROOT); + await initAndInstall(deps, PROJECT_ROOT, "claude"); + + const manifest = await deps.manifestRepo.load(); + const tracked = manifest?.getToolFiles("claude") ?? []; + expect(tracked.length, "the fixture must track at least one file").toBeGreaterThan(0); + + const trackedPath = join(PROJECT_ROOT, tracked[0].relativePath); + await deps.fs.writeFile(trackedPath, "EDITED OUTSIDE THE CLI"); + + return { deps, reader: new PluginDistributionReaderAdapter(deps.fs), trackedPath }; + } + + it("restores a modified tracked file when force is set", async () => { + const { deps, reader, trackedPath } = await setupWithModifiedTrackedFile(); + + const result = await makeRestoreAllUseCase(deps, reader).execute(PROJECT_ROOT, true, false); + + expect(deps.fs.getFile(trackedPath)).not.toBe("EDITED OUTSIDE THE CLI"); + expect(result.errors, "force must reach the decision, not raise InputRequired").toEqual([]); + expect(result.totalRestored).toBeGreaterThan(0); + }); + + it("keeps a modified tracked file and reports why when force is not set", async () => { + const { deps, reader, trackedPath } = await setupWithModifiedTrackedFile(); + + const result = await makeRestoreAllUseCase(deps, reader).execute(PROJECT_ROOT, false, false); + + expect(deps.fs.getFile(trackedPath)).toBe("EDITED OUTSIDE THE CLI"); + expect(result.totalRestored).toBe(0); + expect(result.errors.map((e) => e.message).join(" ")).toContain("--force"); + }); +}); + +describe("RestoreAllUseCase — plugin materialization", () => { + it("restores a corrupted plugin file with exactly one materialization call (translate-mode: claude)", async () => { + const deps = await buildUnitDeps(PROJECT_ROOT); + await initAndInstall(deps, PROJECT_ROOT, "claude"); + await seedFromDirectory(deps.fs, PLUGIN_FIXTURE, { useAbsolutePaths: true }); + await installPlugin(deps, "claude", new PluginDistributionReaderAdapter(deps.fs)); + + const pluginFile = join(PROJECT_ROOT, ".claude/plugins/sample-plugin/commands/greet.md"); + await deps.fs.writeFile(pluginFile, "CORRUPTED CONTENT"); + + // Counting reader wired only from here — installPlugin's own read() must not count. + const { reader, count } = countingReader(deps.fs); + const useCase = makeRestoreAllUseCase(deps, reader); + await useCase.execute(PROJECT_ROOT, false, false); + + expect(deps.fs.getFile(pluginFile)).not.toBe("CORRUPTED CONTENT"); + expect(deps.fs.getFile(pluginFile)).toContain("Greet from sample-plugin."); + expect(count()).toBe(1); + }); + + it("restores a corrupted plugin file with exactly one materialization call (cursor — installScope:user tool)", async () => { + // A local-source install never reaches restoreViaBuiltTree — that path requires + // plugin.marketplace — so this exercises restoreViaTranslate for an installScope:"user" tool. + const deps = await buildUnitDeps(PROJECT_ROOT); + await initAndInstall(deps, PROJECT_ROOT, "cursor"); + await seedFromDirectory(deps.fs, PLUGIN_FIXTURE, { useAbsolutePaths: true }); + await installPlugin(deps, "cursor", new PluginDistributionReaderAdapter(deps.fs)); + + const manifestAfterInstall = await deps.manifestRepo.load(); + const plugin = manifestAfterInstall + ?.getPlugins("cursor") + .find((p) => p.name === "sample-plugin"); + const trackedRelativePath = [...(plugin?.files.keys() ?? [])][0]; + expect(trackedRelativePath).toBeDefined(); + // plugin.files keys are relativePath (see restoreViaTranslate); actual fs storage is + // keyed by the absolute path the file was written to. + const pluginFile = join(PROJECT_ROOT, trackedRelativePath as string); + await deps.fs.writeFile(pluginFile, "CORRUPTED CONTENT"); + + // Counting reader wired only from here — installPlugin's own read() must not count. + const { reader, count } = countingReader(deps.fs); + const useCase = makeRestoreAllUseCase(deps, reader, new OverwritePrompter(), true); + await useCase.execute(PROJECT_ROOT, false, false); + + expect(deps.fs.getFile(pluginFile)).not.toBe("CORRUPTED CONTENT"); + expect(count()).toBe(1); + }); + + it("result.pluginNamesRestored lists the restored plugin exactly once", async () => { + const deps = await buildUnitDeps(PROJECT_ROOT); + await initAndInstall(deps, PROJECT_ROOT, "claude"); + await seedFromDirectory(deps.fs, PLUGIN_FIXTURE, { useAbsolutePaths: true }); + const { reader } = countingReader(deps.fs); + await installPlugin(deps, "claude", reader); + + const pluginFile = join(PROJECT_ROOT, ".claude/plugins/sample-plugin/commands/greet.md"); + await deps.fs.writeFile(pluginFile, "CORRUPTED CONTENT"); + + const result = await makeRestoreAllUseCase(deps, reader).execute(PROJECT_ROOT, false, false); + + expect(result.pluginNamesRestored).toEqual(["sample-plugin"]); + expect(result.errors).toHaveLength(0); + }); + + it("a plugin already up to date is not listed as restored and produces no error", async () => { + const deps = await buildUnitDeps(PROJECT_ROOT); + await initAndInstall(deps, PROJECT_ROOT, "claude"); + await seedFromDirectory(deps.fs, PLUGIN_FIXTURE, { useAbsolutePaths: true }); + const { reader } = countingReader(deps.fs); + await installPlugin(deps, "claude", reader); + + // Nothing corrupted — plugin files are already at their installed state. + const manifestBefore = await deps.manifestRepo.load(); + const pluginBefore = manifestBefore + ?.getPlugins("claude") + .find((p) => p.name === "sample-plugin"); + + const result = await makeRestoreAllUseCase(deps, reader).execute(PROJECT_ROOT, false, false); + + expect(result.pluginNamesRestored).toEqual([]); + expect(result.errors).toHaveLength(0); + const manifestAfter = await deps.manifestRepo.load(); + const pluginAfter = manifestAfter?.getPlugins("claude").find((p) => p.name === "sample-plugin"); + expect(pluginAfter?.files).toEqual(pluginBefore?.files); + }); + + it("interactive restore with an explicit file selection also skips unselected plugin files (translate-mode)", async () => { + // The interactive picker never offers plugin drift, so once the user picks any specific + // regular file ctx.fileFilter is active and no plugin path can match it. + const deps = await buildUnitDeps(PROJECT_ROOT); + await initAndInstall(deps, PROJECT_ROOT, "claude"); + await installTool(deps, PROJECT_ROOT, "vscode"); + await seedFromDirectory(deps.fs, PLUGIN_FIXTURE, { useAbsolutePaths: true }); + const { reader } = countingReader(deps.fs); + await installPlugin(deps, "claude", reader); + + const pluginFile = join(PROJECT_ROOT, ".claude/plugins/sample-plugin/commands/greet.md"); + await deps.fs.writeFile(pluginFile, "CORRUPTED CONTENT"); + // keybindings.json is a plain tracked file (unlike settings.json, which is merge-type + // and reports composite "path > key" drift entries, not a plain selectable path). + const vscodeKeybindingsPath = join(PROJECT_ROOT, ".vscode/keybindings.json"); + await deps.fs.writeFile(vscodeKeybindingsPath, "CORRUPTED KEYBINDINGS"); + + // Any explicit selection turns fileFilter on, and once on it excludes every plugin path. + // Whether keybindings.json is itself repaired is not asserted. + const prompter = new ScriptedPrompter([ + ScriptedPrompter.answer.checkbox([".vscode/keybindings.json"]), + ]); + const useCase = makeRestoreAllUseCase(deps, reader, prompter); + await useCase.execute(PROJECT_ROOT, false, true); + + expect(deps.fs.getFile(pluginFile)).toBe("CORRUPTED CONTENT"); + }); + + it("unscoped restore still restores every installed AI tool's plugins (no regression)", async () => { + const deps = await buildUnitDeps(PROJECT_ROOT); + await initAndInstall(deps, PROJECT_ROOT, "claude"); + await installTool(deps, PROJECT_ROOT, "codex"); + await seedFromDirectory(deps.fs, PLUGIN_FIXTURE, { useAbsolutePaths: true }); + const { reader } = countingReader(deps.fs); + await installPlugin(deps, "claude", reader); + await new PluginAddUseCase( + deps.fs, + deps.manifestRepo, + deps.pluginFetcher, + reader, + deps.hasher, + deps.logger, + deps.marketplaceRegistry, + fakeEnsureBuiltMarketplace() + ).execute({ + source: { kind: "local", path: PLUGIN_FIXTURE }, + toolIds: ["codex"], + projectRoot: PROJECT_ROOT, + interactive: false, + }); + + const claudePluginFile = join(PROJECT_ROOT, ".claude/plugins/sample-plugin/commands/greet.md"); + const codexPluginFile = join(PROJECT_ROOT, ".codex/plugins/sample-plugin/commands/greet.md"); + await deps.fs.writeFile(claudePluginFile, "CORRUPTED CLAUDE"); + await deps.fs.writeFile(codexPluginFile, "CORRUPTED CODEX"); + + await makeRestoreAllUseCase(deps, reader).execute(PROJECT_ROOT, false, false); + + expect(deps.fs.getFile(claudePluginFile)).not.toBe("CORRUPTED CLAUDE"); + expect(deps.fs.getFile(codexPluginFile)).not.toBe("CORRUPTED CODEX"); + }); +}); + +describe("RestoreAllUseCase — consent to overwrite", () => { + type RestoreOptions = Parameters[0]; + + /** Records what RestoreAllUseCase asks the restore to do, without doing it. */ + function recordAsks(restoreUseCase: RestoreUseCase): RestoreOptions[] { + const seen: RestoreOptions[] = []; + restoreUseCase.execute = async (options: RestoreOptions) => { + seen.push(options); + return { + tools: [], + totalRestored: 0, + totalKept: 0, + totalPluginFilesRestored: 0, + restoredPluginNames: [], + unrestorable: [], + nativeOnlyToolIds: [], + }; + }; + return seen; + } + + async function askedWith(interactive: boolean, force: boolean): Promise { + const deps = await buildUnitDeps(PROJECT_ROOT); + await initAndInstall(deps, PROJECT_ROOT, "claude"); + const prompter = new OverwritePrompter(); + const statusUseCase = new StatusUseCase( + deps.fs, + deps.manifestRepo, + deps.hasher, + new DetectPluginDriftUseCase(deps.fs) + ); + const restoreUseCase = new RestoreUseCase( + deps.fs, + deps.manifestRepo, + deps.hasher, + deps.logger, + new FakePlatform("linux"), + prompter + ); + const seen = recordAsks(restoreUseCase); + await new RestoreAllUseCase(deps.manifestRepo, prompter, statusUseCase, restoreUseCase).execute( + PROJECT_ROOT, + interactive, + force + ); + const asked = seen[0]; + expect(asked).toBeDefined(); + return asked as RestoreOptions; + } + + it("carries --force through to the restore it delegates to", async () => { + expect((await askedWith(false, true)).force).toBe(true); + }); + + it("does not overwrite without consent when neither --force nor a TTY is there", async () => { + expect((await askedWith(false, false)).force).toBe(false); + }); + + it("treats the interactive file selection as the consent, so nothing is asked twice", async () => { + expect((await askedWith(true, false)).force).toBe(true); + }); +}); + +describe("RestoreAllUseCase — native-only tools", () => { + it("forwards the native-only tool ids the restore it delegates to found", async () => { + const deps = await buildUnitDeps(PROJECT_ROOT); + await initAndInstall(deps, PROJECT_ROOT, "claude"); + const prompter = new OverwritePrompter(); + const statusUseCase = new StatusUseCase( + deps.fs, + deps.manifestRepo, + deps.hasher, + new DetectPluginDriftUseCase(deps.fs) + ); + const restoreUseCase = new RestoreUseCase( + deps.fs, + deps.manifestRepo, + deps.hasher, + deps.logger, + new FakePlatform("linux"), + prompter + ); + restoreUseCase.execute = async () => ({ + tools: [], + totalRestored: 0, + totalKept: 0, + totalPluginFilesRestored: 0, + restoredPluginNames: [], + unrestorable: [], + nativeOnlyToolIds: ["claude"], + }); + + const result = await new RestoreAllUseCase( + deps.manifestRepo, + prompter, + statusUseCase, + restoreUseCase + ).execute(PROJECT_ROOT, true, false); + + expect(result.nativeOnlyToolIds).toEqual(["claude"]); + }); +}); diff --git a/cli/tests/application/use-cases/restore-use-case.unit.test.ts b/cli/tests/contexts/framework/application/restore-use-case.unit.test.ts similarity index 92% rename from cli/tests/application/use-cases/restore-use-case.unit.test.ts rename to cli/tests/contexts/framework/application/restore-use-case.unit.test.ts index c29b5c48f..87742be04 100644 --- a/cli/tests/application/use-cases/restore-use-case.unit.test.ts +++ b/cli/tests/contexts/framework/application/restore-use-case.unit.test.ts @@ -1,19 +1,19 @@ import { join } from "node:path"; import { describe, expect, it } from "vitest"; -import { PluginAddUseCase } from "../../../src/application/use-cases/plugin/plugin-add-use-case.js"; -import { RestoreUseCase } from "../../../src/application/use-cases/restore/restore-use-case.js"; -import { PluginDistributionReaderAdapter } from "../../../src/infrastructure/adapters/plugin-distribution-reader-adapter.js"; +import { PluginAddUseCase } from "../../../../src/contexts/framework/application/plugin/plugin-add-use-case.js"; +import { RestoreUseCase } from "../../../../src/contexts/framework/application/restore/restore-use-case.js"; +import { PluginDistributionReaderAdapter } from "../../../../src/contexts/framework/infrastructure/plugin-distribution-reader-adapter.js"; import { buildUnitDeps, FIXTURE_DIR, initAndInstall, initProject, installTool, -} from "../../helpers/ports/build-unit-deps.js"; -import { fakeEnsureBuiltMarketplace } from "../../helpers/ports/fake-ensure-built-marketplace.js"; -import { FakePlatform } from "../../helpers/ports/fake-platform.js"; -import { KeepPrompter, OverwritePrompter } from "../../helpers/ports/scripted-prompter.js"; -import { seedFromDirectory } from "../../helpers/ports/seed-from-directory.js"; +} from "../../../helpers/ports/build-unit-deps.js"; +import { fakeEnsureBuiltMarketplace } from "../../../helpers/ports/fake-ensure-built-marketplace.js"; +import { FakePlatform } from "../../../helpers/ports/fake-platform.js"; +import { KeepPrompter, OverwritePrompter } from "../../../helpers/ports/scripted-prompter.js"; +import { seedFromDirectory } from "../../../helpers/ports/seed-from-directory.js"; const PROJECT_ROOT = "/test-project"; const PLUGIN_FIXTURE = join(process.cwd(), "tests/fixtures/plugins/claude-format/sample-plugin"); @@ -41,7 +41,6 @@ async function installPlugin( }); } -/** RecordingPrompter for tracking resolveConflict calls */ class RecordingPrompter extends OverwritePrompter { readonly calls: Array<{ relativePath: string; reason: "deleted" | "modified" }> = []; private readonly response: "keep" | "overwrite"; @@ -85,7 +84,6 @@ describe("restore", () => { useCase.execute({ frameworkPath: FIXTURE_DIR, version: "test", - docsDir: "aidd_docs", projectRoot: PROJECT_ROOT, }) ).rejects.toThrow("aidd setup"); @@ -98,7 +96,6 @@ describe("restore", () => { const result = await makeRestoreUseCase(deps).execute({ frameworkPath: FIXTURE_DIR, version: "test", - docsDir: "aidd_docs", projectRoot: PROJECT_ROOT, }); @@ -115,7 +112,6 @@ describe("restore", () => { const result = await makeRestoreUseCase(deps).execute({ frameworkPath: FIXTURE_DIR, version: "test", - docsDir: "aidd_docs", projectRoot: PROJECT_ROOT, force: true, }); @@ -136,7 +132,6 @@ describe("restore", () => { const result = await makeRestoreUseCase(deps).execute({ frameworkPath: FIXTURE_DIR, version: "test", - docsDir: "aidd_docs", projectRoot: PROJECT_ROOT, force: true, }); @@ -155,7 +150,6 @@ describe("restore", () => { const result = await makeRestoreUseCase(deps, new KeepPrompter()).execute({ frameworkPath: FIXTURE_DIR, version: "test", - docsDir: "aidd_docs", projectRoot: PROJECT_ROOT, interactive: true, }); @@ -178,7 +172,6 @@ describe("restore", () => { await makeRestoreUseCase(deps).execute({ frameworkPath: FIXTURE_DIR, version: "test", - docsDir: "aidd_docs", projectRoot: PROJECT_ROOT, toolIds: ["vscode"], force: true, @@ -207,7 +200,6 @@ describe("restore", () => { await makeRestoreUseCase(deps).execute({ frameworkPath: FIXTURE_DIR, version: "test", - docsDir: "aidd_docs", projectRoot: PROJECT_ROOT, toolIds: ["claude"], force: true, @@ -230,7 +222,6 @@ describe("restore", () => { await makeRestoreUseCase(deps).execute({ frameworkPath: FIXTURE_DIR, version: "test", - docsDir: "aidd_docs", projectRoot: PROJECT_ROOT, toolIds: ["vscode"], force: true, @@ -255,7 +246,6 @@ describe("restore", () => { await makeRestoreUseCase(deps).execute({ frameworkPath: FIXTURE_DIR, version: "test", - docsDir: "aidd_docs", projectRoot: PROJECT_ROOT, force: true, }); @@ -274,7 +264,6 @@ describe("restore", () => { await makeRestoreUseCase(deps).execute({ frameworkPath: FIXTURE_DIR, version: "test", - docsDir: "aidd_docs", projectRoot: PROJECT_ROOT, force: true, }); @@ -292,7 +281,6 @@ describe("restore", () => { const result = await makeRestoreUseCase(deps).execute({ frameworkPath: FIXTURE_DIR, version: "test", - docsDir: "aidd_docs", projectRoot: PROJECT_ROOT, interactive: false, force: false, @@ -313,7 +301,6 @@ describe("restore", () => { makeRestoreUseCase(deps).execute({ frameworkPath: FIXTURE_DIR, version: "test", - docsDir: "aidd_docs", projectRoot: PROJECT_ROOT, interactive: false, force: false, @@ -332,7 +319,6 @@ describe("restore", () => { await makeRestoreUseCase(deps, prompter).execute({ frameworkPath: FIXTURE_DIR, version: "test", - docsDir: "aidd_docs", projectRoot: PROJECT_ROOT, }); @@ -352,7 +338,6 @@ describe("restore", () => { await makeRestoreUseCase(deps, prompter).execute({ frameworkPath: FIXTURE_DIR, version: "test", - docsDir: "aidd_docs", projectRoot: PROJECT_ROOT, interactive: true, }); @@ -371,7 +356,6 @@ describe("restore", () => { const result = await makeRestoreUseCase(deps).execute({ frameworkPath: FIXTURE_DIR, version: "test", - docsDir: "aidd_docs", projectRoot: PROJECT_ROOT, }); @@ -393,7 +377,6 @@ describe("restore", () => { const result = await makeRestoreUseCase(deps).execute({ frameworkPath: FIXTURE_DIR, version: "test", - docsDir: "aidd_docs", projectRoot: PROJECT_ROOT, force: true, }); @@ -413,7 +396,6 @@ describe("restore", () => { const result = await makeRestoreUseCase(deps).execute({ frameworkPath: FIXTURE_DIR, version: "test", - docsDir: "aidd_docs", projectRoot: PROJECT_ROOT, force: true, }); @@ -437,7 +419,6 @@ describe("restore", () => { const result = await makeRestoreUseCase(deps, new KeepPrompter()).execute({ frameworkPath: FIXTURE_DIR, version: "test", - docsDir: "aidd_docs", projectRoot: PROJECT_ROOT, interactive: true, }); @@ -464,7 +445,6 @@ describe("restore", () => { makeRestoreUseCase(deps).execute({ frameworkPath: FIXTURE_DIR, version: "test", - docsDir: "aidd_docs", projectRoot: PROJECT_ROOT, force: false, interactive: false, @@ -486,7 +466,6 @@ describe("restore", () => { const result = await makeRestoreUseCase(deps).execute({ frameworkPath: FIXTURE_DIR, version: "test", - docsDir: "aidd_docs", projectRoot: PROJECT_ROOT, force: true, files: ["CLAUDE.md"], diff --git a/cli/tests/application/use-cases/shared/restore-merge-files-use-case.unit.test.ts b/cli/tests/contexts/framework/application/restore/restore-merge-files-use-case.unit.test.ts similarity index 96% rename from cli/tests/application/use-cases/shared/restore-merge-files-use-case.unit.test.ts rename to cli/tests/contexts/framework/application/restore/restore-merge-files-use-case.unit.test.ts index d07c881a3..3aa70d710 100644 --- a/cli/tests/application/use-cases/shared/restore-merge-files-use-case.unit.test.ts +++ b/cli/tests/contexts/framework/application/restore/restore-merge-files-use-case.unit.test.ts @@ -1,15 +1,15 @@ import { join } from "node:path"; import { describe, expect, it } from "vitest"; -import { InputRequiredError } from "../../../../src/application/errors.js"; -import { RestoreMergeFilesUseCase } from "../../../../src/application/use-cases/shared/restore-merge-files-use-case.js"; -import { InstallationFile } from "../../../../src/domain/models/file.js"; -import type { MergeFileEntry } from "../../../../src/domain/models/merge.js"; -import { buildUnitDeps } from "../../../helpers/ports/build-unit-deps.js"; +import { RestoreMergeFilesUseCase } from "../../../../../src/contexts/framework/application/restore/restore-merge-files-use-case.js"; +import { InputRequiredError } from "../../../../../src/kernel/errors.js"; +import { InstallationFile } from "../../../../../src/kernel/file.js"; +import type { MergeFileEntry } from "../../../../../src/kernel/merge.js"; +import { buildUnitDeps } from "../../../../helpers/ports/build-unit-deps.js"; import { KeepPrompter, OverwritePrompter, ScriptedPrompter, -} from "../../../helpers/ports/scripted-prompter.js"; +} from "../../../../helpers/ports/scripted-prompter.js"; const PROJECT_ROOT = "/test-project"; diff --git a/cli/tests/application/use-cases/shared/restore-regular-files-use-case.unit.test.ts b/cli/tests/contexts/framework/application/restore/restore-regular-files-use-case.unit.test.ts similarity index 96% rename from cli/tests/application/use-cases/shared/restore-regular-files-use-case.unit.test.ts rename to cli/tests/contexts/framework/application/restore/restore-regular-files-use-case.unit.test.ts index 3d208eeb1..81c06f43a 100644 --- a/cli/tests/application/use-cases/shared/restore-regular-files-use-case.unit.test.ts +++ b/cli/tests/contexts/framework/application/restore/restore-regular-files-use-case.unit.test.ts @@ -1,14 +1,14 @@ import { join } from "node:path"; import { describe, expect, it } from "vitest"; -import { InputRequiredError } from "../../../../src/application/errors.js"; -import { RestoreRegularFilesUseCase } from "../../../../src/application/use-cases/shared/restore-regular-files-use-case.js"; -import { InstallationFile } from "../../../../src/domain/models/file.js"; -import { buildUnitDeps } from "../../../helpers/ports/build-unit-deps.js"; +import { RestoreRegularFilesUseCase } from "../../../../../src/contexts/framework/application/restore/restore-regular-files-use-case.js"; +import { InputRequiredError } from "../../../../../src/kernel/errors.js"; +import { InstallationFile } from "../../../../../src/kernel/file.js"; +import { buildUnitDeps } from "../../../../helpers/ports/build-unit-deps.js"; import { KeepPrompter, OverwritePrompter, ScriptedPrompter, -} from "../../../helpers/ports/scripted-prompter.js"; +} from "../../../../helpers/ports/scripted-prompter.js"; const PROJECT_ROOT = "/test-project"; diff --git a/cli/tests/contexts/framework/application/setup-auth-guard.unit.test.ts b/cli/tests/contexts/framework/application/setup-auth-guard.unit.test.ts new file mode 100644 index 000000000..ec4494fd3 --- /dev/null +++ b/cli/tests/contexts/framework/application/setup-auth-guard.unit.test.ts @@ -0,0 +1,178 @@ +import { describe, expect, it, vi } from "vitest"; +import type { MarketplaceRefresh } from "../../../../src/contexts/distribution/application/marketplace-refresh-use-case.js"; +import type { MarketplaceRegisterFramework } from "../../../../src/contexts/distribution/application/marketplace-register-framework-use-case.js"; +import type { ResolveMarketplace } from "../../../../src/contexts/distribution/application/resolve-marketplace-use-case.js"; +import { + FRAMEWORK_MARKETPLACE_NAME, + Marketplace, +} from "../../../../src/contexts/distribution/domain/marketplace.js"; +import { MarketplaceSourceMode } from "../../../../src/contexts/distribution/domain/marketplace-source-mode.js"; +import type { MarketplaceSyncSettings } from "../../../../src/contexts/framework/application/flows/marketplace-sync-settings-use-case.js"; +import type { PluginInstallFromMarketplace } from "../../../../src/contexts/framework/application/plugin/plugin-install-from-marketplace-use-case.js"; +import { SetupMarketplaceSourceUseCase } from "../../../../src/contexts/framework/application/setup/setup-marketplace-source-use-case.js"; +import { SetupToolsUseCase } from "../../../../src/contexts/framework/application/setup/setup-tools-use-case.js"; +import { SetupUseCase } from "../../../../src/contexts/framework/application/setup-use-case.js"; +import { SetupMarketplaceRegistrationUseCase } from "../../../../src/contexts/framework/application/shared/setup-marketplace-registration-use-case.js"; +import { SetupFlow } from "../../../../src/contexts/framework/domain/setup-flow.js"; +import { CatalogFetchAuthError } from "../../../../src/kernel/errors.js"; +import type { PluginPick } from "../../../../src/presentation/prompts/plugin-pick-use-case.js"; +import { SetupPluginsPromptUseCase } from "../../../../src/presentation/prompts/setup-plugins-prompt-use-case.js"; +import type { TokenProvider } from "../../../../src/runtime/auth/ports/token-provider.js"; +import type { LatestReleaseResolver } from "../../../../src/runtime/self-update/latest-release-resolver.js"; +import { buildUnitDeps } from "../../../helpers/ports/build-unit-deps.js"; +import { InMemoryEnvironment } from "../../../helpers/ports/in-memory-environment.js"; +import { InMemoryMarketplaceRegistry } from "../../../helpers/ports/in-memory-marketplace-registry.js"; +import { OverwritePrompter } from "../../../helpers/ports/scripted-prompter.js"; + +function makeReleaseResolver(isPublic: boolean): LatestReleaseResolver { + return { + resolveLatest: vi.fn().mockResolvedValue(null), + listRootReleases: vi.fn().mockResolvedValue([]), + isRepoPublic: vi.fn().mockResolvedValue(isPublic), + }; +} + +// Real values, not empty objects: a no-op double still has to answer with what its +// contract promises, so a caller that starts reading the answer breaks here first. +const FRAMEWORK_MARKETPLACE = Marketplace.create({ + name: FRAMEWORK_MARKETPLACE_NAME, + source: { kind: "local", path: "/framework" }, + scope: "project", + addedAt: "2026-08-20T00:00:00.000Z", +}); + +function makeNoOpPluginPick(): PluginPick { + return { + execute: vi.fn().mockResolvedValue({ marketplace: FRAMEWORK_MARKETPLACE, installed: [] }), + }; +} + +function makeNoOpPluginInstallFromMarketplace(): PluginInstallFromMarketplace { + return { + execute: vi.fn().mockResolvedValue({ + marketplace: FRAMEWORK_MARKETPLACE, + entry: { + name: "aidd-context", + source: { kind: "local", path: "/framework/plugins/aidd-context" }, + recommended: false, + strict: false, + }, + }), + }; +} + +function makeNoOpResolveMarketplace(): ResolveMarketplace { + return { + execute: vi + .fn() + .mockResolvedValue({ marketplace: FRAMEWORK_MARKETPLACE, localPath: "", catalog: null }), + }; +} + +function makeNoOpRegisterFramework(): MarketplaceRegisterFramework { + return { execute: vi.fn().mockResolvedValue({ registered: false, scope: "user" }) }; +} + +function makeNoOpRefresh(): MarketplaceRefresh { + return { execute: vi.fn().mockResolvedValue({ results: [], failedCount: 0 }) }; +} + +function makeNoOpSyncSettings(): MarketplaceSyncSettings { + return { execute: vi.fn().mockResolvedValue({ updatedTools: [] }) }; +} + +function makeTokenProvider(token: string | null): TokenProvider { + return { resolve: vi.fn().mockResolvedValue(token) }; +} + +const PROJECT_ROOT = "/test-project"; + +async function buildSetupUseCase(tokenProvider: TokenProvider, isRepoPublic = false) { + const deps = await buildUnitDeps(PROJECT_ROOT); + const prompter = new OverwritePrompter(); + const setupMarketplaceSourceUseCase = new SetupMarketplaceSourceUseCase( + prompter, + makeReleaseResolver(true) + ); + const setupToolsUseCase = new SetupToolsUseCase( + deps.manifestRepo, + deps.installRuntimeConfigUseCase, + deps.installIdeConfigUseCase + ); + const setupPluginsPromptUseCase = new SetupPluginsPromptUseCase( + makeNoOpPluginPick(), + makeNoOpPluginInstallFromMarketplace(), + new InMemoryMarketplaceRegistry(), + makeNoOpResolveMarketplace() + ); + const setupMarketplaceRegistration = new SetupMarketplaceRegistrationUseCase( + deps.fs, + setupMarketplaceSourceUseCase, + makeNoOpRegisterFramework(), + makeNoOpRefresh(), + deps.currentVersionProvider, + deps.logger, + new InMemoryEnvironment(), + tokenProvider, + makeReleaseResolver(isRepoPublic) + ); + return new SetupUseCase( + deps.fs, + deps.manifestRepo, + setupMarketplaceRegistration, + makeNoOpSyncSettings(), + setupToolsUseCase, + setupPluginsPromptUseCase, + deps.currentVersionProvider + ); +} + +describe("SetupUseCase — auth guard for remote source", () => { + it("throws CatalogFetchAuthError when source is remote, no token, and repo is private", async () => { + const useCase = await buildSetupUseCase(makeTokenProvider(null), false); + + const flow = new SetupFlow({ + projectRoot: PROJECT_ROOT, + source: MarketplaceSourceMode.remote(), + interactive: false, + }); + + await expect(useCase.execute(flow)).rejects.toThrow(CatalogFetchAuthError); + }); + + it("proceeds when source is remote, no token, but repo is public", async () => { + const useCase = await buildSetupUseCase(makeTokenProvider(null), true); + + const flow = new SetupFlow({ + projectRoot: PROJECT_ROOT, + source: MarketplaceSourceMode.remote(), + interactive: false, + }); + + await expect(useCase.execute(flow)).resolves.toBeDefined(); + }); + + it("proceeds without error when source is remote and a token is present", async () => { + const useCase = await buildSetupUseCase(makeTokenProvider("ghp_valid-token")); + + const flow = new SetupFlow({ + projectRoot: PROJECT_ROOT, + source: MarketplaceSourceMode.remote(), + interactive: false, + }); + + await expect(useCase.execute(flow)).resolves.toBeDefined(); + }); + + it("proceeds without error when source is local (no token required)", async () => { + const useCase = await buildSetupUseCase(makeTokenProvider(null)); + + const flow = new SetupFlow({ + projectRoot: PROJECT_ROOT, + source: MarketplaceSourceMode.local("/some/path"), + interactive: false, + }); + + await expect(useCase.execute(flow)).resolves.toBeDefined(); + }); +}); diff --git a/cli/tests/contexts/framework/application/setup-marketplace-conflict.integration.test.ts b/cli/tests/contexts/framework/application/setup-marketplace-conflict.integration.test.ts new file mode 100644 index 000000000..cb7498e41 --- /dev/null +++ b/cli/tests/contexts/framework/application/setup-marketplace-conflict.integration.test.ts @@ -0,0 +1,147 @@ +import { describe, expect, it, vi } from "vitest"; +import "../../../../src/contexts/tools/domain/profiles/claude/profile.js"; +import type { MarketplaceRefresh } from "../../../../src/contexts/distribution/application/marketplace-refresh-use-case.js"; +import type { MarketplaceRegisterFramework } from "../../../../src/contexts/distribution/application/marketplace-register-framework-use-case.js"; +import { Marketplace } from "../../../../src/contexts/distribution/domain/marketplace.js"; +import { MarketplaceSourceMode } from "../../../../src/contexts/distribution/domain/marketplace-source-mode.js"; +import { MarketplaceSyncSettingsUseCase } from "../../../../src/contexts/framework/application/flows/marketplace-sync-settings-use-case.js"; +import type { PluginInstallFromMarketplace } from "../../../../src/contexts/framework/application/plugin/plugin-install-from-marketplace-use-case.js"; +import { SetupMarketplaceSourceUseCase } from "../../../../src/contexts/framework/application/setup/setup-marketplace-source-use-case.js"; +import { SetupToolsUseCase } from "../../../../src/contexts/framework/application/setup/setup-tools-use-case.js"; +import { SetupUseCase } from "../../../../src/contexts/framework/application/setup-use-case.js"; +import { SetupMarketplaceRegistrationUseCase } from "../../../../src/contexts/framework/application/shared/setup-marketplace-registration-use-case.js"; +import { SetupFlow } from "../../../../src/contexts/framework/domain/setup-flow.js"; +import type { ToolId } from "../../../../src/kernel/tool.js"; +import { SetupPluginsPromptUseCase } from "../../../../src/presentation/prompts/setup-plugins-prompt-use-case.js"; +import type { LatestReleaseResolver } from "../../../../src/runtime/self-update/latest-release-resolver.js"; +import { buildUnitDeps } from "../../../helpers/ports/build-unit-deps.js"; +import { fakeEnsureBuiltMarketplace } from "../../../helpers/ports/fake-ensure-built-marketplace.js"; +import { FakeHostMarketplaceRegistryReader } from "../../../helpers/ports/fake-host-marketplace-registry-reader.js"; +import { FakeNativePluginActivator } from "../../../helpers/ports/fake-native-plugin-activator.js"; +import { InMemoryEnvironment } from "../../../helpers/ports/in-memory-environment.js"; +import { InMemoryMarketplaceRegistry } from "../../../helpers/ports/in-memory-marketplace-registry.js"; +import { OverwritePrompter } from "../../../helpers/ports/scripted-prompter.js"; + +const PROJECT_ROOT = "/test-project"; +const REGISTRY_LOCATION = "/home/.claude/plugins/known_marketplaces.json"; + +function makeNoOpLatestResolver(): LatestReleaseResolver { + return { + resolveLatest: vi.fn().mockResolvedValue(null), + listRootReleases: vi.fn().mockResolvedValue([]), + isRepoPublic: vi.fn().mockResolvedValue(true), + }; +} + +function makeNoOpMarketplaceRegisterFramework(): MarketplaceRegisterFramework { + return { execute: vi.fn().mockResolvedValue({ registered: false, scope: "user" }) }; +} + +function makeNoOpMarketplaceRefresh(): MarketplaceRefresh { + return { execute: vi.fn().mockResolvedValue({ results: [], failedCount: 0 }) }; +} + +function makeNoOpPluginInstallFromMarketplace(): PluginInstallFromMarketplace { + return { execute: vi.fn() }; +} + +/** A REAL `MarketplaceSyncSettingsUseCase`, not the no-op double the other setup suites + * substitute, behind a host registry already holding a different catalog under that name. */ +async function buildUseCaseWithConflict() { + const deps = await buildUnitDeps(PROJECT_ROOT); + const prompter = new OverwritePrompter(); + const setupMarketplaceSourceUseCase = new SetupMarketplaceSourceUseCase( + prompter, + makeNoOpLatestResolver() + ); + const setupToolsUseCase = new SetupToolsUseCase( + deps.manifestRepo, + deps.installRuntimeConfigUseCase, + deps.installIdeConfigUseCase + ); + const setupPluginsPromptUseCase = new SetupPluginsPromptUseCase( + { execute: vi.fn() }, + makeNoOpPluginInstallFromMarketplace(), + new InMemoryMarketplaceRegistry(), + { execute: vi.fn() } + ); + + // The marketplace this project already knows, and the built catalog a real sync would + // read back, driven here through `setup` rather than against the use case directly. + await deps.marketplaceRegistry.save( + PROJECT_ROOT, + Marketplace.create({ + name: "probe-mkt", + source: { kind: "local", path: "/source/probe-mkt" }, + scope: "project", + addedAt: "2026-01-01T00:00:00Z", + }) + ); + await deps.fs.writeFile( + "/built/claude/.claude-plugin/marketplace.json", + JSON.stringify({ name: "probe-mkt", version: "1.0.0", plugins: [{ name: "sample-plugin" }] }) + ); + await deps.fs.writeFile( + "/other/src/.claude-plugin/marketplace.json", + JSON.stringify({ name: "probe-mkt", version: "2.0.0", plugins: [{ name: "different-plugin" }] }) + ); + const hostReader = new FakeHostMarketplaceRegistryReader({ + location: REGISTRY_LOCATION, + entries: new Map([["probe-mkt", "/other/src"]]), + }); + const activator = new FakeNativePluginActivator({ available: true, enablesPlugins: false }); + const marketplaceSyncSettingsUseCase = new MarketplaceSyncSettingsUseCase( + deps.fs, + deps.manifestRepo, + deps.marketplaceRegistry, + deps.hasher, + deps.logger, + new Map([["claude", activator]]), + fakeEnsureBuiltMarketplace((target) => `/built/${target}`), + new Map([["claude", hostReader]]) + ); + + const setupMarketplaceRegistration = new SetupMarketplaceRegistrationUseCase( + deps.fs, + setupMarketplaceSourceUseCase, + makeNoOpMarketplaceRegisterFramework(), + makeNoOpMarketplaceRefresh(), + deps.currentVersionProvider, + deps.logger, + new InMemoryEnvironment() + ); + const useCase = new SetupUseCase( + deps.fs, + deps.manifestRepo, + setupMarketplaceRegistration, + marketplaceSyncSettingsUseCase, + setupToolsUseCase, + setupPluginsPromptUseCase, + deps.currentVersionProvider + ); + return { useCase, activator }; +} + +function remoteFlow(aiTools: ToolId[]): SetupFlow { + return new SetupFlow({ + projectRoot: PROJECT_ROOT, + source: MarketplaceSourceMode.remote(), + aiTools, + ideTools: [], + pluginMode: "none", + interactive: false, + }); +} + +describe("setup surfaces a marketplace source conflict instead of exiting clean", () => { + it("reports the conflict in its own activation result rather than discarding it", async () => { + const { useCase, activator } = await buildUseCaseWithConflict(); + + const result = await useCase.execute(remoteFlow(["claude" as ToolId])); + + expect(activator.addedMarketplaces).toHaveLength(0); + expect(result.activation.errors).toHaveLength(1); + expect(result.activation.errors[0]?.message).toMatch(/different catalog/); + expect(result.activation.errors[0]?.message).toMatch(/probe-mkt/); + }); +}); diff --git a/cli/tests/contexts/framework/application/setup-use-case.unit.test.ts b/cli/tests/contexts/framework/application/setup-use-case.unit.test.ts new file mode 100644 index 000000000..cd9212b81 --- /dev/null +++ b/cli/tests/contexts/framework/application/setup-use-case.unit.test.ts @@ -0,0 +1,568 @@ +import { join } from "node:path"; +import { describe, expect, it, vi } from "vitest"; +import type { MarketplaceRefresh } from "../../../../src/contexts/distribution/application/marketplace-refresh-use-case.js"; +import type { MarketplaceRegisterFramework } from "../../../../src/contexts/distribution/application/marketplace-register-framework-use-case.js"; +import type { ResolveMarketplace } from "../../../../src/contexts/distribution/application/resolve-marketplace-use-case.js"; +import type { PluginCatalogEntry } from "../../../../src/contexts/distribution/domain/catalog.js"; +import { + FRAMEWORK_MARKETPLACE_NAME, + Marketplace, +} from "../../../../src/contexts/distribution/domain/marketplace.js"; +import { MarketplaceSourceMode } from "../../../../src/contexts/distribution/domain/marketplace-source-mode.js"; +import type { MarketplaceSyncSettings } from "../../../../src/contexts/framework/application/flows/marketplace-sync-settings-use-case.js"; +import type { PluginInstallFromMarketplace } from "../../../../src/contexts/framework/application/plugin/plugin-install-from-marketplace-use-case.js"; +import { SetupMachineScopeUseCase } from "../../../../src/contexts/framework/application/setup/setup-machine-scope-use-case.js"; +import { SetupMarketplaceSourceUseCase } from "../../../../src/contexts/framework/application/setup/setup-marketplace-source-use-case.js"; +import { SetupToolsUseCase } from "../../../../src/contexts/framework/application/setup/setup-tools-use-case.js"; +import { SetupUseCase } from "../../../../src/contexts/framework/application/setup-use-case.js"; +import { SetupMarketplaceRegistrationUseCase } from "../../../../src/contexts/framework/application/shared/setup-marketplace-registration-use-case.js"; +import type { ManifestRepository } from "../../../../src/contexts/framework/domain/ports/manifest-repository.js"; +import type { UserSourceReferences } from "../../../../src/contexts/framework/domain/ports/user-source-references.js"; +import { SetupFlow } from "../../../../src/contexts/framework/domain/setup-flow.js"; +import { UserSourceReferencesAdapter } from "../../../../src/contexts/framework/infrastructure/user-source-references-adapter.js"; +import type { Logger } from "../../../../src/kernel/ports/logger.js"; +import type { ToolId } from "../../../../src/kernel/tool.js"; +import { AI_TOOL_IDS, IDE_TOOL_IDS } from "../../../../src/kernel/tool.js"; +import type { PluginPick } from "../../../../src/presentation/prompts/plugin-pick-use-case.js"; +import { SetupPluginsPromptUseCase } from "../../../../src/presentation/prompts/setup-plugins-prompt-use-case.js"; +import { SetupToolsPromptUseCase } from "../../../../src/presentation/prompts/setup-tools-prompt-use-case.js"; +import type { LatestReleaseResolver } from "../../../../src/runtime/self-update/latest-release-resolver.js"; +import { + buildUnitDeps, + initAndInstall, + initProject, +} from "../../../helpers/ports/build-unit-deps.js"; +import { CapturingLogger } from "../../../helpers/ports/capturing-logger.js"; +import { InMemoryEnvironment } from "../../../helpers/ports/in-memory-environment.js"; +import { InMemoryFileAdapter } from "../../../helpers/ports/in-memory-file-adapter.js"; +import { InMemoryManifestRepository } from "../../../helpers/ports/in-memory-manifest-repository.js"; +import { InMemoryMarketplaceRegistry } from "../../../helpers/ports/in-memory-marketplace-registry.js"; +import { OverwritePrompter, ScriptedPrompter } from "../../../helpers/ports/scripted-prompter.js"; + +function makeNoOpLatestResolver(): LatestReleaseResolver { + return { + resolveLatest: vi.fn().mockResolvedValue(null), + listRootReleases: vi.fn().mockResolvedValue([]), + isRepoPublic: vi.fn().mockResolvedValue(true), + }; +} + +type RegisterFrameworkMock = MarketplaceRegisterFramework & { execute: ReturnType }; +type RefreshMock = MarketplaceRefresh & { execute: ReturnType }; + +function makeNoOpMarketplaceRegisterFramework(): RegisterFrameworkMock { + const execute = vi.fn().mockResolvedValue({ registered: false, scope: "user" }); + return { execute }; +} + +function makeNoOpMarketplaceRefresh(): RefreshMock { + const execute = vi.fn().mockResolvedValue({ results: [], failedCount: 0 }); + return { execute }; +} + +function makeNoOpMarketplaceSyncSettings(): MarketplaceSyncSettings { + return { execute: vi.fn().mockResolvedValue({ updatedTools: [] }) }; +} + +function makeNoOpPluginPick(): PluginPick { + return { + execute: vi.fn().mockResolvedValue({ marketplace: FRAMEWORK_MARKETPLACE, installed: [] }), + }; +} + +function makeNoOpPluginInstallFromMarketplace(): PluginInstallFromMarketplace { + return { + execute: vi + .fn() + .mockResolvedValue({ marketplace: FRAMEWORK_MARKETPLACE, entry: CATALOG_ENTRY }), + }; +} + +function makeNoOpResolveMarketplace(): ResolveMarketplace { + return { + execute: vi + .fn() + .mockResolvedValue({ marketplace: FRAMEWORK_MARKETPLACE, localPath: "", catalog: null }), + }; +} + +const PROJECT_ROOT = "/test-project"; + +// Real values, not empty objects: a no-op double still has to answer with what its +// contract promises, so a caller that starts reading the answer breaks here first. +const FRAMEWORK_MARKETPLACE = Marketplace.create({ + name: FRAMEWORK_MARKETPLACE_NAME, + source: { kind: "local", path: "/framework" }, + scope: "project", + addedAt: "2026-08-20T00:00:00.000Z", +}); + +const CATALOG_ENTRY: PluginCatalogEntry = { + name: "aidd-context", + source: { kind: "local", path: "/framework/plugins/aidd-context" }, + recommended: false, + strict: false, +}; + +function makeRecordingUserSourceReferences(): UserSourceReferences & { + added: Array<{ version: string; projectRoot: string }>; +} { + const added: Array<{ version: string; projectRoot: string }> = []; + return { + added, + addReference: vi.fn(async (version: string, projectRoot: string) => { + added.push({ version, projectRoot }); + }), + removeReference: vi.fn(async () => undefined), + listAllReferencingProjects: vi.fn(async () => []), + }; +} + +async function buildUseCase( + setupToolsPromptUseCase?: SetupToolsPromptUseCase, + userSourceReferences?: UserSourceReferences, + logger?: Logger, + options?: { + userManifestRepo?: ManifestRepository; + marketplaceSyncSettingsUseCase?: MarketplaceSyncSettings & { + execute: ReturnType; + }; + } +) { + const deps = await buildUnitDeps(PROJECT_ROOT); + const prompter = new OverwritePrompter(); + const setupMarketplaceSourceUseCase = new SetupMarketplaceSourceUseCase( + prompter, + makeNoOpLatestResolver() + ); + const setupToolsUseCase = new SetupToolsUseCase( + deps.manifestRepo, + deps.installRuntimeConfigUseCase, + deps.installIdeConfigUseCase + ); + const setupPluginsPromptUseCase = new SetupPluginsPromptUseCase( + makeNoOpPluginPick(), + makeNoOpPluginInstallFromMarketplace(), + new InMemoryMarketplaceRegistry(), + makeNoOpResolveMarketplace() + ); + const marketplaceRegisterFramework = makeNoOpMarketplaceRegisterFramework(); + const marketplaceRefresh = makeNoOpMarketplaceRefresh(); + const marketplaceSyncSettingsUseCase = + options?.marketplaceSyncSettingsUseCase ?? makeNoOpMarketplaceSyncSettings(); + const setupMarketplaceRegistration = new SetupMarketplaceRegistrationUseCase( + deps.fs, + setupMarketplaceSourceUseCase, + marketplaceRegisterFramework, + marketplaceRefresh, + deps.currentVersionProvider, + logger ?? deps.logger, + new InMemoryEnvironment(), + undefined, + undefined, + userSourceReferences + ); + const setupMachineScopeUseCase = + options?.userManifestRepo === undefined + ? undefined + : new SetupMachineScopeUseCase( + options.userManifestRepo, + setupMarketplaceRegistration, + marketplaceSyncSettingsUseCase, + deps.currentVersionProvider + ); + const useCase = new SetupUseCase( + deps.fs, + deps.manifestRepo, + setupMarketplaceRegistration, + marketplaceSyncSettingsUseCase, + setupToolsUseCase, + setupPluginsPromptUseCase, + deps.currentVersionProvider, + setupToolsPromptUseCase, + undefined, + setupMachineScopeUseCase + ); + return { + useCase, + deps, + marketplaceRegisterFramework, + marketplaceRefresh, + marketplaceSyncSettingsUseCase, + }; +} + +function remoteFlow( + opts: Partial<{ aiTools: ToolId[]; ideTools: ToolId[]; scope: "project" | "user" }> = {} +): SetupFlow { + return new SetupFlow({ + projectRoot: PROJECT_ROOT, + source: MarketplaceSourceMode.remote(), + aiTools: opts.aiTools ?? [], + ideTools: opts.ideTools ?? [], + pluginMode: "none", + interactive: false, + scope: opts.scope, + }); +} + +describe("setup validates before any side effect", () => { + it("non-interactive with no --source rejects without writing a manifest or .gitignore", async () => { + const { useCase, deps } = await buildUseCase(); + const flow = new SetupFlow({ + projectRoot: PROJECT_ROOT, + aiTools: [], + ideTools: [], + pluginMode: "none", + interactive: false, + }); + + await expect(useCase.execute(flow)).rejects.toThrow(/--source/); + + expect(await deps.manifestRepo.load()).toBeNull(); + expect(deps.fs.has(join(PROJECT_ROOT, ".gitignore"))).toBe(false); + }); +}); + +describe("setup without TTY", () => { + it("fresh project with all tools flag initializes and installs all tools", async () => { + const { useCase } = await buildUseCase(); + const result = await useCase.execute( + remoteFlow({ aiTools: [...AI_TOOL_IDS], ideTools: [...IDE_TOOL_IDS] }) + ); + + expect(result.kind).toBe("initialized"); + if (result.kind === "initialized") { + expect(result.install.results.length).toBeGreaterThan(0); + } + }); + + it("fresh project without tool flags initializes docs only and installs no tools", async () => { + const { useCase } = await buildUseCase(); + const result = await useCase.execute(remoteFlow()); + + expect(result.kind).toBe("initialized"); + if (result.kind === "initialized") { + expect(result.install.results).toHaveLength(0); + } + }); + + it("aidd_docs exists without tool signals routes to init and installs tools", async () => { + const { useCase, deps } = await buildUseCase(); + deps.fs.writeFile(join(PROJECT_ROOT, "aidd_docs/.keep"), ""); + + const result = await useCase.execute( + remoteFlow({ aiTools: [...AI_TOOL_IDS], ideTools: [...IDE_TOOL_IDS] }) + ); + + expect(result.kind).toBe("initialized"); + if (result.kind === "initialized") { + expect(result.install.results.length).toBeGreaterThan(0); + } + }); + + it("manifest exists — returns up-to-date even with tool flags (tools still installed)", async () => { + const { useCase, deps } = await buildUseCase(); + await initProject(deps, PROJECT_ROOT); + + const result = await useCase.execute( + remoteFlow({ aiTools: [...AI_TOOL_IDS], ideTools: [...IDE_TOOL_IDS] }) + ); + + expect(result.kind).toBe("up-to-date"); + if (result.kind === "up-to-date") { + expect(result.install.results.length).toBeGreaterThan(0); + } + }); + + it("manifest exists without tool flags returns up-to-date with empty install", async () => { + const { useCase, deps } = await buildUseCase(); + await initProject(deps, PROJECT_ROOT); + + const result = await useCase.execute(remoteFlow()); + + expect(result.kind).toBe("up-to-date"); + if (result.kind === "up-to-date") { + expect(result.install.results).toHaveLength(0); + } + }); + + it("project already up to date — exits without error", async () => { + const { useCase, deps } = await buildUseCase(); + await initAndInstall(deps, PROJECT_ROOT, "claude"); + + const result = await useCase.execute(remoteFlow()); + + expect(result.kind).toBe("up-to-date"); + }); + + describe("default marketplace opt-out (#197)", () => { + it("registers framework marketplace by default", async () => { + const { useCase, marketplaceRegisterFramework, marketplaceRefresh } = await buildUseCase(); + await useCase.execute(remoteFlow({ aiTools: ["claude" as ToolId] })); + expect(marketplaceRegisterFramework.execute).toHaveBeenCalledOnce(); + expect(marketplaceRefresh.execute).toHaveBeenCalledOnce(); + }); + + it("skips framework register + refresh when registerDefaultMarketplace=false", async () => { + const { useCase, marketplaceRegisterFramework, marketplaceRefresh } = await buildUseCase(); + await useCase.execute( + new SetupFlow({ + projectRoot: PROJECT_ROOT, + source: MarketplaceSourceMode.remote(), + aiTools: ["claude" as ToolId], + ideTools: [], + pluginMode: "none", + interactive: false, + registerDefaultMarketplace: false, + }) + ); + expect(marketplaceRegisterFramework.execute).not.toHaveBeenCalled(); + expect(marketplaceRefresh.execute).not.toHaveBeenCalled(); + }); + + it("still installs tools when default marketplace is opted out", async () => { + const { useCase } = await buildUseCase(); + const result = await useCase.execute( + new SetupFlow({ + projectRoot: PROJECT_ROOT, + source: MarketplaceSourceMode.remote(), + aiTools: ["claude" as ToolId], + ideTools: [], + pluginMode: "none", + interactive: false, + registerDefaultMarketplace: false, + }) + ); + expect(result.kind).toBe("initialized"); + if (result.kind === "initialized") { + const claudeResult = result.install.results.find((r) => r.toolId === "claude"); + expect(claudeResult).toBeDefined(); + } + }); + }); + + describe("this project's own reference to the shared source", () => { + it("records it after registering the framework marketplace by default", async () => { + const userSourceReferences = makeRecordingUserSourceReferences(); + const { useCase, deps } = await buildUseCase(undefined, userSourceReferences); + + await useCase.execute(remoteFlow({ aiTools: ["claude" as ToolId] })); + + expect(userSourceReferences.added).toEqual([ + { version: deps.currentVersionProvider.get(), projectRoot: PROJECT_ROOT }, + ]); + }); + + it("records no reference when default marketplace registration is opted out", async () => { + const userSourceReferences = makeRecordingUserSourceReferences(); + const { useCase } = await buildUseCase(undefined, userSourceReferences); + + await useCase.execute( + new SetupFlow({ + projectRoot: PROJECT_ROOT, + source: MarketplaceSourceMode.remote(), + aiTools: ["claude" as ToolId], + ideTools: [], + pluginMode: "none", + interactive: false, + registerDefaultMarketplace: false, + }) + ); + + expect(userSourceReferences.added).toEqual([]); + }); + + // `references.json` is a help, not an authority: a corrupted copy must never block `setup`, + // which does not depend on it. + it("warns and still completes setup when references.json is corrupted", async () => { + const logger = new CapturingLogger(); + const referencesFs = new InMemoryFileAdapter(); + referencesFs.setFile("/fake-home/.config/aidd/references.json", "not json"); + const userSourceReferences = new UserSourceReferencesAdapter( + referencesFs, + () => "/fake-home/.config/aidd" + ); + const { useCase } = await buildUseCase(undefined, userSourceReferences, logger); + + const result = await useCase.execute(remoteFlow({ aiTools: ["claude" as ToolId] })); + + expect(result.kind).toBe("initialized"); + expect(logger.warnMessages.some((m) => m.includes("references.json"))).toBe(true); + }); + }); + + describe("issue #141 — post-uninstall regression", () => { + it("succeeds when aidd_docs/ and .aidd/ exist but no manifest and no tool dirs", async () => { + const { useCase, deps } = await buildUseCase(); + deps.fs.writeFile(join(PROJECT_ROOT, "aidd_docs/.keep"), ""); + deps.fs.writeFile(join(PROJECT_ROOT, ".aidd/.keep"), ""); + + const result = await useCase.execute(remoteFlow({ aiTools: ["claude" as ToolId] })); + + expect(result.kind).toBe("initialized"); + }); + + it("installs selected tools when only aidd_docs/ survives uninstall", async () => { + const { useCase, deps } = await buildUseCase(); + deps.fs.writeFile(join(PROJECT_ROOT, "aidd_docs/.keep"), ""); + deps.fs.writeFile(join(PROJECT_ROOT, ".aidd/.keep"), ""); + + const result = await useCase.execute(remoteFlow({ aiTools: ["opencode" as ToolId] })); + + expect(result.kind).toBe("initialized"); + if (result.kind === "initialized") { + const opencodeTool = result.install.results.find((r) => r.toolId === "opencode"); + expect(opencodeTool).toBeDefined(); + expect(opencodeTool?.skipped).toBe(false); + } + }); + + it("does not fail when only aidd_docs/ exists (no manifest)", async () => { + const { useCase, deps } = await buildUseCase(); + deps.fs.writeFile(join(PROJECT_ROOT, "aidd_docs/.keep"), ""); + deps.fs.writeFile(join(PROJECT_ROOT, ".aidd/.keep"), ""); + + const result = await useCase.execute(remoteFlow()); + + expect(result.kind).toBe("initialized"); + }); + + it("preserves user files in aidd_docs/ across setup", async () => { + const { useCase, deps } = await buildUseCase(); + deps.fs.writeFile(join(PROJECT_ROOT, "aidd_docs/.keep"), ""); + deps.fs.writeFile(join(PROJECT_ROOT, ".aidd/.keep"), ""); + deps.fs.writeFile(join(PROJECT_ROOT, "aidd_docs/README.md"), "my custom readme"); + + await useCase.execute(remoteFlow({ aiTools: ["claude" as ToolId] })); + + const content = deps.fs.getFile(join(PROJECT_ROOT, "aidd_docs/README.md")) ?? ""; + expect(content).toBe("my custom readme"); + }); + }); +}); + +describe("setup interactive tool selection", () => { + function interactiveFlow( + opts: Partial<{ aiTools: ToolId[]; ideTools: ToolId[] }> = {} + ): SetupFlow { + return new SetupFlow({ + projectRoot: PROJECT_ROOT, + source: MarketplaceSourceMode.remote(), + aiTools: opts.aiTools ?? [], + ideTools: opts.ideTools ?? [], + pluginMode: "none", + interactive: true, + }); + } + + it("interactive + empty tools → prompts and installs user-selected tools", async () => { + const prompter = new ScriptedPrompter([ + ScriptedPrompter.answer.checkbox(["claude"]), + ScriptedPrompter.answer.checkbox([]), + ]); + const setupToolsPromptUseCase = new SetupToolsPromptUseCase(prompter); + const { useCase } = await buildUseCase(setupToolsPromptUseCase); + + const result = await useCase.execute(interactiveFlow()); + + expect(result.kind).toBe("initialized"); + if (result.kind === "initialized") { + const installed = result.install.results.map((r) => r.toolId); + expect(installed).toContain("claude"); + } + }); + + it("interactive + tools provided via flow → no extra prompt, installs given tools", async () => { + const prompter = new ScriptedPrompter([]); // no tool prompts expected + const setupToolsPromptUseCase = new SetupToolsPromptUseCase(prompter); + const { useCase } = await buildUseCase(setupToolsPromptUseCase); + + const result = await useCase.execute(interactiveFlow({ aiTools: ["cursor" as ToolId] })); + + expect(result.kind).toBe("initialized"); + if (result.kind === "initialized") { + const installed = result.install.results.map((r) => r.toolId); + expect(installed).toContain("cursor"); + } + }); + + it("non-interactive + empty tools → no prompt, installs nothing", async () => { + const prompter = new ScriptedPrompter([]); // no prompts expected + const setupToolsPromptUseCase = new SetupToolsPromptUseCase(prompter); + const { useCase } = await buildUseCase(setupToolsPromptUseCase); + + const result = await useCase.execute(remoteFlow()); + + expect(result.kind).toBe("initialized"); + if (result.kind === "initialized") { + expect(result.install.results).toHaveLength(0); + } + }); +}); + +describe("setup --scope user", () => { + it("writes nothing at all under projectRoot — full directory delta, not a list", async () => { + const userManifestRepo = new InMemoryManifestRepository(); + const { useCase, deps } = await buildUseCase(undefined, undefined, undefined, { + userManifestRepo, + }); + + await useCase.execute(remoteFlow({ aiTools: ["claude" as ToolId], scope: "user" })); + + expect(deps.fs.listUnder(PROJECT_ROOT)).toEqual([]); + }); + + it("writes the user manifest, never this project's own .aidd/manifest.json", async () => { + const userManifestRepo = new InMemoryManifestRepository(); + const { useCase, deps } = await buildUseCase(undefined, undefined, undefined, { + userManifestRepo, + }); + + await useCase.execute(remoteFlow({ aiTools: ["claude" as ToolId], scope: "user" })); + + expect(userManifestRepo.getCurrent()?.getInstalledToolIds()).toContain("claude"); + expect(await deps.manifestRepo.load()).toBeNull(); + }); + + it("calls marketplace sync settings with scope user and the user manifest repo", async () => { + const userManifestRepo = new InMemoryManifestRepository(); + const marketplaceSyncSettingsUseCase = + makeNoOpMarketplaceSyncSettings() as MarketplaceSyncSettings & { + execute: ReturnType; + }; + const { useCase } = await buildUseCase(undefined, undefined, undefined, { + userManifestRepo, + marketplaceSyncSettingsUseCase, + }); + + await useCase.execute(remoteFlow({ aiTools: ["claude" as ToolId], scope: "user" })); + + expect(marketplaceSyncSettingsUseCase.execute).toHaveBeenCalledWith( + expect.objectContaining({ scope: "user", manifestRepo: userManifestRepo }) + ); + }); + + it("never installs framework files nor prompts for plugins — no project delivery at all", async () => { + const userManifestRepo = new InMemoryManifestRepository(); + const { useCase } = await buildUseCase(undefined, undefined, undefined, { userManifestRepo }); + + const result = await useCase.execute( + remoteFlow({ aiTools: ["claude" as ToolId], scope: "user" }) + ); + + expect(result.install.results).toEqual([]); + }); + + it("records no shared-source reference — there is no project-scope manifest for a later clean to ever decrement", async () => { + const userManifestRepo = new InMemoryManifestRepository(); + const userSourceReferences = makeRecordingUserSourceReferences(); + const { useCase } = await buildUseCase(undefined, userSourceReferences, undefined, { + userManifestRepo, + }); + + await useCase.execute(remoteFlow({ aiTools: ["claude" as ToolId], scope: "user" })); + + expect(userSourceReferences.added).toEqual([]); + }); +}); diff --git a/cli/tests/application/use-cases/setup/project-context-detector.unit.test.ts b/cli/tests/contexts/framework/application/setup/project-context-detector.unit.test.ts similarity index 90% rename from cli/tests/application/use-cases/setup/project-context-detector.unit.test.ts rename to cli/tests/contexts/framework/application/setup/project-context-detector.unit.test.ts index 753ac719b..4b7adb6f0 100644 --- a/cli/tests/application/use-cases/setup/project-context-detector.unit.test.ts +++ b/cli/tests/contexts/framework/application/setup/project-context-detector.unit.test.ts @@ -1,8 +1,8 @@ import { join } from "node:path"; import { describe, expect, it } from "vitest"; -import { ProjectContextDetectorUseCase } from "../../../../src/application/use-cases/setup/project-context-detector-use-case.js"; -import { DeterministicHasher } from "../../../helpers/ports/deterministic-hasher.js"; -import { InMemoryFileAdapter } from "../../../helpers/ports/in-memory-file-adapter.js"; +import { ProjectContextDetectorUseCase } from "../../../../../src/contexts/framework/application/setup/project-context-detector-use-case.js"; +import { DeterministicHasher } from "../../../../helpers/ports/deterministic-hasher.js"; +import { InMemoryFileAdapter } from "../../../../helpers/ports/in-memory-file-adapter.js"; const PROJECT_ROOT = "/proj"; diff --git a/cli/tests/application/use-cases/setup/setup-marketplace-source-use-case.unit.test.ts b/cli/tests/contexts/framework/application/setup/setup-marketplace-source-use-case.unit.test.ts similarity index 86% rename from cli/tests/application/use-cases/setup/setup-marketplace-source-use-case.unit.test.ts rename to cli/tests/contexts/framework/application/setup/setup-marketplace-source-use-case.unit.test.ts index 351929913..ef210ad5e 100644 --- a/cli/tests/application/use-cases/setup/setup-marketplace-source-use-case.unit.test.ts +++ b/cli/tests/contexts/framework/application/setup/setup-marketplace-source-use-case.unit.test.ts @@ -1,13 +1,13 @@ import { isAbsolute, resolve } from "node:path"; import { describe, expect, it, vi } from "vitest"; -import { InputRequiredError } from "../../../../src/application/errors.js"; -import { SetupMarketplaceSourceUseCase } from "../../../../src/application/use-cases/setup/setup-marketplace-source-use-case.js"; import { DEFAULT_FRAMEWORK_REPO, MarketplaceSourceMode, -} from "../../../../src/domain/models/marketplace-source-mode.js"; -import type { LatestReleaseResolver } from "../../../../src/domain/ports/latest-release-resolver.js"; -import { ScriptedPrompter } from "../../../helpers/ports/scripted-prompter.js"; +} from "../../../../../src/contexts/distribution/domain/marketplace-source-mode.js"; +import { SetupMarketplaceSourceUseCase } from "../../../../../src/contexts/framework/application/setup/setup-marketplace-source-use-case.js"; +import { InputRequiredError } from "../../../../../src/kernel/errors.js"; +import type { LatestReleaseResolver } from "../../../../../src/runtime/self-update/latest-release-resolver.js"; +import { ScriptedPrompter } from "../../../../helpers/ports/scripted-prompter.js"; function makeResolver(rootReleases: string[]): LatestReleaseResolver { return { @@ -149,10 +149,8 @@ describe("SetupMarketplaceSourceUseCase", () => { const result = await uc.execute({ projectRoot: PROJECT_ROOT, interactive: true }); expect(result.kind).toBe("local"); - // The use-case runs the prompted path through resolve(), which — on Windows — fills in - // the current drive letter for a rootless absolute path like "/abs/framework". Resolve - // it the same way here so the expectation is the same location, spelled how the platform - // spells it. + // resolve() fills in the current drive letter on Windows for a rootless absolute path, + // so the expectation has to be resolved the same way to name the same location. expect(result.path).toBe(resolve("/abs/framework")); }); @@ -167,9 +165,8 @@ describe("SetupMarketplaceSourceUseCase", () => { const result = await uc.execute({ projectRoot: PROJECT_ROOT, interactive: true }); expect(result.kind).toBe("local"); - // A leading-slash regex assumes an absolute path always starts with a separator, which - // misses a Windows drive letter (e.g. "D:\..."). isAbsolute() is the platform-correct - // check for "resolved to absolute" on every OS. + // A leading-slash regex misses a Windows drive letter ("D:\..."), so isAbsolute() is + // the platform-correct check for "resolved to absolute" on every OS. expect(isAbsolute(result.path)).toBe(true); }); }); diff --git a/cli/tests/application/use-cases/shared/apply-plugin-files-built-tree.unit.test.ts b/cli/tests/contexts/framework/application/shared/apply-plugin-files-built-tree.unit.test.ts similarity index 86% rename from cli/tests/application/use-cases/shared/apply-plugin-files-built-tree.unit.test.ts rename to cli/tests/contexts/framework/application/shared/apply-plugin-files-built-tree.unit.test.ts index 4b4ccfbda..bd38645eb 100644 --- a/cli/tests/application/use-cases/shared/apply-plugin-files-built-tree.unit.test.ts +++ b/cli/tests/contexts/framework/application/shared/apply-plugin-files-built-tree.unit.test.ts @@ -1,14 +1,13 @@ import { join } from "node:path"; import { describe, expect, it } from "vitest"; -import { PluginAddUseCase } from "../../../../src/application/use-cases/plugin/plugin-add-use-case.js"; -import { RestoreAllPluginsUseCase } from "../../../../src/application/use-cases/restore/restore-all-plugins-use-case.js"; -import { Marketplace } from "../../../../src/domain/models/marketplace.js"; -import { DOCS_DIR } from "../../../../src/domain/models/paths.js"; -import { PluginDistributionReaderAdapter } from "../../../../src/infrastructure/adapters/plugin-distribution-reader-adapter.js"; -import { buildUnitDeps, initAndInstall } from "../../../helpers/ports/build-unit-deps.js"; -import { fakeEnsureBuiltMarketplace } from "../../../helpers/ports/fake-ensure-built-marketplace.js"; -import { InMemoryMarketplaceRegistry } from "../../../helpers/ports/in-memory-marketplace-registry.js"; -import { seedFromDirectory } from "../../../helpers/ports/seed-from-directory.js"; +import { Marketplace } from "../../../../../src/contexts/distribution/domain/marketplace.js"; +import { PluginAddUseCase } from "../../../../../src/contexts/framework/application/plugin/plugin-add-use-case.js"; +import { RestoreAllPluginsUseCase } from "../../../../../src/contexts/framework/application/restore/restore-all-plugins-use-case.js"; +import { PluginDistributionReaderAdapter } from "../../../../../src/contexts/framework/infrastructure/plugin-distribution-reader-adapter.js"; +import { buildUnitDeps, initAndInstall } from "../../../../helpers/ports/build-unit-deps.js"; +import { fakeEnsureBuiltMarketplace } from "../../../../helpers/ports/fake-ensure-built-marketplace.js"; +import { InMemoryMarketplaceRegistry } from "../../../../helpers/ports/in-memory-marketplace-registry.js"; +import { seedFromDirectory } from "../../../../helpers/ports/seed-from-directory.js"; const PLUGIN_FIXTURE = join(process.cwd(), "tests/fixtures/plugins/claude-format/sample-plugin"); const PROJECT_ROOT = "/test-project"; @@ -111,7 +110,6 @@ describe("RestoreAllPluginsUseCase — built-tree materialization", () => { const result = await makeRestoreUseCase(deps, registry).execute({ projectRoot: PROJECT_ROOT, manifest, - docsDir: DOCS_DIR, fileFilter: null, }); @@ -154,7 +152,6 @@ describe("RestoreAllPluginsUseCase — built-tree materialization", () => { const result = await makeRestoreUseCase(deps, registry).execute({ projectRoot: PROJECT_ROOT, manifest, - docsDir: DOCS_DIR, fileFilter: null, }); @@ -175,7 +172,6 @@ describe("RestoreAllPluginsUseCase — built-tree materialization", () => { await makeRestoreUseCase(deps, registry).execute({ projectRoot: PROJECT_ROOT, manifest, - docsDir: DOCS_DIR, fileFilter: null, }); diff --git a/cli/tests/application/use-cases/shared/apply-plugin-files-mode-a-marketplace.unit.test.ts b/cli/tests/contexts/framework/application/shared/apply-plugin-files-mode-a-marketplace.unit.test.ts similarity index 82% rename from cli/tests/application/use-cases/shared/apply-plugin-files-mode-a-marketplace.unit.test.ts rename to cli/tests/contexts/framework/application/shared/apply-plugin-files-mode-a-marketplace.unit.test.ts index 93036f1e9..a75c05162 100644 --- a/cli/tests/application/use-cases/shared/apply-plugin-files-mode-a-marketplace.unit.test.ts +++ b/cli/tests/contexts/framework/application/shared/apply-plugin-files-mode-a-marketplace.unit.test.ts @@ -1,14 +1,13 @@ import { join } from "node:path"; import { describe, expect, it } from "vitest"; -import { PluginAddUseCase } from "../../../../src/application/use-cases/plugin/plugin-add-use-case.js"; -import { RestoreAllPluginsUseCase } from "../../../../src/application/use-cases/restore/restore-all-plugins-use-case.js"; -import { Marketplace } from "../../../../src/domain/models/marketplace.js"; -import { DOCS_DIR } from "../../../../src/domain/models/paths.js"; -import { PluginDistributionReaderAdapter } from "../../../../src/infrastructure/adapters/plugin-distribution-reader-adapter.js"; -import { buildUnitDeps, initAndInstall } from "../../../helpers/ports/build-unit-deps.js"; -import { fakeEnsureBuiltMarketplace } from "../../../helpers/ports/fake-ensure-built-marketplace.js"; -import { InMemoryMarketplaceRegistry } from "../../../helpers/ports/in-memory-marketplace-registry.js"; -import { seedFromDirectory } from "../../../helpers/ports/seed-from-directory.js"; +import { Marketplace } from "../../../../../src/contexts/distribution/domain/marketplace.js"; +import { PluginAddUseCase } from "../../../../../src/contexts/framework/application/plugin/plugin-add-use-case.js"; +import { RestoreAllPluginsUseCase } from "../../../../../src/contexts/framework/application/restore/restore-all-plugins-use-case.js"; +import { PluginDistributionReaderAdapter } from "../../../../../src/contexts/framework/infrastructure/plugin-distribution-reader-adapter.js"; +import { buildUnitDeps, initAndInstall } from "../../../../helpers/ports/build-unit-deps.js"; +import { fakeEnsureBuiltMarketplace } from "../../../../helpers/ports/fake-ensure-built-marketplace.js"; +import { InMemoryMarketplaceRegistry } from "../../../../helpers/ports/in-memory-marketplace-registry.js"; +import { seedFromDirectory } from "../../../../helpers/ports/seed-from-directory.js"; const PLUGIN_FIXTURE = join(process.cwd(), "tests/fixtures/plugins/claude-format/sample-plugin"); const PROJECT_ROOT = "/test-project"; @@ -97,8 +96,8 @@ describe("RestoreAllPluginsUseCase — Mode A marketplace tools (claude/codex/co const plugin = manifest.getPlugins("claude").find((p) => p.name === "sample-plugin"); if (plugin === undefined) throw new Error("plugin not found"); - // Simulate the pre-fix bug: a buggy update/restore materialized files and recorded - // them on the manifest entry, even though Mode A's contract is register-only. + // Files materialized and recorded on the manifest entry, though Mode A's own contract + // is register-only. const strayFiles = new Map([ [".claude/plugins/sample-plugin/commands/greet.md", "stray-hash-1"], [".claude/plugins/sample-plugin/agents/reviewer.md", "stray-hash-2"], @@ -111,7 +110,6 @@ describe("RestoreAllPluginsUseCase — Mode A marketplace tools (claude/codex/co const result = await makeRestoreUseCase(deps, registry).execute({ projectRoot: PROJECT_ROOT, manifest, - docsDir: DOCS_DIR, fileFilter: null, }); @@ -138,15 +136,13 @@ describe("RestoreAllPluginsUseCase — Mode A marketplace tools (claude/codex/co const untrackedPath = ".claude/plugins/sample-plugin/notes.md"; manifest.updatePlugin("claude", plugin.withFiles(new Map([[trackedPath, "stray-hash"]]))); await deps.fs.writeFile(join(PROJECT_ROOT, trackedPath), "stray content"); - // Not in the manifest entry — a file the user (or something else) placed alongside the - // plugin's tracked files. The cleanup must never delete this, since it only ever - // iterates the manifest's own keys. + // Not in the manifest entry — a file placed alongside the plugin's tracked ones, which + // the cleanup must never delete, since it only iterates the manifest's own keys. await deps.fs.writeFile(join(PROJECT_ROOT, untrackedPath), "keep me"); await makeRestoreUseCase(deps, registry).execute({ projectRoot: PROJECT_ROOT, manifest, - docsDir: DOCS_DIR, fileFilter: null, }); @@ -168,7 +164,6 @@ describe("RestoreAllPluginsUseCase — Mode A marketplace tools (claude/codex/co const result = await makeRestoreUseCase(deps, registry).execute({ projectRoot: PROJECT_ROOT, manifest, - docsDir: DOCS_DIR, fileFilter: null, }); diff --git a/cli/tests/contexts/framework/application/shared/ensure-built-marketplace-use-case.integration.test.ts b/cli/tests/contexts/framework/application/shared/ensure-built-marketplace-use-case.integration.test.ts new file mode 100644 index 000000000..e4cfe3d20 --- /dev/null +++ b/cli/tests/contexts/framework/application/shared/ensure-built-marketplace-use-case.integration.test.ts @@ -0,0 +1,480 @@ +import { tmpdir } from "node:os"; +import { join, resolve, sep } from "node:path"; +import { beforeEach, describe, expect, it } from "vitest"; +import type { + ResolveMarketplace, + ResolveMarketplaceOptions, +} from "../../../../../src/contexts/distribution/application/resolve-marketplace-use-case.js"; +import { Marketplace } from "../../../../../src/contexts/distribution/domain/marketplace.js"; +import { + EnsureBuiltMarketplaceUseCase, + type FrameworkBuildFor, +} from "../../../../../src/contexts/framework/application/shared/ensure-built-marketplace-use-case.js"; +import type { JsonSchemaValidator } from "../../../../../src/contexts/tools/domain/ports/schema-validator.js"; +import { buildCopilotFlatContract } from "../../../../../src/contexts/tools/domain/profiles/copilot/build.js"; +import { FlatBuildStrategy } from "../../../../../src/contexts/translate/application/strategies/flat-build-strategy.js"; +import { + type FrameworkBuild, + FrameworkBuildUseCase, +} from "../../../../../src/contexts/translate/application/translate-source.js"; +import { BUILT_CACHE_SUBDIR, builtMarketplaceDir } from "../../../../../src/kernel/paths.js"; +import type { AssetProvider } from "../../../../../src/kernel/ports/asset-provider.js"; +import type { VersionReader } from "../../../../../src/kernel/ports/version-reader.js"; +import { CapturingLogger } from "../../../../helpers/ports/capturing-logger.js"; +import { InMemoryFileAdapter } from "../../../../helpers/ports/in-memory-file-adapter.js"; +import { seedFromDirectory } from "../../../../helpers/ports/seed-from-directory.js"; + +const PROJECT = "/proj"; +const FIXTURE_DIR = resolve(process.cwd(), "tests/fixtures/framework"); +const PLUGIN = "aidd-test"; + +const MINIMAL_MANIFEST_SCHEMA = { + type: "object", + required: ["name"], + properties: { name: { type: "string" } }, +}; + +function noopValidator(): JsonSchemaValidator { + return { validate: () => undefined }; +} + +function stubAssetProvider(): AssetProvider { + return { + loadConfigAsset: () => { + throw new Error("not used"); + }, + loadSchema: (name) => (name === "plugin-manifest" ? MINIMAL_MANIFEST_SCHEMA : {}), + }; +} + +function makeIsDirectory(memFs: InMemoryFileAdapter): (path: string) => Promise { + return async (path: string): Promise => { + // The adapter keys every entry with forward slashes whatever the platform joined + // with, so the directory asked about is spelled the same way before the prefix test. + const key = path.replace(/\\/g, "/"); + if (memFs.has(key)) return false; + const prefix = key.endsWith("/") ? key : `${key}/`; + return memFs.listAll().some((k) => k.startsWith(prefix)); + }; +} + +function makeMarketplace(): Marketplace { + return Marketplace.create({ + name: "aidd-framework", + source: { kind: "local", path: "/src/framework" }, + scope: "project", + addedAt: "2026-06-29T00:00:00.000Z", + }); +} + +/** A published source: its version changes when its content does, so it can be believed. */ +function makeRemoteMarketplace(): Marketplace { + return Marketplace.create({ + name: "aidd-framework", + source: { kind: "github", repo: "ai-driven-dev/framework" }, + scope: "project", + addedAt: "2026-06-29T00:00:00.000Z", + }); +} + +function makeUserMarketplace(): Marketplace { + return Marketplace.create({ + name: "shared-mkt", + source: { kind: "local", path: "/src/framework" }, + scope: "user", + addedAt: "2026-06-29T00:00:00.000Z", + }); +} + +function fakeResolve(localPath: string, version: string | undefined): ResolveMarketplace { + return { + execute: async ({ marketplace }: ResolveMarketplaceOptions) => ({ + marketplace, + localPath, + catalog: version === undefined ? null : { version, plugins: [] }, + }), + } satisfies ResolveMarketplace; +} + +function fakeVersion(value: string): VersionReader { + return { get: () => value }; +} + +describe("builtMarketplaceDir", () => { + it("places the per-target tree under .aidd/cache/built//", () => { + expect(builtMarketplaceDir("/p", "aidd", "codex")).toBe( + join("/p", ".aidd", "cache", "built", "aidd", "codex") + ); + }); +}); + +describe("EnsureBuiltMarketplaceUseCase", () => { + let fs: InMemoryFileAdapter; + let builds: number; + let buildFor: FrameworkBuildFor; + + beforeEach(() => { + fs = new InMemoryFileAdapter(); + builds = 0; + buildFor = (_target, _mode, outDir) => + ({ + execute: async () => { + builds += 1; + await fs.writeFile(join(outDir, "plugins/aidd-vcs/SKILL.md"), "built content"); + return { outDir, plugins: [], totalFiles: 1 }; + }, + }) satisfies FrameworkBuild; + }); + + it("rebuilds and writes a sentinel when none exists", async () => { + const uc = new EnsureBuiltMarketplaceUseCase( + fs, + fakeResolve("/src/framework", "1.0.0"), + buildFor, + fakeVersion("5.0.0"), + () => "/user-cache" + ); + const r = await uc.execute({ + projectRoot: PROJECT, + marketplace: makeMarketplace(), + target: "codex", + mode: "marketplace", + }); + expect(r.rebuilt).toBe(true); + expect(builds).toBe(1); + expect(fs.getFile(join(r.builtDir, ".build-version"))).toBe("5.0.0:1.0.0"); + }); + + it("does not rebuild a published source when the sentinel matches (cliVer:catalogVer)", async () => { + // resolve(): a drive-less PROJECT would seed a key the in-memory fs never looks up under, + // since the code's own builtDir is resolved with a drive letter. + const builtDir = resolve(builtMarketplaceDir(PROJECT, "aidd-framework", "codex")); + fs.setFile(join(builtDir, ".build-version"), "5.0.0:1.0.0"); + const uc = new EnsureBuiltMarketplaceUseCase( + fs, + fakeResolve("/src/framework", "1.0.0"), + buildFor, + fakeVersion("5.0.0"), + () => "/user-cache" + ); + const r = await uc.execute({ + projectRoot: PROJECT, + marketplace: makeRemoteMarketplace(), + target: "codex", + mode: "marketplace", + }); + expect(r.rebuilt).toBe(false); + expect(builds).toBe(0); + }); + + // A directory on this machine can change without its version moving — which is all of + // framework development — so the version says nothing about freshness there. + it("rebuilds a local source even when the sentinel matches", async () => { + const builtDir = builtMarketplaceDir(PROJECT, "aidd-framework", "codex"); + fs.setFile(join(builtDir, ".build-version"), "5.0.0:1.0.0"); + const uc = new EnsureBuiltMarketplaceUseCase( + fs, + fakeResolve("/src/framework", "1.0.0"), + buildFor, + fakeVersion("5.0.0"), + () => "/user-cache" + ); + const r = await uc.execute({ + projectRoot: PROJECT, + marketplace: makeMarketplace(), + target: "codex", + mode: "marketplace", + }); + expect(r.rebuilt).toBe(true); + expect(builds).toBe(1); + }); + + // An explicit refresh asks for the source to be re-read; answering from cache would + // answer a different question. + it("rebuilds a published source when a refresh was asked for", async () => { + const builtDir = builtMarketplaceDir(PROJECT, "aidd-framework", "codex"); + fs.setFile(join(builtDir, ".build-version"), "5.0.0:1.0.0"); + const uc = new EnsureBuiltMarketplaceUseCase( + fs, + fakeResolve("/src/framework", "1.0.0"), + buildFor, + fakeVersion("5.0.0"), + () => "/user-cache" + ); + const r = await uc.execute({ + projectRoot: PROJECT, + marketplace: makeRemoteMarketplace(), + target: "codex", + mode: "marketplace", + forceRefresh: true, + }); + expect(r.rebuilt).toBe(true); + }); + + it("rebuilds when the CLI version changed even if catalog version is the same", async () => { + const builtDir = builtMarketplaceDir(PROJECT, "aidd-framework", "codex"); + fs.setFile(join(builtDir, ".build-version"), "4.0.0:1.0.0"); + const uc = new EnsureBuiltMarketplaceUseCase( + fs, + fakeResolve("/src/framework", "1.0.0"), + buildFor, + fakeVersion("5.0.0"), + () => "/user-cache" + ); + const r = await uc.execute({ + projectRoot: PROJECT, + marketplace: makeMarketplace(), + target: "codex", + mode: "marketplace", + }); + expect(r.rebuilt).toBe(true); + expect(builds).toBe(1); + }); + + it("always rebuilds when catalog version is undefined", async () => { + const builtDir = builtMarketplaceDir(PROJECT, "aidd-framework", "codex"); + fs.setFile(join(builtDir, ".build-version"), "5.0.0:unversioned"); + const uc = new EnsureBuiltMarketplaceUseCase( + fs, + fakeResolve("/src/framework", undefined), + buildFor, + fakeVersion("5.0.0"), + () => "/user-cache" + ); + const r = await uc.execute({ + projectRoot: PROJECT, + marketplace: makeMarketplace(), + target: "codex", + mode: "marketplace", + }); + expect(r.rebuilt).toBe(true); + expect(builds).toBe(1); + }); + + it("builds via a temp dir and copies into the cache when the cache nests under the source (dogfood)", async () => { + // Source == project root, so builtDir (.aidd/cache/built/...) nests under source → guardPaths would throw. + const uc = new EnsureBuiltMarketplaceUseCase( + fs, + fakeResolve(PROJECT, "1.0.0"), + buildFor, + fakeVersion("5.0.0"), + () => "/user-cache" + ); + const r = await uc.execute({ + projectRoot: PROJECT, + marketplace: makeMarketplace(), + target: "codex", + mode: "marketplace", + }); + expect(r.builtDir).toBe(resolve(builtMarketplaceDir(PROJECT, "aidd-framework", "codex"))); + expect(fs.getFile(join(r.builtDir, "plugins/aidd-vcs/SKILL.md"))).toBe("built content"); + expect(fs.listUnder(tmpdir()).length).toBe(0); + }); + + it("memoizes within a run: a second call for the same target/version does not rebuild", async () => { + const uc = new EnsureBuiltMarketplaceUseCase( + fs, + fakeResolve("/src/framework", "1.0.0"), + buildFor, + fakeVersion("5.0.0"), + () => "/user-cache" + ); + const opts = { + projectRoot: PROJECT, + marketplace: makeMarketplace(), + target: "codex" as const, + mode: "marketplace" as const, + }; + await uc.execute(opts); + await uc.execute(opts); + expect(builds).toBe(1); + }); +}); + +// outDir here is always builtMarketplaceDir(), an aidd-owned disposable cache, so a collision +// only means a previous build is still there. A real FlatBuildStrategy catches force flipping. +describe("force behavior at the cache-rebuild path", () => { + it("overwrites a colliding file already present in the build cache instead of throwing FlatTargetExistsError", async () => { + const memFs = new InMemoryFileAdapter(); + await seedFromDirectory(memFs, FIXTURE_DIR, { useAbsolutePaths: true }); + + // resolve(): the seeded "stale cache" file must sit at the same key the resolved builtDir is + // passed as outDir, or the in-memory fs's prefix check never finds it on win32. + const builtDir = resolve(builtMarketplaceDir(PROJECT, "aidd-framework", "copilot")); + const agentPath = `${builtDir}/.github/agents/${PLUGIN}-code-reviewer.agent.md`; + memFs.setFile(agentPath, "stale cache content from a previous build"); + + const realBuildFor: FrameworkBuildFor = (_target, _mode, outDir) => { + const validator = noopValidator(); + const assetProvider = stubAssetProvider(); + const strategy = new FlatBuildStrategy( + memFs, + validator, + assetProvider, + buildCopilotFlatContract(), + true, // force:true — mirrors deps.ts wiring for every *:flat target + outDir, + makeIsDirectory(memFs), + new CapturingLogger() + ); + return new FrameworkBuildUseCase( + memFs, + validator, + assetProvider, + new CapturingLogger(), + strategy + ); + }; + + const uc = new EnsureBuiltMarketplaceUseCase( + memFs, + fakeResolve(FIXTURE_DIR, "1.0.0"), + realBuildFor, + fakeVersion("5.0.0"), + () => "/user-cache" + ); + + const result = await uc.execute({ + projectRoot: PROJECT, + marketplace: makeMarketplace(), + target: "copilot", + mode: "flat", + }); + + expect(result.rebuilt).toBe(true); + expect(memFs.getFile(agentPath)).not.toBe("stale cache content from a previous build"); + }); +}); + +// Which outDir runBuild() is called with is what keeps the collision bypass aimed at an +// aidd-owned directory, on the direct path and on the temp-dir path alike. +describe("outDir invariant for the cache-rebuild build path", () => { + it("only ever builds into the aidd build cache or the OS temp dir, never a user directory", async () => { + const memFs = new InMemoryFileAdapter(); + const capturedOutDirs: string[] = []; + const capturingBuildFor: FrameworkBuildFor = (_target, _mode, outDir) => { + capturedOutDirs.push(outDir); + return { + execute: async () => { + await memFs.writeFile(join(outDir, "plugins/aidd-vcs/SKILL.md"), "built content"); + return { outDir, plugins: [], totalFiles: 1 }; + }, + } satisfies FrameworkBuild; + }; + + // Direct path: source lives outside the cache tree → build() writes straight to builtDir. + const direct = new EnsureBuiltMarketplaceUseCase( + memFs, + fakeResolve("/src/framework", "1.0.0"), + capturingBuildFor, + fakeVersion("5.0.0"), + () => "/user-cache" + ); + await direct.execute({ + projectRoot: PROJECT, + marketplace: makeMarketplace(), + target: "codex", + mode: "marketplace", + }); + + // Dogfood path: source is the project root, so builtDir nests under it and buildViaTemp() + // routes the same call through a temp dir instead. + const dogfood = new EnsureBuiltMarketplaceUseCase( + memFs, + fakeResolve(PROJECT, "1.0.0"), + capturingBuildFor, + fakeVersion("5.0.0"), + () => "/user-cache" + ); + await dogfood.execute({ + projectRoot: PROJECT, + marketplace: makeMarketplace(), + target: "cursor", + mode: "marketplace", + }); + + expect(capturedOutDirs).toHaveLength(2); + // resolve(): the direct-path outDir the code passes is itself resolved (drive letter + // on win32), so the cache root it is compared against must be built the same way. + const cacheRoot = resolve(join(PROJECT, BUILT_CACHE_SUBDIR)); + const tmpRoot = tmpdir(); + for (const outDir of capturedOutDirs) { + const underCache = outDir === cacheRoot || outDir.startsWith(`${cacheRoot}${sep}`); + const underTmp = outDir === tmpRoot || outDir.startsWith(`${tmpRoot}${sep}`); + expect(underCache || underTmp).toBe(true); + } + // The dogfood call specifically must have gone through the temp dir, not the cache. + expect(capturedOutDirs[1]?.startsWith(`${tmpRoot}${sep}`)).toBe(true); + }); + + // A user-scope marketplace is declared once for every project, so building it inside whichever + // project registered it would leave the registration pointing at nothing once that one is gone. + it("builds a user-scope marketplace outside the project", async () => { + const memFs = new InMemoryFileAdapter(); + const built: string[] = []; + const capturing: FrameworkBuildFor = (_t, _m, outDir) => + ({ + execute: async () => { + built.push(outDir); + await memFs.writeFile(join(outDir, ".claude-plugin/marketplace.json"), "{}"); + return { outDir, plugins: [], totalFiles: 1 }; + }, + }) satisfies FrameworkBuild; + + const uc = new EnsureBuiltMarketplaceUseCase( + memFs, + fakeResolve("/src/framework", "1.0.0"), + capturing, + fakeVersion("5.0.0"), + () => "/user-cache" + ); + const result = await uc.execute({ + projectRoot: PROJECT, + marketplace: makeUserMarketplace(), + target: "claude", + mode: "marketplace", + }); + + // resolve(): result.builtDir comes back resolved (drive letter on win32), so the + // prefix it is checked against needs the same treatment. + expect(result.builtDir.startsWith(resolve(join("/user-cache")))).toBe(true); + expect(result.builtDir.startsWith(PROJECT)).toBe(false); + expect(result.builtDir).toContain(`${sep}5.0.0${sep}`); + }); + + // The shared source is one per CLI version: two projects on two CLI versions building the same + // user-scope marketplace must land in disjoint trees, so a purge cannot take the other's. + it("builds two different CLI versions of a user-scope marketplace into disjoint directories", async () => { + const memFs = new InMemoryFileAdapter(); + const capturing: FrameworkBuildFor = (_t, _m, outDir) => + ({ + execute: async () => { + await memFs.writeFile(join(outDir, ".claude-plugin/marketplace.json"), "{}"); + return { outDir, plugins: [], totalFiles: 1 }; + }, + }) satisfies FrameworkBuild; + + const buildAt = async (version: string) => { + const uc = new EnsureBuiltMarketplaceUseCase( + memFs, + fakeResolve("/src/framework", "1.0.0"), + capturing, + fakeVersion(version), + () => "/user-cache" + ); + return uc.execute({ + projectRoot: PROJECT, + marketplace: makeUserMarketplace(), + target: "claude", + mode: "marketplace", + }); + }; + + const v1 = await buildAt("1.0.0"); + const v2 = await buildAt("2.0.0"); + + expect(v1.builtDir).not.toBe(v2.builtDir); + expect(v2.builtDir.startsWith(v1.builtDir)).toBe(false); + expect(v1.builtDir.startsWith(v2.builtDir)).toBe(false); + }); +}); diff --git a/cli/tests/contexts/framework/application/shared/host-marketplace-source-conflict.integration.test.ts b/cli/tests/contexts/framework/application/shared/host-marketplace-source-conflict.integration.test.ts new file mode 100644 index 000000000..561710695 --- /dev/null +++ b/cli/tests/contexts/framework/application/shared/host-marketplace-source-conflict.integration.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, it } from "vitest"; +import { + hostMarketplaceSourceConflict, + isDriftFound, +} from "../../../../../src/contexts/framework/application/shared/host-marketplace-source-conflict.js"; +import { userBuiltMarketplaceDir } from "../../../../../src/kernel/paths.js"; +import { FakeHostMarketplaceRegistryReader } from "../../../../helpers/ports/fake-host-marketplace-registry-reader.js"; +import { InMemoryFileAdapter } from "../../../../helpers/ports/in-memory-file-adapter.js"; + +const NAME = "aidd-framework"; +const LOCATION = "/home/.claude/plugins/known_marketplaces.json"; + +/** + * `userConfigDir()` can sit behind a symlink the OS resolves on its own (macOS's `/var` → + * `/private/var`), so an unresolved `userCacheRoot` never matches the realpath'd sources. + */ +describe("hostMarketplaceSourceConflict — resolving every path through the same realpath", () => { + it("still decides a version-behind drift when userCacheRoot is reached through a symlink", async () => { + const fs = new InMemoryFileAdapter(); + fs.setSymlink("/var-home", "/private/var-home"); + const rawUserCacheRoot = "/var-home/.config/aidd"; + const resolvedUserCacheRoot = "/private/var-home/.config/aidd"; + const requestedSource = userBuiltMarketplaceDir(resolvedUserCacheRoot, "1.0.0", NAME, "claude"); + const registeredSource = userBuiltMarketplaceDir( + resolvedUserCacheRoot, + "2.0.0", + NAME, + "claude" + ); + const reader = new FakeHostMarketplaceRegistryReader({ + location: LOCATION, + entries: new Map([[NAME, registeredSource]]), + }); + + const check = await hostMarketplaceSourceConflict( + fs, + "claude", + reader, + requestedSource, + { name: NAME, pluginNames: [] }, + { + userCacheRoot: rawUserCacheRoot, + projectRoot: "/project", + marketplaceName: NAME, + target: "claude", + } + ); + + expect(isDriftFound(check)).toBe(true); + expect(check && isDriftFound(check) ? check.drift : undefined).toEqual({ + kind: "version-behind", + registeredVersion: "2.0.0", + requestedVersion: "1.0.0", + }); + }); +}); diff --git a/cli/tests/contexts/framework/application/shared/resolve-uninstall-scope.unit.test.ts b/cli/tests/contexts/framework/application/shared/resolve-uninstall-scope.unit.test.ts new file mode 100644 index 000000000..7d74cd139 --- /dev/null +++ b/cli/tests/contexts/framework/application/shared/resolve-uninstall-scope.unit.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, it } from "vitest"; +import { resolveUninstallScopeOrder } from "../../../../../src/contexts/framework/application/shared/resolve-uninstall-scope.js"; +import type { HostPluginRegistryReader } from "../../../../../src/contexts/tools/domain/ports/host-plugin-registry-reader.js"; + +const REF = "aidd-telemetry@aidd-framework"; +const PROJECT_ROOT = "/test-project"; + +function readerAnswering( + entries: ReadonlyMap +): HostPluginRegistryReader { + return { read: async () => ({ location: "/registry", refs: entries }) }; +} + +describe("resolveUninstallScopeOrder", () => { + it("trusts the host's own registry when it answers for this ref", async () => { + const reader = readerAnswering(new Map([[REF, { enabled: true, scope: "user" }]])); + + const order = await resolveUninstallScopeOrder(reader, REF, PROJECT_ROOT, "project"); + + expect(order).toEqual(["user"]); + }); + + it("falls back to the manifest's own scope, then the other one, when the registry carries no scope for this ref", async () => { + const reader = readerAnswering(new Map()); + + const order = await resolveUninstallScopeOrder(reader, REF, PROJECT_ROOT, "project"); + + expect(order).toEqual(["project", "user"]); + }); + + it("falls back the same way when no reader exists for this tool at all", async () => { + const order = await resolveUninstallScopeOrder(undefined, REF, PROJECT_ROOT, "user"); + + expect(order).toEqual(["user", "project"]); + }); + + it("falls back when the registry answers for a ref but carries no scope for it (codex, copilot)", async () => { + const reader = readerAnswering(new Map([[REF, { enabled: true }]])); + + const order = await resolveUninstallScopeOrder(reader, REF, PROJECT_ROOT, "project"); + + expect(order).toEqual(["project", "user"]); + }); +}); diff --git a/cli/tests/contexts/framework/application/shared/setup-marketplace-registration-use-case.unit.test.ts b/cli/tests/contexts/framework/application/shared/setup-marketplace-registration-use-case.unit.test.ts new file mode 100644 index 000000000..abf73bd9e --- /dev/null +++ b/cli/tests/contexts/framework/application/shared/setup-marketplace-registration-use-case.unit.test.ts @@ -0,0 +1,76 @@ +import { describe, expect, it, vi } from "vitest"; +import type { MarketplaceRefresh } from "../../../../../src/contexts/distribution/application/marketplace-refresh-use-case.js"; +import type { MarketplaceRegisterFramework } from "../../../../../src/contexts/distribution/application/marketplace-register-framework-use-case.js"; +import { MarketplaceSourceMode } from "../../../../../src/contexts/distribution/domain/marketplace-source-mode.js"; +import { SetupMarketplaceSourceUseCase } from "../../../../../src/contexts/framework/application/setup/setup-marketplace-source-use-case.js"; +import { SetupMarketplaceRegistrationUseCase } from "../../../../../src/contexts/framework/application/shared/setup-marketplace-registration-use-case.js"; +import { SetupFlow } from "../../../../../src/contexts/framework/domain/setup-flow.js"; +import type { LatestReleaseResolver } from "../../../../../src/runtime/self-update/latest-release-resolver.js"; +import { CapturingLogger } from "../../../../helpers/ports/capturing-logger.js"; +import { FakeCurrentVersion } from "../../../../helpers/ports/fake-current-version.js"; +import { InMemoryEnvironment } from "../../../../helpers/ports/in-memory-environment.js"; +import { InMemoryFileAdapter } from "../../../../helpers/ports/in-memory-file-adapter.js"; +import { KeepPrompter } from "../../../../helpers/ports/scripted-prompter.js"; + +const PROJECT_ROOT = "/test-project"; +const SKIP_SWITCH = "AIDD_SKIP_MARKETPLACE_REFRESH"; + +function makeNoOpLatestResolver(): LatestReleaseResolver { + return { + resolveLatest: vi.fn().mockResolvedValue(null), + listRootReleases: vi.fn().mockResolvedValue([]), + isRepoPublic: vi.fn().mockResolvedValue(true), + }; +} + +function makeRegisterFramework(): MarketplaceRegisterFramework { + return { execute: vi.fn().mockResolvedValue({ registered: true, scope: "user" }) }; +} + +function makeUseCase(environment: InMemoryEnvironment): { + useCase: SetupMarketplaceRegistrationUseCase; + refresh: MarketplaceRefresh; +} { + const refresh: MarketplaceRefresh = { + execute: vi.fn().mockResolvedValue({ results: [], failedCount: 0 }), + }; + const useCase = new SetupMarketplaceRegistrationUseCase( + new InMemoryFileAdapter(), + new SetupMarketplaceSourceUseCase(new KeepPrompter(), makeNoOpLatestResolver()), + makeRegisterFramework(), + refresh, + new FakeCurrentVersion(), + new CapturingLogger(), + environment + ); + return { useCase, refresh }; +} + +async function register(environment: InMemoryEnvironment): Promise { + const { useCase, refresh } = makeUseCase(environment); + const flow = new SetupFlow({ projectRoot: PROJECT_ROOT }); + await useCase.registerIfPresent(flow, MarketplaceSourceMode.local("/framework-source")); + return refresh; +} + +describe("SetupMarketplaceRegistrationUseCase", () => { + describe("catalog refresh", () => { + it("refreshes the catalog when the environment carries no skip switch", async () => { + const refresh = await register(new InMemoryEnvironment()); + + expect(refresh.execute).toHaveBeenCalledWith({ projectRoot: PROJECT_ROOT }); + }); + + it("skips the refresh when the environment sets the skip switch to 1", async () => { + const refresh = await register(new InMemoryEnvironment({ [SKIP_SWITCH]: "1" })); + + expect(refresh.execute).not.toHaveBeenCalled(); + }); + + it("refreshes when the skip switch carries any other value", async () => { + const refresh = await register(new InMemoryEnvironment({ [SKIP_SWITCH]: "0" })); + + expect(refresh.execute).toHaveBeenCalledOnce(); + }); + }); +}); diff --git a/cli/tests/contexts/framework/application/shared/shared-source-reference-support.integration.test.ts b/cli/tests/contexts/framework/application/shared/shared-source-reference-support.integration.test.ts new file mode 100644 index 000000000..375e1e16f --- /dev/null +++ b/cli/tests/contexts/framework/application/shared/shared-source-reference-support.integration.test.ts @@ -0,0 +1,44 @@ +import { mkdir, mkdtemp, realpath, rm, symlink } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { resolveProjectRootForReferences } from "../../../../../src/contexts/framework/application/shared/shared-source-reference-support.js"; +import { CLIOutput } from "../../../../../src/presentation/output.js"; +import { FileAdapter } from "../../../../../src/runtime/filesystem/file-adapter.js"; +import { HasherAdapter } from "../../../../../src/runtime/filesystem/hasher-adapter.js"; + +let root: string; + +beforeEach(async () => { + // macOS aliases its own tmpdir under a symlink (`/var` -> `/private/var`), so a fixture + // built under an unresolved `root` would disagree with itself. + root = await realpath(await mkdtemp(join(tmpdir(), "aidd-shared-source-reference-"))); +}); + +afterEach(async () => { + await rm(root, { recursive: true, force: true }); +}); + +describe("resolveProjectRootForReferences against a real symlink", () => { + it("resolves a project reached through a symlink to its real location", async () => { + const real = join(root, "real-project"); + const link = join(root, "link-project"); + await mkdir(real, { recursive: true }); + await symlink(real, link); + const fs = new FileAdapter(new HasherAdapter(), new CLIOutput(false)); + + const resolved = await resolveProjectRootForReferences(fs, link); + + expect(resolved).toBe(real); + expect(resolved).not.toBe(link); + }); + + it("falls back to the path as given for a project that no longer exists", async () => { + const vanished = join(root, "gone"); + const fs = new FileAdapter(new HasherAdapter(), new CLIOutput(false)); + + const resolved = await resolveProjectRootForReferences(fs, vanished); + + expect(resolved).toBe(vanished); + }); +}); diff --git a/cli/tests/contexts/framework/application/shared/shared-source-reference-support.unit.test.ts b/cli/tests/contexts/framework/application/shared/shared-source-reference-support.unit.test.ts new file mode 100644 index 000000000..fab57e16d --- /dev/null +++ b/cli/tests/contexts/framework/application/shared/shared-source-reference-support.unit.test.ts @@ -0,0 +1,96 @@ +import { describe, expect, it } from "vitest"; +import { + describeFullRemovalInstruction, + describeGuardedPluginRefMessage, + refAnotherProjectStillNeeds, +} from "../../../../../src/contexts/framework/application/shared/shared-source-reference-support.js"; + +const BASE = { + ref: "aidd-dev@aidd-framework", + sharedSourceHostName: "aidd-framework", + enablementIsMachineGlobal: true, + otherProjects: ["/other-project"], +} as const; + +describe("refAnotherProjectStillNeeds", () => { + it("is true when the ref came from the shared source, enablement is machine-global, and another project still references it", () => { + expect(refAnotherProjectStillNeeds(BASE)).toBe(true); + }); + + it("is false when no other project references the shared source", () => { + expect(refAnotherProjectStillNeeds({ ...BASE, otherProjects: [] })).toBe(false); + }); + + it("is false when this host's own enablement is not machine-global (claude)", () => { + expect(refAnotherProjectStillNeeds({ ...BASE, enablementIsMachineGlobal: false })).toBe(false); + }); + + it("is false when the ref does not come from the shared source's own hostName", () => { + expect( + refAnotherProjectStillNeeds({ ...BASE, ref: "some-plugin@a-different-marketplace" }) + ).toBe(false); + }); + + it("is false when this run never resolved the shared source's own hostName for this tool", () => { + expect(refAnotherProjectStillNeeds({ ...BASE, sharedSourceHostName: undefined })).toBe(false); + }); +}); + +// Nothing else in the suite pins this sentence's grammar, so a swapped singular/plural branch +// ("references"/"reference", "project"/"projects") would pass every other test. +describe("describeGuardedPluginRefMessage", () => { + it("uses singular wording for exactly one other project", () => { + const message = describeGuardedPluginRefMessage({ + binary: "codex", + ref: "aidd-vcs@aidd-framework", + otherProjects: ["/other-project"], + }); + expect(message).toContain("1 other project still references"); + }); + + it("uses plural wording for more than one other project", () => { + const message = describeGuardedPluginRefMessage({ + binary: "codex", + ref: "aidd-vcs@aidd-framework", + otherProjects: ["/other-project", "/third-project"], + }); + expect(message).toContain("2 other projects still reference"); + }); + + // Full removal names both commands in order — `aidd clean` in each other project before + // `aidd clean --scope user` — the same clause `clean --scope user`'s own report states. + it("names the projects and both removal commands, in order, sharing the same clause clean --scope user uses", () => { + const message = describeGuardedPluginRefMessage({ + binary: "codex", + ref: "aidd-vcs@aidd-framework", + otherProjects: ["/other-project"], + }); + expect(message).toContain("/other-project"); + expect(message).toContain(describeFullRemovalInstruction()); + expect(message.indexOf("`aidd clean`")).toBeGreaterThanOrEqual(0); + expect(message.indexOf("`aidd clean`")).toBeLessThan( + message.indexOf("`aidd clean --scope user`") + ); + }); + + it("states the fact once, not twice ('still reference' the source, not also 'still need it')", () => { + const message = describeGuardedPluginRefMessage({ + binary: "codex", + ref: "aidd-vcs@aidd-framework", + otherProjects: ["/other-project"], + }); + expect(message).toContain("still references the shared source"); + expect(message).not.toContain("need it"); + }); +}); + +describe("describeFullRemovalInstruction", () => { + it("names both commands, aidd clean before aidd clean --scope user", () => { + const instruction = describeFullRemovalInstruction(); + expect(instruction).toContain("`aidd clean`"); + expect(instruction).toContain("`aidd clean --scope user`"); + expect(instruction.indexOf("`aidd clean`")).toBeLessThan( + instruction.indexOf("`aidd clean --scope user`") + ); + }); +}); diff --git a/cli/tests/application/use-cases/status-all-use-case.unit.test.ts b/cli/tests/contexts/framework/application/status-all-use-case.unit.test.ts similarity index 84% rename from cli/tests/application/use-cases/status-all-use-case.unit.test.ts rename to cli/tests/contexts/framework/application/status-all-use-case.unit.test.ts index 6e7277b72..095f39d2a 100644 --- a/cli/tests/application/use-cases/status-all-use-case.unit.test.ts +++ b/cli/tests/contexts/framework/application/status-all-use-case.unit.test.ts @@ -1,18 +1,24 @@ import { describe, expect, it, vi } from "vitest"; -import { StatusAllUseCase } from "../../../src/application/use-cases/global/status-all-use-case.js"; +import { StatusAllUseCase } from "../../../../src/contexts/framework/application/global/status-all-use-case.js"; import type { StatusOptions, StatusQuery, StatusReport, -} from "../../../src/application/use-cases/status-use-case.js"; +} from "../../../../src/contexts/framework/application/status-use-case.js"; const PROJECT_ROOT = "/test-project"; type Report = StatusReport; type Options = StatusOptions; -const DRIFT = [{ toolId: "claude" as const, pluginName: "sample", driftedFiles: ["a.md"] }]; - +const DRIFT = [ + { + toolId: "claude" as const, + pluginName: "sample", + driftedFiles: ["a.md"], + notInstalledOnMachine: false, + }, +]; function report(over: Partial = {}): Report { return { tools: [], pluginDrift: [], inSync: true, ...over } as Report; } diff --git a/cli/tests/contexts/framework/application/status-plugin-user-scope.unit.test.ts b/cli/tests/contexts/framework/application/status-plugin-user-scope.unit.test.ts new file mode 100644 index 000000000..da749819d --- /dev/null +++ b/cli/tests/contexts/framework/application/status-plugin-user-scope.unit.test.ts @@ -0,0 +1,157 @@ +import "../../../../src/contexts/tools/domain/profiles/cursor/profile.js"; +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; +import { DetectPluginDriftUseCase } from "../../../../src/contexts/framework/application/shared/detect-plugin-drift-use-case.js"; +import { StatusUseCase } from "../../../../src/contexts/framework/application/status-use-case.js"; +import { Manifest } from "../../../../src/contexts/framework/domain/manifest.js"; +import { InstalledPlugin } from "../../../../src/contexts/framework/domain/plugins/installed-plugin.js"; +import type { ManifestRepository } from "../../../../src/contexts/framework/domain/ports/manifest-repository.js"; +import { FileHash } from "../../../../src/kernel/file.js"; +import type { FileReader } from "../../../../src/kernel/ports/file-reader.js"; +import type { Hasher } from "../../../../src/kernel/ports/hasher.js"; + +const EXPECTED_HASH = "abc123abc123abc123abc123abc123ab"; +const DRIFTED_HASH = "def456def456def456def456def456de"; + +// Cursor Mode B: file key is base-relative (no absolute prefix, relative to user plugins dir) +const PLUGIN_KEY = "aidd-context/commands/hello.md"; + +function makeManifest(pluginFileHash: string): Manifest { + const manifest = Manifest.create(); + manifest.addTool("cursor", "1.0.0", []); + manifest.addPlugin( + "cursor", + InstalledPlugin.fromJSON({ + name: "aidd-context", + source: { kind: "local", path: "/some/path" }, + version: "1.0.0", + strict: false, + files: { [PLUGIN_KEY]: pluginFileHash }, + scope: "user", + }) + ); + return manifest; +} + +function makeFs(fileExists: boolean, diskHash: string): FileReader { + return { + fileExists: async () => fileExists, + isExecutable: async () => false, + realpath: async (path: string) => path, + readFileHash: async () => new FileHash(diskHash), + readFile: async () => "", + listDirectory: async () => [], + listFilesRecursive: async () => [], + }; +} + +function makeManifestRepo(manifest: Manifest): ManifestRepository { + return { + path: "/proj/.aidd/manifest.json", + load: async () => manifest, + save: async () => {}, + delete: async () => {}, + }; +} + +const noopHasher: Hasher = { + hash: () => new FileHash("00000000000000000000000000000000"), +}; + +describe("StatusUseCase — cursor plugin drift (user-scope)", () => { + describe("when cursor plugin file has drifted (base-relative key)", () => { + it("resolves absolute path from homedir via resolvePluginsBaseDir before checking disk", async () => { + const manifest = makeManifest(EXPECTED_HASH); + const checkedPaths: string[] = []; + const fs: FileReader = { + fileExists: async (p: string) => { + checkedPaths.push(p); + return true; + }, + isExecutable: async () => false, + realpath: async (path: string) => path, + readFileHash: async () => new FileHash(DRIFTED_HASH), + readFile: async () => "", + listDirectory: async () => [], + listFilesRecursive: async () => [], + }; + + const useCase = new StatusUseCase( + fs, + makeManifestRepo(manifest), + noopHasher, + new DetectPluginDriftUseCase(fs) + ); + await useCase.execute({ projectRoot: "/proj" }); + + expect(checkedPaths.some((p) => p.includes(join(".cursor", "plugins", "local")))).toBe(true); + expect(checkedPaths.every((p) => !p.includes(join("/proj", PLUGIN_KEY)))).toBe(true); + }); + + it("returns plugin drift entry with the relative key", async () => { + const manifest = makeManifest(EXPECTED_HASH); + const fs = makeFs(true, DRIFTED_HASH); + const useCase = new StatusUseCase( + fs, + makeManifestRepo(manifest), + noopHasher, + new DetectPluginDriftUseCase(fs) + ); + + const report = await useCase.execute({ projectRoot: "/proj" }); + + expect(report.pluginDrift).toHaveLength(1); + expect(report.pluginDrift[0].toolId).toBe("cursor"); + expect(report.pluginDrift[0].pluginName).toBe("aidd-context"); + expect(report.pluginDrift[0].driftedFiles).toContain(PLUGIN_KEY); + }); + }); + + describe("when cursor was never installed on this machine (every tracked file missing)", () => { + it("reports one collapsed drift entry instead of one per file", async () => { + const manifest = Manifest.create(); + manifest.addTool("cursor", "1.0.0", []); + manifest.addPlugin( + "cursor", + InstalledPlugin.fromJSON({ + name: "aidd-context", + source: { kind: "local", path: "/some/path" }, + version: "1.0.0", + strict: false, + files: { "aidd-context/a.md": EXPECTED_HASH, "aidd-context/b.md": EXPECTED_HASH }, + scope: "user", + }) + ); + const fs = makeFs(false, EXPECTED_HASH); + const useCase = new StatusUseCase( + fs, + makeManifestRepo(manifest), + noopHasher, + new DetectPluginDriftUseCase(fs) + ); + + const report = await useCase.execute({ projectRoot: "/proj" }); + + expect(report.pluginDrift).toHaveLength(1); + expect(report.pluginDrift[0].notInstalledOnMachine).toBe(true); + expect(report.pluginDrift[0].driftedFiles).toEqual([]); + }); + }); + + describe("when cursor plugin file is in sync (base-relative key)", () => { + it("returns empty pluginDrift", async () => { + const manifest = makeManifest(EXPECTED_HASH); + const fs = makeFs(true, EXPECTED_HASH); + const useCase = new StatusUseCase( + fs, + makeManifestRepo(manifest), + noopHasher, + new DetectPluginDriftUseCase(fs) + ); + + const report = await useCase.execute({ projectRoot: "/proj" }); + + expect(report.pluginDrift).toHaveLength(0); + }); + }); +}); diff --git a/cli/tests/application/use-cases/status-plugin.unit.test.ts b/cli/tests/contexts/framework/application/status-plugin.unit.test.ts similarity index 78% rename from cli/tests/application/use-cases/status-plugin.unit.test.ts rename to cli/tests/contexts/framework/application/status-plugin.unit.test.ts index 8a1e2be6e..fcaed453f 100644 --- a/cli/tests/application/use-cases/status-plugin.unit.test.ts +++ b/cli/tests/contexts/framework/application/status-plugin.unit.test.ts @@ -1,14 +1,14 @@ import { describe, expect, it } from "vitest"; -import "../../../src/domain/tools/ai/claude.js"; -import "../../../src/domain/tools/ai/cursor.js"; -import { DetectPluginDriftUseCase } from "../../../src/application/use-cases/shared/detect-plugin-drift-use-case.js"; -import { StatusUseCase } from "../../../src/application/use-cases/status-use-case.js"; -import { FileHash } from "../../../src/domain/models/file.js"; -import { Manifest } from "../../../src/domain/models/manifest.js"; -import { Plugin } from "../../../src/domain/models/plugin.js"; -import type { FileReader } from "../../../src/domain/ports/file-reader.js"; -import type { Hasher } from "../../../src/domain/ports/hasher.js"; -import type { ManifestRepository } from "../../../src/domain/ports/manifest-repository.js"; +import "../../../../src/contexts/tools/domain/profiles/claude/profile.js"; +import "../../../../src/contexts/tools/domain/profiles/cursor/profile.js"; +import { DetectPluginDriftUseCase } from "../../../../src/contexts/framework/application/shared/detect-plugin-drift-use-case.js"; +import { StatusUseCase } from "../../../../src/contexts/framework/application/status-use-case.js"; +import { Manifest } from "../../../../src/contexts/framework/domain/manifest.js"; +import { InstalledPlugin } from "../../../../src/contexts/framework/domain/plugins/installed-plugin.js"; +import type { ManifestRepository } from "../../../../src/contexts/framework/domain/ports/manifest-repository.js"; +import { FileHash } from "../../../../src/kernel/file.js"; +import type { FileReader } from "../../../../src/kernel/ports/file-reader.js"; +import type { Hasher } from "../../../../src/kernel/ports/hasher.js"; const EXPECTED_HASH = "abc123abc123abc123abc123abc123ab"; const DRIFTED_HASH = "def456def456def456def456def456de"; @@ -19,12 +19,13 @@ function makeManifest(pluginFileHash: string): Manifest { manifest.addTool("claude", "1.0.0", []); manifest.addPlugin( "claude", - Plugin.fromJSON({ + InstalledPlugin.fromJSON({ name: "test-plugin", source: { kind: "local", path: "/some/path" }, version: "1.0.0", strict: false, files: { [PLUGIN_FILE]: pluginFileHash }, + scope: "project", }) ); return manifest; @@ -34,6 +35,7 @@ function makeFs(fileExists: boolean, diskHash: string): FileReader { return { fileExists: async () => fileExists, isExecutable: async () => false, + realpath: async (path: string) => path, readFileHash: async () => new FileHash(diskHash), readFile: async () => "", listDirectory: async () => [], diff --git a/cli/tests/contexts/framework/application/status-use-case.unit.test.ts b/cli/tests/contexts/framework/application/status-use-case.unit.test.ts new file mode 100644 index 000000000..01ea91a11 --- /dev/null +++ b/cli/tests/contexts/framework/application/status-use-case.unit.test.ts @@ -0,0 +1,82 @@ +import { describe, expect, it } from "vitest"; +import "../../../../src/contexts/tools/domain/profiles/claude/profile.js"; +import "../../../../src/contexts/tools/domain/profiles/codex/profile.js"; +import "../../../../src/contexts/tools/domain/profiles/copilot/profile.js"; +import "../../../../src/contexts/tools/domain/profiles/cursor/profile.js"; +import "../../../../src/contexts/tools/domain/profiles/opencode/profile.js"; +import "../../../../src/contexts/tools/domain/profiles/vscode/profile.js"; +import { InitUseCase } from "../../../../src/contexts/framework/application/init-use-case.js"; +import { DetectPluginDriftUseCase } from "../../../../src/contexts/framework/application/shared/detect-plugin-drift-use-case.js"; +import { StatusUseCase } from "../../../../src/contexts/framework/application/status-use-case.js"; +import { machineLocalFilesOf } from "../../../../src/contexts/tools/domain/registry.js"; +import { compareSemver } from "../../../../src/kernel/semver.js"; +import { buildUnitDeps } from "../../../helpers/ports/build-unit-deps.js"; + +const PROJECT_ROOT = "/test-project"; + +describe("status", () => { + it("reports no drift when no tools are installed", async () => { + const deps = await buildUnitDeps(PROJECT_ROOT); + await new InitUseCase(deps.fs, deps.manifestRepo).execute({ projectRoot: PROJECT_ROOT }); + + const useCase = new StatusUseCase( + deps.fs, + deps.manifestRepo, + deps.hasher, + new DetectPluginDriftUseCase(deps.fs) + ); + const report = await useCase.execute({ projectRoot: PROJECT_ROOT }); + + expect(report.tools).toHaveLength(0); + expect(report.inSync).toBe(true); + }); + + it("does not call a machine-local file an addition, whatever the profile declares", async () => { + const deps = await buildUnitDeps(PROJECT_ROOT); + await new InitUseCase(deps.fs, deps.manifestRepo).execute({ projectRoot: PROJECT_ROOT }); + const manifest = await deps.manifestRepo.load(); + if (manifest === null) throw new Error("manifest missing"); + manifest.addTool("claude", "test", []); + await deps.manifestRepo.save(manifest); + + // Written by the CLI on purpose and never tracked. Reading the exclusion off + // `machineLocalFilesOf` is what keeps it matching the path the profile declares. + for (const relativePath of machineLocalFilesOf("claude")) { + await deps.fs.writeFile(`${PROJECT_ROOT}/${relativePath}`, "{}"); + } + expect(machineLocalFilesOf("claude").length).toBeGreaterThan(0); + + const report = await new StatusUseCase( + deps.fs, + deps.manifestRepo, + deps.hasher, + new DetectPluginDriftUseCase(deps.fs) + ).execute({ projectRoot: PROJECT_ROOT }); + + const drifted = report.tools.flatMap((tool) => tool.drifted); + expect(drifted).toEqual([]); + expect(report.inSync).toBe(true); + }); + + describe("compareSemver()", () => { + it("orders lower major version as smaller", () => { + expect(compareSemver("1.0.0", "2.0.0")).toBe(-1); + }); + + it("orders lower minor version as smaller", () => { + expect(compareSemver("3.1.0", "3.2.0")).toBe(-1); + }); + + it("orders higher patch version as greater", () => { + expect(compareSemver("3.1.1", "3.1.0")).toBe(1); + }); + + it("treats identical versions as equal", () => { + expect(compareSemver("3.1.0", "3.1.0")).toBe(0); + }); + + it("handles v-prefix", () => { + expect(compareSemver("3.0.0", "v3.1.0")).toBe(-1); + }); + }); +}); diff --git a/cli/tests/application/use-cases/uninstall-ide-use-case.unit.test.ts b/cli/tests/contexts/framework/application/uninstall-ide-use-case.unit.test.ts similarity index 89% rename from cli/tests/application/use-cases/uninstall-ide-use-case.unit.test.ts rename to cli/tests/contexts/framework/application/uninstall-ide-use-case.unit.test.ts index df4ea78f4..7e5139b04 100644 --- a/cli/tests/application/use-cases/uninstall-ide-use-case.unit.test.ts +++ b/cli/tests/contexts/framework/application/uninstall-ide-use-case.unit.test.ts @@ -1,8 +1,8 @@ import { join } from "node:path"; import { describe, expect, it } from "vitest"; -import { UninstallIdeUseCase } from "../../../src/application/use-cases/uninstall/uninstall-ide-use-case.js"; -import { UninstallToolsUseCase } from "../../../src/application/use-cases/uninstall/uninstall-tools-use-case.js"; -import { buildUnitDeps, initProject, installTool } from "../../helpers/ports/build-unit-deps.js"; +import { UninstallIdeUseCase } from "../../../../src/contexts/framework/application/uninstall/uninstall-ide-use-case.js"; +import { UninstallToolsUseCase } from "../../../../src/contexts/framework/application/uninstall/uninstall-tools-use-case.js"; +import { buildUnitDeps, initProject, installTool } from "../../../helpers/ports/build-unit-deps.js"; const PROJECT_ROOT = "/test-project"; const SETTINGS = join(PROJECT_ROOT, ".vscode/settings.json"); diff --git a/cli/tests/contexts/framework/application/uninstall-plugin.unit.test.ts b/cli/tests/contexts/framework/application/uninstall-plugin.unit.test.ts new file mode 100644 index 000000000..cb98b8ce1 --- /dev/null +++ b/cli/tests/contexts/framework/application/uninstall-plugin.unit.test.ts @@ -0,0 +1,67 @@ +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; +import "../../../../src/contexts/tools/domain/profiles/claude/profile.js"; +import { PluginAddUseCase } from "../../../../src/contexts/framework/application/plugin/plugin-add-use-case.js"; +import { UninstallUseCase } from "../../../../src/contexts/framework/application/uninstall/uninstall-use-case.js"; +import { PluginDistributionReaderAdapter } from "../../../../src/contexts/framework/infrastructure/plugin-distribution-reader-adapter.js"; +import { PluginNotFoundError } from "../../../../src/kernel/errors.js"; +import { buildUnitDeps, initAndInstall } from "../../../helpers/ports/build-unit-deps.js"; +import { fakeEnsureBuiltMarketplace } from "../../../helpers/ports/fake-ensure-built-marketplace.js"; + +const PLUGIN_FIXTURE = join(process.cwd(), "tests/fixtures/plugins/claude-format/sample-plugin"); +const PROJECT_ROOT = "/test-project"; + +describe("UninstallUseCase — plugin scope", () => { + it("removes plugin files and unregisters from manifest when --plugin given", async () => { + const deps = await buildUnitDeps(PROJECT_ROOT); + const { seedFromDirectory } = await import("../../../helpers/ports/seed-from-directory.js"); + await seedFromDirectory(deps.fs, PLUGIN_FIXTURE, { useAbsolutePaths: true }); + + await initAndInstall(deps, PROJECT_ROOT, "claude"); + + const reader = new PluginDistributionReaderAdapter(deps.fs); + await new PluginAddUseCase( + deps.fs, + deps.manifestRepo, + deps.pluginFetcher, + reader, + deps.hasher, + deps.logger, + deps.marketplaceRegistry, + fakeEnsureBuiltMarketplace() + ).execute({ + source: { kind: "local", path: PLUGIN_FIXTURE }, + toolIds: ["claude"], + projectRoot: PROJECT_ROOT, + interactive: false, + }); + + const pluginFile = join(PROJECT_ROOT, ".claude/plugins/sample-plugin/commands/greet.md"); + expect(deps.fs.has(pluginFile)).toBe(true); + + await new UninstallUseCase(deps.fs, deps.manifestRepo, deps.logger).execute({ + toolIds: [], + projectRoot: PROJECT_ROOT, + mcpFilter: [], + pluginName: "sample-plugin", + }); + + expect(deps.fs.has(pluginFile)).toBe(false); + const manifest = await deps.manifestRepo.load(); + expect(manifest?.getPlugins("claude").find((p) => p.name === "sample-plugin")).toBeUndefined(); + }); + + it("throws PluginNotFoundError when the plugin is not installed on any tool", async () => { + const deps = await buildUnitDeps(PROJECT_ROOT); + await initAndInstall(deps, PROJECT_ROOT, "claude"); + + await expect( + new UninstallUseCase(deps.fs, deps.manifestRepo, deps.logger).execute({ + toolIds: [], + projectRoot: PROJECT_ROOT, + mcpFilter: [], + pluginName: "nonexistent", + }) + ).rejects.toThrow(PluginNotFoundError); + }); +}); diff --git a/cli/tests/contexts/framework/application/uninstall-tools-use-case.unit.test.ts b/cli/tests/contexts/framework/application/uninstall-tools-use-case.unit.test.ts new file mode 100644 index 000000000..a97234d58 --- /dev/null +++ b/cli/tests/contexts/framework/application/uninstall-tools-use-case.unit.test.ts @@ -0,0 +1,51 @@ +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; +import "../../../../src/contexts/tools/domain/profiles/cursor/profile.js"; +import { UninstallToolsUseCase } from "../../../../src/contexts/framework/application/uninstall/uninstall-tools-use-case.js"; +import { Manifest } from "../../../../src/contexts/framework/domain/manifest.js"; +import { InstalledPlugin } from "../../../../src/contexts/framework/domain/plugins/installed-plugin.js"; +import { CapturingLogger } from "../../../helpers/ports/capturing-logger.js"; +import { InMemoryFileAdapter } from "../../../helpers/ports/in-memory-file-adapter.js"; + +const PROJECT_ROOT = "/test-project"; + +/** Records every path `deleteFile` is called with, so a test can prove where a plugin's + * file actually got deleted from without inspecting private use-case state. */ +class RecordingFileAdapter extends InMemoryFileAdapter { + readonly deletedPaths: string[] = []; + + override async deleteFile(path: string): Promise { + this.deletedPaths.push(path); + return super.deleteFile(path); + } +} + +// Cursor Mode B: the file key is base-relative, resolved against the user plugins dir. +const PLUGIN_KEY = "aidd-context/commands/hello.md"; + +describe("UninstallToolsUseCase — cursor plugin file (user-scope)", () => { + it("deletes the plugin's file from its resolved home directory, not projectRoot", async () => { + const manifest = Manifest.create(); + manifest.addTool("cursor", "1.0.0", []); + manifest.addPlugin( + "cursor", + InstalledPlugin.fromJSON({ + name: "aidd-context", + source: { kind: "local", path: "/some/path" }, + version: "1.0.0", + strict: false, + files: { [PLUGIN_KEY]: "abc123abc123abc123abc123abc123ab" }, + scope: "user", + }) + ); + + const fs = new RecordingFileAdapter(); + const useCase = new UninstallToolsUseCase(fs, new CapturingLogger()); + await useCase.execute({ toolIds: ["cursor"], manifest, projectRoot: PROJECT_ROOT }); + + expect( + fs.deletedPaths.some((p) => p.endsWith(join(".cursor", "plugins", "local", PLUGIN_KEY))) + ).toBe(true); + expect(fs.deletedPaths).not.toContain(join(PROJECT_ROOT, PLUGIN_KEY)); + }); +}); diff --git a/cli/tests/application/use-cases/uninstall-use-case.unit.test.ts b/cli/tests/contexts/framework/application/uninstall-use-case.unit.test.ts similarity index 84% rename from cli/tests/application/use-cases/uninstall-use-case.unit.test.ts rename to cli/tests/contexts/framework/application/uninstall-use-case.unit.test.ts index 82b3536f3..334fa83c6 100644 --- a/cli/tests/application/use-cases/uninstall-use-case.unit.test.ts +++ b/cli/tests/contexts/framework/application/uninstall-use-case.unit.test.ts @@ -1,14 +1,14 @@ import { join } from "node:path"; import { describe, expect, it } from "vitest"; -import "../../../src/domain/tools/ai/claude.js"; -import "../../../src/domain/tools/ai/codex.js"; -import "../../../src/domain/tools/ai/copilot.js"; -import "../../../src/domain/tools/ai/cursor.js"; -import "../../../src/domain/tools/ai/opencode.js"; -import "../../../src/domain/tools/ide/vscode.js"; -import { UninstallUseCase } from "../../../src/application/use-cases/uninstall/uninstall-use-case.js"; -import type { ToolId } from "../../../src/domain/tools/registry.js"; -import { buildUnitDeps, initProject, installTool } from "../../helpers/ports/build-unit-deps.js"; +import "../../../../src/contexts/tools/domain/profiles/claude/profile.js"; +import "../../../../src/contexts/tools/domain/profiles/codex/profile.js"; +import "../../../../src/contexts/tools/domain/profiles/copilot/profile.js"; +import "../../../../src/contexts/tools/domain/profiles/cursor/profile.js"; +import "../../../../src/contexts/tools/domain/profiles/opencode/profile.js"; +import "../../../../src/contexts/tools/domain/profiles/vscode/profile.js"; +import { UninstallUseCase } from "../../../../src/contexts/framework/application/uninstall/uninstall-use-case.js"; +import type { ToolId } from "../../../../src/kernel/tool.js"; +import { buildUnitDeps, initProject, installTool } from "../../../helpers/ports/build-unit-deps.js"; const PROJECT_ROOT = "/test-project"; @@ -34,7 +34,6 @@ describe("uninstall", () => { await initProject(deps, PROJECT_ROOT); await installTool(deps, PROJECT_ROOT, "claude" as ToolId); - // Delete .claude/ files from in-memory FS const claudeFiles = deps.fs.listUnder(join(PROJECT_ROOT, ".claude")); for (const f of claudeFiles) { await deps.fs.deleteFile(f); diff --git a/cli/tests/domain/formats/markdown-references.unit.test.ts b/cli/tests/contexts/framework/domain/formats/markdown-references.unit.test.ts similarity index 96% rename from cli/tests/domain/formats/markdown-references.unit.test.ts rename to cli/tests/contexts/framework/domain/formats/markdown-references.unit.test.ts index 8fcaf8114..da212858f 100644 --- a/cli/tests/domain/formats/markdown-references.unit.test.ts +++ b/cli/tests/contexts/framework/domain/formats/markdown-references.unit.test.ts @@ -3,7 +3,7 @@ import { extractAtReferences, extractMarkdownLinkTargets, isFileReference, -} from "../../../src/domain/formats/markdown-references.js"; +} from "../../../../../src/contexts/framework/domain/formats/markdown-references.js"; describe("isFileReference", () => { it("returns true for a path with a file extension", () => { diff --git a/cli/tests/domain/models/install-scope.unit.test.ts b/cli/tests/contexts/framework/domain/install-scope.unit.test.ts similarity index 81% rename from cli/tests/domain/models/install-scope.unit.test.ts rename to cli/tests/contexts/framework/domain/install-scope.unit.test.ts index f2c4a7866..965395f54 100644 --- a/cli/tests/domain/models/install-scope.unit.test.ts +++ b/cli/tests/contexts/framework/domain/install-scope.unit.test.ts @@ -1,16 +1,16 @@ -import "../../../src/domain/tools/ai/claude.js"; -import "../../../src/domain/tools/ai/codex.js"; -import "../../../src/domain/tools/ai/copilot.js"; -import "../../../src/domain/tools/ai/cursor.js"; -import "../../../src/domain/tools/ai/opencode.js"; +import "../../../../src/contexts/tools/domain/profiles/claude/profile.js"; +import "../../../../src/contexts/tools/domain/profiles/codex/profile.js"; +import "../../../../src/contexts/tools/domain/profiles/copilot/profile.js"; +import "../../../../src/contexts/tools/domain/profiles/cursor/profile.js"; +import "../../../../src/contexts/tools/domain/profiles/opencode/profile.js"; import { describe, expect, it } from "vitest"; -import { InvalidPluginScopeError } from "../../../src/domain/errors.js"; import { assertToolSupportsScope, getToolSupportedScope, isInstallScope, parseInstallScope, -} from "../../../src/domain/models/install-scope.js"; +} from "../../../../src/contexts/framework/domain/install-scope.js"; +import { InvalidPluginScopeError } from "../../../../src/kernel/errors.js"; describe("install-scope value object", () => { describe("isInstallScope", () => { diff --git a/cli/tests/domain/models/installed-hook-resolves.unit.test.ts b/cli/tests/contexts/framework/domain/installed-hook-resolves.unit.test.ts similarity index 75% rename from cli/tests/domain/models/installed-hook-resolves.unit.test.ts rename to cli/tests/contexts/framework/domain/installed-hook-resolves.unit.test.ts index 9d6ba2697..d2f34a523 100644 --- a/cli/tests/domain/models/installed-hook-resolves.unit.test.ts +++ b/cli/tests/contexts/framework/domain/installed-hook-resolves.unit.test.ts @@ -1,25 +1,22 @@ import { describe, expect, it } from "vitest"; -import { - buildClaudeContract, - buildCodexContract, - buildCopilotMarketplaceContract, - buildCursorContract, -} from "../../../src/application/use-cases/framework/strategies/tool-contracts.js"; -import { rewritePluginRootToken } from "../../../src/domain/formats/plugin-root-token-rewrite.js"; -import { FileHash } from "../../../src/domain/models/file.js"; -import { PluginContentTranslator } from "../../../src/domain/models/plugin-content-translator.js"; -import { PluginDistribution } from "../../../src/domain/models/plugin-distribution.js"; -import { claude } from "../../../src/domain/tools/ai/claude.js"; -import { codex } from "../../../src/domain/tools/ai/codex.js"; -import { copilot } from "../../../src/domain/tools/ai/copilot.js"; -import { cursor } from "../../../src/domain/tools/ai/cursor.js"; -import { opencode } from "../../../src/domain/tools/ai/opencode.js"; -import type { AiTool, HasPlugins } from "../../../src/domain/tools/contracts.js"; +import type { AiTool, HasPlugins } from "../../../../src/contexts/tools/domain/contracts.js"; +import { buildClaudeContract } from "../../../../src/contexts/tools/domain/profiles/claude/build.js"; +import { claude } from "../../../../src/contexts/tools/domain/profiles/claude/profile.js"; +import { buildCodexContract } from "../../../../src/contexts/tools/domain/profiles/codex/build.js"; +import { codex } from "../../../../src/contexts/tools/domain/profiles/codex/profile.js"; +import { buildCopilotMarketplaceContract } from "../../../../src/contexts/tools/domain/profiles/copilot/build.js"; +import { copilot } from "../../../../src/contexts/tools/domain/profiles/copilot/profile.js"; +import { buildCursorContract } from "../../../../src/contexts/tools/domain/profiles/cursor/build.js"; +import { cursor } from "../../../../src/contexts/tools/domain/profiles/cursor/profile.js"; +import { opencode } from "../../../../src/contexts/tools/domain/profiles/opencode/profile.js"; +import { PluginContentTranslator } from "../../../../src/contexts/translate/domain/content-translator.js"; +import { rewritePluginRootToken } from "../../../../src/contexts/translate/domain/formats/plugin-root-token-rewrite.js"; +import { PluginDistribution } from "../../../../src/contexts/translate/domain/plugin-distribution.js"; +import { FileHash } from "../../../../src/kernel/file.js"; /** - * A hook command that points at nothing installs clean, reports success, and does nothing. - * Every failure in this ticket had that shape, so this reads the command back out of what - * was installed and asks whether the file it names is there. + * A hook command pointing at nothing installs clean and does nothing, so the command is read + * back out of what was installed and the file it names is looked for. */ const stubHasher = { hash: (_content: string) => new FileHash("a".repeat(32)) }; @@ -66,7 +63,7 @@ function commandTargets(manifest: string, tool: AiTool): string[] { } function installed(tool: AiTool): { paths: string[]; manifest: string } { - const { files } = translator.translateWithComponentPaths(distribution(), tool, "docs"); + const { files } = translator.translateWithComponentPaths(distribution(), tool); const root = `${tool.capabilities.plugins.pluginsDir}${PLUGIN}/`; return { paths: files.map((file) => file.relativePath.replace(root, "")), @@ -133,11 +130,7 @@ describe("the two ways a plugin gets installed", () => { it("deliver hooks exactly when the tool runs them", () => { for (const tool of [...HOOK_HOSTS, opencode]) { - const { files, skipped } = translator.translateWithComponentPaths( - distribution(), - tool, - "docs" - ); + const { files, skipped } = translator.translateWithComponentPaths(distribution(), tool); const carriesHooks = files.some((file) => file.relativePath.endsWith(".cjs")); expect(carriesHooks, tool.toolId).toBe(tool.capabilities.plugins.acceptsHooks); diff --git a/cli/tests/domain/models/installed-rule.unit.test.ts b/cli/tests/contexts/framework/domain/installed-rule.unit.test.ts similarity index 77% rename from cli/tests/domain/models/installed-rule.unit.test.ts rename to cli/tests/contexts/framework/domain/installed-rule.unit.test.ts index e6bd9286b..5ee34790a 100644 --- a/cli/tests/domain/models/installed-rule.unit.test.ts +++ b/cli/tests/contexts/framework/domain/installed-rule.unit.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { toInstalledRule } from "../../../src/domain/models/installed-rule.js"; +import { toInstalledRule } from "../../../../src/contexts/framework/domain/installed-rule.js"; describe("toInstalledRule — one installed file, read as a rule", () => { it("names the rule from its own file, never from the frontmatter", () => { @@ -41,10 +41,8 @@ describe("toInstalledRule — one installed file, read as a rule", () => { expect(bare.description).toBe(""); }); - /** Each tool names the scope field differently — `paths` for Claude Code and Codex, - * `globs` for Cursor, `applyTo` for Copilot — and a reader comparing two tools needs one - * name. Merged rather than picked: a file converted between tools can carry more than - * one, and dropping either would lose a scope the rule really states. */ + /** Each tool names the scope field differently — `paths`, `globs`, `applyTo` — and a file + * converted between tools can carry more than one, so they are merged rather than picked. */ it("merges every spelling of the scope field into one list", () => { const rule = toInstalledRule( "cursor", @@ -76,4 +74,17 @@ describe("toInstalledRule — one installed file, read as a rule", () => { expect(rule.paths).toEqual(["src/**", "tests/**"]); }); + + /** `SCOPE_FIELDS.flatMap` concatenates without deduplicating, so a glob carried under two + * field names at once would come out twice in `InstalledRule.paths`. */ + it("states a glob carried under two field names only once", () => { + const rule = toInstalledRule( + "cursor", + ".cursor/rules/a.mdc", + ".mdc", + '---\npaths:\n - "src/**"\nglobs:\n - "src/**"\n---\n' + ); + + expect(rule.paths).toEqual(["src/**"]); + }); }); diff --git a/cli/tests/contexts/framework/domain/manifest-plugins.unit.test.ts b/cli/tests/contexts/framework/domain/manifest-plugins.unit.test.ts new file mode 100644 index 000000000..7af067da8 --- /dev/null +++ b/cli/tests/contexts/framework/domain/manifest-plugins.unit.test.ts @@ -0,0 +1,112 @@ +import { describe, expect, it } from "vitest"; +import { Manifest } from "../../../../src/contexts/framework/domain/manifest.js"; +import { InstalledPlugin } from "../../../../src/contexts/framework/domain/plugins/installed-plugin.js"; +import { DuplicatePluginError, PluginNotFoundError } from "../../../../src/kernel/errors.js"; +import { FileHash, InstallationFile } from "../../../../src/kernel/file.js"; +import type { ToolId } from "../../../../src/kernel/tool.js"; + +const CLAUDE = "claude" as ToolId; +const CURSOR = "cursor" as ToolId; + +const makeFile = (relativePath: string, hashHex: string): InstallationFile => + new InstallationFile({ relativePath, content: "content", hash: new FileHash(hashHex) }); + +const makeManifest = (): Manifest => { + const manifest = Manifest.create(); + manifest.addTool(CLAUDE, "3.0.0", [makeFile(".claude/CLAUDE.md", "a".repeat(32))]); + manifest.addTool(CURSOR, "1.0.0", [makeFile(".cursor/rules/naming.md", "b".repeat(32))]); + return manifest; +}; + +const makePlugin = (name = "my-plugin") => + InstalledPlugin.fromJSON({ + name, + source: { kind: "github", repo: "owner/my-plugin" }, + version: "1.0.0", + strict: false, + files: { [`.claude/plugins/${name}/README.md`]: "c".repeat(32) }, + scope: "project", + }); + +describe("plugin serialization round-trip", () => { + it("serializes and re-parses a manifest with plugins", () => { + const manifest = makeManifest(); + manifest.addPlugin(CLAUDE, makePlugin("cool-plugin")); + const serialized = manifest.toJSON(); + const reparsed = Manifest.fromJSON(serialized); + const plugins = reparsed.getPlugins(CLAUDE); + expect(plugins).toHaveLength(1); + expect(plugins[0].name).toBe("cool-plugin"); + expect(plugins[0].version).toBe("1.0.0"); + }); + + it("round-trips a manifest with no plugins identically to one without plugin field", () => { + const manifest = makeManifest(); + const json = manifest.toJSON(); + expect(json.tools.claude.plugins).toBeUndefined(); + expect(json.tools.cursor.plugins).toBeUndefined(); + }); +}); + +describe("addPlugin()", () => { + it("adds a plugin to the specified tool", () => { + const manifest = makeManifest(); + manifest.addPlugin(CLAUDE, makePlugin()); + expect(manifest.getPlugins(CLAUDE)).toHaveLength(1); + }); + + it("throws DuplicatePluginError when adding a plugin with the same name", () => { + const manifest = makeManifest(); + manifest.addPlugin(CLAUDE, makePlugin("dup")); + expect(() => manifest.addPlugin(CLAUDE, makePlugin("dup"))).toThrow(DuplicatePluginError); + }); + + it("does not affect other tools", () => { + const manifest = makeManifest(); + manifest.addPlugin(CLAUDE, makePlugin()); + expect(manifest.getPlugins(CURSOR)).toHaveLength(0); + }); +}); + +describe("removePlugin()", () => { + it("removes a plugin by name", () => { + const manifest = makeManifest(); + manifest.addPlugin(CLAUDE, makePlugin("to-remove")); + manifest.removePlugin(CLAUDE, "to-remove"); + expect(manifest.getPlugins(CLAUDE)).toHaveLength(0); + }); + + it("throws PluginNotFoundError when plugin does not exist", () => { + const manifest = makeManifest(); + expect(() => manifest.removePlugin(CLAUDE, "ghost")).toThrow(PluginNotFoundError); + }); + + it("does not remove a plugin from the wrong tool", () => { + const manifest = makeManifest(); + manifest.addPlugin(CLAUDE, makePlugin("shared-name")); + expect(() => manifest.removePlugin(CURSOR, "shared-name")).toThrow(PluginNotFoundError); + }); +}); + +describe("isFileTracked() with plugins", () => { + it("returns true for a file tracked inside a plugin", () => { + const manifest = makeManifest(); + manifest.addPlugin(CLAUDE, makePlugin("my-plugin")); + expect(manifest.isFileTracked(".claude/plugins/my-plugin/README.md")).toBe(true); + }); + + it("returns false for an untracked file not in any plugin", () => { + const manifest = makeManifest(); + expect(manifest.isFileTracked(".claude/plugins/unknown/README.md")).toBe(false); + }); +}); + +describe("addTool() preserves existing plugins on re-add", () => { + it("keeps plugins when addTool is called again", () => { + const manifest = makeManifest(); + manifest.addPlugin(CLAUDE, makePlugin("keep-me")); + manifest.addTool(CLAUDE, "4.0.0", []); + expect(manifest.getPlugins(CLAUDE)).toHaveLength(1); + expect(manifest.getPlugins(CLAUDE)[0].name).toBe("keep-me"); + }); +}); diff --git a/cli/tests/contexts/framework/domain/manifest-round-trip.unit.test.ts b/cli/tests/contexts/framework/domain/manifest-round-trip.unit.test.ts new file mode 100644 index 000000000..3fb07bbd6 --- /dev/null +++ b/cli/tests/contexts/framework/domain/manifest-round-trip.unit.test.ts @@ -0,0 +1,35 @@ +// Each fixture is a byte-for-byte capture of a real manifest, so a rewritten shape that is +// merely self-consistent — which a fixed-point test would still pass — fails here. +import { readdirSync, readFileSync } from "node:fs"; +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; +import { Manifest } from "../../../../src/contexts/framework/domain/manifest.js"; + +const FIXTURES_DIR = join(__dirname, "../../../fixtures/manifests"); + +function fixtureNames(): string[] { + return readdirSync(FIXTURES_DIR) + .filter((f) => f.endsWith(".json")) + .sort(); +} + +describe("Manifest round-trip: every fixture rewrites byte-identical", () => { + it.each(fixtureNames())("%s", (name) => { + const path = join(FIXTURES_DIR, name); + const original = readFileSync(path, "utf-8"); + + const manifest = Manifest.fromJSON(JSON.parse(original)); + const rewritten = `${JSON.stringify(manifest.toJSON(), null, 2)}\n`; + + expect(rewritten).toBe(original); + }); + + it("covers at least one fixture per manifest member", () => { + const names = fixtureNames(); + expect(names).toContain("multi-tool.json"); + expect(names).toContain("merge-files.json"); + expect(names).toContain("mcp-exclusions.json"); + expect(names).toContain("plugins.json"); + expect(names).toContain("full.json"); + }); +}); diff --git a/cli/tests/contexts/framework/domain/manifest.property.unit.test.ts b/cli/tests/contexts/framework/domain/manifest.property.unit.test.ts new file mode 100644 index 000000000..b0bd56a17 --- /dev/null +++ b/cli/tests/contexts/framework/domain/manifest.property.unit.test.ts @@ -0,0 +1,69 @@ +import * as fc from "fast-check"; +import { describe, expect, it } from "vitest"; +import { Manifest } from "../../../../src/contexts/framework/domain/manifest.js"; +import { FileHash, InstallationFile } from "../../../../src/kernel/file.js"; +import type { ToolId } from "../../../../src/kernel/tool.js"; +import { VALID_TOOL_IDS } from "../../../../src/kernel/tool.js"; + +/** 32-char lowercase hex → valid MD5. fast-check v4 removed hexaString; use stringMatching. */ +const md5Arb = fc.stringMatching(/^[0-9a-f]{32}$/); + +const relativePathArb = fc + .string({ minLength: 1, maxLength: 60 }) + .filter((s) => !s.includes("\0") && !s.startsWith("/") && s.trim().length > 0); + +const installationFileArb = fc + .record({ relativePath: relativePathArb, hash: md5Arb }) + .map( + ({ relativePath, hash }) => + new InstallationFile({ relativePath, content: "x", hash: new FileHash(hash) }) + ); + +const toolIdArb = fc.constantFrom(...(VALID_TOOL_IDS as ToolId[])); + +const toolEntryArb = fc.record({ + toolId: toolIdArb, + version: fc + .string({ minLength: 1, maxLength: 20 }) + .filter((s) => !s.includes("\n") && s.trim().length > 0), + files: fc.array(installationFileArb, { maxLength: 6 }), +}); + +/** Deduplicates by toolId, last one winning, the way `addTool` does. */ +function buildManifest( + tools: Array<{ toolId: ToolId; version: string; files: InstallationFile[] }> +): Manifest { + const m = Manifest.create(); + for (const t of tools) { + m.addTool(t.toolId, t.version, t.files); + } + return m; +} + +describe("Manifest property tests", () => { + it("toJSON → fromJSON → toJSON is identity", () => { + fc.assert( + fc.property(fc.array(toolEntryArb, { maxLength: 4 }), (tools) => { + const m = buildManifest(tools); + const firstSerialized = m.toJSON(); + const reparsed = Manifest.fromJSON(firstSerialized); + const secondSerialized = reparsed.toJSON(); + expect(secondSerialized).toEqual(firstSerialized); + }), + { numRuns: 100 } + ); + }); + + it("fromJSON on v6 input round-trips cleanly (guard is a no-op at the supported version)", () => { + fc.assert( + fc.property(fc.array(toolEntryArb, { maxLength: 4 }), (tools) => { + const m = buildManifest(tools); + const v6 = m.toJSON(); + const once = Manifest.fromJSON(v6).toJSON(); + const twice = Manifest.fromJSON(once).toJSON(); + expect(twice).toEqual(once); + }), + { numRuns: 100 } + ); + }); +}); diff --git a/cli/tests/contexts/framework/domain/manifest.unit.test.ts b/cli/tests/contexts/framework/domain/manifest.unit.test.ts new file mode 100644 index 000000000..c9bc39c87 --- /dev/null +++ b/cli/tests/contexts/framework/domain/manifest.unit.test.ts @@ -0,0 +1,489 @@ +import { describe, expect, it } from "vitest"; +import { Manifest } from "../../../../src/contexts/framework/domain/manifest.js"; +import type { McpExclusion } from "../../../../src/contexts/tools/domain/mcp-exclusion.js"; +import { FileHash, InstallationFile } from "../../../../src/kernel/file.js"; +import type { MergeFileEntry } from "../../../../src/kernel/merge.js"; +import type { ToolId } from "../../../../src/kernel/tool.js"; + +const makeHash = (hex: string): FileHash => new FileHash(hex.padEnd(32, "0")); + +const makeFile = (path: string, hashHex: string): InstallationFile => + new InstallationFile({ + relativePath: path, + content: "content", + hash: makeHash(hashHex), + }); + +const claudeFiles = [ + makeFile(".claude/agents/code-reviewer.md", "aabbcc"), + makeFile(".claude/rules/naming.md", "ddeeff"), +]; + +describe("Manifest", () => { + describe("addTool()", () => { + it("adds a new tool entry", () => { + const manifest = Manifest.create(); + manifest.addTool("claude" as ToolId, "3.0.0", claudeFiles); + expect(manifest.hasTool("claude" as ToolId)).toBe(true); + }); + + it("replaces an existing tool entry", () => { + const manifest = Manifest.create(); + manifest.addTool("claude" as ToolId, "3.0.0", claudeFiles); + const newFiles = [makeFile(".claude/agents/new-agent.md", "112233")]; + manifest.addTool("claude" as ToolId, "3.1.0", newFiles); + expect(manifest.getToolVersion("claude" as ToolId)).toBe("3.1.0"); + }); + }); + + describe("removeTool()", () => { + it("removes only the specified tool", () => { + const manifest = Manifest.create(); + manifest.addTool("claude" as ToolId, "3.0.0", claudeFiles); + manifest.addTool("cursor" as ToolId, "3.0.0", [ + makeFile(".cursor/rules/naming.md", "445566"), + ]); + manifest.removeTool("claude" as ToolId); + expect(manifest.hasTool("claude" as ToolId)).toBe(false); + expect(manifest.hasTool("cursor" as ToolId)).toBe(true); + }); + + it("aborts when removing a tool that is not installed", () => { + const manifest = Manifest.create(); + expect(() => manifest.removeTool("claude" as ToolId)).toThrow(); + }); + }); + + describe("hasTool()", () => { + it("returns true when tool is installed", () => { + const manifest = Manifest.create(); + manifest.addTool("claude" as ToolId, "3.0.0", claudeFiles); + expect(manifest.hasTool("claude" as ToolId)).toBe(true); + }); + + it("returns false when tool is not installed", () => { + const manifest = Manifest.create(); + expect(manifest.hasTool("claude" as ToolId)).toBe(false); + }); + }); + + describe("getToolVersion()", () => { + it("returns version for installed tool", () => { + const manifest = Manifest.create(); + manifest.addTool("claude" as ToolId, "3.0.0", claudeFiles); + expect(manifest.getToolVersion("claude" as ToolId)).toBe("3.0.0"); + }); + + it("returns undefined for missing tool", () => { + const manifest = Manifest.create(); + expect(manifest.getToolVersion("claude" as ToolId)).toBeUndefined(); + }); + }); + + describe("serialization round-trip", () => { + it("fromJSON() rejects unsupported manifest version", () => { + const manifest = Manifest.create(); + manifest.addTool("claude" as ToolId, "3.0.0", claudeFiles); + const json = manifest.toJSON(); + const badVersion = { ...json, version: 99 }; + expect(() => Manifest.fromJSON(badVersion)).toThrow(/version/); + }); + + it("toJSON() / fromJSON() preserves tool entries", () => { + const manifest = Manifest.create(); + manifest.addTool("claude" as ToolId, "3.0.0", claudeFiles); + manifest.addTool("cursor" as ToolId, "3.0.0", [ + makeFile(".cursor/rules/naming.md", "445566"), + ]); + + const json = manifest.toJSON(); + const restored = Manifest.fromJSON(json); + + expect(restored.hasTool("claude" as ToolId)).toBe(true); + expect(restored.hasTool("cursor" as ToolId)).toBe(true); + expect(restored.getToolVersion("claude" as ToolId)).toBe("3.0.0"); + expect(restored.getToolVersion("cursor" as ToolId)).toBe("3.0.0"); + }); + + it("marketplaces field is absent in a fresh manifest JSON", () => { + const manifest = Manifest.create(); + const json = manifest.toJSON(); + expect("marketplaces" in json).toBe(false); + }); + + it("file hashes are preserved after round-trip", () => { + const manifest = Manifest.create(); + manifest.addTool("claude" as ToolId, "3.0.0", claudeFiles); + + const restored = Manifest.fromJSON(manifest.toJSON()); + const restoredJson = restored.toJSON(); + + expect(restoredJson.tools.claude).toBeDefined(); + expect(restoredJson.tools.claude.files).toHaveLength(2); + expect(restoredJson.tools.claude.files[0].hash).toBe(`aabbcc${"0".repeat(26)}`); + }); + + it("fromJSON() reports an error on invalid data", () => { + expect(() => Manifest.fromJSON(null)).toThrow(); + }); + }); + + describe("isFileTracked()", () => { + it("returns true for a file tracked by a tool", () => { + const manifest = Manifest.create(); + manifest.addTool("claude" as ToolId, "3.0.0", claudeFiles); + expect(manifest.isFileTracked(".claude/agents/code-reviewer.md")).toBe(true); + }); + + it("returns false for a file not in the manifest", () => { + const manifest = Manifest.create(); + manifest.addTool("claude" as ToolId, "3.0.0", claudeFiles); + expect(manifest.isFileTracked("some/unknown/file.md")).toBe(false); + }); + }); + + describe("mergeFiles", () => { + const mergeFiles: MergeFileEntry[] = [ + { + relativePath: ".mcp.json", + sectionKey: "mcpServers", + entries: { + playwright: makeHash("aabb11"), + github: makeHash("ccdd22"), + }, + }, + ]; + + it("addTool stores mergeFiles entries", () => { + const manifest = Manifest.create(); + manifest.addTool("claude" as ToolId, "3.0.0", claudeFiles, mergeFiles); + expect(manifest.getMergeFiles("claude" as ToolId)).toHaveLength(1); + expect(manifest.getMergeFiles("claude" as ToolId)[0].relativePath).toBe(".mcp.json"); + }); + + it("getMergeFiles returns empty array for tool without merge files", () => { + const manifest = Manifest.create(); + manifest.addTool("claude" as ToolId, "3.0.0", claudeFiles); + expect(manifest.getMergeFiles("claude" as ToolId)).toEqual([]); + }); + + it("getMergeFiles returns empty array for missing tool", () => { + const manifest = Manifest.create(); + expect(manifest.getMergeFiles("claude" as ToolId)).toEqual([]); + }); + + it("isFileTracked returns true for merge file paths", () => { + const manifest = Manifest.create(); + manifest.addTool("claude" as ToolId, "3.0.0", claudeFiles, mergeFiles); + expect(manifest.isFileTracked(".mcp.json")).toBe(true); + }); + + it("serialization round-trip preserves mergeFiles", () => { + const manifest = Manifest.create(); + manifest.addTool("claude" as ToolId, "3.0.0", claudeFiles, mergeFiles); + const restored = Manifest.fromJSON(manifest.toJSON()); + const restoredMerge = restored.getMergeFiles("claude" as ToolId); + expect(restoredMerge).toHaveLength(1); + expect(restoredMerge[0].relativePath).toBe(".mcp.json"); + expect(restoredMerge[0].sectionKey).toBe("mcpServers"); + expect(Object.keys(restoredMerge[0].entries)).toEqual(["playwright", "github"]); + expect(restoredMerge[0].entries.playwright.value).toBe(`aabb11${"0".repeat(26)}`); + }); + + it("toJSON produces version 7", () => { + const manifest = Manifest.create(); + manifest.addTool("claude" as ToolId, "3.0.0", claudeFiles); + expect(manifest.toJSON().version).toBe(8); + }); + }); + + describe("MCP exclusion tracking", () => { + const exclusionA: McpExclusion = { configPath: ".mcp.json", entryKey: "playwright" }; + const exclusionB: McpExclusion = { configPath: ".mcp.json", entryKey: "github" }; + + it("addTool with excludedMcp stores exclusions", () => { + const manifest = Manifest.create(); + manifest.addTool("claude" as ToolId, "3.0.0", claudeFiles, [], [exclusionA]); + expect(manifest.getExcludedMcp("claude" as ToolId)).toEqual([exclusionA]); + }); + + it("getExcludedMcp returns empty array for tool without exclusions", () => { + const manifest = Manifest.create(); + manifest.addTool("claude" as ToolId, "3.0.0", claudeFiles); + expect(manifest.getExcludedMcp("claude" as ToolId)).toEqual([]); + }); + + it("addExcludedMcp appends and deduplicates", () => { + const manifest = Manifest.create(); + manifest.addTool("claude" as ToolId, "3.0.0", claudeFiles); + manifest.addExcludedMcp("claude" as ToolId, [exclusionA]); + manifest.addExcludedMcp("claude" as ToolId, [exclusionA, exclusionB]); + const result = manifest.getExcludedMcp("claude" as ToolId); + expect(result).toHaveLength(2); + expect(result).toEqual([exclusionA, exclusionB]); + }); + + it("addExcludedMcp throws for uninstalled tool", () => { + const manifest = Manifest.create(); + expect(() => manifest.addExcludedMcp("claude" as ToolId, [exclusionA])).toThrow( + /not installed/ + ); + }); + + it("removeExcludedMcp removes matching entries", () => { + const manifest = Manifest.create(); + manifest.addTool("claude" as ToolId, "3.0.0", claudeFiles, [], [exclusionA, exclusionB]); + manifest.removeExcludedMcp("claude" as ToolId, [exclusionA]); + expect(manifest.getExcludedMcp("claude" as ToolId)).toEqual([exclusionB]); + }); + + it("removeExcludedMcp throws for uninstalled tool", () => { + const manifest = Manifest.create(); + expect(() => manifest.removeExcludedMcp("claude" as ToolId, [exclusionA])).toThrow( + /not installed/ + ); + }); + + it("clearExcludedMcp empties the list", () => { + const manifest = Manifest.create(); + manifest.addTool("claude" as ToolId, "3.0.0", claudeFiles, [], [exclusionA, exclusionB]); + manifest.clearExcludedMcp("claude" as ToolId); + expect(manifest.getExcludedMcp("claude" as ToolId)).toEqual([]); + }); + + it("clearExcludedMcp throws for uninstalled tool", () => { + const manifest = Manifest.create(); + expect(() => manifest.clearExcludedMcp("claude" as ToolId)).toThrow(/not installed/); + }); + + it("toJSON/fromJSON round-trip preserves excludedMcp", () => { + const manifest = Manifest.create(); + manifest.addTool("claude" as ToolId, "3.0.0", claudeFiles, [], [exclusionA, exclusionB]); + const restored = Manifest.fromJSON(manifest.toJSON()); + expect(restored.getExcludedMcp("claude" as ToolId)).toEqual([exclusionA, exclusionB]); + }); + + it("fromJSON handles missing excludedMcp (backward compat)", () => { + const manifest = Manifest.create(); + manifest.addTool("claude" as ToolId, "3.0.0", claudeFiles); + const json = manifest.toJSON(); + const restored = Manifest.fromJSON(json); + expect(restored.getExcludedMcp("claude" as ToolId)).toEqual([]); + }); + + it("toJSON omits excludedMcp when empty", () => { + const manifest = Manifest.create(); + manifest.addTool("claude" as ToolId, "3.0.0", claudeFiles); + const json = manifest.toJSON(); + expect(json.tools.claude).not.toHaveProperty("excludedMcp"); + }); + + it("updateToolMergeFiles replaces merge files without touching regular files", () => { + const mergeEntry: MergeFileEntry = { + relativePath: ".mcp.json", + sectionKey: "mcpServers", + entries: { playwright: makeHash("aabb") }, + }; + const manifest = Manifest.create(); + manifest.addTool("claude" as ToolId, "3.0.0", claudeFiles, [mergeEntry], [exclusionA]); + const updatedMerge: MergeFileEntry = { + relativePath: ".mcp.json", + sectionKey: "mcpServers", + entries: {}, + }; + manifest.updateToolMergeFiles("claude" as ToolId, [updatedMerge]); + expect(manifest.getMergeFiles("claude" as ToolId)).toEqual([updatedMerge]); + expect(manifest.getToolFiles("claude" as ToolId)).toHaveLength(2); + expect(manifest.getExcludedMcp("claude" as ToolId)).toEqual([exclusionA]); + }); + + it("updateToolMergeFiles throws for uninstalled tool", () => { + const manifest = Manifest.create(); + expect(() => manifest.updateToolMergeFiles("claude" as ToolId, [])).toThrow(/not installed/); + }); + }); + + describe("version guard", () => { + it("v8 manifest loads without error", () => { + const manifest = Manifest.create(); + manifest.addTool("copilot" as ToolId, "1.0.0", []); + const json = manifest.toJSON(); + expect(json.version).toBe(8); + expect(() => Manifest.fromJSON(json)).not.toThrow(); + }); + + it("v8 round-trip is stable", () => { + const manifest = Manifest.create(); + manifest.addTool("claude" as ToolId, "3.0.0", claudeFiles); + const restored = Manifest.fromJSON(manifest.toJSON()); + expect(restored.toJSON().version).toBe(8); + }); + + // No published CLI has ever written v7, and every write path loads through this same guard + // before it can save, so no command migrates it forward: deleting the document is the exit. + const RECOVERY_INVOCATION = /delete \.aidd\/manifest\.json.*aidd setup/; + + it("rejects a version below 8 and names the file to delete, not a command to migrate it", () => { + const v7 = { version: 7, tools: {} }; + expect(() => Manifest.fromJSON(v7)).toThrow(RECOVERY_INVOCATION); + // The string this guard used to send a stuck user toward: a CLI that only ever wrote + // v7 and would refuse the resulting document all over again. + expect(() => Manifest.fromJSON(v7)).not.toThrow(/update --force/); + expect(() => Manifest.fromJSON(v7)).toThrow(/No published CLI can write this version/); + }); + + // 5.2.2 is a published CLI that migrated a v5 document to v6 and re-saved it, so "no + // published CLI can write this version" is false for v6, the one version it must not name. + it("names 5.2.2 for a version 6 manifest, since that published CLI actually wrote it", () => { + const v6 = { version: 6, tools: {} }; + expect(() => Manifest.fromJSON(v6)).toThrow(RECOVERY_INVOCATION); + expect(() => Manifest.fromJSON(v6)).toThrow(/5\.2\.2/); + expect(() => Manifest.fromJSON(v6)).not.toThrow(/No published CLI can write this version/); + }); + + // 5.2.2's own reader accepts exactly version 6, so its `clean --force` can still unregister + // a host's native registrations — only before the manifest naming them is deleted. + it("names `clean --force` on 5.2.2 before naming the deletion, for a version 6 manifest", () => { + const v6 = { version: 6, tools: {} }; + let message = ""; + try { + Manifest.fromJSON(v6); + } catch (error) { + message = error instanceof Error ? error.message : String(error); + } + expect(message).toContain("npx @ai-driven-dev/cli@5.2.2 clean --force"); + const cleanIndex = message.indexOf("clean --force"); + const deleteIndex = message.indexOf("delete .aidd/manifest.json"); + expect(cleanIndex).toBeGreaterThan(-1); + expect(deleteIndex).toBeGreaterThan(cleanIndex); + }); + + // 5.2.2 refuses to read anything past its own native version 6, so naming its `clean` for a + // v7 document would send a stuck user to a command that cannot even open the file. + it("never names 5.2.2's clean for a version 7 manifest, which that CLI cannot read either", () => { + const v7 = { version: 7, tools: {} }; + expect(() => Manifest.fromJSON(v7)).not.toThrow(/5\.2\.2/); + }); + + it("v0 manifest throws, naming the recovery invocation", () => { + const v0 = { version: 0, tools: {} }; + expect(() => Manifest.fromJSON(v0)).toThrow(/version/); + expect(() => Manifest.fromJSON(v0)).toThrow(RECOVERY_INVOCATION); + }); + + it("rejects a version above 8 by pointing at update, not a downgrade", () => { + const v99 = { version: 99, tools: {} }; + expect(() => Manifest.fromJSON(v99)).toThrow(/version/); + expect(() => Manifest.fromJSON(v99)).toThrow(/aidd update/); + expect(() => Manifest.fromJSON(v99)).not.toThrow(RECOVERY_INVOCATION); + }); + }); + + describe("malformed tool entry", () => { + it("throws an instructive, typed error naming the field when files is missing", () => { + const data = { version: 8, tools: { claude: { toolId: "claude", version: "1.0.0" } } }; + expect(() => Manifest.fromJSON(data)).toThrow(/tools\.claude\.files/); + }); + + it("throws an instructive, typed error naming the field when files is the wrong type", () => { + const data = { + version: 8, + tools: { claude: { toolId: "claude", version: "1.0.0", files: "nope" } }, + }; + expect(() => Manifest.fromJSON(data)).toThrow(/tools\.claude\.files/); + }); + + it("throws an instructive, typed error when a tool entry is not an object", () => { + const data = { version: 8, tools: { claude: "nope" } }; + expect(() => Manifest.fromJSON(data)).toThrow(/tools\.claude/); + }); + + // `scope` is mandatory since v7: a default would guess exactly what the field exists to stop + // guessing. Naming the plugin is what lets a person find the entry among several. + it("rejects a v8 plugin entry carrying no scope, naming the plugin", () => { + const data = { + version: 8, + tools: { + claude: { + toolId: "claude", + version: "1.0.0", + files: [], + plugins: [ + { + name: "aidd-context", + source: { kind: "local", path: "/fixture" }, + version: "1.0.0", + strict: false, + files: {}, + }, + ], + }, + }, + }; + expect(() => Manifest.fromJSON(data)).toThrow(/aidd-context/); + }); + + it("rejects a v8 plugin entry whose scope is neither project nor user", () => { + const data = { + version: 8, + tools: { + claude: { + toolId: "claude", + version: "1.0.0", + files: [], + plugins: [ + { + name: "aidd-context", + source: { kind: "local", path: "/fixture" }, + version: "1.0.0", + strict: false, + files: {}, + scope: "global", + }, + ], + }, + }, + }; + expect(() => Manifest.fromJSON(data)).toThrow(/aidd-context/); + }); + }); + + describe("updateTrackedFileHash()", () => { + it("updates the hash when the file is already tracked", () => { + const manifest = Manifest.create(); + manifest.addTool("claude" as ToolId, "3.0.0", claudeFiles); + manifest.updateTrackedFileHash( + "claude" as ToolId, + ".claude/agents/code-reviewer.md", + makeHash("999999") + ); + const tracked = manifest + .getToolFiles("claude" as ToolId) + .find((f) => f.relativePath === ".claude/agents/code-reviewer.md"); + expect(tracked?.hash.value).toBe(makeHash("999999").value); + }); + + it("appends a new tracked file entry when the path is not yet tracked", () => { + const manifest = Manifest.create(); + manifest.addTool("codex" as ToolId, "3.0.0", []); + manifest.updateTrackedFileHash("codex" as ToolId, ".codex/config.json", makeHash("abcdef")); + expect(manifest.isFileTracked(".codex/config.json")).toBe(true); + const tracked = manifest + .getToolFiles("codex" as ToolId) + .find((f) => f.relativePath === ".codex/config.json"); + expect(tracked?.hash.value).toBe(makeHash("abcdef").value); + }); + + it("is a no-op when the tool is not installed", () => { + const manifest = Manifest.create(); + expect(() => + manifest.updateTrackedFileHash( + "claude" as ToolId, + ".claude/settings.json", + makeHash("111111") + ) + ).not.toThrow(); + expect(manifest.hasTool("claude" as ToolId)).toBe(false); + }); + }); +}); diff --git a/cli/tests/contexts/framework/domain/marketplace-source-drift.unit.test.ts b/cli/tests/contexts/framework/domain/marketplace-source-drift.unit.test.ts new file mode 100644 index 000000000..4b1c342fe --- /dev/null +++ b/cli/tests/contexts/framework/domain/marketplace-source-drift.unit.test.ts @@ -0,0 +1,109 @@ +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; +import { marketplaceSourceDrift } from "../../../../src/contexts/framework/domain/marketplace-source-drift.js"; +import { userBuiltMarketplaceDir } from "../../../../src/kernel/paths.js"; + +const DRIFT_CONTEXT = { + userCacheRoot: "/user-cache", + projectRoot: "/project", + marketplaceName: "aidd-framework", + target: "claude", +}; + +function sharedPath(version: string): string { + return userBuiltMarketplaceDir( + DRIFT_CONTEXT.userCacheRoot, + version, + DRIFT_CONTEXT.marketplaceName, + DRIFT_CONTEXT.target + ); +} + +describe("marketplaceSourceDrift — deciding purely from the path's own segments, never a catalog read", () => { + it("is undefined when both sides are the exact same shared-source version", () => { + const path = sharedPath("5.0.0"); + + expect(marketplaceSourceDrift(path, path, DRIFT_CONTEXT)).toBeUndefined(); + }); + + it("is a version-behind drift when the registered version is ahead of what this run requests", () => { + const drift = marketplaceSourceDrift(sharedPath("2.0.0"), sharedPath("1.0.0"), DRIFT_CONTEXT); + + expect(drift).toEqual({ + kind: "version-behind", + registeredVersion: "2.0.0", + requestedVersion: "1.0.0", + }); + }); + + // The comparison respects semver precedence, pre-release included, so a host on the final + // release and a run on its own release candidate are not told "no drift". + it("is a version-behind drift when the registered version is a release and the requested one is that release's own pre-release", () => { + const drift = marketplaceSourceDrift( + sharedPath("5.3.0"), + sharedPath("5.3.0-rc.1"), + DRIFT_CONTEXT + ); + + expect(drift).toEqual({ + kind: "version-behind", + registeredVersion: "5.3.0", + requestedVersion: "5.3.0-rc.1", + }); + }); + + it("is undefined — a legitimate update, not a drift — when the requested version is ahead of the registered one", () => { + expect( + marketplaceSourceDrift(sharedPath("1.0.0"), sharedPath("2.0.0"), DRIFT_CONTEXT) + ).toBeUndefined(); + }); + + it("is an unmigrated-project-source drift when the registered path is this project's own pre-migration cache", () => { + const registered = "/project/.aidd/cache/built/aidd-framework/claude"; + + const drift = marketplaceSourceDrift(registered, sharedPath("1.0.0"), DRIFT_CONTEXT); + + expect(drift).toEqual({ kind: "unmigrated-project-source" }); + }); + + it("is an unmigrated-foreign-project-source drift when the registered path is another project's pre-migration cache", () => { + const registered = join( + "/other-project", + ".aidd", + "cache", + "built", + "aidd-framework", + "claude" + ); + + const drift = marketplaceSourceDrift(registered, sharedPath("1.0.0"), DRIFT_CONTEXT); + + expect(drift).toEqual({ + kind: "unmigrated-foreign-project-source", + projectRoot: join("/other-project"), + }); + }); + + it("is undefined when the requested path is not the shared user-scope shape at all", () => { + expect( + marketplaceSourceDrift(sharedPath("2.0.0"), "/some/other/path", DRIFT_CONTEXT) + ).toBeUndefined(); + }); + + it("is undefined when the registered path is a foreign source, neither shared nor this project's own cache", () => { + expect( + marketplaceSourceDrift("/completely/unrelated/src", sharedPath("1.0.0"), DRIFT_CONTEXT) + ).toBeUndefined(); + }); + + it("is undefined when the registered path names a different marketplace or tool under the shared cache root", () => { + const registered = userBuiltMarketplaceDir( + DRIFT_CONTEXT.userCacheRoot, + "9.0.0", + "other-mkt", + "claude" + ); + + expect(marketplaceSourceDrift(registered, sharedPath("1.0.0"), DRIFT_CONTEXT)).toBeUndefined(); + }); +}); diff --git a/cli/tests/contexts/framework/domain/paths.unit.test.ts b/cli/tests/contexts/framework/domain/paths.unit.test.ts new file mode 100644 index 000000000..398c3c544 --- /dev/null +++ b/cli/tests/contexts/framework/domain/paths.unit.test.ts @@ -0,0 +1,294 @@ +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; +import { + dedupePathSegments, + parseBuiltMarketplaceDir, + parseBuiltMarketplaceDirAtAnyRoot, + parseUserBuiltMarketplaceDir, + pathContainsOrEquals, + pathsOverlap, + posixRelative, + samePathSegment, + userBuiltCacheRoot, + userBuiltMarketplaceDir, + userManifestPath, +} from "../../../../src/kernel/paths.js"; + +// Asked with backslash-spelled paths, which is what a Windows run passes and what a +// hardcoded "/" comparison never recognises. +describe("pathContainsOrEquals()", () => { + it("sees the same directory spelled either way", () => { + expect(pathContainsOrEquals("/a/b", "/a/b")).toBe(true); + expect(pathContainsOrEquals("C:\\a\\b", "C:\\a\\b")).toBe(true); + }); + + it("sees a directory inside another, with either separator", () => { + expect(pathContainsOrEquals("/a", "/a/b/c")).toBe(true); + expect(pathContainsOrEquals("C:\\a", "C:\\a\\b\\c")).toBe(true); + }); + + it("does not mistake a shared name prefix for containment", () => { + expect(pathContainsOrEquals("/a/build", "/a/build-cache")).toBe(false); + expect(pathContainsOrEquals("C:\\a\\build", "C:\\a\\build-cache")).toBe(false); + }); + + it("answers in one direction only", () => { + expect(pathContainsOrEquals("/a/b/c", "/a")).toBe(false); + expect(pathContainsOrEquals("C:\\a\\b\\c", "C:\\a")).toBe(false); + }); + + it("separates unrelated directories", () => { + expect(pathContainsOrEquals("/a", "/b")).toBe(false); + expect(pathContainsOrEquals("C:\\a", "D:\\a")).toBe(false); + }); +}); + +describe("pathsOverlap()", () => { + it("answers in both directions, with either separator", () => { + expect(pathsOverlap("/a", "/a/b")).toBe(true); + expect(pathsOverlap("/a/b", "/a")).toBe(true); + expect(pathsOverlap("C:\\a", "C:\\a\\b")).toBe(true); + expect(pathsOverlap("C:\\a\\b", "C:\\a")).toBe(true); + }); + + it("leaves genuinely separate trees alone", () => { + expect(pathsOverlap("/a", "/b")).toBe(false); + expect(pathsOverlap("C:\\src", "C:\\out")).toBe(false); + }); +}); + +// The shared source is one per CLI version, so a purge of one version is a single `rm -rf`: +// two versions resolving to the same directory would take the other's registrations too. +describe("userBuiltMarketplaceDir()", () => { + it("places the version segment before the marketplace name", () => { + expect(userBuiltMarketplaceDir("/user-cache", "5.0.0", "aidd-framework", "claude")).toBe( + join("/user-cache", "cache", "built", "5.0.0", "aidd-framework", "claude") + ); + }); + + it("produces disjoint directories for two different CLI versions", () => { + const v1 = userBuiltMarketplaceDir("/user-cache", "1.0.0", "aidd-framework", "claude"); + const v2 = userBuiltMarketplaceDir("/user-cache", "2.0.0", "aidd-framework", "claude"); + + expect(v1).not.toBe(v2); + expect(pathContainsOrEquals(v1, v2)).toBe(false); + expect(pathContainsOrEquals(v2, v1)).toBe(false); + }); +}); + +describe("userBuiltCacheRoot()", () => { + it("is the parent every version directory sits under, one join above the version", () => { + const root = userBuiltCacheRoot("/user-cache"); + const versioned = userBuiltMarketplaceDir("/user-cache", "5.0.0", "aidd-framework", "claude"); + + expect(root).toBe(join("/user-cache", "cache", "built")); + expect(pathContainsOrEquals(root, versioned)).toBe(true); + }); +}); + +describe("userManifestPath()", () => { + it("names manifest.json directly under the user config dir, no .aidd nesting", () => { + expect(userManifestPath("/user-cache")).toBe(join("/user-cache", "manifest.json")); + }); +}); + +describe("parseUserBuiltMarketplaceDir()", () => { + it("reads back the version, marketplace name and target userBuiltMarketplaceDir encoded", () => { + const path = userBuiltMarketplaceDir("/user-cache", "5.0.0", "aidd-framework", "claude"); + + expect(parseUserBuiltMarketplaceDir("/user-cache", path)).toEqual({ + version: "5.0.0", + marketplaceName: "aidd-framework", + target: "claude", + }); + }); + + it("is undefined for a path outside the user cache root", () => { + expect( + parseUserBuiltMarketplaceDir("/user-cache", "/elsewhere/5.0.0/aidd-framework/claude") + ).toBeUndefined(); + }); + + it("is undefined for a path missing a segment", () => { + expect( + parseUserBuiltMarketplaceDir("/user-cache", "/user-cache/cache/built/5.0.0") + ).toBeUndefined(); + }); + + it("reads either separator, matching pathContainsOrEquals", () => { + expect( + parseUserBuiltMarketplaceDir( + "C:\\user-cache", + "C:\\user-cache\\cache\\built\\5.0.0\\aidd-framework\\claude" + ) + ).toEqual({ version: "5.0.0", marketplaceName: "aidd-framework", target: "claude" }); + }); + + // A real filesystem tolerates a trailing separator on a directory, so `userConfigDir()` + // returning one is not a corrupted path. + it("tolerates a trailing separator on the user config dir", () => { + const path = userBuiltMarketplaceDir("/user-cache", "5.0.0", "aidd-framework", "claude"); + + expect(parseUserBuiltMarketplaceDir("/user-cache/", path)).toEqual({ + version: "5.0.0", + marketplaceName: "aidd-framework", + target: "claude", + }); + }); + + // On a case-insensitive platform `C:\Users\A` and `c:\users\a` are the same directory; the + // platform is passed explicitly rather than read from `process.platform`. + it("matches a case-differing user config dir on a case-insensitive platform", () => { + expect( + parseUserBuiltMarketplaceDir( + "C:\\Users\\A", + "c:\\users\\a\\cache\\built\\5.0.0\\aidd-framework\\claude", + "win32" + ) + ).toEqual({ version: "5.0.0", marketplaceName: "aidd-framework", target: "claude" }); + }); + + it("does not fold case on a case-sensitive platform", () => { + expect( + parseUserBuiltMarketplaceDir( + "/User-Cache", + "/user-cache/cache/built/5.0.0/aidd-framework/claude", + "linux" + ) + ).toBeUndefined(); + }); +}); + +describe("samePathSegment()", () => { + it("compares case-sensitively on a case-sensitive platform", () => { + expect(samePathSegment("aidd-framework", "AIDD-FRAMEWORK", "linux")).toBe(false); + }); + + it("compares case-insensitively on win32", () => { + expect(samePathSegment("aidd-framework", "AIDD-FRAMEWORK", "win32")).toBe(true); + }); +}); + +describe("dedupePathSegments()", () => { + it("collapses a win32 case-only spelling difference, keeping the first spelling", () => { + expect(dedupePathSegments(["/A/proj", "/a/proj"], "win32")).toEqual(["/A/proj"]); + }); + + it("keeps both spellings on a case-sensitive platform", () => { + expect(dedupePathSegments(["/A/proj", "/a/proj"], "linux")).toEqual(["/A/proj", "/a/proj"]); + }); +}); + +describe("parseBuiltMarketplaceDir()", () => { + it("reads back the marketplace name and target from a project-scope built path", () => { + expect( + parseBuiltMarketplaceDir("/proj", "/proj/.aidd/cache/built/aidd-framework/claude") + ).toEqual({ + marketplaceName: "aidd-framework", + target: "claude", + }); + }); + + it("is undefined for a path outside the project root", () => { + expect( + parseBuiltMarketplaceDir("/proj", "/other/.aidd/cache/built/aidd-framework/claude") + ).toBeUndefined(); + }); +}); + +describe("parseBuiltMarketplaceDirAtAnyRoot()", () => { + it("reads back the project root alongside the name and target, with no root known in advance", () => { + expect( + parseBuiltMarketplaceDirAtAnyRoot( + "/other-project/.aidd/cache/built/aidd-framework/claude", + "linux" + ) + ).toEqual({ + projectRoot: "/other-project", + marketplaceName: "aidd-framework", + target: "claude", + }); + }); + + it("reads back a nested project root", () => { + expect( + parseBuiltMarketplaceDirAtAnyRoot( + "/work/repos/other-project/.aidd/cache/built/aidd-framework/codex", + "linux" + ) + ).toEqual({ + projectRoot: "/work/repos/other-project", + marketplaceName: "aidd-framework", + target: "codex", + }); + }); + + it("is undefined for a path that never carries the built-cache shape at all", () => { + expect(parseBuiltMarketplaceDirAtAnyRoot("/completely/unrelated/src")).toBeUndefined(); + }); + + it("is undefined for a path with nothing before the marker — no project root to report", () => { + expect( + parseBuiltMarketplaceDirAtAnyRoot("/.aidd/cache/built/aidd-framework/claude") + ).toBeUndefined(); + }); + + it("compares the marker's own segments case-insensitively on win32", () => { + expect( + parseBuiltMarketplaceDirAtAnyRoot( + "C:\\other-project\\.AIDD\\CACHE\\BUILT\\aidd-framework\\claude", + "win32" + ) + ).toEqual({ + projectRoot: "C:\\other-project", + marketplaceName: "aidd-framework", + target: "claude", + }); + }); + + // Every other writer of `references.json` records a `realpath` result, backslash-separated + // on win32, and `samePathSegment` folds case but never separators. + it("keeps the platform's own separator in the returned project root on win32", () => { + expect( + parseBuiltMarketplaceDirAtAnyRoot( + "C:\\proj\\.aidd\\cache\\built\\aidd-framework\\claude", + "win32" + ) + ).toEqual({ + projectRoot: "C:\\proj", + marketplaceName: "aidd-framework", + target: "claude", + }); + }); + + it("reads back a nested win32 project root with backslashes throughout", () => { + expect( + parseBuiltMarketplaceDirAtAnyRoot( + "C:\\work\\repos\\other-project\\.aidd\\cache\\built\\aidd-framework\\codex", + "win32" + ) + ).toEqual({ + projectRoot: "C:\\work\\repos\\other-project", + marketplaceName: "aidd-framework", + target: "codex", + }); + }); +}); + +describe("posixRelative()", () => { + it("spells a Windows-joined path with forward slashes, the form a manifest records", () => { + expect(posixRelative("C:\\proj\\plugin", "C:\\proj\\plugin\\hooks\\check.sh", "win32")).toBe( + "hooks/check.sh" + ); + }); + + it("leaves a POSIX path as it is", () => { + expect(posixRelative("/proj/plugin", "/proj/plugin/skills/commit/SKILL.md", "linux")).toBe( + "skills/commit/SKILL.md" + ); + }); + + it("answers an empty string for the base itself", () => { + expect(posixRelative("C:\\proj", "C:\\proj", "win32")).toBe(""); + }); +}); diff --git a/cli/tests/contexts/framework/domain/plugin-asset-translation.unit.test.ts b/cli/tests/contexts/framework/domain/plugin-asset-translation.unit.test.ts new file mode 100644 index 000000000..e889af5c2 --- /dev/null +++ b/cli/tests/contexts/framework/domain/plugin-asset-translation.unit.test.ts @@ -0,0 +1,155 @@ +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; +import { REPOSITORY_ROOT } from "../../../helpers/repository-root.js"; +import "../../../../src/contexts/tools/domain/profiles/claude/profile.js"; +import "../../../../src/contexts/tools/domain/profiles/codex/profile.js"; +import "../../../../src/contexts/tools/domain/profiles/copilot/profile.js"; +import "../../../../src/contexts/tools/domain/profiles/cursor/profile.js"; +import "../../../../src/contexts/tools/domain/profiles/opencode/profile.js"; +import { claude } from "../../../../src/contexts/tools/domain/profiles/claude/profile.js"; +import { codex } from "../../../../src/contexts/tools/domain/profiles/codex/profile.js"; +import { copilot } from "../../../../src/contexts/tools/domain/profiles/copilot/profile.js"; +import { cursor } from "../../../../src/contexts/tools/domain/profiles/cursor/profile.js"; +import { opencode } from "../../../../src/contexts/tools/domain/profiles/opencode/profile.js"; +import { getAiToolConfig } from "../../../../src/contexts/tools/domain/registry.js"; +import { PluginContentTranslator } from "../../../../src/contexts/translate/domain/content-translator.js"; +import { PluginDistribution } from "../../../../src/contexts/translate/domain/plugin-distribution.js"; +import { FileHash } from "../../../../src/kernel/file.js"; +import { AI_TOOL_IDS } from "../../../../src/kernel/tool.js"; + +/** + * Prose — a skill, an agent, a rule — is translated: frontmatter converted, paths rewritten. + * An artefact is carried byte for byte: a path rewritten inside a program no longer parses. + */ +function pluginFile(relativePath: string): string { + return readFileSync(join(REPOSITORY_ROOT, "plugins", "aidd-telemetry", relativePath), "utf8"); +} + +const ARTEFACTS = [ + "hooks/journal.cjs", + "hooks/journal.cjs", + "hooks/lib/record.cjs", + "hooks/lib/repo.cjs", + "hooks/lib/file-writes.cjs", + "hooks/lib/step-starts.cjs", + "hooks/lib/host.cjs", +] as const; + +describe("a plugin's executable files survive being installed", () => { + for (const relativePath of ARTEFACTS) { + it(`${relativePath} is not what any tool's own rewrite would make of it`, () => { + const content = pluginFile(relativePath); + const rewritten = AI_TOOL_IDS.map((tool) => getAiToolConfig(tool).rewriteContent(content)); + + // The rewrite is the thing the translator must not apply to this file. Where a tool's + // rewrite happens to leave it alone, that is luck; where it does not, this names it. + const damagedBy = AI_TOOL_IDS.filter((_, index) => rewritten[index] !== content); + expect( + damagedBy.length === 0 || relativePath.endsWith(".js"), + `${relativePath} is rewritten by ${damagedBy.join(", ")} and is not carried verbatim` + ).toBe(true); + }); + } +}); + +/** The decisive check: not "would a rewrite damage it", but "does installing the plugin + * actually put it there, unchanged". The path is the fixture; the content is real bytes. */ +describe("installing the plugin carries a skill's own script, on every tool", () => { + const SCRIPT = "skills/02-check/scripts/example.cjs"; + const SCRIPT_CONTENT = pluginFile("hooks/journal.cjs"); + const translator = new PluginContentTranslator({ hash: () => new FileHash("a".repeat(32)) }); + + function distributionOf(): PluginDistribution { + const skills = [ + { relativePath: "skills/02-check/SKILL.md", content: pluginFile("skills/02-check/SKILL.md") }, + { relativePath: SCRIPT, content: SCRIPT_CONTENT }, + ]; + const hooks = [ + { relativePath: "hooks/hooks.json", content: pluginFile("hooks/hooks.json") }, + { relativePath: "hooks/journal.cjs", content: pluginFile("hooks/journal.cjs") }, + ]; + return new PluginDistribution({ + manifest: { name: "aidd-telemetry", version: "0.1.0" }, + format: "claude", + files: [...skills, ...hooks], + components: { skills, commands: [], agents: [], rules: [], hooks, mcp: [] }, + }); + } + + for (const tool of [claude, codex, copilot, cursor, opencode]) { + it(`${tool.toolId} installs it byte for byte`, () => { + const installed = translator + .translate(distributionOf(), tool) + .find((file) => file.relativePath.endsWith("02-check/scripts/example.cjs")); + + expect(installed, `${tool.toolId} drops the script entirely`).toBeDefined(); + expect(installed?.content).toBe(SCRIPT_CONTENT); + }); + } + + it("still translates the prose beside it", () => { + const installed = translator + .translate(distributionOf(), claude) + .find((file) => file.relativePath.endsWith("02-check/SKILL.md")); + + // Carrying artefacts verbatim must not turn every skill into an artefact: this one + // still goes through the frontmatter conversion, so it is not byte-identical. + expect(installed?.content).not.toBe(pluginFile("skills/02-check/SKILL.md")); + expect(installed?.content).toContain("States what is in place"); + }); + + /** A script whose text that tool's own rewrite really does change. Each tool rewrites its + * own directory's paths, so a single shared sample would let three tools pass by luck. */ + function rewritableScript(directory: string): string { + return `const p = "${directory}commands/01_plan/x";\nconst q = "@${directory}commands/02_do/y";\n`; + } + + function distributionWithScript(content: string): PluginDistribution { + const skills = [ + { relativePath: "skills/02-check/SKILL.md", content: pluginFile("skills/02-check/SKILL.md") }, + { relativePath: SCRIPT, content }, + ]; + return new PluginDistribution({ + manifest: { name: "aidd-telemetry", version: "0.1.0" }, + format: "claude", + files: skills, + components: { skills, commands: [], agents: [], rules: [], hooks: [], mcp: [] }, + }); + } + + for (const tool of [claude, codex, copilot, cursor, opencode]) { + it(`${tool.toolId} leaves a script's own paths alone`, () => { + // Paths this tool's own rewrite is built to touch, in a file that is not prose. That the + // guard is not vacuous is asserted once, below, over every tool at once. + const script = rewritableScript(tool.directory); + + const installed = translator + .translate(distributionWithScript(script), tool) + .find((file) => file.relativePath.endsWith("02-check/scripts/example.cjs")); + + expect(installed?.content).toBe(script); + }); + } + + it("carries it verbatim on a flat install too, not just a native one", () => { + // OpenCode installs flat: skills keep their sub-path but every file used to be rewritten + // on the way. The script survives there only because that rewrite leaves it alone. + const installed = translator + .translate(distributionOf(), opencode) + .find((file) => file.relativePath.endsWith("02-check/scripts/example.cjs")); + + expect(installed, "opencode drops the script entirely").toBeDefined(); + expect(installed?.content).toBe(SCRIPT_CONTENT); + }); + it("guards against a rewrite that some tool really would apply", () => { + // Without this, every assertion above could pass over content no rewrite touches, and + // the guard would be protecting nothing while looking thorough. + const rewritten = [claude, codex, copilot, cursor, opencode].filter((tool) => { + const script = rewritableScript(tool.directory); + return tool.rewriteContent(script) !== script; + }); + + expect(rewritten.length).toBeGreaterThan(0); + }); +}); diff --git a/cli/tests/contexts/framework/domain/plugin-content-translator-notice.unit.test.ts b/cli/tests/contexts/framework/domain/plugin-content-translator-notice.unit.test.ts new file mode 100644 index 000000000..a87af8dad --- /dev/null +++ b/cli/tests/contexts/framework/domain/plugin-content-translator-notice.unit.test.ts @@ -0,0 +1,63 @@ +import { describe, expect, it } from "vitest"; +import { codex } from "../../../../src/contexts/tools/domain/profiles/codex/profile.js"; +import { cursor } from "../../../../src/contexts/tools/domain/profiles/cursor/profile.js"; +import { opencode } from "../../../../src/contexts/tools/domain/profiles/opencode/profile.js"; +import { PluginContentTranslator } from "../../../../src/contexts/translate/domain/content-translator.js"; +import { PluginDistribution } from "../../../../src/contexts/translate/domain/plugin-distribution.js"; +import { FileHash } from "../../../../src/kernel/file.js"; + +const stubHasher = { hash: (_content: string) => new FileHash("a".repeat(32)) }; +const translator = new PluginContentTranslator(stubHasher); + +const HOOKS_CONTENT = JSON.stringify({ + hooks: { SessionStart: [{ hooks: [{ type: "command", command: "node ./hooks/start.js" }] }] }, +}); + +function buildDist(hasHooks: boolean, name = "test-plugin"): PluginDistribution { + const hooksFile = { relativePath: "hooks/hooks.json", content: HOOKS_CONTENT }; + return new PluginDistribution({ + manifest: { name, version: "1.0.0" }, + format: "claude", + files: hasHooks ? [hooksFile] : [], + components: { + commands: [], + agents: [], + rules: [], + skills: [], + hooks: hasHooks ? [hooksFile] : [], + mcp: [], + }, + }); +} + +describe("PluginContentTranslator hook trust notice", () => { + it("names what Codex still requires when the plugin actually delivers a hook", () => { + const result = translator.translateWithComponentPaths(buildDist(true), codex); + + expect(result.notices).toHaveLength(1); + expect(result.notices[0]).toMatchObject({ + pluginName: "test-plugin", + component: "hooks", + toolId: "codex", + message: codex.capabilities.plugins.hooksTrustNotice, + }); + }); + + it("says nothing when the plugin delivers no hook, even for a gated tool", () => { + const result = translator.translateWithComponentPaths(buildDist(false), codex); + + expect(result.notices).toEqual([]); + }); + + it("says nothing for a tool that runs a delivered hook with no trust gate", () => { + const result = translator.translateWithComponentPaths(buildDist(true), cursor); + + expect(result.notices).toEqual([]); + }); + + it("says nothing in flat mode, where a delivered hook is never native-materialized", () => { + const result = translator.translateWithComponentPaths(buildDist(true), opencode); + + expect(result.notices).toEqual([]); + }); +}); diff --git a/cli/tests/contexts/framework/domain/plugin-hooks-install.unit.test.ts b/cli/tests/contexts/framework/domain/plugin-hooks-install.unit.test.ts new file mode 100644 index 000000000..14641589a --- /dev/null +++ b/cli/tests/contexts/framework/domain/plugin-hooks-install.unit.test.ts @@ -0,0 +1,128 @@ +import { describe, expect, it } from "vitest"; +import type { AiTool, HasPlugins } from "../../../../src/contexts/tools/domain/contracts.js"; +import { claude } from "../../../../src/contexts/tools/domain/profiles/claude/profile.js"; +import { codex } from "../../../../src/contexts/tools/domain/profiles/codex/profile.js"; +import { copilot } from "../../../../src/contexts/tools/domain/profiles/copilot/profile.js"; +import { cursor } from "../../../../src/contexts/tools/domain/profiles/cursor/profile.js"; +import { opencode } from "../../../../src/contexts/tools/domain/profiles/opencode/profile.js"; +import { PluginContentTranslator } from "../../../../src/contexts/translate/domain/content-translator.js"; +import { PluginDistribution } from "../../../../src/contexts/translate/domain/plugin-distribution.js"; +import { FileHash } from "../../../../src/kernel/file.js"; + +/** A hook that arrives is not a hook that runs: every failure this covers installed cleanly + * and did nothing. */ + +const stubHasher = { hash: (_content: string) => new FileHash("a".repeat(32)) }; +const translator = new PluginContentTranslator(stubHasher); + +const SOURCE_TOKEN = claude.capabilities.plugins.pluginRootToken ?? ""; +const HOOK_COMMAND = `node ${SOURCE_TOKEN}/hooks/journal.cjs session-start`; +const SCRIPT = `#!/usr/bin/env node\n// carries ${SOURCE_TOKEN} in a comment\n`; +const HOOKS_JSON = JSON.stringify({ + hooks: { SessionStart: [{ hooks: [{ type: "command", command: HOOK_COMMAND }] }] }, +}); + +const HOOK_HOSTS: ReadonlyArray> = [claude, cursor, copilot, codex]; + +const MCP_JSON = JSON.stringify({ + mcpServers: { local: { command: `${SOURCE_TOKEN}/bin/server.js`, args: [] } }, +}); + +function pluginWithHookAndScript(): PluginDistribution { + const hooks = [ + { relativePath: "hooks/hooks.json", content: HOOKS_JSON }, + { relativePath: "hooks/journal.cjs", content: SCRIPT }, + ]; + const mcp = [{ relativePath: ".mcp.json", content: MCP_JSON }]; + return new PluginDistribution({ + manifest: { name: "aidd-telemetry", version: "1.0.0" }, + format: "claude", + files: [...hooks, ...mcp], + components: { commands: [], agents: [], rules: [], skills: [], hooks, mcp }, + }); +} + +function installedFor(tool: AiTool) { + return translator.translateWithComponentPaths(pluginWithHookAndScript(), tool); +} + +function contentEndingWith( + result: ReturnType, + suffix: string +): string | undefined { + return result.files.find((file) => file.relativePath.endsWith(suffix))?.content; +} + +describe("installing a plugin that ships hooks", () => { + it("delivers them to every tool that runs hooks", () => { + for (const tool of HOOK_HOSTS) { + expect(installedFor(tool).files, tool.toolId).not.toHaveLength(0); + } + }); + + it("writes a command naming the variable that tool expands, never another tool's", () => { + for (const tool of HOOK_HOSTS) { + if (tool.capabilities.plugins.hooksContentFormat !== "matchers") continue; + const manifest = contentEndingWith(installedFor(tool), "hooks.json") ?? ""; + + expect(manifest, tool.toolId).toContain(tool.capabilities.plugins.pluginRootToken); + if (tool.capabilities.plugins.pluginRootToken === SOURCE_TOKEN) continue; + expect(manifest, tool.toolId).not.toContain(SOURCE_TOKEN); + } + }); + + it("resolves the root itself for a tool whose whole hook format is rewritten", () => { + // Cursor's converter turns the root into a path relative to the plugin, a third answer + // to the same question, and its hooks are the one set that could not be observed running. + const manifest = contentEndingWith(installedFor(cursor), "hooks.json") ?? ""; + + expect(manifest).toContain('"command": "node ./hooks/journal.cjs session-start"'); + expect(manifest).not.toContain(SOURCE_TOKEN); + expect(cursor.capabilities.plugins.pluginRootToken).not.toBe("./"); + }); + + it("leaves a script beside the hook byte for byte, its plugin root untouched", () => { + // Measured: rewriting a script's content changed it by six bytes on one tool and lost + // one on another. A script is carried, never translated. + for (const tool of HOOK_HOSTS) { + expect(contentEndingWith(installedFor(tool), "journal.cjs"), tool.toolId).toBe(SCRIPT); + } + }); + + it("points an mcp server at the plugin root the target tool expands", () => { + // The one place the substitution changes an installed byte: Cursor's own converter + // rewrites a hook manifest wholesale, every other tool expands the source's spelling. + for (const tool of HOOK_HOSTS) { + const served = contentEndingWith( + installedFor(tool), + tool.capabilities.plugins.mcpRelativePath + ); + + // A tool that delivered no mcp file would pass the assertion below by never + // reaching it, which is the failure shape this whole file exists to catch. + expect(served, `${tool.toolId} installed no mcp file to check`).toBeDefined(); + expect(served, tool.toolId).toContain(tool.capabilities.plugins.pluginRootToken); + } + }); + + it("delivers OpenCode's script under flatHooksDir instead of skipping it (Phase 7)", () => { + const result = installedFor(opencode); + + expect(result.skipped).toEqual([]); + const flatHooksDir = opencode.capabilities.plugins.flatHooksDir ?? ""; + expect(contentEndingWith(result, "journal.cjs")).toBe(SCRIPT); + expect(result.files.some((file) => file.relativePath === `${flatHooksDir}hooks.json`)).toBe( + false + ); + }); +}); + +describe("what a tool says about the hooks it runs", () => { + it("never leaves its answer to a default", () => { + for (const tool of [...HOOK_HOSTS, opencode]) { + const { acceptsHooks, hooksUnsupportedReason } = tool.capabilities.plugins; + + expect(acceptsHooks === (hooksUnsupportedReason === null), tool.toolId).toBe(true); + } + }); +}); diff --git a/cli/tests/contexts/framework/domain/plugins/installed-plugin.unit.test.ts b/cli/tests/contexts/framework/domain/plugins/installed-plugin.unit.test.ts new file mode 100644 index 000000000..8e87fc4ce --- /dev/null +++ b/cli/tests/contexts/framework/domain/plugins/installed-plugin.unit.test.ts @@ -0,0 +1,176 @@ +import { describe, expect, it } from "vitest"; +import { + type ComponentPathMap, + InstalledPlugin, + type McpDigestMap, + type PluginEntryData, +} from "../../../../../src/contexts/framework/domain/plugins/installed-plugin.js"; +import { + InvalidPluginNameError, + InvalidPluginVersionError, + MalformedPluginScopeError, +} from "../../../../../src/kernel/errors.js"; + +const makePluginData = (overrides: Partial = {}): PluginEntryData => ({ + name: "my-plugin", + source: { kind: "github", repo: "owner/my-plugin" }, + version: "1.0.0", + strict: false, + files: { ".claude/plugins/my-plugin/CLAUDE.md": "abc123" }, + scope: "project", + ...overrides, +}); + +describe("InstalledPlugin", () => { + describe("fromJSON()", () => { + it("creates a plugin from valid data", () => { + const plugin = InstalledPlugin.fromJSON(makePluginData()); + expect(plugin.name).toBe("my-plugin"); + expect(plugin.version).toBe("1.0.0"); + expect(plugin.strict).toBe(false); + }); + + it("throws InvalidPluginNameError when name is invalid", () => { + expect(() => InstalledPlugin.fromJSON(makePluginData({ name: "My Plugin!" }))).toThrow( + InvalidPluginNameError + ); + }); + + it("throws InvalidPluginNameError for names with uppercase letters", () => { + expect(() => InstalledPlugin.fromJSON(makePluginData({ name: "MyPlugin" }))).toThrow( + InvalidPluginNameError + ); + }); + + it("throws InvalidPluginNameError for names with leading hyphens", () => { + expect(() => InstalledPlugin.fromJSON(makePluginData({ name: "-plugin" }))).toThrow( + InvalidPluginNameError + ); + }); + + it("throws InvalidPluginVersionError when version is not semver", () => { + expect(() => InstalledPlugin.fromJSON(makePluginData({ version: "not-a-version" }))).toThrow( + InvalidPluginVersionError + ); + }); + + it("throws MalformedPluginScopeError, naming the plugin, when scope is missing", () => { + const data = makePluginData(); + // Proving the runtime guard a missing field trips, which a type-level `Omit` + // cannot construct as invalid data. + delete (data as { scope?: unknown }).scope; + expect(() => InstalledPlugin.fromJSON(data)).toThrow(MalformedPluginScopeError); + expect(() => InstalledPlugin.fromJSON(data)).toThrow(/my-plugin/); + }); + + it("throws MalformedPluginScopeError when scope is neither project nor user", () => { + // A value the type forbids, written the way a hand-edited file would carry it. + const data = makePluginData(); + (data as { scope: string }).scope = "global"; + expect(() => InstalledPlugin.fromJSON(data)).toThrow(MalformedPluginScopeError); + }); + + it("accepts scope: user", () => { + const plugin = InstalledPlugin.fromJSON(makePluginData({ scope: "user" })); + expect(plugin.scope).toBe("user"); + }); + + it("accepts single-segment names", () => { + const plugin = InstalledPlugin.fromJSON(makePluginData({ name: "plugin" })); + expect(plugin.name).toBe("plugin"); + }); + + it("accepts multi-segment names", () => { + const plugin = InstalledPlugin.fromJSON(makePluginData({ name: "my-cool-plugin" })); + expect(plugin.name).toBe("my-cool-plugin"); + }); + + it("parses files into a ReadonlyMap", () => { + const plugin = InstalledPlugin.fromJSON(makePluginData()); + expect(plugin.files.get(".claude/plugins/my-plugin/CLAUDE.md")).toBe("abc123"); + }); + }); + + describe("toJSON()", () => { + it("round-trips via fromJSON/toJSON", () => { + const data = makePluginData(); + const plugin = InstalledPlugin.fromJSON(data); + expect(plugin.toJSON()).toEqual(data); + }); + + it("round-trips scope, project and user alike", () => { + expect(InstalledPlugin.fromJSON(makePluginData({ scope: "project" })).toJSON().scope).toBe( + "project" + ); + expect(InstalledPlugin.fromJSON(makePluginData({ scope: "user" })).toJSON().scope).toBe( + "user" + ); + }); + }); + + describe("isFileTracked()", () => { + it("returns true for a tracked file path", () => { + const plugin = InstalledPlugin.fromJSON(makePluginData()); + expect(plugin.isFileTracked(".claude/plugins/my-plugin/CLAUDE.md")).toBe(true); + }); + + it("returns false for an untracked file path", () => { + const plugin = InstalledPlugin.fromJSON(makePluginData()); + expect(plugin.isFileTracked(".claude/agents/alexia.md")).toBe(false); + }); + }); + + describe("withVersion()", () => { + it("returns a new plugin with the updated version", () => { + const plugin = InstalledPlugin.fromJSON(makePluginData()); + const updated = plugin.withVersion("2.0.0"); + expect(updated.version).toBe("2.0.0"); + expect(plugin.version).toBe("1.0.0"); + }); + + it("preserves all other fields", () => { + const plugin = InstalledPlugin.fromJSON(makePluginData()); + const updated = plugin.withVersion("2.0.0"); + expect(updated.name).toBe(plugin.name); + expect(updated.strict).toBe(plugin.strict); + expect(updated.files).toBe(plugin.files); + expect(updated.scope).toBe(plugin.scope); + }); + }); + + describe("withFiles()", () => { + it("returns a new plugin with updated files", () => { + const plugin = InstalledPlugin.fromJSON(makePluginData()); + const newFiles = new Map([["new/path.md", "hash-value"]]); + const updated = plugin.withFiles(newFiles); + expect(updated.files.get("new/path.md")).toBe("hash-value"); + expect(plugin.files.has("new/path.md")).toBe(false); + }); + + it("preserves all other fields", () => { + const plugin = InstalledPlugin.fromJSON(makePluginData()); + const updated = plugin.withFiles(new Map()); + expect(updated.name).toBe(plugin.name); + expect(updated.version).toBe(plugin.version); + expect(updated.scope).toBe(plugin.scope); + }); + }); + + describe("the three maps cannot be swapped", () => { + it("fails to compile when one map's field is passed where another is expected", () => { + const plugin = InstalledPlugin.fromJSON(makePluginData()); + + function acceptsComponentPaths(_m: ComponentPathMap): void {} + // @ts-expect-error files is a PathHashMap, not a ComponentPathMap — same runtime + // shape (ReadonlyMap), different brand. + acceptsComponentPaths(plugin.files); + + function acceptsMcpEntries(_m: McpDigestMap): void {} + // @ts-expect-error componentPaths is a ComponentPathMap, not a McpDigestMap. + acceptsMcpEntries(plugin.componentPaths); + + // The types are branded, but the underlying maps are still plain ReadonlyMaps at runtime. + expect(plugin.files).toBeInstanceOf(Map); + }); + }); +}); diff --git a/cli/tests/domain/models/plugin-source-resolver.unit.test.ts b/cli/tests/contexts/framework/domain/plugins/plugin-source-resolver.unit.test.ts similarity index 85% rename from cli/tests/domain/models/plugin-source-resolver.unit.test.ts rename to cli/tests/contexts/framework/domain/plugins/plugin-source-resolver.unit.test.ts index cd5d5a7d4..2061a28a5 100644 --- a/cli/tests/domain/models/plugin-source-resolver.unit.test.ts +++ b/cli/tests/contexts/framework/domain/plugins/plugin-source-resolver.unit.test.ts @@ -1,8 +1,18 @@ -import { join } from "node:path"; -import { describe, expect, it } from "vitest"; -import { Marketplace } from "../../../src/domain/models/marketplace.js"; -import type { PluginSource } from "../../../src/domain/models/plugin-source.js"; -import { resolvePluginSourceFromMarketplace } from "../../../src/domain/models/plugin-source-resolver.js"; +import { describe, expect, it, vi } from "vitest"; + +// Simulates node:path's relative() as it behaves on Windows: real POSIX-computed segments, +// but "\"-joined. The suite runs on POSIX, the only way to prove the output is "/"-joined. +vi.mock("node:path", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + relative: (from: string, to: string) => actual.relative(from, to).split(actual.sep).join("\\"), + }; +}); + +import { Marketplace } from "../../../../../src/contexts/distribution/domain/marketplace.js"; +import { resolvePluginSourceFromMarketplace } from "../../../../../src/contexts/framework/domain/plugins/plugin-source-resolver.js"; +import type { PluginSource } from "../../../../../src/kernel/source.js"; const MARKETPLACE_LOCAL_PATH = "/home/user/.aidd/cache/marketplaces/aidd-framework"; @@ -117,9 +127,9 @@ describe("resolvePluginSourceFromMarketplace", () => { expect(result).toEqual({ kind: "git-subdir", url: "https://github.com/ai-driven-dev/framework.git", - // relative() (used by the resolver for a pre-resolved absolute path) returns the - // platform's native separator, unlike the raw string handling for a ./-relative path. - path: join("plugins", "aidd-context"), + // The resolver must always hand git sparse-checkout a "/"-separated path, even where + // node:path's relative() would have returned "\"-joined segments on Windows. + path: "plugins/aidd-context", ref: "v4.1.0-beta.14", }); }); diff --git a/cli/tests/contexts/framework/domain/plugins/user-scope-containment.unit.test.ts b/cli/tests/contexts/framework/domain/plugins/user-scope-containment.unit.test.ts new file mode 100644 index 000000000..3a566c9e3 --- /dev/null +++ b/cli/tests/contexts/framework/domain/plugins/user-scope-containment.unit.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from "vitest"; +import { isStrictlyWithinUserScope } from "../../../../../src/contexts/framework/domain/plugins/user-scope-containment.js"; + +const BOUNDARY = "/home/dev/.cursor/plugins/local"; + +describe("isStrictlyWithinUserScope", () => { + it("accepts a plugin's own subdirectory inside the boundary", () => { + expect(isStrictlyWithinUserScope(`${BOUNDARY}/aidd-context/skills/hello.md`, BOUNDARY)).toBe( + true + ); + }); + + it("rejects a manifest entry whose `..` segments resolve outside the boundary", () => { + // What a real `realpath` returns for `${BOUNDARY}/aidd-context/../../../.ssh/id_rsa` — + // this function trusts its caller already resolved the path, it only compares. + const escapedViaDotDot = "/home/dev/.ssh/id_rsa"; + expect(isStrictlyWithinUserScope(escapedViaDotDot, BOUNDARY)).toBe(false); + }); + + it("rejects a symlinked plugin directory whose real location is outside the boundary", () => { + // What `realpath` returns when `${BOUNDARY}/aidd-context` is itself a symlink to + // somewhere else on disk — no `..` anywhere in the manifest's own path this time. + const escapedViaSymlink = "/tmp/evil/payload"; + expect(isStrictlyWithinUserScope(escapedViaSymlink, BOUNDARY)).toBe(false); + }); + + it("rejects the boundary directory itself, never treating it as its own plugin", () => { + expect(isStrictlyWithinUserScope(BOUNDARY, BOUNDARY)).toBe(false); + }); + + it("rejects a path that merely starts with the boundary's characters without a separator", () => { + // Textually starts with BOUNDARY but is a sibling directory, not something inside it — + // a naive `startsWith` would wrongly accept this. + expect(isStrictlyWithinUserScope(`${BOUNDARY}-evil/payload`, BOUNDARY)).toBe(false); + }); +}); diff --git a/cli/tests/contexts/framework/domain/setup-flow.unit.test.ts b/cli/tests/contexts/framework/domain/setup-flow.unit.test.ts new file mode 100644 index 000000000..c371d47db --- /dev/null +++ b/cli/tests/contexts/framework/domain/setup-flow.unit.test.ts @@ -0,0 +1,94 @@ +import { describe, expect, it } from "vitest"; +import { SetupFlow } from "../../../../src/contexts/framework/domain/setup-flow.js"; +import "../../../../src/contexts/tools/domain/profiles/claude/profile.js"; +import "../../../../src/contexts/tools/domain/profiles/codex/profile.js"; +import "../../../../src/contexts/tools/domain/profiles/copilot/profile.js"; +import "../../../../src/contexts/tools/domain/profiles/cursor/profile.js"; +import "../../../../src/contexts/tools/domain/profiles/opencode/profile.js"; +import "../../../../src/contexts/tools/domain/profiles/vscode/profile.js"; +import { + InvalidPluginModeConfigError, + InvalidSetupToolIdError, + UserScopeIdeToolsError, + UserScopeNoToolsError, + UserScopePluginModeError, + UserScopeUnsupportedAiToolsError, +} from "../../../../src/kernel/errors.js"; + +const ROOT = "/project"; + +function makeFlow(overrides: Partial[0]> = {}): SetupFlow { + return new SetupFlow({ projectRoot: ROOT, ...overrides }); +} + +describe("SetupFlow", () => { + describe("constructor validation", () => { + it("throws InvalidSetupToolIdError for unknown AI tool IDs", () => { + expect(() => makeFlow({ aiTools: ["unknown-tool" as "claude"] })).toThrow( + InvalidSetupToolIdError + ); + }); + + it("throws InvalidPluginModeConfigError when mode is 'named' with no names", () => { + expect(() => makeFlow({ pluginMode: "named", pluginNames: [] })).toThrow( + InvalidPluginModeConfigError + ); + }); + + it("throws InvalidPluginModeConfigError when names provided but mode is not 'named'", () => { + expect(() => makeFlow({ pluginMode: "all", pluginNames: ["my-plugin"] })).toThrow( + InvalidPluginModeConfigError + ); + }); + + it("constructs successfully with valid params", () => { + const flow = makeFlow({ aiTools: ["claude"], pluginMode: "none" }); + expect(flow.projectRoot).toBe(ROOT); + expect(flow.aiTools).toEqual(["claude"]); + }); + }); + + describe("scope", () => { + it("defaults to project", () => { + expect(makeFlow().scope).toBe("project"); + }); + + it("carries user through when asked", () => { + expect(makeFlow({ scope: "user", aiTools: ["claude"] }).scope).toBe("user"); + }); + + it("refuses an IDE tool at user scope — an IDE tool has no user scope to install at", () => { + expect(() => makeFlow({ scope: "user", ideTools: ["vscode"] })).toThrow( + UserScopeIdeToolsError + ); + }); + + it("accepts an IDE tool at project scope, unaffected", () => { + expect(() => makeFlow({ scope: "project", ideTools: ["vscode"] })).not.toThrow(); + }); + + it("refuses --scope user with no --ai — nothing would be registered for any tool", () => { + expect(() => makeFlow({ scope: "user", aiTools: [] })).toThrow(UserScopeNoToolsError); + }); + + it("refuses an AI tool with no user-scope activation at --scope user (opencode)", () => { + expect(() => makeFlow({ scope: "user", aiTools: ["opencode"] })).toThrow( + UserScopeUnsupportedAiToolsError + ); + }); + + it("accepts an AI tool that installs to a user-scope directory at --scope user (cursor)", () => { + expect(() => makeFlow({ scope: "user", aiTools: ["cursor"] })).not.toThrow(); + }); + + it("accepts a tool driving native activation at --scope user (claude)", () => { + expect(() => makeFlow({ scope: "user", aiTools: ["claude"] })).not.toThrow(); + }); + + it("refuses --plugins at --scope user — no manifest entry exists yet to enable one against", () => { + expect(() => makeFlow({ scope: "user", aiTools: ["claude"], pluginMode: "all" })).toThrow( + UserScopePluginModeError + ); + }); + }); +}); diff --git a/cli/tests/contexts/framework/infrastructure/manifest-repository-adapter.integration.test.ts b/cli/tests/contexts/framework/infrastructure/manifest-repository-adapter.integration.test.ts new file mode 100644 index 000000000..0e5913bd0 --- /dev/null +++ b/cli/tests/contexts/framework/infrastructure/manifest-repository-adapter.integration.test.ts @@ -0,0 +1,101 @@ +import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { Manifest } from "../../../../src/contexts/framework/domain/manifest.js"; +import { ManifestRepositoryAdapter } from "../../../../src/contexts/framework/infrastructure/manifest-repository-adapter.js"; + +describe("ManifestRepositoryAdapter", () => { + let tempDir: string; + let adapter: ManifestRepositoryAdapter; + + beforeEach(async () => { + tempDir = await mkdtemp(join(tmpdir(), "aidd-manifest-repo-")); + adapter = new ManifestRepositoryAdapter(tempDir); + }); + + afterEach(async () => { + await rm(tempDir, { recursive: true, force: true }); + }); + + describe("load()", () => { + it("returns null when manifest file does not exist", async () => { + const result = await adapter.load(); + expect(result).toBeNull(); + }); + + it("rejects, rather than returning null, when manifest.json is a directory", async () => { + const manifestDir = join(tempDir, ".aidd", "manifest.json"); + await mkdir(manifestDir, { recursive: true }); + + await expect(adapter.load()).rejects.toThrow(); + }); + + it("rejects with an instructive error naming the file when manifest.json is truncated", async () => { + const manifestPath = join(tempDir, ".aidd", "manifest.json"); + await mkdir(join(tempDir, ".aidd"), { recursive: true }); + await writeFile(manifestPath, '{"version": 6, "tools": {'); + + await expect(adapter.load()).rejects.toThrow(manifestPath); + }); + }); + + describe("save() + load() roundtrip", () => { + it("persists and restores manifest without data loss", async () => { + const manifest = Manifest.create(); + await adapter.save(manifest); + + const loaded = await adapter.load(); + expect(loaded).not.toBeNull(); + expect(loaded?.getInstalledToolIds()).toHaveLength(0); + }); + + it("manifest version is 8 after roundtrip", async () => { + const manifest = Manifest.create(); + await adapter.save(manifest); + + const loaded = await adapter.load(); + const json = loaded?.toJSON(); + expect(json?.version).toBe(8); + expect("marketplaces" in (json ?? {})).toBe(false); + expect("docsDir" in (json ?? {})).toBe(false); + }); + }); + + describe("delete()", () => { + it("deletes manifest file from disk", async () => { + const manifest = Manifest.create(); + await adapter.save(manifest); + + await adapter.delete(); + + const result = await adapter.load(); + expect(result).toBeNull(); + }); + + it("prunes empty .aidd/ directory after manifest deletion", async () => { + const manifest = Manifest.create(); + await adapter.save(manifest); + + await adapter.delete(); + + const { existsSync } = await import("node:fs"); + const aiddDir = join(tempDir, ".aidd"); + expect(existsSync(aiddDir)).toBe(false); + }); + + it("silently succeeds when no manifest to delete", async () => { + await expect(adapter.delete()).resolves.toBeUndefined(); + }); + }); + + describe("manifest persistence", () => { + it("creates .aidd/ directory if it does not exist", async () => { + const manifest = Manifest.create(); + await adapter.save(manifest); + + const { existsSync } = await import("node:fs"); + expect(existsSync(join(tempDir, ".aidd", "manifest.json"))).toBe(true); + }); + }); +}); diff --git a/cli/tests/contexts/framework/infrastructure/plugin-distribution-reader-adapter.integration.test.ts b/cli/tests/contexts/framework/infrastructure/plugin-distribution-reader-adapter.integration.test.ts new file mode 100644 index 000000000..3346af9ec --- /dev/null +++ b/cli/tests/contexts/framework/infrastructure/plugin-distribution-reader-adapter.integration.test.ts @@ -0,0 +1,169 @@ +import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; +import { PluginDistributionReaderAdapter } from "../../../../src/contexts/framework/infrastructure/plugin-distribution-reader-adapter.js"; +// Side-effect imports: the adapter reads each tool's declared manifest locations off the +// registry, so an unregistered profile is a format it cannot recognise. +import "../../../../src/contexts/tools/domain/profiles/claude/profile.js"; +import "../../../../src/contexts/tools/domain/profiles/codex/profile.js"; +import "../../../../src/contexts/tools/domain/profiles/copilot/profile.js"; +import "../../../../src/contexts/tools/domain/profiles/cursor/profile.js"; +import "../../../../src/contexts/tools/domain/profiles/opencode/profile.js"; +import { + InvalidPluginManifestError, + InvalidPluginNameError, +} from "../../../../src/kernel/errors.js"; +import { FileAdapter } from "../../../../src/runtime/filesystem/file-adapter.js"; +import { HasherAdapter } from "../../../../src/runtime/filesystem/hasher-adapter.js"; + +const FIXTURE_DIR = join(process.cwd(), "tests/fixtures/plugins"); + +function makeAdapter(): PluginDistributionReaderAdapter { + return new PluginDistributionReaderAdapter(new FileAdapter(new HasherAdapter())); +} + +describe("PluginDistributionReaderAdapter", () => { + describe("claude-format fixture", () => { + it("detects claude format", async () => { + const adapter = makeAdapter(); + const dist = await adapter.read(join(FIXTURE_DIR, "claude-format/sample-plugin")); + expect(dist.format).toBe("claude"); + }); + + it("includes all hooks/ files including companion scripts", async () => { + const adapter = makeAdapter(); + const dist = await adapter.read(join(FIXTURE_DIR, "claude-format/sample-plugin")); + const paths = dist.files.map((f) => f.relativePath); + expect(paths).toContain("hooks/hooks.json"); + expect(paths).toContain("hooks/update_memory.js"); + }); + + it("parses manifest fields", async () => { + const adapter = makeAdapter(); + const dist = await adapter.read(join(FIXTURE_DIR, "claude-format/sample-plugin")); + expect(dist.manifest.name).toBe("sample-plugin"); + expect(dist.manifest.version).toBe("1.0.0"); + }); + + it("collects component files", async () => { + const adapter = makeAdapter(); + const dist = await adapter.read(join(FIXTURE_DIR, "claude-format/sample-plugin")); + expect(dist.files.length).toBeGreaterThan(0); + }); + + it("categorizes skills correctly", async () => { + const adapter = makeAdapter(); + const dist = await adapter.read(join(FIXTURE_DIR, "claude-format/sample-plugin")); + expect(dist.components.skills.length).toBe(1); + expect(dist.components.skills[0].relativePath).toBe("skills/hello/SKILL.md"); + }); + + it("categorizes commands correctly", async () => { + const adapter = makeAdapter(); + const dist = await adapter.read(join(FIXTURE_DIR, "claude-format/sample-plugin")); + expect(dist.components.commands.length).toBe(1); + expect(dist.components.commands[0].relativePath).toBe("commands/greet.md"); + }); + + it("categorizes agents correctly", async () => { + const adapter = makeAdapter(); + const dist = await adapter.read(join(FIXTURE_DIR, "claude-format/sample-plugin")); + expect(dist.components.agents.length).toBe(1); + expect(dist.components.agents[0].relativePath).toBe("agents/reviewer.md"); + }); + + it("reads file content", async () => { + const adapter = makeAdapter(); + const dist = await adapter.read(join(FIXTURE_DIR, "claude-format/sample-plugin")); + const skill = dist.components.skills[0]; + expect(skill.content).toContain("Hello from sample-plugin skill."); + }); + + it("includes the plugin manifest in files for native installation", async () => { + const adapter = makeAdapter(); + const dist = await adapter.read(join(FIXTURE_DIR, "claude-format/sample-plugin")); + const paths = dist.files.map((f) => f.relativePath); + expect(paths).toContain(".claude-plugin/plugin.json"); + }); + }); + + describe("cursor-format fixture", () => { + it("detects cursor format", async () => { + const adapter = makeAdapter(); + const dist = await adapter.read(join(FIXTURE_DIR, "cursor-format/sample-plugin")); + expect(dist.format).toBe("cursor"); + }); + }); + + describe("codex-format fixture", () => { + it("detects codex format", async () => { + const adapter = makeAdapter(); + const dist = await adapter.read(join(FIXTURE_DIR, "codex-format/sample-plugin")); + expect(dist.format).toBe("codex"); + }); + }); + + describe("copilot-format fixture", () => { + it("detects copilot format", async () => { + const adapter = makeAdapter(); + const dist = await adapter.read(join(FIXTURE_DIR, "copilot-format/sample-plugin")); + expect(dist.format).toBe("copilot"); + }); + }); + + describe("broken-plugin fixture", () => { + it("throws InvalidPluginNameError for invalid plugin name", async () => { + const adapter = makeAdapter(); + await expect(adapter.read(join(FIXTURE_DIR, "broken-plugin"))).rejects.toThrow( + InvalidPluginNameError + ); + }); + }); + + describe("a directory two tools could claim", () => { + // copilot accepts a bare root `plugin.json` and is declared before codex, so the probes + // are ordered deepest-path-first: read in declaration order, codex would read as copilot. + it("resolves to the tool whose location is the more specific one", async () => { + const root = await mkdtemp(join(tmpdir(), "aidd-ambiguous-")); + try { + const manifest = JSON.stringify({ name: "sample-plugin", version: "1.0.0" }); + await mkdir(join(root, ".codex-plugin"), { recursive: true }); + await writeFile(join(root, ".codex-plugin/plugin.json"), manifest); + await writeFile(join(root, "plugin.json"), manifest); + + const dist = await makeAdapter().read(root); + + expect(dist.format).toBe("codex"); + } finally { + await rm(root, { recursive: true, force: true }); + } + }); + }); + + describe("a manifest carrying a strict field", () => { + it("is read without it: strict belongs to the catalog entry, not to the plugin", async () => { + const dir = await mkdtemp(join(tmpdir(), "aidd-plugin-strict-")); + try { + await mkdir(join(dir, ".claude-plugin"), { recursive: true }); + await writeFile( + join(dir, ".claude-plugin", "plugin.json"), + JSON.stringify({ name: "sample", version: "1.0.0", strict: true }) + ); + const dist = await makeAdapter().read(dir); + expect(dist.manifest.strict).toBeUndefined(); + } finally { + await rm(dir, { recursive: true, force: true }); + } + }); + }); + + describe("non-existent directory", () => { + it("throws InvalidPluginManifestError when directory has no plugin.json", async () => { + const adapter = makeAdapter(); + await expect(adapter.read(join(FIXTURE_DIR, "nonexistent-plugin"))).rejects.toThrow( + InvalidPluginManifestError + ); + }); + }); +}); diff --git a/cli/tests/contexts/framework/infrastructure/user-manifest-repository-adapter.integration.test.ts b/cli/tests/contexts/framework/infrastructure/user-manifest-repository-adapter.integration.test.ts new file mode 100644 index 000000000..3490b66e8 --- /dev/null +++ b/cli/tests/contexts/framework/infrastructure/user-manifest-repository-adapter.integration.test.ts @@ -0,0 +1,109 @@ +import { existsSync } from "node:fs"; +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { Manifest } from "../../../../src/contexts/framework/domain/manifest.js"; +import { UserManifestRepositoryAdapter } from "../../../../src/contexts/framework/infrastructure/user-manifest-repository-adapter.js"; + +describe("UserManifestRepositoryAdapter", () => { + let userConfigDir: string; + let adapter: UserManifestRepositoryAdapter; + + beforeEach(async () => { + userConfigDir = await mkdtemp(join(tmpdir(), "aidd-user-manifest-repo-")); + adapter = new UserManifestRepositoryAdapter(() => userConfigDir); + }); + + afterEach(async () => { + await rm(userConfigDir, { recursive: true, force: true }); + }); + + it("names manifest.json directly under the user config dir, no .aidd nesting", () => { + expect(adapter.path).toBe(join(userConfigDir, "manifest.json")); + }); + + describe("load()", () => { + it("returns null when manifest.json does not exist", async () => { + expect(await adapter.load()).toBeNull(); + }); + + it("rejects with an instructive error naming the file when manifest.json is truncated", async () => { + const manifestPath = join(userConfigDir, "manifest.json"); + await writeFile(manifestPath, '{"version": 8, "tools": {'); + + await expect(adapter.load()).rejects.toThrow(manifestPath); + }); + + it('rejects a refused manifest version naming the real user manifest path and `aidd setup --scope user`, never .aidd/manifest.json or "in this project"', async () => { + const manifestPath = join(userConfigDir, "manifest.json"); + await writeFile(manifestPath, '{"version": 7, "tools": {}}'); + + await expect(adapter.load()).rejects.toThrow( + new RegExp( + `${manifestPath.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}.*aidd setup --scope user` + ) + ); + await expect(adapter.load()).rejects.not.toThrow(/\.aidd\/manifest\.json/); + await expect(adapter.load()).rejects.not.toThrow(/in this project/); + }); + }); + + describe("save() + load() roundtrip", () => { + it("persists and restores the manifest without data loss", async () => { + const manifest = Manifest.create(); + await adapter.save(manifest); + + const loaded = await adapter.load(); + expect(loaded?.getInstalledToolIds()).toHaveLength(0); + }); + + it("manifest version is 8 after roundtrip — same schema, same version as the project manifest", async () => { + const manifest = Manifest.create(); + await adapter.save(manifest); + + const loaded = await adapter.load(); + expect(loaded?.toJSON().version).toBe(8); + }); + }); + + describe("delete()", () => { + it("deletes manifest.json from disk", async () => { + const manifest = Manifest.create(); + await adapter.save(manifest); + + await adapter.delete(); + + expect(await adapter.load()).toBeNull(); + }); + + it("never removes the user config dir itself — auth.json, marketplaces.json and references.json live there too", async () => { + const manifest = Manifest.create(); + await adapter.save(manifest); + // A neighbour this adapter must never touch, standing in for everything else living + // directly under userConfigDir. + const neighbour = join(userConfigDir, "marketplaces.json"); + await writeFile(neighbour, "{}"); + + await adapter.delete(); + + expect(existsSync(userConfigDir)).toBe(true); + expect(existsSync(neighbour)).toBe(true); + }); + + it("silently succeeds when there is no manifest to delete", async () => { + await expect(adapter.delete()).resolves.toBeUndefined(); + }); + }); + + describe("manifest persistence", () => { + it("creates the user config dir if it does not exist yet", async () => { + const freshDir = join(userConfigDir, "not-yet-created"); + const freshAdapter = new UserManifestRepositoryAdapter(() => freshDir); + + await freshAdapter.save(Manifest.create()); + + expect(existsSync(join(freshDir, "manifest.json"))).toBe(true); + }); + }); +}); diff --git a/cli/tests/contexts/framework/infrastructure/user-source-references-adapter.unit.test.ts b/cli/tests/contexts/framework/infrastructure/user-source-references-adapter.unit.test.ts new file mode 100644 index 000000000..d52fea93b --- /dev/null +++ b/cli/tests/contexts/framework/infrastructure/user-source-references-adapter.unit.test.ts @@ -0,0 +1,197 @@ +import { describe, expect, it } from "vitest"; +import { UserSourceReferencesAdapter } from "../../../../src/contexts/framework/infrastructure/user-source-references-adapter.js"; +import { UnreadableUserSourceReferencesError } from "../../../../src/kernel/errors.js"; +import { InMemoryFileAdapter } from "../../../helpers/ports/in-memory-file-adapter.js"; + +const USER_CONFIG_DIR = "/fake-home/.config/aidd"; +const REFERENCES_PATH = `${USER_CONFIG_DIR}/references.json`; + +function adapter(fs: InMemoryFileAdapter = new InMemoryFileAdapter()): UserSourceReferencesAdapter { + return new UserSourceReferencesAdapter(fs, () => USER_CONFIG_DIR); +} + +/** Marks `root` as an existing project directory, the way a real one always has at least + * `.aidd/manifest.json`; a bare path with no children reads as "gone" to `fileExists`. */ +function markExisting(fs: InMemoryFileAdapter, root: string): void { + fs.setFile(`${root}/marker`, ""); +} + +describe("the shared source's own project references", () => { + it("adds two projects under the same version", async () => { + const fs = new InMemoryFileAdapter(); + markExisting(fs, "/project-a"); + markExisting(fs, "/project-b"); + const refs = adapter(fs); + + await refs.addReference("1.0.0", "/project-a"); + await refs.addReference("1.0.0", "/project-b"); + + const written = JSON.parse(fs.getFile(REFERENCES_PATH) ?? "{}") as Record; + expect(written["1.0.0"]).toEqual(["/project-a", "/project-b"]); + }); + + it("adding the same project twice changes nothing", async () => { + const fs = new InMemoryFileAdapter(); + markExisting(fs, "/project-a"); + const refs = adapter(fs); + + await refs.addReference("1.0.0", "/project-a"); + await refs.addReference("1.0.0", "/project-a"); + + const written = JSON.parse(fs.getFile(REFERENCES_PATH) ?? "{}") as Record; + expect(written).toEqual({ "1.0.0": ["/project-a"] }); + }); + + it("gives two CLI versions two separate keys", async () => { + const fs = new InMemoryFileAdapter(); + markExisting(fs, "/project-a"); + markExisting(fs, "/project-b"); + const refs = adapter(fs); + + await refs.addReference("1.0.0", "/project-a"); + await refs.addReference("2.0.0", "/project-b"); + + const written = JSON.parse(fs.getFile(REFERENCES_PATH) ?? "{}") as Record; + expect(Object.keys(written).sort()).toEqual(["1.0.0", "2.0.0"]); + expect(written["1.0.0"]).toEqual(["/project-a"]); + expect(written["2.0.0"]).toEqual(["/project-b"]); + }); + + // A help, not an authority: a project a person deleted with `rm -rf` decrements + // nothing, so it must never be counted as still live either. + it("ignores a reference whose own projectRoot no longer exists", async () => { + const fs = new InMemoryFileAdapter(); + const refs = adapter(fs); + + await refs.addReference("1.0.0", "/gone"); + + expect(await refs.listAllReferencingProjects()).toEqual([]); + }); + + // A help, not an authority, all the way to the file: a vanished project's entry is ignored at + // read but survives until the next write, so every read keeps `stat`-ing a dead path. + it("purges a vanished project's own entry from the file at the next write", async () => { + const fs = new InMemoryFileAdapter(); + markExisting(fs, "/project-a"); + const refs = adapter(fs); + await refs.addReference("1.0.0", "/project-a"); + // /project-b is never marked existing: the same `rm -rf` situation as the test + // above, recorded once and then abandoned. + await refs.addReference("1.0.0", "/project-b"); + + // Another project's own `setup` runs later on this machine and adds its claim — + // the ordinary event that triggers the next write to this file. + markExisting(fs, "/project-c"); + await refs.addReference("1.0.0", "/project-c"); + + const written = JSON.parse(fs.getFile(REFERENCES_PATH) ?? "{}") as Record; + expect(written["1.0.0"]).toEqual(["/project-a", "/project-c"]); + }); + + // Nothing here asks which version is "current", only where the project is recorded, so a CLI + // self-update between the `sync` that wrote the reference and this read cannot strand it. + it("adding the same project under a new version drops its claim on the old one", async () => { + const fs = new InMemoryFileAdapter(); + markExisting(fs, "/project-a"); + const refs = adapter(fs); + await refs.addReference("1.0.0", "/project-a"); + + await refs.addReference("2.0.0", "/project-a"); + + const written = JSON.parse(fs.getFile(REFERENCES_PATH) ?? "{}") as Record; + expect(written).toEqual({ "2.0.0": ["/project-a"] }); + }); + + describe("removeReference", () => { + it("drops this project's own claim, wherever it is recorded, leaving every other one", async () => { + const fs = new InMemoryFileAdapter(); + markExisting(fs, "/project-a"); + markExisting(fs, "/project-b"); + const refs = adapter(fs); + // Recorded while the CLI was at 1.0.0 — never re-synced since, the ordinary case + // an `aidd update` in between produces. + await refs.addReference("1.0.0", "/project-a"); + await refs.addReference("1.0.0", "/project-b"); + + await refs.removeReference("/project-a"); + + const written = JSON.parse(fs.getFile(REFERENCES_PATH) ?? "{}") as Record; + expect(written["1.0.0"]).toEqual(["/project-b"]); + }); + + it("removes the version key entirely once its last reference is gone", async () => { + const fs = new InMemoryFileAdapter(); + markExisting(fs, "/project-a"); + const refs = adapter(fs); + await refs.addReference("1.0.0", "/project-a"); + + await refs.removeReference("/project-a"); + + const written = JSON.parse(fs.getFile(REFERENCES_PATH) ?? "{}") as Record; + expect(written).toEqual({}); + }); + + it("does nothing when this project never held a reference", async () => { + const fs = new InMemoryFileAdapter(); + markExisting(fs, "/project-a"); + const refs = adapter(fs); + + await expect(refs.removeReference("/project-a")).resolves.toBeUndefined(); + // Never found a claim to drop, so it never had a reason to write at all — + // the file this project's own `setup` or `sync` never ran stays absent. + expect(fs.getFile(REFERENCES_PATH)).toBeUndefined(); + }); + }); + + it("throws rather than silently treating a corrupted file as empty", async () => { + const fs = new InMemoryFileAdapter(); + fs.setFile(REFERENCES_PATH, "not json"); + const refs = adapter(fs); + + await expect(refs.listAllReferencingProjects()).rejects.toThrow( + UnreadableUserSourceReferencesError + ); + }); + + it("throws when a version's own entry is not a list of project paths", async () => { + const fs = new InMemoryFileAdapter(); + fs.setFile(REFERENCES_PATH, JSON.stringify({ "1.0.0": "/project-a" })); + const refs = adapter(fs); + + await expect(refs.listAllReferencingProjects()).rejects.toThrow( + UnreadableUserSourceReferencesError + ); + }); + + describe("listAllReferencingProjects", () => { + it("lists existing projects across every version key, deduplicated", async () => { + const fs = new InMemoryFileAdapter(); + markExisting(fs, "/project-a"); + markExisting(fs, "/project-b"); + const refs = adapter(fs); + await refs.addReference("1.0.0", "/project-a"); + await refs.addReference("2.0.0", "/project-b"); + + expect([...(await refs.listAllReferencingProjects())].sort()).toEqual([ + "/project-a", + "/project-b", + ]); + }); + + it("leaves out a project whose own root no longer exists", async () => { + const fs = new InMemoryFileAdapter(); + markExisting(fs, "/project-a"); + const refs = adapter(fs); + await refs.addReference("1.0.0", "/project-a"); + await refs.addReference("1.0.0", "/gone"); + + expect(await refs.listAllReferencingProjects()).toEqual(["/project-a"]); + }); + + it("is empty when the file has never been written", async () => { + const refs = adapter(); + + expect(await refs.listAllReferencingProjects()).toEqual([]); + }); + }); +}); diff --git a/cli/tests/application/use-cases/telemetry/diagnose-telemetry-use-case.unit.test.ts b/cli/tests/contexts/telemetry/application/diagnose-telemetry-use-case.unit.test.ts similarity index 79% rename from cli/tests/application/use-cases/telemetry/diagnose-telemetry-use-case.unit.test.ts rename to cli/tests/contexts/telemetry/application/diagnose-telemetry-use-case.unit.test.ts index 8acf88d79..5b4c177d3 100644 --- a/cli/tests/application/use-cases/telemetry/diagnose-telemetry-use-case.unit.test.ts +++ b/cli/tests/contexts/telemetry/application/diagnose-telemetry-use-case.unit.test.ts @@ -1,24 +1,25 @@ import { describe, expect, it } from "vitest"; -import "../../../../src/domain/tools/ai/claude.js"; -import "../../../../src/domain/tools/ai/codex.js"; -import "../../../../src/domain/tools/ai/copilot.js"; -import "../../../../src/domain/tools/ai/cursor.js"; -import "../../../../src/domain/tools/ai/opencode.js"; -import { DiagnoseTelemetryUseCase } from "../../../../src/application/use-cases/telemetry/diagnose-telemetry-use-case.js"; -import { Manifest } from "../../../../src/domain/models/manifest.js"; -import { Plugin } from "../../../../src/domain/models/plugin.js"; -import type { TelemetryCodexHookTrust } from "../../../../src/domain/models/telemetry-claim.js"; -import type { AiToolId } from "../../../../src/domain/models/tool-ids.js"; -import type { HookTrustReader } from "../../../../src/domain/ports/hook-trust-reader.js"; -import type { HostPluginRegistryReader } from "../../../../src/domain/ports/host-plugin-registry-reader.js"; -import type { ManifestRepository } from "../../../../src/domain/ports/manifest-repository.js"; -import type { RunJournal } from "../../../../src/domain/ports/run-journal-reader.js"; +import "../../../../src/contexts/tools/domain/profiles/claude/profile.js"; +import "../../../../src/contexts/tools/domain/profiles/codex/profile.js"; +import "../../../../src/contexts/tools/domain/profiles/copilot/profile.js"; +import "../../../../src/contexts/tools/domain/profiles/cursor/profile.js"; +import "../../../../src/contexts/tools/domain/profiles/opencode/profile.js"; +import { Manifest } from "../../../../src/contexts/framework/domain/manifest.js"; +import { InstalledPlugin } from "../../../../src/contexts/framework/domain/plugins/installed-plugin.js"; +import type { ManifestRepository } from "../../../../src/contexts/framework/domain/ports/manifest-repository.js"; +import { DiagnoseTelemetryUseCase } from "../../../../src/contexts/telemetry/application/diagnose-telemetry-use-case.js"; +import type { HookTrustReader } from "../../../../src/contexts/telemetry/domain/ports/hook-trust-reader.js"; +import type { RunJournal } from "../../../../src/contexts/telemetry/domain/ports/run-journal-reader.js"; import type { LocalCostCandidateRecord, LocalCostReadResult, SessionCostReader, -} from "../../../../src/domain/ports/session-cost-reader.js"; -import type { VersionControl } from "../../../../src/domain/ports/version-control.js"; +} from "../../../../src/contexts/telemetry/domain/ports/session-cost-reader.js"; +import type { VersionControl } from "../../../../src/contexts/telemetry/domain/ports/version-control.js"; +import type { TelemetryCodexHookTrust } from "../../../../src/contexts/telemetry/domain/telemetry-claim.js"; +import type { HostPluginRegistryReader } from "../../../../src/contexts/tools/domain/ports/host-plugin-registry-reader.js"; +import type { AiToolId } from "../../../../src/kernel/tool.js"; +import { installedPluginsFromManifest } from "../../../../src/runtime/wiring/installed-plugins-from-manifest.js"; import { FakeCurrentVersion } from "../../../helpers/ports/fake-current-version.js"; import { InMemoryManifestRepository } from "../../../helpers/ports/in-memory-manifest-repository.js"; import { InMemoryPersonIdentityStore } from "../../../helpers/ports/in-memory-person-identity-store.js"; @@ -40,9 +41,8 @@ class StubHookTrustReader implements HookTrustReader { function versionControl(isRepository: boolean): VersionControl { return { - installCommitMessageDelegate: async () => false, - removeCommitMessageDelegate: async () => false, - getRemoteUrl: async () => null, + installCommitMessageDelegate: async () => ({ lineAdded: false }), + removeCommitMessageDelegate: async () => ({ removed: false }), listTrackedFiles: async () => [], isRepository: async () => isRepository, readCommitTrailerSetup: async () => ({ @@ -110,15 +110,17 @@ function buildUseCase(options: { new InMemoryPersonIdentityStore(), new InMemoryTelemetrySink(), new FakeCurrentVersion("9.9.9-check"), - options.manifestRepo ?? { - path: "/test-project/.aidd/manifest.json", - load: async () => null, - save: async () => {}, - delete: async () => {}, - }, + installedPluginsFromManifest( + options.manifestRepo ?? { + path: "/test-project/.aidd/manifest.json", + load: async () => null, + save: async () => {}, + delete: async () => {}, + } + ), options.hostRegistries ?? new Map() ); - return { useCase, evidence, hookTrustReader }; + return { useCase, evidence, hookTrustReader, journalReader }; } function runOptions(env: NodeJS.ProcessEnv = {}) { @@ -146,9 +148,8 @@ describe("DiagnoseTelemetryUseCase — gating", () => { }); }); -// Finding 1 (review.md, "one route, and every sentence about it true"): a stale export in -// a tool's own settings file exports whether or not this project's own switch is on, so it -// is gathered and reported on both sides of the gate, never folded into a claim. +// A stale export in a tool's own settings file exports whether or not this project's switch is +// on, so it is gathered and reported on both sides of the gate, never folded into a claim. describe("DiagnoseTelemetryUseCase — a leftover export config", () => { const LEFTOVER = [ { path: "/repo/.claude/settings.local.json", keys: ["CLAUDE_CODE_ENABLE_TELEMETRY"] }, @@ -190,14 +191,28 @@ describe("DiagnoseTelemetryUseCase — a leftover export config", () => { }); describe("DiagnoseTelemetryUseCase — gathering local evidence", () => { + it("reads the run journal once per check, never once for setup and again for evidence", async () => { + const journal: RunJournal = { + session: sessionStart("s-1"), + boundaries: [ + { type: "step_start", at: "2026-08-20T09:00:30Z", skill: "aidd-dev:02-implement" }, + ], + filesWritten: [], + taskDeclarations: [], + }; + const { useCase, journalReader } = buildUseCase({ journals: [journal] }); + + const result = await useCase.execute(runOptions({ CLAUDE_CODE_SESSION_ID: "s-1" })); + + if (result.gate !== undefined) throw new Error("expected the run to pass the gate"); + expect(journalReader.listCalls).toBe(1); + }); + it("reads every covered tool's own files for every journalled session", async () => { const journal: RunJournal = { session: sessionStart("s-1"), - // The pause is what the step interval is capped at, and it is why the candidate below - // falls inside one at all. A journal whose only line is the opener witnesses no later - // moment, so the step it opened covers nothing and `records-join` reads fail - see - // `buildStepIntervals`'s own doc comment. Every host this fixture stands for writes a - // pause: `journal.cjs` maps a stop event for Claude Code, Cursor and OpenCode. + // The pause is what caps the step interval, and is why the candidate below falls inside + // one at all: a journal whose only line is the opener witnesses no later moment. boundaries: [ { type: "step_start", at: "2026-08-20T09:00:30Z", skill: "aidd-dev:02-implement" }, { type: "turn_end", at: "2026-08-20T09:30:00Z" }, @@ -218,14 +233,8 @@ describe("DiagnoseTelemetryUseCase — gathering local evidence", () => { expect(result.claims.find((c) => c.claim === "records-join")?.verdict).toBe("ok"); }); - // The consequence of capping an unclosed step, pinned where a person actually meets it. - // Copilot fires no stop event - `journal.cjs`'s own `HOOK_EVENT_NAME_TO_CANONICAL` maps - // one for Claude Code, Cursor and OpenCode and none for it - so a Copilot session that - // opened a skill and then wrote no file and declared no task leaves a journal whose only - // line is the opener. It witnesses no later moment, so the step covers nothing, and a - // record carrying no step of its own joins nothing. `check` says so rather than reporting - // a join it cannot see: an interval with no end in evidence used to reach forward - // indefinitely, which is what made this read ok. + // Copilot fires no stop event, so a Copilot session that opened a skill and then wrote no file + // leaves a journal whose only line is the opener, and the step it opened covers nothing. it("says records join nothing when the journal's only line is the step that opened", async () => { const journal: RunJournal = { session: sessionStart("s-1"), @@ -314,9 +323,8 @@ describe("DiagnoseTelemetryUseCase — every claim is judged", () => { }); }); -// The wiring proof for "not yet" stops being a failure: the same declaration -// `gatherSetup` already read for the stated half is what the first claim judges by — never -// the absence of a run file, which looks identical either way. +// The first claim judges by the same recorder declaration `gatherSetup` reads, never by the +// absence of a run file, which looks identical whether or not the recorder was declared. describe("DiagnoseTelemetryUseCase — the first claim reads the same declaration setup prints", () => { it("reports nothing to evaluate when the setup's own recorder declaration is true", async () => { const evidence = new StubEvidenceReader(); @@ -376,14 +384,6 @@ describe("DiagnoseTelemetryUseCase — the first claim reads the same declaratio }); }); -/** - * Which build produced what a person is reading. - * - * Neither version reached any output before this: `cli_version` and `plugin_version` were - * written onto records and journal lines that only a person opening `~/.config/aidd` by - * hand would ever see. `check` is the command whose job is telling someone the state of - * their setup, so it is where they belong. - */ describe("the versions check reports", () => { function sessionAt(at: string, pluginVersion?: string): RunJournal { return { @@ -442,9 +442,8 @@ describe("the versions check reports", () => { }); it("tells a project that measured nothing yet apart from one whose hook could not name itself", async () => { - // The two silences mean different things: nothing journalled says nothing about the - // plugin, while a journalled session with no version is a plugin that arrived by - // neither install route. Collapsing them would let "not measured yet" read as damage. + // The two silences differ: nothing journalled says nothing about the plugin, while a + // journalled session carrying no version is a plugin that arrived by neither install route. const nothing = await buildUseCase({}).useCase.execute(runOptions()); const journalledWithout = await buildUseCase({ journals: [sessionAt("2026-09-01T10:00:00Z")], @@ -455,9 +454,8 @@ describe("the versions check reports", () => { }); }); -/** A repository whose load throws, which is what a hand-edited `.aidd/manifest.json` - * actually produces: `Manifest`'s parser maps over fields it does not guard. Written as a - * real implementation of the port rather than a cast, so it cannot drift from it. */ +/** A hand-edited `.aidd/manifest.json` makes `load` throw: `Manifest`'s parser maps over fields + * it does not guard. A real implementation of the port rather than a cast, so it cannot drift. */ class ThrowingManifestRepository implements ManifestRepository { readonly path = "/test-project/.aidd/manifest.json"; constructor(private readonly failure: Error) {} @@ -473,11 +471,12 @@ function manifestWithClaudePlugin(marketplace?: string): InMemoryManifestReposit manifest.addTool("claude", "test", []); manifest.addPlugin( "claude", - Plugin.fromMetadata( + InstalledPlugin.fromMetadata( "aidd-telemetry", "1.0.0", { kind: "github", repo: "ai-driven-dev/framework" }, true, + "project", marketplace ) ); @@ -488,7 +487,10 @@ function registryCarrying( refs: readonly string[] ): ReadonlyMap { const reader: HostPluginRegistryReader = { - read: async () => ({ location: REGISTRY, refs: new Map(refs.map((ref) => [ref, true])) }), + read: async () => ({ + location: REGISTRY, + refs: new Map(refs.map((ref) => [ref, { enabled: true }])), + }), }; return new Map([["claude", reader]]); } @@ -508,7 +510,6 @@ describe("DiagnoseTelemetryUseCase — what the host will actually load", () => expect(result.setup.hostRegistration.entries[0]?.answer).toBe("registered"); }); - // #703 itself, through the use-case: the declaration is fine and the host will drop it. it("says a plugin the registry lacks is not registered, and names the file", async () => { const { useCase } = buildUseCase({ manifestRepo: manifestWithClaudePlugin("aidd-framework"), @@ -532,12 +533,6 @@ describe("DiagnoseTelemetryUseCase — what the host will actually load", () => ); }); - /** - * Verified against the built binary before it was fixed: it printed - * `Cannot read properties of undefined (reading 'map')` and died. Nothing loaded the - * manifest from `check` until this fact existed, so that crash is one the diagnostic put - * on its own path — and a damaged manifest is exactly when someone runs `check`. - */ it("survives a manifest it cannot parse, and says so instead of dying", async () => { const { useCase } = buildUseCase({ manifestRepo: new ThrowingManifestRepository( diff --git a/cli/tests/application/use-cases/telemetry/forget-telemetry-use-case.unit.test.ts b/cli/tests/contexts/telemetry/application/forget-telemetry-use-case.unit.test.ts similarity index 88% rename from cli/tests/application/use-cases/telemetry/forget-telemetry-use-case.unit.test.ts rename to cli/tests/contexts/telemetry/application/forget-telemetry-use-case.unit.test.ts index 3099c2ec5..36c4fc811 100644 --- a/cli/tests/application/use-cases/telemetry/forget-telemetry-use-case.unit.test.ts +++ b/cli/tests/contexts/telemetry/application/forget-telemetry-use-case.unit.test.ts @@ -1,20 +1,18 @@ import { describe, expect, it } from "vitest"; -import { ForgetTelemetryUseCase } from "../../../../src/application/use-cases/telemetry/forget-telemetry-use-case.js"; -import { telemetryRemovalIsEmpty } from "../../../../src/domain/models/telemetry-removal.js"; -import type { TelemetrySinkRecord } from "../../../../src/domain/models/telemetry-sink-record.js"; -import type { VersionControl } from "../../../../src/domain/ports/version-control.js"; +import { ForgetTelemetryUseCase } from "../../../../src/contexts/telemetry/application/forget-telemetry-use-case.js"; +import type { VersionControl } from "../../../../src/contexts/telemetry/domain/ports/version-control.js"; +import { telemetryRemovalIsEmpty } from "../../../../src/contexts/telemetry/domain/telemetry-removal.js"; +import type { TelemetrySinkRecord } from "../../../../src/contexts/telemetry/domain/telemetry-sink-record.js"; +import { noGit } from "../../../contexts/framework/application/helpers.js"; import { InMemoryPersonIdentityStore } from "../../../helpers/ports/in-memory-person-identity-store.js"; import { InMemoryRunJournalReader } from "../../../helpers/ports/in-memory-run-journal-reader.js"; import { InMemoryTelemetrySink } from "../../../helpers/ports/in-memory-telemetry-sink.js"; -import { noGit } from "../helpers.js"; const PROJECT_ROOT = "/repo"; const RUNS_ENTRY = "aidd_docs/runs/"; -// Most tests here care about the journal/sink/identity, not about git — this stands for -// "inside a repository, nothing tracked yet", the common case, so `history` reads -// `"possible"` rather than `"none"` by default. Tests about history itself override -// `isRepository`/`listTrackedFiles`/`hasHistoryFor` explicitly. +// "Inside a repository, nothing tracked yet", the common case, so `history` reads `"possible"` +// by default. Tests about history itself override the git answers explicitly. const insideRepoNoTracking: VersionControl = { ...noGit, isRepository: async () => true }; const RECORD: TelemetrySinkRecord = { @@ -98,9 +96,8 @@ describe("ForgetTelemetryUseCase.preview() — every location, resolved once, an }); it("reads a staged-but-never-committed journal honestly — tracked, not certainly held", async () => { - // `git add`ed but never committed: the index (`listTrackedFiles`) says tracked, but - // history (`hasHistoryFor`) has nothing for it yet — the exact gap `git ls-files` - // alone cannot see (finding: "history certainly holds it" was over-asserted this way). + // `git add`ed but never committed: the index says tracked while history holds nothing for it, + // the gap `git ls-files` alone cannot see. const git = { ...insideRepoNoTracking, listTrackedFiles: async () => ["aidd_docs/runs/staged.jsonl"], @@ -270,9 +267,8 @@ describe("ForgetTelemetryUseCase.remove() — acts on the value preview() produc const realPreview = await useCase.preview({ projectRoot: PROJECT_ROOT }); expect(realPreview.sink.dayFileNames).toEqual(["2026-08-19.jsonl", "2026-08-20.jsonl"]); - // A person was shown only one of the two day files - the mutated value below is what - // "shown" means for this test, standing in for a preview built before a second day - // file appeared. Handing this to `remove()` must delete only what it names. + // A person was shown only one of the two day files, standing in for a preview built before the + // second appeared. Handing this to `remove()` must delete only what it names. const shownOnlyOneFile = { ...realPreview, sink: { ...realPreview.sink, dayFileNames: ["2026-08-19.jsonl"] }, @@ -280,9 +276,8 @@ describe("ForgetTelemetryUseCase.remove() — acts on the value preview() produc await useCase.remove(shownOnlyOneFile); - // The file never named in what was shown survives - a fresh `listDayFiles()` inside - // `remove()` would have deleted it anyway, which is exactly the failure this design - // exists to make impossible. + // The file never named in what was shown survives: a fresh `listDayFiles()` inside `remove()` + // would have deleted it anyway, which is the failure this design exists to make impossible. expect(await sink.listDayFiles()).toEqual(["2026-08-20.jsonl"]); expect(sink.deletedFiles).toEqual(["2026-08-19.jsonl"]); }); @@ -293,10 +288,8 @@ describe("ForgetTelemetryUseCase.remove() — acts on the value preview() produc const realPreview = await useCase.preview({ projectRoot: PROJECT_ROOT }); expect(realPreview.journal.path).toBe(runJournalReader.runsDir); - // A relocated `AIDD_RUNS_DIR` between preview and remove would change what - // `runJournalReader.runsDir` answers on the next call, but never what was already - // shown — standing in for exactly that by handing `remove()` a preview naming a - // different directory than the reader's own. + // A relocated `AIDD_RUNS_DIR` between preview and remove changes what + // `runJournalReader.runsDir` answers next, but never what was already shown. const shownElsewhere = { ...realPreview, journal: { ...realPreview.journal, path: "/elsewhere/relocated-runs" }, diff --git a/cli/tests/application/use-cases/telemetry/person-identity-use-case.unit.test.ts b/cli/tests/contexts/telemetry/application/person-identity-use-case.unit.test.ts similarity index 85% rename from cli/tests/application/use-cases/telemetry/person-identity-use-case.unit.test.ts rename to cli/tests/contexts/telemetry/application/person-identity-use-case.unit.test.ts index 0a1c4ff33..4c0894e50 100644 --- a/cli/tests/application/use-cases/telemetry/person-identity-use-case.unit.test.ts +++ b/cli/tests/contexts/telemetry/application/person-identity-use-case.unit.test.ts @@ -1,12 +1,12 @@ import { readFileSync } from "node:fs"; import { describe, expect, it } from "vitest"; +import { PersonIdentityUseCase } from "../../../../src/contexts/telemetry/application/person-identity-use-case.js"; import { EmptyDisplayNameError, EmptyIdentifierError, IdentityRequiredToLinkError, -} from "../../../../src/application/errors.js"; -import { PersonIdentityUseCase } from "../../../../src/application/use-cases/telemetry/person-identity-use-case.js"; -import { UnreadableIdentityFileError } from "../../../../src/domain/errors.js"; + UnreadableIdentityFileError, +} from "../../../../src/kernel/errors.js"; import { InMemoryPersonIdentityStore } from "../../../helpers/ports/in-memory-person-identity-store.js"; function useCase(store: InMemoryPersonIdentityStore): PersonIdentityUseCase { @@ -265,13 +265,8 @@ describe("PersonIdentityUseCase.unlink", () => { describe("PersonIdentityUseCase.use, minted apart from adopted", () => { /** - * The one distinction the merge had to carry across. - * - * `on` and `use` were two commands, so nothing could confuse them. Behind one door the - * difference lives entirely in a word, and the word decides what a person is told: an - * identifier this machine created gets the disclosure about what it will attach to, while - * one carried here from another machine has to say what it replaced instead. Reporting - * either as the other is a sentence about the wrong event. + * An identifier this machine minted gets the disclosure about what it will attach to; one + * carried here from another machine has to say what it replaced instead. */ it("calls a fresh identifier minted, and one carried here adopted", async () => { const minted = await useCase(new InMemoryPersonIdentityStore(null)).use({}); @@ -281,7 +276,6 @@ describe("PersonIdentityUseCase.use, minted apart from adopted", () => { expect(minted.outcome).toBe("minted"); expect(adopted.outcome).toBe("adopted"); - // And on disk, where a later reader looks: the same distinction, independently. expect(minted.identity.origin).toBe("minted"); expect(adopted.identity.origin).toBe("adopted"); }); @@ -302,11 +296,8 @@ describe("PersonIdentityUseCase.use, minted apart from adopted", () => { describe("PersonIdentityUseCase.use, attaching a display name", () => { it("mints an identifier for a name given when none stands, rather than refusing", async () => { - // The separate `name` verb refused here, and had to: it could only decorate something - // that already existed. Under one door the refusal has no reason left — `use` is the - // verb that opts in, and `--name` is a property of what it settles on. A person typing - // `identity use --name Ada` with nothing standing is saying who they are, not asking to - // rename a thing that is not there. + // `--name` is a property of what `use` settles on: a person typing `identity use --name Ada` + // with nothing standing is saying who they are, not renaming a thing that is not there. const store = new InMemoryPersonIdentityStore(null); const result = await useCase(store).use({ displayName: "Ada" }); @@ -367,8 +358,7 @@ describe("PersonIdentityUseCase.off", () => { }); // A file holding an empty `person_id` parses to "nobody chose" while still sitting on - // disk. Deciding removal from that read left it there with no verb able to remove it, - // against the contract's own "withdrawing removes the whole declaration". + // disk, and withdrawing removes the whole declaration. it("removes a file that exists but names nobody, rather than reading it as already off", async () => { const store = new InMemoryPersonIdentityStore(null); store.filePresent = true; @@ -395,10 +385,8 @@ describe("PersonIdentityUseCase.off", () => { expect(result.identity.personId).not.toBe("withdrawn-id"); }); - // The store reads back nothing, because the file is a directory - a shape that cannot - // also parse to an identity. Seeding one here would let `removed` come from the identity - // instead of from the file, which is exactly the confusion `forget()` answering from the - // filesystem exists to end. + // The store reads back nothing because the file is a directory — a shape that cannot also + // parse to an identity, so `removed` can only come from the filesystem. it("discards a damaged identity file rather than leaving a person unable to withdraw", async () => { const store = new InMemoryPersonIdentityStore(null); store.filePresent = true; @@ -438,17 +426,8 @@ describe("PersonIdentityUseCase.off", () => { }); /** - * Every command an error names is a command the CLI still has. - * - * An error message is the one surface that tells a person what to run next, and it is the - * one nothing typechecks. Reducing `identity` from seven verbs to four left - * `EmptyDisplayNameError` saying "run `aidd telemetry identity name`" — a verb the same - * change had just deleted, reachable from an ordinary typo, and invisible to every gate - * because the only test asserted the error's *class* and never its sentence. - * - * So this reads the sentences instead. It is deliberately a scan of the source rather than - * a list of expected strings: a list would have to be updated by the same hand that forgets - * to update the message, which is how the first one went stale. + * Every command an error names must be a command the CLI still has, and no gate typechecks + * a message — so this scans the source rather than a list that goes stale the same way. */ describe("what the errors tell a person to run", () => { /** The verbs `registerTelemetryIdentityCommand` actually registers, plus the bare noun. */ @@ -456,7 +435,7 @@ describe("what the errors tell a person to run", () => { it("names no identity verb the command surface does not have", () => { const source = readFileSync( - new URL("../../../../src/application/errors.ts", import.meta.url), + new URL("../../../../src/kernel/errors.ts", import.meta.url), "utf8" ); const named = [...source.matchAll(/aidd telemetry identity ?([a-z-]*)/gu)].map( diff --git a/cli/tests/application/use-cases/telemetry/read-local-cost-use-case.unit.test.ts b/cli/tests/contexts/telemetry/application/read-local-cost-use-case.unit.test.ts similarity index 86% rename from cli/tests/application/use-cases/telemetry/read-local-cost-use-case.unit.test.ts rename to cli/tests/contexts/telemetry/application/read-local-cost-use-case.unit.test.ts index cf81ea26b..388511732 100644 --- a/cli/tests/application/use-cases/telemetry/read-local-cost-use-case.unit.test.ts +++ b/cli/tests/contexts/telemetry/application/read-local-cost-use-case.unit.test.ts @@ -3,22 +3,22 @@ import { fileURLToPath } from "node:url"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; // Side-effect imports: the use-case resolves each tool's local-read declaration from the // registry, so every AI tool must be registered for these tests to see it. -import "../../../../src/domain/tools/ai/claude.js"; -import "../../../../src/domain/tools/ai/codex.js"; -import "../../../../src/domain/tools/ai/copilot.js"; -import "../../../../src/domain/tools/ai/cursor.js"; -import "../../../../src/domain/tools/ai/opencode.js"; -import { ReadLocalCostUseCase } from "../../../../src/application/use-cases/telemetry/read-local-cost-use-case.js"; -import { mapCodexRolloutToSinkRecords } from "../../../../src/domain/formats/codex-rollout.js"; -import type { TelemetrySinkRecord } from "../../../../src/domain/models/telemetry-sink-record.js"; -import type { AiToolId } from "../../../../src/domain/models/tool-ids.js"; -import type { RunJournal } from "../../../../src/domain/ports/run-journal-reader.js"; +import "../../../../src/contexts/tools/domain/profiles/claude/profile.js"; +import "../../../../src/contexts/tools/domain/profiles/codex/profile.js"; +import "../../../../src/contexts/tools/domain/profiles/copilot/profile.js"; +import "../../../../src/contexts/tools/domain/profiles/cursor/profile.js"; +import "../../../../src/contexts/tools/domain/profiles/opencode/profile.js"; +import { ReadLocalCostUseCase } from "../../../../src/contexts/telemetry/application/read-local-cost-use-case.js"; +import { mapCodexRolloutToSinkRecords } from "../../../../src/contexts/telemetry/domain/formats/codex-rollout.js"; +import type { RunJournal } from "../../../../src/contexts/telemetry/domain/ports/run-journal-reader.js"; import type { LocalCostCandidateRecord, SessionCostReader, -} from "../../../../src/domain/ports/session-cost-reader.js"; -import type { AiTool } from "../../../../src/domain/tools/contracts.js"; -import { getAiToolConfig, registerTool } from "../../../../src/domain/tools/registry.js"; +} from "../../../../src/contexts/telemetry/domain/ports/session-cost-reader.js"; +import type { TelemetrySinkRecord } from "../../../../src/contexts/telemetry/domain/telemetry-sink-record.js"; +import type { AiTool } from "../../../../src/contexts/tools/domain/contracts.js"; +import { getAiToolConfig, registerTool } from "../../../../src/contexts/tools/domain/registry.js"; +import type { AiToolId } from "../../../../src/kernel/tool.js"; import { FakeCurrentVersion } from "../../../helpers/ports/fake-current-version.js"; import { InMemoryPersonIdentityReader, @@ -33,10 +33,8 @@ import { StubTelemetryEvidenceReader } from "../../../helpers/ports/stub-telemet const SESSION_ID = "s-1"; const PROJECT_ROOT = "/repo"; -// Every test in this file is about what the sink, the journal or a reader hold, not about -// the project switch - it always answers "on" here, the same reasoning -// `report-cost-use-case.unit.test.ts`'s own `BASE_OPTIONS` documents. The refusal itself is -// covered on its own, below. +// The project switch always answers "on" here: every test in this file is about what the +// sink, the journal or a reader hold. Its refusal is covered on its own, below. const TELEMETRY_EVIDENCE_READER = new StubTelemetryEvidenceReader(); function stubReader(records: readonly LocalCostCandidateRecord[]): SessionCostReader { @@ -56,10 +54,8 @@ function journalWithTurnEnd(at: string): RunJournal { }; } -// The same real, redacted rollout excerpt codex-rollout.unit.test.ts asserts against -// (captured 2026-08-20 on Codex CLI 0.145.0-alpha.27) — its last turn's two `token_count` -// lines are cut down to one, to stand in for "read while the session was still running": -// only the first of the turn's two token increments has landed on disk yet. +// The same real, redacted rollout excerpt `codex-rollout.unit.test.ts` asserts against, its +// last turn's two `token_count` lines cut to one: a session read while it was still running. const CODEX_TARGET_ID = "019fae6f-2009-7cd3-86b2-b8f83481b160"; const CODEX_FIXTURE_PATH = ".codex/sessions/2026/07/29/rollout-2026-07-29T17-12-26-019fae6f-2009-7cd3-86b2-b8f83481b160.jsonl"; @@ -70,10 +66,8 @@ function loadCodexFixture(): string { return readFileSync(fileURLToPath(url), "utf8"); } -/** The real Codex reader, fed a different snapshot of the rollout's own bytes on each - * successive call — exactly what `CodexCostReaderAdapter` sees re-opening the same growing - * file. The last snapshot repeats once the sequence is exhausted, for a session that has - * stopped changing. */ +/** The real Codex reader, fed a different snapshot of the rollout's bytes on each call, as + * `CodexCostReaderAdapter` sees a growing file. The last snapshot repeats once exhausted. */ function growingCodexReader(...snapshots: readonly string[]): SessionCostReader { let call = 0; return { @@ -86,19 +80,14 @@ function growingCodexReader(...snapshots: readonly string[]): SessionCostReader }; } -// The full fixture's last turn (see codex-rollout.unit.test.ts) is `019fae71-...`'s two -// `token_count` lines summing to input 5032 / output 3550 / cache-read 99840 / cache-write -// 0. Dropping the fixture's own final line leaves only the first of those two increments — -// input 2816 / output 1401 / cache-read 48896 / cache-write 0 — standing in for "read while -// the session was still running, before the rest of this turn had landed on disk". +// The full fixture's last turn sums to input 5032 / output 3550 / cache-read 99840; dropping +// its final line leaves only its first increment, input 2816 / output 1401 / cache-read 48896. function truncatedCodexFixture(): string { return loadCodexFixture().split("\n").slice(0, 7).join("\n"); } -// Shaped like a real Claude Code transcript reader's output (see -// domain/formats/claude-code-transcript.ts), but this file stubs `SessionCostReader` -// throughout — it tests the use-case's own orchestration (dedup, status, provenance -// stamping), independent of any tool's real reader. +// Shaped like a real Claude Code transcript record, but this file stubs `SessionCostReader` +// throughout: it tests the use-case's orchestration, not any tool's real reader. const CANDIDATE: LocalCostCandidateRecord = { kind: "request", vendor_id: SESSION_ID, @@ -125,8 +114,7 @@ describe("ReadLocalCostUseCase", () => { registerTool(claudeConfig); }); - // What this stub route supplies is not what this file is about; it declares the minimum - // the type requires so the use case's own orchestration is what gets tested. + // The minimum the type requires: what a route supplies is not what this file tests. const SUPPLIES_NOTHING = { tokenCounters: false, amount: false, @@ -317,8 +305,6 @@ describe("ReadLocalCostUseCase", () => { expect(stored.person_display_name).toBe("Baptiste"); }); - // The spec's own line: a choice made today does not reach backwards. Re-reading an - // already-stored session after opting in must not retroactively name it. it("leaves a session stored before opting in unnamed, even on a later read", async () => { declareClaudeReadable(); const sink = new InMemoryTelemetrySink(); @@ -339,9 +325,8 @@ describe("ReadLocalCostUseCase", () => { expect("person_id" in stored).toBe(false); }); - // Task 2's own criterion: the use-case names the tool it asked, never the candidate - // itself — `CANDIDATE` carries no `tool` field at all (the type omits it), so this is - // structurally impossible for the reader to have supplied. + // `CANDIDATE` carries no `tool` field — the type omits it — so the stamp cannot have come + // from the reader. it("stamps the tool it asked", async () => { declareClaudeReadable(); const sink = new InMemoryTelemetrySink(); @@ -359,12 +344,8 @@ describe("ReadLocalCostUseCase", () => { expect(stored.tool).toBe("claude"); }); - // A reader cannot name its own tool, and the proof belongs to the compiler rather than - // to a run: `LocalCostCandidateRecord` omits `tool`, so the attempt below does not - // compile. `@ts-expect-error` inverts that into an assertion — the day the field becomes - // settable, the directive has nothing to suppress and `tsc` fails on it. A runtime test - // would have had to widen the type to build the value it forbids, which is the hole - // being closed, not a way to check it is closed. + // `LocalCostCandidateRecord` omits `tool`, so the assignment below does not compile; + // `@ts-expect-error` inverts that into the assertion — make it settable and `tsc` fails. it("forbids a reader from naming its own tool, at compile time", () => { const candidate: LocalCostCandidateRecord = { ...CANDIDATE, @@ -396,8 +377,7 @@ describe("ReadLocalCostUseCase", () => { const afterSecond = JSON.stringify([...sink.files.values()]); expect(afterSecond).toBe(afterFirst); - // Still "found", not "empty": the reader returned a record, dedup just skipped it — - // collapsing this into "empty" would erase the distinction task 5 exists to keep. + // Still "found", not "empty": the reader returned a record, dedup just skipped it. const claudeReport = second.toolReports.find((r) => r.tool === "claude"); expect(claudeReport).toMatchObject({ status: "found", recordsFound: 1, recordsStored: 0 }); }); @@ -423,12 +403,11 @@ describe("ReadLocalCostUseCase", () => { expect(cursor?.reason).toContain("token count"); }); - it("reports an unmeasured tool as not-covered with no reason invented for it", async () => { - // Every AI tool is either declared or explicitly unsupported as of phase 3, so - // "unmeasured" is exercised here via an override rather than a real tool — the - // use-case must still report it as not-covered, with no reason fabricated for a - // fact that has not been established either way. - registerTool({ ...claudeConfig, telemetryLocalRead: { kind: "unmeasured" } }); + it("reports an unsupported tool's not-covered reason as exactly what it declared, nothing invented", async () => { + const cursorLocalRead = getAiToolConfig("cursor").telemetryLocalRead; + if (cursorLocalRead.kind !== "unsupported") { + throw new Error("cursor is expected to declare an unsupported local read for this test"); + } const sink = new InMemoryTelemetrySink(); const useCase = new ReadLocalCostUseCase( sink, @@ -444,11 +423,8 @@ describe("ReadLocalCostUseCase", () => { sessionId: SESSION_ID, }); - const claude = result.toolReports.find((r) => r.tool === "claude"); - expect(claude).toMatchObject({ status: "not-covered" }); - // The key is absent, not present-and-empty: this codebase omits rather than nulls, so - // a reason that shows up as a blank line downstream is a bug, not a formatting choice. - expect(claude).not.toHaveProperty("reason"); + const cursor = result.toolReports.find((r) => r.tool === "cursor"); + expect(cursor?.reason).toBe(cursorLocalRead.reason); }); it("distinguishes not-covered from covered-and-empty", async () => { @@ -493,12 +469,8 @@ describe("ReadLocalCostUseCase", () => { expect([...sink.files.values()].flat()).toHaveLength(1); }); - // Measured 2026-09-04 on a live sink: 339 groups of byte-identical records, 474 extra - // lines, every one of them a subagent record. The source explains it - of 29,741 distinct - // requestIds in that project's transcripts, 350 appear in more than one file, and one read - // of the session hands both copies to this method as two candidates of the same batch. The - // index of what is already stored was read once, before the loop, so the first copy was - // appended without the second ever being matched against it. + // A requestId appearing in more than one transcript file hands one read both copies as two + // candidates of the same batch, so the index of what is stored has to stay live inside it. it("stores one line when a single read hands it the same turn twice", async () => { declareClaudeReadable(); const sink = new InMemoryTelemetrySink(); @@ -520,9 +492,8 @@ describe("ReadLocalCostUseCase", () => { expect(result.toolReports.find((r) => r.tool === "claude")?.recordsStored).toBe(1); }); - // The correction route still works inside one batch: a second candidate for the same turn - // that strictly improves on the first is what `isLocalReadTurnCorrection` exists for, and - // making the index live must not turn it into a drop. + // `isLocalReadTurnCorrection` still has to fire inside one batch: a live index must not + // turn a strictly improving second candidate into a drop. it("still lands a correction when the larger reading arrives in the same read", async () => { declareClaudeReadable(); const sink = new InMemoryTelemetrySink(); @@ -540,9 +511,8 @@ describe("ReadLocalCostUseCase", () => { expect([...sink.files.values()].flat().map((r) => r.output_tokens)).toEqual([20, 900]); }); - // The live index must key on a real identifier and nothing else. Folding the keyless - // records under one shared key would let the second of them be matched against the first - // and dropped - the opposite of the contract the test below states for a re-read. + // The live index keys on a real identifier and nothing else: one shared key for the keyless + // records would match the second against the first and drop it. it("appends every keyless candidate of one batch, never matching two of them to each other", async () => { declareClaudeReadable(); const keyless: LocalCostCandidateRecord = { ...CANDIDATE, turn_id: undefined }; @@ -588,10 +558,8 @@ describe("ReadLocalCostUseCase", () => { } }); - // The defect this task fixes: `codex-rollout.ts:156` flushes the pending turn - // unconditionally, so a Codex turn read while its session is still running is stored - // partial, and — before this task — `storeNewCandidates` matched the completed reading's - // `turn_id` against that partial record and dropped it, permanently. + // `codex-rollout.ts` flushes the pending turn unconditionally, so a Codex turn read while + // its session runs is stored partial, and its completed reading shares that `turn_id`. describe("a Codex turn read while it runs is not the last word", () => { it("lands the completed figures once the rest of the turn arrives", async () => { const sink = new InMemoryTelemetrySink(); @@ -634,9 +602,8 @@ describe("ReadLocalCostUseCase", () => { output_tokens: 3550, input_tokens: 5032, }); - // The earlier, partial reading is still there — the sink never edits a stored line — - // but the built report (cost-report.ts's `collapseSupersededTurns`) is what a - // consumer actually reads, and it keeps only the larger of the two. + // The partial reading stays: the sink never edits a stored line, and it is + // `collapseSupersededTurns` in the built report that keeps only the larger of the two. }); it("stops re-appending once a re-read brings nothing new", async () => { @@ -699,16 +666,11 @@ describe("ReadLocalCostUseCase", () => { }); it("lands the completed figures even once the run journal's own turn_end has been seen", async () => { - // A `turn_end` line only ever says no *more* growth is coming — it must never be - // read as a reason to refuse a candidate that is strictly larger than what is - // stored. A strictly larger candidate is itself proof the stored reading was not - // final, whatever the journal's clock says. (This is the exact trap the fix's first - // draft fell into: gating the correction on `turn_end` re-created the defect by - // freezing the partial reading the moment a `turn_end` line existed at all.) + // A `turn_end` says only that no *more* growth is coming; a strictly larger candidate is + // itself proof the stored reading was not final, whatever the journal's clock says. const sink = new InMemoryTelemetrySink(); const journalReader = new InMemoryRunJournalReader(); - // The last turn opens at 2026-07-29T15:15:13.692Z (codex-rollout.unit.test.ts); this - // turn_end lands after that. + // This turn_end lands after the fixture's last turn opens. journalReader.set(CODEX_TARGET_ID, journalWithTurnEnd("2026-07-29T15:20:00Z")); const reader = growingCodexReader(truncatedCodexFixture(), loadCodexFixture()); const useCase = new ReadLocalCostUseCase( @@ -778,8 +740,7 @@ describe("ReadLocalCostUseCase", () => { }); // A `kind: "session"` record is a one-shot cumulative total, never a growing per-turn - // snapshot — a re-read matching its turn_id is dropped exactly as it always was, - // never treated as a correction opportunity. + // snapshot, so a re-read matching its turn_id is a drop and never a correction. expect(second.toolReports.find((r) => r.tool === "claude")?.recordsStored).toBe(0); expect([...sink.files.values()].flat().filter((r) => r.turn_id === "shutdown-1")).toHaveLength( 1 @@ -886,8 +847,8 @@ describe("ReadLocalCostUseCase", () => { expect(stored.step).toBeUndefined(); }); - // Task 3's own criterion: a journal interval covers the same moment too, and still - // loses — the tool's own answer is exact, an interval is only ever an inference. + // A journal interval covers the same moment too, and still loses: the tool's own answer is + // exact, an interval is only ever an inference. it("prefers the tool's own stated step over a journal interval that also covers it", async () => { declareClaudeReadable(); const sink = new InMemoryTelemetrySink(); @@ -912,8 +873,8 @@ describe("ReadLocalCostUseCase", () => { }); }); - // Task 4's own criterion: attribution is an addition, never a precondition. The same - // transcript, read with and without a journal beside it, must store the same figures. + // Attribution is an addition, never a precondition: the same transcript, read with and + // without a journal beside it, stores the same figures. it("yields identical counters whether a journal is present or not", async () => { declareClaudeReadable(); const withJournalSink = new InMemoryTelemetrySink(); @@ -945,7 +906,6 @@ describe("ReadLocalCostUseCase", () => { cache_creation_tokens: record.cache_creation_tokens, }); expect(counters(withStored)).toEqual(counters(withoutStored)); - // The one thing that does differ is the attribution itself. expect(withStored.step_attribution).toBe("journal-interval"); expect(withoutStored.step_attribution).toBe("unattributed"); }); @@ -1126,7 +1086,6 @@ describe("a reader that fails", () => { ); expect(failed.map((report) => report.status)).toEqual(["unreadable", "unreadable"]); expect(failed.every((report) => report.recordsFound === 0)).toBe(true); - // Nothing anywhere in the answer claims a tool cost zero. expect(result.toolReports.some((report) => report.status === "empty")).toBe(false); }); @@ -1323,9 +1282,8 @@ describe("reading every session the journal knows", () => { describe("a failure in a sweep does not disappear behind a success", () => { it("reports the tool as read, and still says how many sessions it could not read", async () => { - // Nineteen good sessions and one bad is the case that matters: the figures are real, - // so the status is honest, and a failure visible only in the status would vanish - // exactly where there is most to lose. + // Nineteen good sessions and one bad is the case that matters: the figures are real, so a + // failure visible only in the status would vanish exactly where there is most to lose. const journal = new InMemoryRunJournalReader(); for (const vendorId of ["s-good", "s-bad"]) { journal.set(vendorId, { @@ -1386,9 +1344,8 @@ describe("a failure in a sweep does not disappear behind a success", () => { expect(claude?.failureReason).toBeUndefined(); }); - // Retention had exactly one caller — the OTLP receiver — and deleting that route left - // the sink pruned by nothing. `read` is now the only thing that writes a day file, so - // it is the only thing that can bound how many there are. + // `read` is the only thing that writes a day file, so it is the only thing that can bound + // how many there are. it("prunes day files outside the retention window, once per sweep", async () => { const sink = new InMemoryTelemetrySink(); for (const day of ["2026-01-01", "2026-01-02", "2026-01-03"]) { @@ -1457,8 +1414,8 @@ describe("a failure in a sweep does not disappear behind a success", () => { }); }); -// Finding 4 (review.md, "one route, and every sentence about it true"): this is the one -// remaining writer of the sink, so a refusal that does not hold here is cosmetic. +// This is the one remaining writer of the sink, so a refusal that does not hold here is +// cosmetic. describe("a refusal holds on the one writer left", () => { it("reads nothing and stores nothing when the project switch is off", async () => { const sink = new InMemoryTelemetrySink(); @@ -1515,14 +1472,8 @@ describe("a refusal holds on the one writer left", () => { }); }); -/** - * Which readers a session actually reaches. - * - * Reading every tool for every session was pure waste the journal could already have - * prevented, and one tool charged for it: the OpenCode reader shells out to its binary and - * waits, measured at 1.15s for a session it does not have. Nothing guarded the behaviour - * either way, so these tests are what make the narrowing a decision rather than a habit. - */ +/** Which readers a session actually reaches. Reading every tool costs real time: the + * OpenCode reader shells out and waits 1.15s for a session it does not have. */ describe("which readers a session reaches", () => { const SPIED_SESSION = "spied-session"; const SUPPLIES = { @@ -1639,9 +1590,8 @@ describe("which readers a session reaches", () => { sessionId: SPIED_SESSION, }); - // Cursor's declaration says its files carry no counter at all — a fact about the tool, - // true of every session. Answering "not this session's tool" would trade the reason a - // person needs for one that says less. + // Cursor's declaration says its files carry no counter at all: a fact about the tool, true + // of every session, and stronger than "not this session's tool". const cursor = result.toolReports.find((r) => r.tool === "cursor"); expect(cursor?.status).toBe("not-covered"); expect(cursor?.reason).toBeTruthy(); diff --git a/cli/tests/contexts/telemetry/application/report-cost-use-case.unit.test.ts b/cli/tests/contexts/telemetry/application/report-cost-use-case.unit.test.ts new file mode 100644 index 000000000..515fd004d --- /dev/null +++ b/cli/tests/contexts/telemetry/application/report-cost-use-case.unit.test.ts @@ -0,0 +1,894 @@ +import { readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { beforeEach, describe, expect, it } from "vitest"; +import "../../../../src/contexts/tools/domain/profiles/claude/profile.js"; +import "../../../../src/contexts/tools/domain/profiles/codex/profile.js"; +import "../../../../src/contexts/tools/domain/profiles/copilot/profile.js"; +import "../../../../src/contexts/tools/domain/profiles/cursor/profile.js"; +import "../../../../src/contexts/tools/domain/profiles/opencode/profile.js"; +import { ReadLocalCostUseCase } from "../../../../src/contexts/telemetry/application/read-local-cost-use-case.js"; +import { ReportCostUseCase } from "../../../../src/contexts/telemetry/application/report-cost-use-case.js"; +import { toMicroUsd } from "../../../../src/contexts/telemetry/domain/cost-report.js"; +import type { RunJournal } from "../../../../src/contexts/telemetry/domain/ports/run-journal-reader.js"; +import type { LocalCostCandidateRecord } from "../../../../src/contexts/telemetry/domain/ports/session-cost-reader.js"; +import { taskFolderPathFromIdentity } from "../../../../src/contexts/telemetry/domain/task-backlog-link.js"; +import type { TelemetrySinkRecord } from "../../../../src/contexts/telemetry/domain/telemetry-sink-record.js"; +import { UnreadableIdentityFileError } from "../../../../src/kernel/errors.js"; +import { AI_TOOL_IDS } from "../../../../src/kernel/tool.js"; +import { CapturingLogger } from "../../../helpers/ports/capturing-logger.js"; +import { NULL_PERSON_IDENTITY_READER } from "../../../helpers/ports/in-memory-person-identity-reader.js"; +import { InMemoryPersonIdentityStore } from "../../../helpers/ports/in-memory-person-identity-store.js"; +import { InMemoryRunJournalReader } from "../../../helpers/ports/in-memory-run-journal-reader.js"; +import { InMemoryTaskBacklogReader } from "../../../helpers/ports/in-memory-task-backlog-reader.js"; +import { InMemoryTelemetrySink } from "../../../helpers/ports/in-memory-telemetry-sink.js"; +import { StubTelemetryEvidenceReader } from "../../../helpers/ports/stub-telemetry-evidence-reader.js"; + +const PERIOD = { fromDay: "2026-08-17", toDay: "2026-08-21" } as const; +// Every test here is about what the sink and the journal hold, not the project switch: the +// fixed root and empty env only satisfy `execute()`'s shape. +const BASE_OPTIONS = { projectRoot: "/project", env: {} } as const; +const STORED_ON = new Date("2026-08-21T09:00:00Z"); +const TASK = "2026_08/2026_08_21_cost-reporter"; + +function record(overrides: Partial): TelemetrySinkRecord { + return { + sink_schema_version: 2, + kind: "request", + provenance: "local-read", + tool: "claude", + vendor_id: "s-1", + vendor_field: "sessionId", + step_attribution: "unattributed", + event_timestamp: "2026-08-18T10:00:00.000Z", + ...overrides, + }; +} + +describe("ReportCostUseCase", () => { + let sink: InMemoryTelemetrySink; + let journals: InMemoryRunJournalReader; + let identity: InMemoryPersonIdentityStore; + let evidence: StubTelemetryEvidenceReader; + let taskBacklog: InMemoryTaskBacklogReader; + let useCase: ReportCostUseCase; + + beforeEach(() => { + sink = new InMemoryTelemetrySink(); + journals = new InMemoryRunJournalReader(); + identity = new InMemoryPersonIdentityStore(); + evidence = new StubTelemetryEvidenceReader(); + taskBacklog = new InMemoryTaskBacklogReader(); + useCase = new ReportCostUseCase( + sink, + journals, + identity, + evidence, + taskBacklog, + new CapturingLogger() + ); + }); + + async function store(...records: readonly TelemetrySinkRecord[]): Promise { + for (const stored of records) await sink.appendRecord(stored, STORED_ON); + } + + it("reports a period from what the sink holds, whatever session it belongs to", async () => { + await store( + record({ vendor_id: "s-1", cost_usd: 0.1 }), + record({ vendor_id: "s-2", cost_usd: 0.2 }) + ); + + const built = await useCase.execute({ ...BASE_OPTIONS, period: PERIOD }); + + expect(built.sessions).toBe(2); + expect(built.totals.costMicroUsd).toBe(toMicroUsd(0.3)); + expect([built.fromDay, built.toDay]).toEqual(["2026-08-17", "2026-08-21"]); + }); + + it("leaves out work that happened before the period, however recently it was stored", async () => { + // Both lines are appended on the same day; only their own moments differ. + await store( + record({ vendor_id: "july", cost_usd: 9, event_timestamp: "2026-07-29T15:12:27.889Z" }), + record({ vendor_id: "august", cost_usd: 1 }) + ); + + const built = await useCase.execute({ ...BASE_OPTIONS, period: PERIOD }); + + expect(built.totals.costMicroUsd).toBe(toMicroUsd(1)); + expect(built.sessions).toBe(1); + }); + + it("restricts to the sessions that wrote into the task asked for", async () => { + journals.set("s-task", { + boundaries: [], + session: { + type: "session_start", + at: "2026-08-18T09:00:00Z", + run_id: "01ARZ3NDEKTSV4RRFFQ69G5FAV", + tool: "claude-code", + vendor_id: "s-task", + }, + filesWritten: [ + { + type: "file_written", + at: "2026-08-18T09:30:00Z", + path: `aidd_docs/tasks/${TASK}/plan.md`, + }, + ], + taskDeclarations: [], + }); + await store( + record({ vendor_id: "s-task", cost_usd: 1 }), + record({ vendor_id: "s-elsewhere", cost_usd: 8 }) + ); + + const built = await useCase.execute({ ...BASE_OPTIONS, period: PERIOD, task: TASK }); + + expect(built.task).toBe(TASK); + expect(built.totals.costMicroUsd).toBe(toMicroUsd(1)); + }); + + // The second record predates the journal's own span, so no written-file inference can + // reach it however many files that session went on to write. + it("names a record inside the journal's span after the only task folder that session wrote into", async () => { + journals.set("s-inferred", { + boundaries: [], + session: { + type: "session_start", + at: "2026-08-18T09:00:00Z", + run_id: "01ARZ3NDEKTSV4RRFFQ69G5FAV", + tool: "claude-code", + vendor_id: "s-inferred", + }, + filesWritten: [ + { + type: "file_written", + at: "2026-08-18T09:30:00Z", + path: `aidd_docs/tasks/${TASK}/plan.md`, + }, + ], + taskDeclarations: [], + }); + await store( + record({ + vendor_id: "s-inferred", + cost_usd: 1, + event_timestamp: "2026-08-18T09:15:00Z", + }), + record({ + vendor_id: "s-inferred", + cost_usd: 2, + event_timestamp: "2026-08-17T09:15:00Z", + }) + ); + + const built = await useCase.execute({ ...BASE_OPTIONS, period: PERIOD }); + + const inferred = built.byTasks.find((row) => row.attribution === "inferred"); + expect(inferred?.task).toBe(TASK); + expect(inferred?.totals.costMicroUsd).toBe(toMicroUsd(1)); + expect(built.byTasks.some((row) => row.reason !== undefined)).toBe(true); + }); + + it("gives every declared tool a row, with the reason an unreadable one cannot be read", async () => { + await store(record({ cost_usd: 1 })); + + const built = await useCase.execute({ ...BASE_OPTIONS, period: PERIOD }); + + expect(built.byTools.map((row) => row.tool)).toEqual([...AI_TOOL_IDS]); + const cursor = built.byTools.find((row) => row.tool === "cursor"); + expect(cursor?.coverage).toBe("not-covered"); + expect(cursor?.reason).toBeTruthy(); + }); + + it("reports what the read could not place or could not parse", async () => { + await store( + record({ cost_usd: 1 }), + record({ vendor_id: "no-moment", event_timestamp: undefined }) + ); + + const built = await useCase.execute({ ...BASE_OPTIONS, period: PERIOD }); + + expect(built.undatedRecords).toBe(1); + expect(built.totals.requests).toBe(1); + }); + + it("answers an empty period with an empty report and no error", async () => { + const built = await useCase.execute({ ...BASE_OPTIONS, period: PERIOD }); + + expect(built.sessions).toBe(0); + expect(built.totals).toEqual({ requests: 0 }); + expect(built.byTools.every((row) => row.totals.requests === 0)).toBe(true); + }); + + it("reports a period whose sessions have no journal at all", async () => { + await store(record({ cost_usd: 1 })); + + expect((await useCase.execute({ ...BASE_OPTIONS, period: PERIOD })).totals.requests).toBe(1); + }); + + it("resolves byPeople against the identity this store holds", async () => { + identity = new InMemoryPersonIdentityStore({ + personId: "person-a", + origin: "adopted", + alsoMe: ["machine-1"], + }); + useCase = new ReportCostUseCase( + sink, + journals, + identity, + evidence, + new InMemoryTaskBacklogReader(), + new CapturingLogger() + ); + await store(record({ vendor_id: "s-1", cost_usd: 1, person_id: "machine-1" })); + + const built = await useCase.execute({ ...BASE_OPTIONS, period: PERIOD }); + + const mapped = built.byPeople.find((row) => row.resolution === "mapped"); + expect(mapped?.person).toBe("person-a"); + }); + + it("survives an identity that cannot be read, reporting every figure with the caveat set", async () => { + identity.throwOnRead = new UnreadableIdentityFileError(identity.filePath, "EISDIR"); + await store(record({ vendor_id: "s-1", cost_usd: 1, person_id: "machine-1" })); + + const built = await useCase.execute({ ...BASE_OPTIONS, period: PERIOD }); + + expect(built.totals.requests).toBe(1); + expect(built.identityUnusableCause).toBe("unreadable"); + expect(built.byPeople.every((row) => row.resolution !== "mapped")).toBe(true); + }); + + it("reports no identity declared as its own cause, distinct from unreadable", async () => { + await store(record({ vendor_id: "s-1", cost_usd: 1, person_id: "machine-1" })); + + const built = await useCase.execute({ ...BASE_OPTIONS, period: PERIOD }); + + expect(built.identityUnusableCause).toBe("absent"); + }); + + it("reports whether the project switch is on, from the evidence reader alone", async () => { + evidence.enabled = false; + + const built = await useCase.execute({ ...BASE_OPTIONS, period: PERIOD }); + + expect(built.measurementEnabled).toBe(false); + }); + + it("reports the switch as on when the evidence reader says so, even with nothing measured", async () => { + evidence.enabled = true; + + const built = await useCase.execute({ ...BASE_OPTIONS, period: PERIOD }); + + expect(built.measurementEnabled).toBe(true); + expect(built.totals.requests).toBe(0); + }); + + it("re-throws an error it does not recognise rather than mislabelling it as a named cause", async () => { + identity.throwOnRead = new Error("some other failure entirely"); + + await expect(useCase.execute({ ...BASE_OPTIONS, period: PERIOD })).rejects.toThrow( + "some other failure entirely" + ); + }); + + // A journal moment is second-precision — the writing hook strips the milliseconds — while + // a record keeps them, so a record landing in that same second still counts as inside. + it("counts a record inside the last second its journal wrote as witnessed", async () => { + journals.set("s-same-second", { + boundaries: [], + session: { + type: "session_start", + at: "2026-08-18T09:00:00Z", + run_id: "01ARZ3NDEKTSV4RRFFQ69G5FB0", + tool: "claude-code", + vendor_id: "s-same-second", + }, + filesWritten: [ + { + type: "file_written", + at: "2026-08-18T09:30:00Z", + path: `aidd_docs/tasks/${TASK}/plan.md`, + }, + ], + taskDeclarations: [], + }); + await store( + record({ + vendor_id: "s-same-second", + cost_usd: 5, + event_timestamp: "2026-08-18T09:30:00.351Z", + }) + ); + + const built = await useCase.execute({ ...BASE_OPTIONS, period: PERIOD }); + + expect(built.byTasks.find((row) => row.attribution === "inferred")?.task).toBe(TASK); + }); + + it("resolves the backlog declaration of a task no interval ever declared", async () => { + journals.set("s-written-only", { + boundaries: [], + session: { + type: "session_start", + at: "2026-08-18T09:00:00Z", + run_id: "01ARZ3NDEKTSV4RRFFQ69G5FAX", + tool: "claude-code", + vendor_id: "s-written-only", + }, + filesWritten: [ + { + type: "file_written", + at: "2026-08-18T09:40:00Z", + path: `aidd_docs/tasks/${TASK}/plan.md`, + }, + ], + taskDeclarations: [], + }); + taskBacklog.set(taskFolderPathFromIdentity(TASK), { + kind: "declared", + link: { backlog: "acme/widgets#742", writtenAt: "2026-08-18T09:00:00Z", writtenBy: "x" }, + }); + await store( + record({ + vendor_id: "s-written-only", + cost_usd: 3, + event_timestamp: "2026-08-18T09:20:00Z", + }) + ); + + const built = await useCase.execute({ ...BASE_OPTIONS, period: PERIOD }); + + expect(built.byBacklog.map((row) => row.backlog)).toContain("acme/widgets#742"); + }); + + it("resolves the declaration through TaskBacklogReader, keyed on the folder the task identity resolves to", async () => { + // The double is set on the exact folder path a real adapter would be asked to read, + // never on the bare task identity string. + journals.set("s-task", { + boundaries: [], + session: { + type: "session_start", + at: "2026-08-18T09:00:00Z", + run_id: "01ARZ3NDEKTSV4RRFFQ69G5FAW", + tool: "claude-code", + vendor_id: "s-task", + }, + // A witnessed moment after the record's own timestamp: without one the declared + // interval's end collapses to its start and the record below falls outside it. + filesWritten: [ + { + type: "file_written", + at: "2026-08-18T09:40:00Z", + path: `aidd_docs/tasks/${TASK}/plan.md`, + }, + ], + taskDeclarations: [ + { + type: "task_declared", + at: "2026-08-18T09:00:00Z", + path: `aidd_docs/tasks/${TASK}/spec.md`, + }, + ], + }); + taskBacklog.set(taskFolderPathFromIdentity(TASK), { + kind: "declared", + link: { + backlog: "acme/widgets#661", + writtenAt: "2026-08-18T08:00:00Z", + writtenBy: "aidd-pm:04-spec", + }, + }); + await store( + record({ vendor_id: "s-task", cost_usd: 4, event_timestamp: "2026-08-18T09:30:00Z" }) + ); + + const built = await useCase.execute({ ...BASE_OPTIONS, period: PERIOD }); + + const named = built.byBacklog.find((row) => row.backlog === "acme/widgets#661"); + expect(named?.totals.requests).toBe(1); + expect(named?.totals.costMicroUsd).toBe(toMicroUsd(4)); + }); + + it("names no tool, by string literal", () => { + const source = readFileSync( + fileURLToPath( + new URL( + "../../../../src/contexts/telemetry/application/report-cost-use-case.ts", + import.meta.url + ) + ), + "utf8" + ); + + for (const toolId of AI_TOOL_IDS) { + expect(source).not.toContain(`"${toolId}"`); + expect(source).not.toContain(`'${toolId}'`); + } + }); +}); + +describe("a report that catches the sink up first", () => { + const SESSION = "s-catch-up"; + const AT = "2026-08-18T10:00:00.000Z"; + + let sink: InMemoryTelemetrySink; + let journals: InMemoryRunJournalReader; + let evidence: StubTelemetryEvidenceReader; + + function journalAt(at: string): RunJournal { + return { + boundaries: [], + filesWritten: [], + taskDeclarations: [], + session: { + type: "session_start", + at, + run_id: "01ARZ3NDEKTSV4RRFFQ69G5FAV", + tool: "claude-code", + vendor_id: SESSION, + }, + }; + } + + /** The real local read over in-memory ports, never a double: a stand-in could be made to + * show `report` reaching the read whether or not it does. */ + function localRead(records: readonly LocalCostCandidateRecord[]): ReadLocalCostUseCase { + return new ReadLocalCostUseCase( + sink, + new Map([["claude", { read: async () => ({ records, sessionFound: true }) }]]), + journals, + NULL_PERSON_IDENTITY_READER, + evidence + ); + } + + function reportWith(read?: ReadLocalCostUseCase): ReportCostUseCase { + return new ReportCostUseCase( + sink, + journals, + new InMemoryPersonIdentityStore(), + evidence, + new InMemoryTaskBacklogReader(), + new CapturingLogger(), + read + ); + } + + const CANDIDATE: LocalCostCandidateRecord = { + kind: "request", + vendor_id: SESSION, + vendor_field: "sessionId", + turn_id: "t-1", + event_timestamp: AT, + input_tokens: 100, + output_tokens: 10, + }; + + beforeEach(() => { + sink = new InMemoryTelemetrySink(); + journals = new InMemoryRunJournalReader(); + evidence = new StubTelemetryEvidenceReader(); + }); + + it("reports a journalled session nobody ran a read for", async () => { + journals.set(SESSION, journalAt(AT)); + + const built = await reportWith(localRead([CANDIDATE])).execute({ + ...BASE_OPTIONS, + period: PERIOD, + }); + + expect(built.totals.requests).toBe(1); + expect(built.totals.inputTokens).toBe(100); + }); + + it("reports only what the sink holds when no read was wired, rather than guessing", async () => { + journals.set(SESSION, journalAt(AT)); + + const built = await reportWith().execute({ ...BASE_OPTIONS, period: PERIOD }); + + expect(built.totals.requests).toBe(0); + }); + + // A journal can disappear while its records stay: `aidd_docs/runs/` is git-ignored, so a + // clean checkout holds the figures and none of the boundaries. + it("keeps a stored step for a session the period's journals say nothing about", async () => { + await sink.appendRecord( + record({ + vendor_id: "s-no-journal", + event_timestamp: "2026-08-18T10:00:00.000Z", + step_attribution: "journal-interval", + step: "aidd-dev:05-review", + }), + STORED_ON + ); + + const built = await reportWith().execute({ ...BASE_OPTIONS, period: PERIOD }); + + expect(built.bySteps).toContainEqual( + expect.objectContaining({ attribution: "journal-interval", step: "aidd-dev:05-review" }) + ); + }); + + it("leaves a tool-stated step alone, even where the journal's interval names another", async () => { + const journal = journalAt("2026-08-18T09:00:00Z"); + journals.set(SESSION, { + ...journal, + boundaries: [ + { type: "step_start", at: "2026-08-18T09:30:00Z", skill: "aidd-dev:02-implement" }, + ], + }); + await sink.appendRecord( + record({ + vendor_id: SESSION, + event_timestamp: "2026-08-18T10:00:00.000Z", + step_attribution: "tool-stated", + step: "aidd-vcs:01-commit", + }), + STORED_ON + ); + + const built = await reportWith().execute({ ...BASE_OPTIONS, period: PERIOD }); + + expect(built.bySteps).toContainEqual( + expect.objectContaining({ attribution: "tool-stated", step: "aidd-vcs:01-commit" }) + ); + }); + + // The record's moment falls outside every interval, so only the prompt both sides name can + // attribute it; three steps really do open under one prompt on a measured live session. + it("names the step a shared prompt opened first, never the last to reuse it", async () => { + const journal = journalAt("2026-08-18T09:00:00Z"); + journals.set(SESSION, { + ...journal, + boundaries: [ + { + type: "step_start", + at: "2026-08-18T11:00:00Z", + skill: "aidd-pm:04-spec", + turn_id: "p-abc", + }, + { + type: "step_start", + at: "2026-08-18T11:30:00Z", + skill: "aidd-dev:01-plan", + turn_id: "p-abc", + }, + ], + }); + await sink.appendRecord( + record({ + vendor_id: SESSION, + event_timestamp: "2026-08-18T10:00:00.000Z", + prompt_id: "p-abc", + }), + STORED_ON + ); + + const built = await reportWith().execute({ ...BASE_OPTIONS, period: PERIOD }); + + expect(built.bySteps).toContainEqual( + expect.objectContaining({ attribution: "prompt-matched", step: "aidd-pm:04-spec" }) + ); + }); + + it("attributes on the prompt both sides name, where no interval covers the moment", async () => { + const journal = journalAt("2026-08-18T09:00:00Z"); + journals.set(SESSION, { + ...journal, + boundaries: [ + { + type: "step_start", + at: "2026-08-18T11:00:00Z", + skill: "aidd-dev:02-implement", + turn_id: "p-abc", + }, + ], + }); + await sink.appendRecord( + record({ + vendor_id: SESSION, + // Before the step ever opened: no interval can reach it. + event_timestamp: "2026-08-18T10:00:00.000Z", + prompt_id: "p-abc", + }), + STORED_ON + ); + + const built = await reportWith().execute({ ...BASE_OPTIONS, period: PERIOD }); + + expect(built.bySteps).toContainEqual( + expect.objectContaining({ attribution: "prompt-matched", step: "aidd-dev:02-implement" }) + ); + }); + + /** The journal never opened the step because the hook was not installed when the session + * ran; the record still carries the skill its own transcript named for that prompt. */ + it("attributes on the skill the record's own prompt invoked, where no journal saw it", async () => { + journals.set(SESSION, journalAt("2026-08-18T09:00:00Z")); + await sink.appendRecord( + record({ + vendor_id: SESSION, + event_timestamp: "2026-08-18T10:00:00.000Z", + prompt_id: "p-abc", + prompt_skill: "aidd-dev:01-plan", + }), + STORED_ON + ); + + const built = await reportWith().execute({ ...BASE_OPTIONS, period: PERIOD }); + + expect(built.bySteps).toContainEqual( + expect.objectContaining({ attribution: "prompt-matched", step: "aidd-dev:01-plan" }) + ); + }); + + // The journal was written by a hook the host itself fired while the transcript is read back + // afterwards, so the reading with a witness wins. + it("keeps the journal's own answer when both sides name a skill for the same prompt", async () => { + const journal = journalAt("2026-08-18T09:00:00Z"); + journals.set(SESSION, { + ...journal, + boundaries: [ + { + type: "step_start", + at: "2026-08-18T11:00:00Z", + skill: "aidd-pm:04-spec", + turn_id: "p-abc", + }, + ], + }); + await sink.appendRecord( + record({ + vendor_id: SESSION, + event_timestamp: "2026-08-18T10:00:00.000Z", + prompt_id: "p-abc", + prompt_skill: "aidd-dev:01-plan", + }), + STORED_ON + ); + + const built = await reportWith().execute({ ...BASE_OPTIONS, period: PERIOD }); + + expect(built.bySteps).toContainEqual( + expect.objectContaining({ attribution: "prompt-matched", step: "aidd-pm:04-spec" }) + ); + }); + + it("derives a stored record's step from the journal rather than trusting the stored one", async () => { + const at = "2026-08-18T10:00:00.000Z"; + const journal = journalAt("2026-08-18T09:00:00Z"); + journals.set(SESSION, { + ...journal, + // The pause after the record is what the step is capped at: a step runs past a pause + // but never past the last moment its own journal witnessed. + boundaries: [ + { type: "step_start", at: "2026-08-18T09:30:00Z", skill: "aidd-dev:02-implement" }, + { type: "turn_end", at: "2026-08-18T10:30:00Z" }, + ], + }); + await sink.appendRecord( + record({ vendor_id: SESSION, event_timestamp: at, step_attribution: "unattributed" }), + STORED_ON + ); + + const built = await reportWith().execute({ ...BASE_OPTIONS, period: PERIOD }); + + expect(built.bySteps).toContainEqual( + expect.objectContaining({ attribution: "journal-interval", step: "aidd-dev:02-implement" }) + ); + }); + + // `groupByTurnId` indexes nothing without a `turn_id`, so re-reading a session whose + // records carry none would append them a second time. + it("leaves a session alone when its stored records carry no turn id to match on", async () => { + journals.set(SESSION, journalAt(AT)); + await sink.appendRecord(record({ vendor_id: SESSION, event_timestamp: AT }), STORED_ON); + let reads = 0; + const counting = new ReadLocalCostUseCase( + sink, + new Map([ + [ + "claude", + { + read: async () => { + reads += 1; + return { records: [CANDIDATE], sessionFound: true }; + }, + }, + ], + ]), + journals, + NULL_PERSON_IDENTITY_READER, + evidence + ); + + await reportWith(counting).execute({ ...BASE_OPTIONS, period: PERIOD }); + + expect(reads).toBe(0); + }); + + it("reads a stored session again, so a live session's later turns land", async () => { + journals.set(SESSION, journalAt(AT)); + // A `turn_id` is what makes a re-read reconcilable rather than duplicating. + await sink.appendRecord( + record({ vendor_id: SESSION, event_timestamp: AT, turn_id: "req_1" }), + STORED_ON + ); + let reads = 0; + const counting = new ReadLocalCostUseCase( + sink, + new Map([ + [ + "claude", + { + read: async () => { + reads += 1; + return { records: [CANDIDATE], sessionFound: true }; + }, + }, + ], + ]), + journals, + NULL_PERSON_IDENTITY_READER, + evidence + ); + + await reportWith(counting).execute({ ...BASE_OPTIONS, period: PERIOD }); + + expect(reads).toBe(1); + }); + + it("reaches a session journalled on the last day of the period, which runs to midnight", async () => { + // The bound is the first instant after `toDay`, not `toDay` at 00:00: getting it wrong + // drops the whole last day, which `--days N` always makes today. + journals.set(SESSION, journalAt(`${PERIOD.toDay}T23:59:59.999Z`)); + + const built = await reportWith( + localRead([{ ...CANDIDATE, event_timestamp: `${PERIOD.toDay}T23:59:59.999Z` }]) + ).execute({ ...BASE_OPTIONS, period: PERIOD }); + + expect(built.totals.requests).toBe(1); + }); + + it("reaches a session journalled at the very first instant of the period", async () => { + journals.set(SESSION, journalAt(`${PERIOD.fromDay}T00:00:00.000Z`)); + + const built = await reportWith( + localRead([{ ...CANDIDATE, event_timestamp: `${PERIOD.fromDay}T00:00:00.000Z` }]) + ).execute({ ...BASE_OPTIONS, period: PERIOD }); + + expect(built.totals.requests).toBe(1); + }); + + it("never reaches for a session journalled the instant the period ends", async () => { + // Midnight opening the day after `toDay` is the first moment outside, not the last one + // inside — the half a `>` instead of a `>=` would silently widen. + const dayAfter = new Date(Date.parse(`${PERIOD.toDay}T00:00:00Z`) + 86_400_000); + journals.set(SESSION, journalAt(dayAfter.toISOString())); + + const built = await reportWith(localRead([CANDIDATE])).execute({ + ...BASE_OPTIONS, + period: PERIOD, + }); + + expect(built.totals.requests).toBe(0); + }); + + it("never reaches for a session whose own moment falls outside the period asked about", async () => { + // Otherwise catching up would cost with the age of the repository rather than with the + // length of the period asked about. + journals.set(SESSION, journalAt("2020-01-01T00:00:00.000Z")); + + const built = await reportWith(localRead([CANDIDATE])).execute({ + ...BASE_OPTIONS, + period: PERIOD, + }); + + expect(built.totals.requests).toBe(0); + }); + + it("deletes no stored day file, since a question is not housekeeping", async () => { + // `read` prunes past its retention window, which is housekeeping; behind a report that + // would delete measurement as a side effect of being asked a question. + journals.set(SESSION, journalAt(AT)); + for (let day = 1; day <= 120; day += 1) { + const stamp = new Date(Date.UTC(2025, 0, day)); + await sink.appendRecord(record({ vendor_id: `old-${day}` }), stamp); + } + const before = await sink.listDayFiles(); + + await reportWith(localRead([CANDIDATE])).execute({ ...BASE_OPTIONS, period: PERIOD }); + + // Asserted as "none of these is gone", not as an unchanged count: the catch-up stores + // what it reads, so it adds a day file of its own. + const after = new Set(await sink.listDayFiles()); + expect(before.filter((file) => !after.has(file))).toEqual([]); + }); + + it("says what a reader could not answer, rather than reporting the silence as no spend", async () => { + // A period where every reader threw would otherwise print exactly what a period with no + // spend prints, and nobody sees the read surface's own output behind a report. + journals.set(SESSION, journalAt(AT)); + const warnings: string[] = []; + const throwing = new ReadLocalCostUseCase( + sink, + new Map([ + [ + "claude", + { + read: async () => { + throw new Error("the transcript directory is unreadable"); + }, + }, + ], + ]), + journals, + NULL_PERSON_IDENTITY_READER, + evidence + ); + const report = new ReportCostUseCase( + sink, + journals, + new InMemoryPersonIdentityStore(), + evidence, + new InMemoryTaskBacklogReader(), + { debug: () => {}, info: () => {}, warn: (m: string) => warnings.push(m) }, + throwing + ); + + const built = await report.execute({ ...BASE_OPTIONS, period: PERIOD }); + + expect(built.totals.requests).toBe(0); + expect(warnings.join("\n")).toContain("the transcript directory is unreadable"); + }); + + it("skips a journal whose own moment cannot be read at all, rather than treating it as now", async () => { + journals.set(SESSION, journalAt("not a moment")); + + const built = await reportWith(localRead([CANDIDATE])).execute({ + ...BASE_OPTIONS, + period: PERIOD, + }); + + expect(built.totals.requests).toBe(0); + }); + + it("opens no tool's files at all when the project switch is off", async () => { + // Asserted on whether the reader was reached, not on the total: a refused catch-up and + // an absent one both report zero, so a total cannot tell them apart. + journals.set(SESSION, journalAt(AT)); + let reads = 0; + const counting = new ReadLocalCostUseCase( + sink, + new Map([ + [ + "claude", + { + read: async () => { + reads += 1; + return { records: [CANDIDATE], sessionFound: true }; + }, + }, + ], + ]), + journals, + NULL_PERSON_IDENTITY_READER, + evidence + ); + + evidence.enabled = true; + await reportWith(counting).execute({ ...BASE_OPTIONS, period: PERIOD }); + const readWhenOn = reads; + + sink = new InMemoryTelemetrySink(); + reads = 0; + evidence.enabled = false; + await reportWith(counting).execute({ ...BASE_OPTIONS, period: PERIOD }); + + expect(readWhenOn).toBeGreaterThan(0); + expect(reads).toBe(0); + }); +}); diff --git a/cli/tests/application/use-cases/telemetry/telemetry-off-use-case.unit.test.ts b/cli/tests/contexts/telemetry/application/telemetry-off-use-case.unit.test.ts similarity index 75% rename from cli/tests/application/use-cases/telemetry/telemetry-off-use-case.unit.test.ts rename to cli/tests/contexts/telemetry/application/telemetry-off-use-case.unit.test.ts index 38da95d1e..e8931c588 100644 --- a/cli/tests/application/use-cases/telemetry/telemetry-off-use-case.unit.test.ts +++ b/cli/tests/contexts/telemetry/application/telemetry-off-use-case.unit.test.ts @@ -1,13 +1,16 @@ import { join } from "node:path"; import { describe, expect, it } from "vitest"; -import { TelemetryOffUseCase } from "../../../../src/application/use-cases/telemetry/telemetry-off-use-case.js"; -import { SESSION_TRAILER_TOKEN } from "../../../../src/domain/formats/commit-session-trailer.js"; -import type { VersionControl } from "../../../../src/domain/ports/version-control.js"; +import { TelemetryOffUseCase } from "../../../../src/contexts/telemetry/application/telemetry-off-use-case.js"; +import { SESSION_TRAILER_TOKEN } from "../../../../src/contexts/telemetry/domain/formats/commit-session-trailer.js"; +import type { + CommitMessageDelegateRemoval, + VersionControl, +} from "../../../../src/contexts/telemetry/domain/ports/version-control.js"; +import { noGit } from "../../../contexts/framework/application/helpers.js"; import { CapturingLogger } from "../../../helpers/ports/capturing-logger.js"; import { DeterministicHasher } from "../../../helpers/ports/deterministic-hasher.js"; import { InMemoryFileAdapter } from "../../../helpers/ports/in-memory-file-adapter.js"; import { StubTelemetryEvidenceReader } from "../../../helpers/ports/stub-telemetry-evidence-reader.js"; -import { noGit } from "../helpers.js"; const PROJECT_ROOT = "/repo"; const SWITCH_PATH = join(PROJECT_ROOT, ".aidd", "config.json"); @@ -90,10 +93,8 @@ describe("TelemetryOffUseCase — an endpoint configuration is untouched", () => }); }); -// `off` cannot clear a stale export — the writer that could is gone — but silence is -// exactly the failure this exists to close (see finding 1, review.md). Detection itself -// lives in `TelemetryEvidenceAdapter` (a real settings file, exercised end to end); this -// only proves `off` relays what it is told, by name, on `warn`. +// `off` cannot clear a stale export — the writer that could is gone — so all it owes is to +// relay what it is told, by name, on `warn`; detection lives in `TelemetryEvidenceAdapter`. describe("TelemetryOffUseCase — names a leftover export it cannot clear", () => { it("warns with the file and the keys still set, when one is found", async () => { const { logger, evidence, useCase } = buildUseCase(); @@ -122,7 +123,7 @@ describe("TelemetryOffUseCase — names a leftover export it cannot clear", () = }); describe("TelemetryOffUseCase — taking back what on installed", () => { - function buildWith(removed: boolean, seed: Record = {}) { + function buildWith(result: CommitMessageDelegateRemoval, seed: Record = {}) { const fs = new InMemoryFileAdapter(seed, new DeterministicHasher()); const logger = new CapturingLogger(); const asked: string[] = []; @@ -130,7 +131,7 @@ describe("TelemetryOffUseCase — taking back what on installed", () => { ...noGit, removeCommitMessageDelegate: async (_root, delegateFile) => { asked.push(delegateFile); - return removed; + return result; }, }; const evidence = new StubTelemetryEvidenceReader(); @@ -138,7 +139,7 @@ describe("TelemetryOffUseCase — taking back what on installed", () => { } it("asks git to remove the delegate, whatever the switch's previous state was", async () => { - const { asked, useCase } = buildWith(true); + const { asked, useCase } = buildWith({ removed: true }); await useCase.execute({ projectRoot: PROJECT_ROOT }); @@ -146,7 +147,7 @@ describe("TelemetryOffUseCase — taking back what on installed", () => { }); it("says new commits carry nothing, and that the old ones keep theirs", async () => { - const { logger, useCase } = buildWith(true); + const { logger, useCase } = buildWith({ removed: true }); await useCase.execute({ projectRoot: PROJECT_ROOT }); @@ -156,10 +157,39 @@ describe("TelemetryOffUseCase — taking back what on installed", () => { }); it("says nothing when there was nothing installed to take back", async () => { - const { logger, useCase } = buildWith(false); + const { logger, useCase } = buildWith({ removed: false }); await useCase.execute({ projectRoot: PROJECT_ROOT }); expect(logger.allMessages.join("\n")).not.toContain(SESSION_TRAILER_TOKEN); }); + + // A manager's own hand-added job outlives the delegate script `off` just deleted: that + // config is committed and shared, so silence leaves a live-looking line doing nothing. + it("names the manager job left behind, and says its guard now makes it a no-op", async () => { + const { logger, useCase } = buildWith({ + removed: true, + hookManager: "lefthook", + managerCallsDelegate: true, + }); + + await useCase.execute({ projectRoot: PROJECT_ROOT }); + + const said = logger.allMessages.join("\n"); + expect(said).toContain("lefthook.yml"); + expect(said).toContain("still calls the delegate"); + expect(said).toMatch(/\[ -f \]|no-op|runs nothing/u); + }); + + it("says nothing about a manager job when its own config never called the delegate", async () => { + const { logger, useCase } = buildWith({ + removed: true, + hookManager: "lefthook", + managerCallsDelegate: false, + }); + + await useCase.execute({ projectRoot: PROJECT_ROOT }); + + expect(logger.allMessages.join("\n")).not.toContain("still calls the delegate"); + }); }); diff --git a/cli/tests/contexts/telemetry/application/telemetry-on-hook-manager.integration.test.ts b/cli/tests/contexts/telemetry/application/telemetry-on-hook-manager.integration.test.ts new file mode 100644 index 000000000..12eca99da --- /dev/null +++ b/cli/tests/contexts/telemetry/application/telemetry-on-hook-manager.integration.test.ts @@ -0,0 +1,102 @@ +import { describe, expect, it } from "vitest"; +import { GitignoreUseCase } from "../../../../src/contexts/framework/application/gitignore-use-case.js"; +import { TelemetryOnUseCase } from "../../../../src/contexts/telemetry/application/telemetry-on-use-case.js"; +import { SESSION_TRAILER_TOKEN } from "../../../../src/contexts/telemetry/domain/formats/commit-session-trailer.js"; +import type { VersionControl } from "../../../../src/contexts/telemetry/domain/ports/version-control.js"; +import type { HookManager } from "../../../../src/contexts/telemetry/domain/telemetry-setup.js"; +import { noGit } from "../../../contexts/framework/application/helpers.js"; +import { CapturingLogger } from "../../../helpers/ports/capturing-logger.js"; +import { DeterministicHasher } from "../../../helpers/ports/deterministic-hasher.js"; +import { InMemoryFileAdapter } from "../../../helpers/ports/in-memory-file-adapter.js"; +import { InMemoryTelemetrySink } from "../../../helpers/ports/in-memory-telemetry-sink.js"; + +const PROJECT_ROOT = "/repo"; + +/** `on` cannot promise a trailer where lefthook or husky owns `prepare-commit-msg` and + * regenerates it out from under whatever this CLI appended. */ +function managedGit(hookManager: HookManager, managerCallsDelegate: boolean): VersionControl { + return { + ...noGit, + installCommitMessageDelegate: async () => ({ + lineAdded: false, + hookManager, + managerCallsDelegate, + }), + }; +} + +function useCaseWith(git: VersionControl) { + const fs = new InMemoryFileAdapter({}, new DeterministicHasher()); + const logger = new CapturingLogger(); + return { + logger, + useCase: new TelemetryOnUseCase( + fs, + logger, + new GitignoreUseCase(fs), + git, + new InMemoryTelemetrySink() + ), + }; +} + +describe("TelemetryOnUseCase — where a manager owns prepare-commit-msg", () => { + it("prints the job to add and stops promising a trailer, for lefthook", async () => { + const { logger, useCase } = useCaseWith(managedGit("lefthook", false)); + + await useCase.execute({ projectRoot: PROJECT_ROOT, confirmed: true }); + + const said = logger.allMessages.join("\n"); + expect(said).toContain("lefthook"); + expect(said).toContain("prepare-commit-msg:"); + expect(said).toContain("lefthook.yml"); + expect(said).not.toContain("so what a session cost can be read per commit"); + }); + + it("prints the line to add and stops promising a trailer, for husky", async () => { + const { logger, useCase } = useCaseWith(managedGit("husky", false)); + + await useCase.execute({ projectRoot: PROJECT_ROOT, confirmed: true }); + + const said = logger.allMessages.join("\n"); + expect(said).toContain("husky"); + expect(said).toContain(".husky/prepare-commit-msg"); + expect(said).toContain('"$@"'); + expect(said).not.toContain("so what a session cost can be read per commit"); + }); + + it("prints nothing about the trailer once the manager's own config already calls it", async () => { + const { logger, useCase } = useCaseWith(managedGit("lefthook", true)); + + await useCase.execute({ projectRoot: PROJECT_ROOT, confirmed: true }); + + const said = logger.allMessages.join("\n"); + expect(said).not.toContain(SESSION_TRAILER_TOKEN); + expect(said).not.toContain("lefthook.yml"); + }); + + /** Under lefthook the append is wiped by the next regeneration, so the real adapter reports + * `lineAdded: true` on every `on`, never only the first. */ + it("reads hookManager over lineAdded, so a lefthook repo never gets the false promise", async () => { + const { logger, useCase } = useCaseWith(managedGit("lefthook", false)); + // Faking the adapter's own measured shape: `lineAdded` still reports `true` because the + // hook was rewritten again, but a manager owns it regardless. + const gitReportingTrueAnyway: VersionControl = { + ...noGit, + installCommitMessageDelegate: async () => ({ + lineAdded: true, + hookManager: "lefthook", + managerCallsDelegate: false, + }), + }; + const { logger: secondLogger, useCase: secondUseCase } = useCaseWith(gitReportingTrueAnyway); + + await useCase.execute({ projectRoot: PROJECT_ROOT, confirmed: true }); + await secondUseCase.execute({ projectRoot: PROJECT_ROOT, confirmed: true }); + + for (const said of [logger.allMessages.join("\n"), secondLogger.allMessages.join("\n")]) { + expect(said).not.toContain("so what a session cost can be read per commit"); + expect(said).toContain("lefthook.yml"); + } + }); +}); diff --git a/cli/tests/contexts/telemetry/application/telemetry-on-use-case.unit.test.ts b/cli/tests/contexts/telemetry/application/telemetry-on-use-case.unit.test.ts new file mode 100644 index 000000000..35916d1b2 --- /dev/null +++ b/cli/tests/contexts/telemetry/application/telemetry-on-use-case.unit.test.ts @@ -0,0 +1,254 @@ +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; +import { GitignoreUseCase } from "../../../../src/contexts/framework/application/gitignore-use-case.js"; +import { TelemetryOnUseCase } from "../../../../src/contexts/telemetry/application/telemetry-on-use-case.js"; +import { + SESSION_TRAILER_DELEGATE_FILE, + SESSION_TRAILER_TOKEN, + sessionTrailerDelegateScript, +} from "../../../../src/contexts/telemetry/domain/formats/commit-session-trailer.js"; +import type { VersionControl } from "../../../../src/contexts/telemetry/domain/ports/version-control.js"; +import { TelemetryProjectScopeRequiresYesError } from "../../../../src/kernel/errors.js"; +import type { FileReader } from "../../../../src/kernel/ports/file-reader.js"; +import type { FileWriter } from "../../../../src/kernel/ports/file-writer.js"; +import { noGit } from "../../../contexts/framework/application/helpers.js"; +import { CapturingLogger } from "../../../helpers/ports/capturing-logger.js"; +import { DeterministicHasher } from "../../../helpers/ports/deterministic-hasher.js"; +import { InMemoryFileAdapter } from "../../../helpers/ports/in-memory-file-adapter.js"; +import { InMemoryTelemetrySink } from "../../../helpers/ports/in-memory-telemetry-sink.js"; + +/** Fails a write to one path alone — a disk-full or permission failure on a single file — + * so a write failing afterwards must not leave the switch written anyway. */ +class ThrowingWriteAdapter implements FileReader, FileWriter { + constructor( + private readonly inner: InMemoryFileAdapter, + private readonly failingPath: string + ) {} + + readFile(path: string): Promise { + return this.inner.readFile(path); + } + listDirectory(path: string): Promise { + return this.inner.listDirectory(path); + } + fileExists(path: string): Promise { + return this.inner.fileExists(path); + } + readFileHash(path: string): ReturnType { + return this.inner.readFileHash(path); + } + listFilesRecursive(dirPath: string): Promise { + return this.inner.listFilesRecursive(dirPath); + } + isExecutable(path: string): Promise { + return this.inner.isExecutable(path); + } + realpath(path: string): Promise { + return this.inner.realpath(path); + } + async writeFile(path: string, content: string): Promise { + if (path === this.failingPath) throw new Error(`disk full writing ${path}`); + await this.inner.writeFile(path, content); + } + deleteFile(path: string): Promise { + return this.inner.deleteFile(path); + } + createDirectory(path: string): Promise { + return this.inner.createDirectory(path); + } + deleteEmptyDirectories(path: string): Promise { + return this.inner.deleteEmptyDirectories(path); + } + deleteDirectory(path: string): Promise { + return this.inner.deleteDirectory(path); + } + chmodExecutable(path: string): Promise { + return this.inner.chmodExecutable(path); + } +} + +const PROJECT_ROOT = "/repo"; +const SWITCH_PATH = join(PROJECT_ROOT, ".aidd", "config.json"); +const LOCAL_SETTINGS_PATH = join(PROJECT_ROOT, ".claude", "settings.local.json"); + +function buildUseCase(seed: Record = {}) { + const hasher = new DeterministicHasher(); + const fs = new InMemoryFileAdapter(seed, hasher); + const logger = new CapturingLogger(); + const sink = new InMemoryTelemetrySink(); + const useCase = new TelemetryOnUseCase(fs, logger, new GitignoreUseCase(fs), noGit, sink); + return { fs, logger, useCase, sink }; +} + +describe("TelemetryOnUseCase — the switch alone", () => { + it("refuses when the records directory cannot be written, rather than losing them later", async () => { + // `appendRecord` creates the directory itself, so without this the first failure comes + // at the first record — to whoever runs `read`, not to whoever turned it on. + const { fs, useCase, sink } = buildUseCase(); + sink.unwritable = true; + + await expect(useCase.execute({ projectRoot: PROJECT_ROOT, confirmed: true })).rejects.toThrow( + "not writable" + ); + expect(fs.has(SWITCH_PATH)).toBe(false); + }); + + it("a gitignore write that fails leaves the switch unwritten, never enabled: true over a half-finished setup", async () => { + const inner = new InMemoryFileAdapter({}, new DeterministicHasher()); + // Matches GitignoreUseCase's own literal "/" join, never `join()`, which would key this + // adapter's failing path differently from the one the use case actually writes. + const fs = new ThrowingWriteAdapter(inner, `${PROJECT_ROOT}/.gitignore`); + const logger = new CapturingLogger(); + const useCase = new TelemetryOnUseCase( + fs, + logger, + new GitignoreUseCase(fs), + noGit, + new InMemoryTelemetrySink() + ); + + await expect(useCase.execute({ projectRoot: PROJECT_ROOT, confirmed: true })).rejects.toThrow( + "disk full" + ); + expect(inner.has(SWITCH_PATH)).toBe(false); + }); + + it("succeeds with no endpoint anywhere, and writes no tool's settings file", async () => { + const { fs, useCase } = buildUseCase(); + const result = await useCase.execute({ projectRoot: PROJECT_ROOT, confirmed: true }); + + expect(result.switchChanged).toBe(true); + const written = JSON.parse(fs.getFile(SWITCH_PATH) ?? "null"); + expect(written.telemetry).toEqual({ enabled: true }); + expect(fs.has(LOCAL_SETTINGS_PATH)).toBe(false); + }); + + it("prints the resolved switch path before writing anything", async () => { + const { logger, useCase } = buildUseCase(); + await useCase.execute({ projectRoot: PROJECT_ROOT, confirmed: true }); + expect(logger.infoMessages[0]).toBe(`AIDD telemetry switch -> ${SWITCH_PATH}`); + }); + + it("preserves an endpoint already recorded in the switch file — `on` has no opinion on it", async () => { + const seed = { + [SWITCH_PATH]: JSON.stringify({ + telemetry: { enabled: false, endpoint: "https://otel.example.com" }, + }), + }; + const { fs, useCase } = buildUseCase(seed); + await useCase.execute({ projectRoot: PROJECT_ROOT, confirmed: true }); + const written = JSON.parse(fs.getFile(SWITCH_PATH) as string); + expect(written.telemetry).toEqual({ enabled: true, endpoint: "https://otel.example.com" }); + }); + + it("enabling twice reports the switch unchanged the second time", async () => { + const { useCase } = buildUseCase(); + const first = await useCase.execute({ projectRoot: PROJECT_ROOT, confirmed: true }); + const second = await useCase.execute({ projectRoot: PROJECT_ROOT, confirmed: true }); + expect(first.switchChanged).toBe(true); + expect(second.switchChanged).toBe(false); + }); +}); + +describe("TelemetryOnUseCase — the same consent `endpoint --scope project` already demands", () => { + it("without --yes, refuses and writes nothing, naming the consequence", async () => { + const { fs, useCase } = buildUseCase(); + await expect(useCase.execute({ projectRoot: PROJECT_ROOT, confirmed: false })).rejects.toThrow( + TelemetryProjectScopeRequiresYesError + ); + await expect(useCase.execute({ projectRoot: PROJECT_ROOT, confirmed: false })).rejects.toThrow( + /everyone who clones/ + ); + expect(fs.has(SWITCH_PATH)).toBe(false); + }); + + it("fires even when the switch is already on — the same unconditional guard `endpoint` uses", async () => { + const { fs, useCase } = buildUseCase(); + await useCase.execute({ projectRoot: PROJECT_ROOT, confirmed: true }); + expect(fs.has(SWITCH_PATH)).toBe(true); + + await expect(useCase.execute({ projectRoot: PROJECT_ROOT, confirmed: false })).rejects.toThrow( + TelemetryProjectScopeRequiresYesError + ); + }); + + it("with --yes, writes the switch", async () => { + const { fs, useCase } = buildUseCase(); + const result = await useCase.execute({ projectRoot: PROJECT_ROOT, confirmed: true }); + expect(result.switchChanged).toBe(true); + expect(fs.has(SWITCH_PATH)).toBe(true); + }); +}); + +describe("TelemetryOnUseCase — making commits joinable to the session that made them", () => { + /** Holds the decision — install, then say so — apart from the mechanics of writing a hook, + * which the adapter's own integration suite proves against real repositories. */ + function recordingGit(lineAdded: boolean) { + const calls: { delegateFile: string; script: string }[] = []; + const git: VersionControl = { + ...noGit, + installCommitMessageDelegate: async (_root, delegateFile, script) => { + calls.push({ delegateFile, script }); + return { lineAdded }; + }, + }; + return { calls, git }; + } + + function useCaseWith(git: VersionControl) { + const fs = new InMemoryFileAdapter({}, new DeterministicHasher()); + const logger = new CapturingLogger(); + return { + fs, + logger, + useCase: new TelemetryOnUseCase( + fs, + logger, + new GitignoreUseCase(fs), + git, + new InMemoryTelemetrySink() + ), + }; + } + + it("installs the delegate the domain declares, never a script written out a second time", async () => { + const { calls, git } = recordingGit(true); + const { useCase } = useCaseWith(git); + + await useCase.execute({ projectRoot: PROJECT_ROOT, confirmed: true }); + + expect(calls).toHaveLength(1); + expect(calls[0]?.delegateFile).toBe(SESSION_TRAILER_DELEGATE_FILE); + expect(calls[0]?.script).toBe(sessionTrailerDelegateScript()); + }); + + it("says what it will write into commit messages, and how to undo it", async () => { + const { git } = recordingGit(true); + const { logger, useCase } = useCaseWith(git); + + await useCase.execute({ projectRoot: PROJECT_ROOT, confirmed: true }); + + const said = logger.allMessages.join("\n"); + expect(said).toContain(SESSION_TRAILER_TOKEN); + expect(said).toContain("aidd telemetry off"); + }); + + it("says nothing when it was already installed - a no-op is not news", async () => { + const { git } = recordingGit(false); + const { logger, useCase } = useCaseWith(git); + + await useCase.execute({ projectRoot: PROJECT_ROOT, confirmed: true }); + + expect(logger.allMessages.join("\n")).not.toContain(SESSION_TRAILER_TOKEN); + }); + + it("installs on every successful on, so a project turned on before this is caught up", async () => { + const { calls, git } = recordingGit(false); + const { useCase } = useCaseWith(git); + + await useCase.execute({ projectRoot: PROJECT_ROOT, confirmed: true }); + await useCase.execute({ projectRoot: PROJECT_ROOT, confirmed: true }); + + expect(calls).toHaveLength(2); + }); +}); diff --git a/cli/tests/contexts/telemetry/application/tool-attribution.unit.test.ts b/cli/tests/contexts/telemetry/application/tool-attribution.unit.test.ts new file mode 100644 index 000000000..e22146052 --- /dev/null +++ b/cli/tests/contexts/telemetry/application/tool-attribution.unit.test.ts @@ -0,0 +1,92 @@ +import { readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { describe, expect, it } from "vitest"; +// Side-effect imports: the use-case resolves each tool's declaration from the registry, +// so every AI tool must be registered for these tests to see Claude Code's and Codex's. +import "../../../../src/contexts/tools/domain/profiles/claude/profile.js"; +import "../../../../src/contexts/tools/domain/profiles/codex/profile.js"; +import "../../../../src/contexts/tools/domain/profiles/copilot/profile.js"; +import "../../../../src/contexts/tools/domain/profiles/cursor/profile.js"; +import "../../../../src/contexts/tools/domain/profiles/opencode/profile.js"; +import { ReadLocalCostUseCase } from "../../../../src/contexts/telemetry/application/read-local-cost-use-case.js"; +import { mapClaudeCodeTranscriptToSinkRecords } from "../../../../src/contexts/telemetry/domain/formats/claude-code-transcript.js"; +import type { SessionCostReader } from "../../../../src/contexts/telemetry/domain/ports/session-cost-reader.js"; +import type { TelemetrySinkRecord } from "../../../../src/contexts/telemetry/domain/telemetry-sink-record.js"; +import { AI_TOOL_IDS } from "../../../../src/kernel/tool.js"; +import { NULL_PERSON_IDENTITY_READER } from "../../../helpers/ports/in-memory-person-identity-reader.js"; +import { NULL_RUN_JOURNAL_READER } from "../../../helpers/ports/in-memory-run-journal-reader.js"; +import { InMemoryTelemetrySink } from "../../../helpers/ports/in-memory-telemetry-sink.js"; +import { StubTelemetryEvidenceReader } from "../../../helpers/ports/stub-telemetry-evidence-reader.js"; + +const TRANSCRIPT_SESSION_ID = "22222222-2222-4222-8222-222222222222"; +const PROJECT_ROOT = "/repo"; + +function loadCapturedTranscript(): string { + const url = new URL( + `../../../fixtures/local-cost/.claude/projects/fake-project/${TRANSCRIPT_SESSION_ID}.jsonl`, + import.meta.url + ); + return readFileSync(fileURLToPath(url), "utf8"); +} + +function readSourceFile(relativePathFromSrc: string): string { + const url = new URL(`../../../../src/${relativePathFromSrc}`, import.meta.url); + return readFileSync(fileURLToPath(url), "utf8"); +} + +/** Only the file-walking adapter is stubbed: the captured transcript is parsed by the real + * mapper, so this stays a unit test and still proves the use case's own stamping. */ +async function readCapturedTranscript(): Promise<{ + readonly sink: InMemoryTelemetrySink; + readonly records: readonly TelemetrySinkRecord[]; +}> { + const candidates = mapClaudeCodeTranscriptToSinkRecords(loadCapturedTranscript()); + const stubReader: SessionCostReader = { + read: async () => ({ records: candidates, sessionFound: true }), + }; + const sink = new InMemoryTelemetrySink(); + const useCase = new ReadLocalCostUseCase( + sink, + new Map([["claude", stubReader]]), + NULL_RUN_JOURNAL_READER, + NULL_PERSON_IDENTITY_READER, + new StubTelemetryEvidenceReader() + ); + await useCase.execute({ + projectRoot: PROJECT_ROOT, + env: {}, + sessionId: TRANSCRIPT_SESSION_ID, + }); + return { sink, records: [...sink.files.values()].flat() }; +} + +describe("every stored record names its tool", () => { + it("names a tool on every record produced from a captured transcript", async () => { + const { records } = await readCapturedTranscript(); + expect(records.length).toBeGreaterThan(0); + expect(records.every((record) => record.tool !== undefined)).toBe(true); + }); + + it("names only a declared tool identifier, never a free string", async () => { + const { records } = await readCapturedTranscript(); + for (const record of records) { + expect(AI_TOOL_IDS).toContain(record.tool); + } + }); + + it("names the tool consistently, and the vendor field it read the identity from", async () => { + const { records } = await readCapturedTranscript(); + expect(records.every((record) => record.tool === "claude")).toBe(true); + expect(records[0]?.vendor_field).toBe("sessionId"); + }); + + // Derived from AI_TOOL_IDS, never hand-listed: a hardcoded name would defeat the criterion + // it proves, that adding a tool is a declaration the use case is never told about by name. + it("contains no tool name, by string literal, in the local-read use-case", () => { + const source = readSourceFile("contexts/telemetry/application/read-local-cost-use-case.ts"); + for (const toolId of AI_TOOL_IDS) { + expect(source).not.toContain(`"${toolId}"`); + expect(source).not.toContain(`'${toolId}'`); + } + }); +}); diff --git a/cli/tests/domain/models/cost-report-backlog.unit.test.ts b/cli/tests/contexts/telemetry/domain/cost-report-backlog.unit.test.ts similarity index 86% rename from cli/tests/domain/models/cost-report-backlog.unit.test.ts rename to cli/tests/contexts/telemetry/domain/cost-report-backlog.unit.test.ts index 8d61be040..8c0de3403 100644 --- a/cli/tests/domain/models/cost-report-backlog.unit.test.ts +++ b/cli/tests/contexts/telemetry/domain/cost-report-backlog.unit.test.ts @@ -5,10 +5,10 @@ import { type CostReportSessionJournal, type CostTotals, toMicroUsd, -} from "../../../src/domain/models/cost-report.js"; -import type { TaskBacklogDeclaration } from "../../../src/domain/models/task-backlog-link.js"; -import type { TaskIdentity } from "../../../src/domain/models/task-identity.js"; -import type { TelemetrySinkRecord } from "../../../src/domain/models/telemetry-sink-record.js"; +} from "../../../../src/contexts/telemetry/domain/cost-report.js"; +import type { TaskBacklogDeclaration } from "../../../../src/contexts/telemetry/domain/task-backlog-link.js"; +import type { TaskIdentity } from "../../../../src/contexts/telemetry/domain/task-identity.js"; +import type { TelemetrySinkRecord } from "../../../../src/contexts/telemetry/domain/telemetry-sink-record.js"; const BASE: TelemetrySinkRecord = { sink_schema_version: 2, @@ -55,10 +55,8 @@ function sumOf(rows: readonly { readonly totals: CostTotals }[]): CostTotals { ); } -// Four tasks declared in sequence by one session, each closed the moment the next opens - -// two declaring the same backlog item (the merge this axis exists for), one declaring -// none, one whose declaration could not be read. A fifth record precedes the first -// declaration, landing in the axis' own pass-through reason row. +// Four tasks declared in sequence, two naming the same backlog item (the merge this axis +// exists for), one naming none, one unreadable; a fifth record precedes them all. const ITEM_TASK_A = "2026_08/item-task-a"; const ITEM_TASK_B = "2026_08/item-task-b"; const NONE_TASK = "2026_08/none-task"; @@ -206,10 +204,8 @@ describe("buildCostReport — by_backlog regroups tasks by what their folder dec }); it("a task with no entry in the resolved declarations still counts, defaulting to none rather than dropping the record", () => { - // The map a report is actually handed always resolves every task identity its own - // journals can name (ReportCostUseCase's job) - this proves the domain does not - // silently lose a record's figures were that ever not true, the same defensive - // default `declaredTaskKeyOf`'s own fallback documents. + // A real report always resolves every task identity its journals name, so this pins + // the defensive default `taskRowOf` falls back to were that ever not true. const built = report({ records: RECORDS, journals: JOURNALS, @@ -222,10 +218,8 @@ describe("buildCostReport — by_backlog regroups tasks by what their folder dec }); it("mutation proof: a task declaring no item is never silently merged into one that declared", () => { - // Mutating the fixture to make the "none" task declare the same item the merge test - // uses would move its record into the named row - the guard this test exists to prove - // never happens on its own: the none row and the named row must stay disjoint unless a - // declaration is actually changed. + // The none row and the named row must stay disjoint unless a declaration is actually + // changed: making "none" declare the merge test's item would move its record over. const built = report({ records: RECORDS, journals: JOURNALS, diff --git a/cli/tests/contexts/telemetry/domain/cost-report-contract.unit.test.ts b/cli/tests/contexts/telemetry/domain/cost-report-contract.unit.test.ts new file mode 100644 index 000000000..eede37264 --- /dev/null +++ b/cli/tests/contexts/telemetry/domain/cost-report-contract.unit.test.ts @@ -0,0 +1,79 @@ +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; +import { describe, expect, it } from "vitest"; +import { COST_REPORT_ENVELOPE_VERSION } from "../../../../src/contexts/telemetry/domain/cost-report-envelope.js"; +import { STEP_ATTRIBUTION_SOURCES } from "../../../../src/contexts/telemetry/domain/step-attribution.js"; +import { TASK_UNATTRIBUTED_REASONS } from "../../../../src/contexts/telemetry/domain/task-attribution.js"; +import { ARTEFACT_AXES } from "../../../../src/presentation/display/cost-report-artefact.js"; +import { REPOSITORY_ROOT } from "../../../helpers/repository-root.js"; + +// Never a hand-maintained list on either side: only the code's own exported values and the +// document's own prose, both read fresh off disk. + +const CONTRACT_DOC_URL = pathToFileURL( + join(REPOSITORY_ROOT, "aidd_docs", "product", "cost-report-contract.md") +); + +function contractText(): string { + return readFileSync(fileURLToPath(CONTRACT_DOC_URL), "utf8"); +} + +describe("the cost report contract document", () => { + it("names every reason a row with no task can carry", () => { + const document = contractText(); + + // Required as a table cell, not merely somewhere in the prose: a reason named only in a + // version note is still missing from the table a reader parses against. + const undocumented = TASK_UNATTRIBUTED_REASONS.filter( + (reason) => !document.includes(`| \`"${reason}"\` |`) + ); + + expect(undocumented).toEqual([]); + }); + + // A journal file really can be read and still yield no session - `report-cost-use-case.ts` + // drops one whose `session_start` header is torn - so the word this hinges on is "usable". + it("never claims the unattributed reason means no journal existed", () => { + const document = contractText(); + + expect(document).not.toMatch(/no run journal was read for this record's session at all/i); + expect(document).toContain("no usable run journal"); + }); + + it("states the envelope version the code actually emits", () => { + const stated = /Every object carries `cost_report_version`, currently `(\d+)`/.exec( + contractText() + ); + + expect(stated?.[1]).toBe(String(COST_REPORT_ENVELOPE_VERSION)); + }); + + // A reader parses against the worked example, so pinning only the prose sentence above it + // guards the half nobody copies. + it("shows that same version in its own worked example", () => { + const shown = /"cost_report_version": (\d+)/.exec(contractText()); + + expect(shown?.[1]).toBe(String(COST_REPORT_ENVELOPE_VERSION)); + }); + + // The same drift the task-reason table already guards against: a source can join the + // code's own fixed order and never join the table a reader parses `attribution` against. + it("names every source a step's attribution can carry", () => { + const document = contractText(); + + const undocumented = STEP_ATTRIBUTION_SOURCES.filter( + (source) => !document.includes(`| \`${source}\` |`) + ); + + expect(undocumented).toEqual([]); + }); + + // The quoted example in the `--axis` usage error is prose, not code - it drifts the same + // way the worked example above does, silently, the moment a new axis is added. + it("quotes the exact axis list --axis's own usage error prints", () => { + const document = contractText(); + + expect(document).toContain(ARTEFACT_AXES.join(", ")); + }); +}); diff --git a/cli/tests/domain/models/cost-report-envelope.unit.test.ts b/cli/tests/contexts/telemetry/domain/cost-report-envelope.unit.test.ts similarity index 86% rename from cli/tests/domain/models/cost-report-envelope.unit.test.ts rename to cli/tests/contexts/telemetry/domain/cost-report-envelope.unit.test.ts index 04d94efc2..5d0f12206 100644 --- a/cli/tests/domain/models/cost-report-envelope.unit.test.ts +++ b/cli/tests/contexts/telemetry/domain/cost-report-envelope.unit.test.ts @@ -1,23 +1,25 @@ import { readFileSync } from "node:fs"; +import { join } from "node:path"; import { fileURLToPath } from "node:url"; import { describe, expect, it } from "vitest"; -import "../../../src/domain/tools/ai/claude.js"; -import "../../../src/domain/tools/ai/codex.js"; -import "../../../src/domain/tools/ai/copilot.js"; -import "../../../src/domain/tools/ai/cursor.js"; -import "../../../src/domain/tools/ai/opencode.js"; -import { printCostReport } from "../../../src/application/display/cost-report-display.js"; -import { CLIOutput } from "../../../src/application/output.js"; +import { REPOSITORY_ROOT } from "../../../helpers/repository-root.js"; +import "../../../../src/contexts/tools/domain/profiles/claude/profile.js"; +import "../../../../src/contexts/tools/domain/profiles/codex/profile.js"; +import "../../../../src/contexts/tools/domain/profiles/copilot/profile.js"; +import "../../../../src/contexts/tools/domain/profiles/cursor/profile.js"; +import "../../../../src/contexts/tools/domain/profiles/opencode/profile.js"; import { buildCostReport, type CostReportInput, type CostReportToolDeclaration, -} from "../../../src/domain/models/cost-report.js"; +} from "../../../../src/contexts/telemetry/domain/cost-report.js"; import { COST_REPORT_ENVELOPE_VERSION, toCostReportEnvelope, -} from "../../../src/domain/models/cost-report-envelope.js"; -import type { TelemetrySinkRecord } from "../../../src/domain/models/telemetry-sink-record.js"; +} from "../../../../src/contexts/telemetry/domain/cost-report-envelope.js"; +import type { TelemetrySinkRecord } from "../../../../src/contexts/telemetry/domain/telemetry-sink-record.js"; +import { printCostReport } from "../../../../src/presentation/display/cost-report-display.js"; +import { CapturingOutput } from "../../../helpers/ports/capturing-output.js"; const DECLARED: readonly CostReportToolDeclaration[] = [ { @@ -94,9 +96,8 @@ describe("toCostReportEnvelope", () => { expect(envelopeOf().period).toEqual({ from_day: "2026-08-17", to_day: "2026-08-21" }); }); - // Finding 3 (review.md, "one route, and every sentence about it true"): --json had no - // way to say measurement was off at all, breaking the spec's own constraint that a - // report says whether measurement is on. + // --json had no way to say measurement was off at all, breaking the constraint that a + // report states whether measurement is on. it("carries measurement_enabled, the one field the terminal rendering could see and this could not", () => { expect(envelopeOf({ measurementEnabled: true }).measurement_enabled).toBe(true); expect(envelopeOf({ measurementEnabled: false }).measurement_enabled).toBe(false); @@ -231,8 +232,7 @@ describe("toCostReportEnvelope", () => { }); // snake_case on the wire like every other row, and `started_at` beside the id because an - // opaque prompt id alone is not something a person can look up. The row for records that - // named no prompt carries neither field - see `CostReportPromptRow`. + // opaque prompt id alone is not something a person can look up. it("carries one row per prompt, dated, and one undated row for records that named none", () => { const envelope = envelopeOf({ records: [ @@ -288,24 +288,29 @@ describe("toCostReportEnvelope", () => { it("reads no clock and no filesystem", () => { const source = readFileSync( - fileURLToPath(new URL("../../../src/domain/models/cost-report-envelope.ts", import.meta.url)), + fileURLToPath( + new URL( + "../../../../src/contexts/telemetry/domain/cost-report-envelope.ts", + import.meta.url + ) + ), "utf8" ); expect(source).not.toContain("node:fs"); expect(source).not.toContain("Date"); }); -}); -/** Extends the real output rather than standing in for it, so a widened double cannot stop - * failing the day the class grows a method the printer starts calling. */ -class CapturingOutput extends CLIOutput { - readonly lines: string[] = []; + // A bump is one edit; the contract naming the new version is a second one nothing forces. + it("is the version the product contract names as current", () => { + const contract = readFileSync( + join(REPOSITORY_ROOT, "aidd_docs", "product", "cost-report-contract.md"), + "utf8" + ); - override print(message: string): void { - this.lines.push(message); - } -} + expect(contract).toContain(`currently \`${COST_REPORT_ENVELOPE_VERSION}\``); + }); +}); describe("the two renderings are one computation", () => { const RECORDS: readonly TelemetrySinkRecord[] = [ @@ -356,12 +361,17 @@ describe("the two renderings are one computation", () => { it("takes the same value on both sides, so neither can see a figure the other cannot", () => { const printerSource = readFileSync( fileURLToPath( - new URL("../../../src/application/display/cost-report-display.ts", import.meta.url) + new URL("../../../../src/presentation/display/cost-report-display.ts", import.meta.url) ), "utf8" ); const envelopeSource = readFileSync( - fileURLToPath(new URL("../../../src/domain/models/cost-report-envelope.ts", import.meta.url)), + fileURLToPath( + new URL( + "../../../../src/contexts/telemetry/domain/cost-report-envelope.ts", + import.meta.url + ) + ), "utf8" ); diff --git a/cli/tests/contexts/telemetry/domain/cost-report-order.property.unit.test.ts b/cli/tests/contexts/telemetry/domain/cost-report-order.property.unit.test.ts new file mode 100644 index 000000000..d9edae04c --- /dev/null +++ b/cli/tests/contexts/telemetry/domain/cost-report-order.property.unit.test.ts @@ -0,0 +1,162 @@ +import "../../../../src/contexts/tools/domain/profiles/claude/profile.js"; +import "../../../../src/contexts/tools/domain/profiles/codex/profile.js"; +import * as fc from "fast-check"; +import { describe, expect, it } from "vitest"; +import { + buildCostReport, + type CostReportInput, + type CostReportSessionJournal, +} from "../../../../src/contexts/telemetry/domain/cost-report.js"; +import type { TelemetrySinkRecord } from "../../../../src/contexts/telemetry/domain/telemetry-sink-record.js"; + +// A re-read appends, so one session's lines sit in different orders on two machines. Mixed key +// kinds in one `Map` — `by_flow` keys on an interval object or on a skill name — leak that order. +const AT = "2026-08-18T10:00:00Z"; +const LATER = "2026-08-18T11:30:00Z"; + +const NAMES_AGENTS = { + localRead: { tokenCounters: true, amount: false, toolStatedStep: true, agentName: true }, + export: null, + journalAttributable: true, + taskAttributable: true, +} as const; + +const NAMES_NO_AGENT = { + localRead: { tokenCounters: true, amount: false, toolStatedStep: false, agentName: false }, + export: null, + journalAttributable: false, + taskAttributable: false, +} as const; + +const DECLARED = [ + { tool: "claude", coverage: "covered", capability: NAMES_AGENTS }, + { tool: "codex", coverage: "covered", capability: NAMES_NO_AGENT }, +] as const; + +/** One session the journal witnessed, so an interval-derived flow row exists beside a + * tool-stated one — the two key kinds this property is about. */ +const JOURNALS: readonly CostReportSessionJournal[] = [ + { + vendorId: "s-witnessed", + tool: "claude-code", + writtenPaths: [], + taskIntervals: [], + flowIntervals: [ + { + skill: "aidd-orchestrator:01-sdlc", + startMs: Date.parse("2026-08-18T09:00:00Z"), + endMs: Date.parse("2026-08-18T10:30:00Z"), + closedBy: "boundary", + }, + ], + }, +]; + +function record(overrides: Partial): TelemetrySinkRecord { + return { + sink_schema_version: 2, + kind: "request", + provenance: "local-read", + tool: "claude", + vendor_id: "s-witnessed", + vendor_field: "sessionId", + step_attribution: "unattributed", + event_timestamp: AT, + cost_usd: 1, + ...overrides, + }; +} + +/** Every row kind the report can produce, once each — including a pair with identical figures + * whose order only a tie-break on the row's own key can decide. */ +const RECORDS: readonly TelemetrySinkRecord[] = [ + // Inside the witnessed flow, agent named by the tool. + record({ turn_id: "a", agent_name: "aidd-dev:executor", model: "opus", prompt_id: "p-1" }), + // Inside the witnessed flow, no agent — the main thread, since claude names agents. + record({ turn_id: "b", model: "haiku", prompt_id: "p-1" }), + // Outside every interval, but the tool named an orchestrating skill: a tool-stated flow. + record({ + turn_id: "c", + vendor_id: "s-unwitnessed", + event_timestamp: LATER, + step_attribution: "tool-stated", + step: "aidd-orchestrator:01-sdlc", + model: "opus", + }), + record({ + turn_id: "d", + vendor_id: "s-unwitnessed", + event_timestamp: LATER, + step_attribution: "tool-stated", + step: "aidd-orchestrator:02-backlog", + model: "haiku", + }), + // A tool that never names an agent: the third agent row, and it must not read as a main + // thread however the records arrive. + record({ turn_id: "e", tool: "codex", vendor_id: "s-codex", event_timestamp: LATER }), + // Two rows with identical figures, so only the tie-break on the row's own key can order + // them — the case repetition alone never catches. + record({ turn_id: "f", model: "zulu", cost_usd: 3, prompt_id: "p-2" }), + record({ turn_id: "g", model: "alpha", cost_usd: 3, prompt_id: "p-3" }), + // One billed call two routes saw, with equal counters and different content: picking + // `group[0]` would make the survivor depend on which line the day file listed first. + record({ + turn_id: "h", + billed_request_id: "req-1", + model: "opus", + cost_usd: 4, + input_tokens: 10, + agent_name: "Explore", + }), + record({ + turn_id: "i", + billed_request_id: "req-1", + model: "opus", + cost_usd: 4, + input_tokens: 10, + prompt_id: "p-4", + }), +]; + +function reportOf(records: readonly TelemetrySinkRecord[]): string { + const input: CostReportInput = { + fromDay: "2026-08-17", + toDay: "2026-08-21", + records, + journals: JOURNALS, + declaredTools: DECLARED, + undatedRecords: 0, + unreadableLines: 0, + measurementEnabled: true, + }; + return JSON.stringify(buildCostReport(input)); +} + +describe("buildCostReport — every row kind, arriving in any order", () => { + it("answers the same report for every permutation of the same records", () => { + const expected = reportOf(RECORDS); + + fc.assert( + fc.property(fc.shuffledSubarray([...RECORDS], { minLength: RECORDS.length }), (shuffled) => { + expect(reportOf(shuffled)).toBe(expected); + }), + { numRuns: 300 } + ); + }); + + // The fixture has to actually exercise what the property is about: a permutation of records + // that produce only one kind of row proves nothing about mixed keys. + it("covers both flow row kinds and all three agent attributions", () => { + const report = JSON.parse(reportOf(RECORDS)) as { + byFlows: { attribution: string }[]; + byAgents: { attribution: string }[]; + }; + + expect(new Set(report.byFlows.map((row) => row.attribution))).toEqual( + new Set(["journal-interval", "tool-stated", "unattributed"]) + ); + expect(new Set(report.byAgents.map((row) => row.attribution))).toEqual( + new Set(["tool-stated", "main-thread", "not-stated"]) + ); + }); +}); diff --git a/cli/tests/domain/models/cost-report-person.unit.test.ts b/cli/tests/contexts/telemetry/domain/cost-report-person.unit.test.ts similarity index 89% rename from cli/tests/domain/models/cost-report-person.unit.test.ts rename to cli/tests/contexts/telemetry/domain/cost-report-person.unit.test.ts index 450d8188c..04f26785a 100644 --- a/cli/tests/domain/models/cost-report-person.unit.test.ts +++ b/cli/tests/contexts/telemetry/domain/cost-report-person.unit.test.ts @@ -4,13 +4,13 @@ import { type CostReportInput, type CostTotals, toMicroUsd, -} from "../../../src/domain/models/cost-report.js"; +} from "../../../../src/contexts/telemetry/domain/cost-report.js"; import { COST_REPORT_ENVELOPE_VERSION, toCostReportEnvelope, -} from "../../../src/domain/models/cost-report-envelope.js"; -import type { TelemetrySinkRecord } from "../../../src/domain/models/telemetry-sink-record.js"; -import type { PersonIdentity } from "../../../src/domain/ports/person-identity-reader.js"; +} from "../../../../src/contexts/telemetry/domain/cost-report-envelope.js"; +import type { PersonIdentity } from "../../../../src/contexts/telemetry/domain/ports/person-identity-reader.js"; +import type { TelemetrySinkRecord } from "../../../../src/contexts/telemetry/domain/telemetry-sink-record.js"; const NO_CAPABILITY = { localRead: null, @@ -47,8 +47,6 @@ function report(overrides: Partial = {}) { }); } -/** One person declared two identifiers, from two different machines - the Test Scope's own - * setup. */ function twoIdentitiesOnePerson(): PersonIdentity { return { personId: "person-a", origin: "adopted", alsoMe: ["machine-1", "machine-2"] }; } @@ -115,9 +113,8 @@ describe("byPeople — one raw identity resolved per group, never merged or drop ]); }); - // With an identity declared, a record that carried no identifier is this machine's own - // person: the sink has one writer, and every line in it was read by this machine. Kept - // apart from `mapped`, which is the record naming a person this identity claims. + // A record that carried no identifier is this machine's own person: the sink has one writer. + // Kept apart from `mapped`, which is a record naming a person this identity claims. it("a record with no identifier lands in this machine's own row, distinct from every unresolved one", () => { const built = report({ identity: twoIdentitiesOnePerson(), @@ -203,9 +200,8 @@ describe("byPeople — one raw identity resolved per group, never merged or drop expect(built.totals.cacheCreationTokens).toBe(20); }); - // Strongest claim first, and every resolution present is placed - a filter per group - // drops whatever it does not name, which is exactly how `this-machine` rows went missing - // from this breakdown while the totals they belonged to stayed. + // A filter per group drops whatever it does not name, which is how `this-machine` rows went + // missing from this breakdown while the totals they belonged to stayed. it("orders mapped rows first, then this machine's own, then unresolved, then no identifier", () => { const withIdentity = report({ identity: twoIdentitiesOnePerson(), @@ -291,11 +287,8 @@ describe("the envelope carries by_person for a program to parse", () => { }); describe("byPeople — a billed call seen by both routes keeps its person", () => { - // The export route never carries a person (telemetry-sink-record.ts's own contract), so - // the survivor `mergeBilledRequestGroup` picks by `cost_usd` is exactly the export - // record - the one sibling in the group with no person_id at all. Discharging the note - // `mergeBilledRequestGroup`'s doc comment used to carry: without `withPersonBackfill`, - // this exact case would silently drop a mapped person's own work into `"none"`. + // The export route never carries a person, so the survivor `mergeBilledRequestGroup` picks + // by `cost_usd` is the one sibling in the group with no person_id at all. it("backfills the local-read sibling's person_id onto the export-route survivor", () => { const built = report({ identity: twoIdentitiesOnePerson(), diff --git a/cli/tests/domain/models/cost-report-task.unit.test.ts b/cli/tests/contexts/telemetry/domain/cost-report-task.unit.test.ts similarity index 80% rename from cli/tests/domain/models/cost-report-task.unit.test.ts rename to cli/tests/contexts/telemetry/domain/cost-report-task.unit.test.ts index c10222c2b..2c8f35319 100644 --- a/cli/tests/domain/models/cost-report-task.unit.test.ts +++ b/cli/tests/contexts/telemetry/domain/cost-report-task.unit.test.ts @@ -5,8 +5,8 @@ import { type CostReportSessionJournal, type CostTotals, toMicroUsd, -} from "../../../src/domain/models/cost-report.js"; -import type { TelemetrySinkRecord } from "../../../src/domain/models/telemetry-sink-record.js"; +} from "../../../../src/contexts/telemetry/domain/cost-report.js"; +import type { TelemetrySinkRecord } from "../../../../src/contexts/telemetry/domain/telemetry-sink-record.js"; const BASE: TelemetrySinkRecord = { sink_schema_version: 2, @@ -54,9 +54,7 @@ function sumOf(rows: readonly { readonly totals: CostTotals }[]): CostTotals { } // One session that declares two tasks in sequence, closing the first the moment the -// second opens - the shape `buildTaskIntervals` produces from two `task_declared` lines -// with no `turn_end` between them. A record before the first declaration falls in -// neither. +// second opens. A record before the first declaration falls in neither. const FIRST_TASK = "2026_08/first-task"; const SECOND_TASK = "2026_08/second-task"; const JOURNALS: readonly CostReportSessionJournal[] = [ @@ -170,12 +168,8 @@ describe("buildCostReport — by_task groups by the declared interval a record f expect(sumOf(built.byTasks).requests).toBe(built.totals.requests); }); - // Two absences a person acts on differently, and the report used to give them one name. - // A session whose journal was read and declared nothing is a fact about the work. A - // session with no journal at all is a fact about the read - the directory was missing, - // the machine that wrote it was another one, the project is not this one. Calling the - // second "no usable task declaration in this session" asserts something about work this - // layer never looked at, which is an unknown reported as a zero. + // A session whose journal was read and declared nothing is a fact about the work; a + // session with no journal at all is a fact about the read. One name for both is a zero. it("says no journal was read, rather than that the session declared nothing", () => { const records: readonly TelemetrySinkRecord[] = [ request({ vendor_id: "s-unjournalled", event_timestamp: "2026-08-17T10:00:00Z" }), @@ -221,12 +215,8 @@ describe("buildCostReport — by_task groups by the declared interval a record f expect(built.byBacklog[0]?.reason).toBe("no-journal"); }); - // 27 of a real session's 1073 records sat between `session_start` and its first - // declaration - 38 minutes of work before the flow named its ticket. That session wrote - // into exactly one task folder for its whole life, so those records have an answer the - // breakdown was not reading. Marked `inferred`, never merged into the declared row: the - // same task holds 1045 records the journal declared and 27 it did not, and one row - // carrying the weaker attribution would state something false about the 1045. + // A session bills records before its flow names a ticket. Where it wrote into exactly one + // task folder those have an answer, marked `inferred`, never merged into the declared row. it("names a record no declaration covers after the only task folder the session wrote into", () => { const journals: readonly CostReportSessionJournal[] = [ { @@ -261,9 +251,8 @@ describe("buildCostReport — by_task groups by the declared interval a record f expect(sumOf(built.byTasks).requests).toBe(built.totals.requests); }); - // Two candidates and no reason to choose between them. The objection that kept written - // paths out of this breakdown entirely - one session placed under two task rows at once - - // is answered by refusing, never by picking the first. + // Two candidates and no reason to choose between them: one session placed under two task + // rows at once is answered by refusing, never by picking the first. it("infers nothing for a session that wrote into two task folders", () => { const journals: readonly CostReportSessionJournal[] = [ { @@ -292,11 +281,8 @@ describe("buildCostReport — by_task groups by the declared interval a record f expect(built.byTasks[0]?.reason).toBe("no-declaration"); }); - // The bound that stops this route from inventing history. A session whose journal was lost - // and recreated witnesses only the time since: its earlier records are in the sink, and - // attributing them to a folder that session touched today would be false by days. Measured - // on a live machine, where one session's journal began at 09:54 while its own records ran - // back a week. + // A session whose journal was lost and recreated witnesses only the time since: its earlier + // records are in the sink, and attributing them to a folder it touched today is false. it("infers nothing for a record outside the span its journal actually witnessed", () => { const journals: readonly CostReportSessionJournal[] = [ { @@ -322,11 +308,8 @@ describe("buildCostReport — by_task groups by the declared interval a record f expect(built.byTasks[0]?.reason).toBe("precedes-journal"); }); - // The live shape, and what the reason is for. A resumed transcript hands over turns billed - // days before the session that read them opened its journal; the same session then declares - // a task and works inside it. Both records are unattributed and they are unattributed for - // two different reasons - one predates the journal entirely, one is a genuinely late - // declaration - and one row for both would say the flow declared late in every case. + // A resumed transcript hands over turns billed days before its journal opened, and the same + // session then declares a task: both records are unattributed, for two different reasons. it("separates a record older than its journal from one that merely preceded a declaration", () => { const journals: readonly CostReportSessionJournal[] = [ { @@ -364,9 +347,8 @@ describe("buildCostReport — by_task groups by the declared interval a record f }); it("never lets the whole-session written-path inference the --task filter uses leak into this breakdown", () => { - // A session that wrote into a task folder, but never declared - the --task filter's - // own "inferred" route would attribute the whole session to it; this breakdown does - // not consult written paths at all, so the record lands in the no-task row. + // A session that wrote into a task folder but never declared - this breakdown does not + // consult written paths at all, so the record lands in the no-task row. const journals: readonly CostReportSessionJournal[] = [ { vendorId: "s-written-only", @@ -391,11 +373,8 @@ describe("buildCostReport — by_task groups by the declared interval a record f }); it("never contradicts a --task header: the inferred route's own record still names no declared interval", () => { - // A session that wrote into first-task's folder but never declared an interval - the - // --task filter's own "inferred" route keeps this record in scope (report.task is - // set), but by_task does not read that route, so the record still lands in the row - // with no `task`. That row must never be read as "this session touched no task" - - // see cost-report-contract.md's own note on this interaction. + // The --task filter's own "inferred" route keeps this record in scope, but by_task does + // not read that route: its no-`task` row is never "this session touched no task". const journals: readonly CostReportSessionJournal[] = [ { vendorId: "s-inferred-only", @@ -424,11 +403,8 @@ describe("buildCostReport — by_task groups by the declared interval a record f }); it("resolves a declared interval whose path merely contains '..' as text, never misreading live coverage as journal-silent", () => { - // The hook's own gate for a declared path (task-declared.cjs) allows "..' inside a - // folder name - "2026_02_10_a..b" is a name, not a climb - and used to be rejected by - // a blanket substring check here, which then fell through to `journal-silent` for a - // record squarely inside the declared, still-open interval. That is a false claim - // about the journal's own timing, not about the path. + // A declared path may hold ".." as text - "2026_02_10_a..b" is a name, not a climb - and a + // blanket substring check falls through to `journal-silent` for a live, open interval. const journals: readonly CostReportSessionJournal[] = [ { vendorId: "s-dotted-name", diff --git a/cli/tests/domain/models/cost-report.unit.test.ts b/cli/tests/contexts/telemetry/domain/cost-report.unit.test.ts similarity index 85% rename from cli/tests/domain/models/cost-report.unit.test.ts rename to cli/tests/contexts/telemetry/domain/cost-report.unit.test.ts index aacc8a5c3..cb2720a79 100644 --- a/cli/tests/domain/models/cost-report.unit.test.ts +++ b/cli/tests/contexts/telemetry/domain/cost-report.unit.test.ts @@ -7,12 +7,12 @@ import { type CostReportSessionJournal, type CostTotals, toMicroUsd, -} from "../../../src/domain/models/cost-report.js"; +} from "../../../../src/contexts/telemetry/domain/cost-report.js"; import { parseTelemetrySinkLine, type TelemetrySinkRecord, -} from "../../../src/domain/models/telemetry-sink-record.js"; -import { AI_TOOL_IDS } from "../../../src/domain/models/tool-ids.js"; +} from "../../../../src/contexts/telemetry/domain/telemetry-sink-record.js"; +import { AI_TOOL_IDS } from "../../../../src/kernel/tool.js"; const BASE: TelemetrySinkRecord = { sink_schema_version: 2, @@ -32,9 +32,8 @@ function sessionMeasure(overrides: Partial = {}): Telemetry return { ...BASE, kind: "session", ...overrides }; } -/** What a tool can supply is not what these tests are about; they declare the minimum the - * type requires, and the declarations' own truth is checked in - * tests/domain/tools/telemetry-route-supply.unit.test.ts against captured files. */ +/** The minimum the type requires; what a tool can really supply is not what these tests + * are about. */ const NO_CAPABILITY = { localRead: null, export: null, @@ -42,9 +41,8 @@ const NO_CAPABILITY = { taskAttributable: false, } as const; -/** A tool whose local read does name the agent that ran - what tells "the main thread" apart - * from "a tool that could never have said". Only a declaration says which; `NO_CAPABILITY` - * declares no route at all, so a record of that tool can support neither reading. */ +/** A tool whose local read does name the agent that ran - what tells "the main thread" + * apart from "a tool that could never have said". */ const NAMES_AGENTS = { localRead: { tokenCounters: true, amount: false, toolStatedStep: false, agentName: true }, export: null, @@ -53,8 +51,7 @@ const NAMES_AGENTS = { } as const; /** Codex's real shape: a declared local read that supplies token counters and names no - * agent. Distinct from `NO_CAPABILITY`, which declares no route at all - the reading must - * be the same for both, and only a route that says `agentName` can support a main thread. */ + * agent. Only a route that says `agentName` can support a main thread. */ const READS_BUT_NAMES_NO_AGENT = { localRead: { tokenCounters: true, amount: false, toolStatedStep: false, agentName: false }, export: null, @@ -127,25 +124,8 @@ describe("buildCostReport — the two kinds are never summed", () => { }); }); -// A user who enables the OTLP export and also runs the local read sees every billed -// request line twice: once from each route. The export route (the OTLP receiver, the -// mapper, and the two payloads these three records were originally mapped from — -// `otlp-logs-claude-code.json` and `otlp-logs-claude-code-subagent.json`) was deleted in -// "one route, and every sentence about it true" -// (aidd_docs/tasks/2026_08/2026_08_28_one-route-that-is-true/): three billed calls, matching -// the defect report's own worked count. These three records are the exact output the real -// production mapper produced from those two payloads, hand-transcribed here rather than -// mapped live, because a stored line outlives the code that wrote it — this test proves the -// double-count rule still holds against a record shaped exactly like one an earlier version -// of this tool actually wrote to someone's real sink. The local-read half is what -// `read-local-cost-use-case.ts` would produce for those exact same three billed calls: same -// `billed_request_id` (Claude Code's `requestId`, carried by both routes for the same -// call), no `cost_usd` (no local reader has ever captured one), a tool-stated `step` the -// export route never carried at all. `requests` and `inputTokens` below reproduce the -// defect report's own figures exactly (6 naive, 3 collapsed; 12 naive, 6 collapsed); -// `outputTokens`/`cacheReadTokens` do not — these two payloads carried smaller figures than -// whatever fuller session the report was written against, so this test checks its own -// union's true totals rather than asserting numbers these payloads never produced. +// Both routes stored the same three billed calls: the export half carries the money, the +// local half a tool-stated step, and both carry the same `billed_request_id`. describe("buildCostReport — one billed call, seen by both routes, counts once", () => { function exportedApiRequests(): readonly TelemetrySinkRecord[] { return [ @@ -239,8 +219,7 @@ describe("buildCostReport — one billed call, seen by both routes, counts once" const local = exported.map(localCounterpartOf); // What a naive reader gets by concatenating every route's records with no collapse: - // six request lines for three real billed calls — money and tokens read double. Six - // and twelve are the defect report's own figures, from these same two fixtures. + // six request lines for three real billed calls — money and tokens read double. const naiveUnion = [...exported, ...local]; expect(naiveUnion).toHaveLength(6); const naiveInputTokens = naiveUnion.reduce((sum, r) => sum + (r.input_tokens ?? 0), 0); @@ -259,8 +238,7 @@ describe("buildCostReport — one billed call, seen by both routes, counts once" const built = report({ records: union }); // One billed call, counted once, whichever route or routes saw it — never the naive - // union's six, and never the true figures doubled. 3 requests and 6 input tokens match - // the defect report's own worked numbers exactly. + // union's six, and never the true figures doubled. expect(built.totals.requests).toBe(3); expect(trueInputTokens).toBe(6); expect(built.totals.costMicroUsd).toBe(trueCostMicroUsd); @@ -269,24 +247,20 @@ describe("buildCostReport — one billed call, seen by both routes, counts once" expect(built.totals.cacheReadTokens).toBe(trueCacheReadTokens); // Neither route's own strength is thrown away for the other's: the export's money - // survives, and so does the local read's tool-stated step — which the export record - // alone could never have supplied (metrics-contract.md, "Step attribution"). + // survives, and so does the local read's tool-stated step. const toolStated = built.attributionMix.find((row) => row.attribution === "tool-stated"); expect(toolStated?.totals.requests).toBe(3); expect(toolStated?.totals.costMicroUsd).toBe(trueCostMicroUsd); - // Order-independent, the same guarantee `accumulate` already gives every other record: - // a re-read's line order is never something a consumer controls, and neither is which - // of two duplicate deliveries for one billed call arrives first. + // Order-independent: a re-read's line order is never something a consumer controls, + // and neither is which of two duplicate deliveries for one billed call arrives first. const reversed = report({ records: [...union].reverse() }); expect(JSON.stringify(reversed)).toBe(JSON.stringify(built)); }); }); -// A Codex turn read while it was still open, then read again once more of it had arrived — -// the exact shape `storeNewCandidates` can now leave in the sink (phase-1, "A turn read -// while it runs is not the last word"): two `kind: "request"`, `provenance: "local-read"` -// lines sharing one `tool`/`vendor_id`/`turn_id`, neither an edit of the other. +// A Codex turn read while it was still open, then read again once more of it had arrived: +// two local-read request lines sharing one turn_id, neither an edit of the other. describe("buildCostReport — a still-open local-read turn is superseded, never doubled", () => { const partial = request({ turn_id: "turn-1", @@ -331,8 +305,7 @@ describe("buildCostReport — a still-open local-read turn is superseded, never it("never collapses the export route's own turn_id, which several billed calls share", () => { // prompt.id-shaped: a main-agent request and a subagent request under one turn_id, - // each its own billed call — collapsing here the same way would merge two real calls - // into one, exactly the trap task 1's plan warns against. + // each its own billed call — collapsing here would merge two real calls into one. const mainAgent = request({ provenance: "export", turn_id: "prompt-1", @@ -354,10 +327,8 @@ describe("buildCostReport — a still-open local-read turn is superseded, never }); it("never collapses a kind: 'session' record sharing a turn_id (Copilot's shutdown total)", () => { - // Copilot's own session-kind record is keyed on the shutdown event's own id, which a - // re-read matches on the same way every other reader's turn_id is matched — but it is - // a one-shot cumulative figure, never a growing per-turn snapshot, and must never be - // treated as one more corrigible turn. + // Copilot's own session-kind record is keyed on the shutdown event's own id, matched + // like any turn_id — but it is a one-shot cumulative total, never a corrigible turn. const first = sessionMeasure({ turn_id: "shutdown-1", cache_read_tokens: 5 }); const second = sessionMeasure({ turn_id: "shutdown-1", cache_read_tokens: 7 }); @@ -370,20 +341,8 @@ describe("buildCostReport — a still-open local-read turn is superseded, never }); it("prefers an observed zero over an unmentioned counter when two readings tie on weight", () => { - // Codex sometimes omits `cache_write_input_tokens` from its earliest events for a turn - // and starts reporting it as `0` once a later event states it explicitly (see - // codex-rollout.ts's own "omits a counter never observed" test). Two readings of that - // turn can end up with the same `counterWeight` — 0 contributes nothing either way — - // so the weight alone cannot break the tie, and picking the wrong side would report - // "unknown" for a counter the tool actually measured as zero. - // `model` is set on both, deliberately smaller on the less-defined reading: appending a - // key to an otherwise-identical object always makes its serialization sort first (a - // `,"key":…` continuing the string sorts below the `}` that would have closed it), so - // without the tie-break `pickDeterministically`'s plain JSON-string sort would already - // happen to favor whichever record has the extra key — masking exactly the bug this - // test exists to catch. Giving the less-defined reading the earlier-sorting `model` - // value forces the ordinary sort to prefer *it*, so only `definedCounterCount` can save - // the observed zero. + // Two readings of one turn tie on `counterWeight`, since a zero weighs nothing either + // way; `model` is set so the plain sort favours the reading the tie-break must reject. const missesTheCounter = request({ turn_id: "turn-2", model: "aaa", @@ -579,11 +538,8 @@ describe("buildCostReport — every breakdown reconciles", () => { } }); - // 93% of a real session's tokens are a subagent's: measured on a live transcript, 432M of - // 466M, across ten subagent files. Every one of those lines names its agent - // (`attributionAgent`, 100% of subagent tokens) and almost never its skill (2.7%), which is - // why `by_step` reads 3.7% while the spend is elsewhere. The record already carried - // `agent_name` — 924 of 1018 stored records hold one — and nothing exposed it. + // Almost all of a real session's tokens are a subagent's, and every one of those lines + // names its agent while almost none names its skill - so `by_step` reads near-empty. it("breaks the period down by the agent that ran, main thread included as its own row", () => { const built = report({ declaredTools: TOOL_THAT_NAMES_AGENTS, @@ -602,11 +558,8 @@ describe("buildCostReport — every breakdown reconciles", () => { ]); }); - // The reading this axis used to give every tool: `agent_name` absent was read as the main - // thread, whatever the tool was. Only Claude Code's reader ever sets the field - Codex, - // Copilot and OpenCode never do - so on those tools every record was reported as the main - // thread on no evidence at all. An unknown is never a zero, and it is never a main thread - // either. + // Only Claude Code's reader ever sets `agent_name`, so reading its absence as the main + // thread reported every record of every other tool as a main thread on no evidence. it("claims no main thread for a tool whose route never names an agent", () => { const built = report({ declaredTools: [{ tool: "codex", coverage: "covered", capability: NO_CAPABILITY }], @@ -618,9 +571,8 @@ describe("buildCostReport — every breakdown reconciles", () => { ]); }); - // A declared route is not the same as a route that names agents. Codex reads token - // counters from its own rollout files and names no agent anywhere in them, so its records - // must read exactly as a tool with no declared route at all does. + // A declared route is not a route that names agents: Codex reads token counters and + // names no agent, so its records must read as a tool with no declared route does. it("claims no main thread for a route that is declared and still names no agent", () => { const built = report({ declaredTools: [{ tool: "codex", coverage: "covered", capability: READS_BUT_NAMES_NO_AGENT }], @@ -630,9 +582,8 @@ describe("buildCostReport — every breakdown reconciles", () => { expect(built.byAgents.map((row) => row.attribution)).toEqual(["not-stated"]); }); - // Two records that named no agent, from two tools, are two rows and not one: merging them - // would put work nobody could attribute in the same row as work a tool measured as its own - // main thread. + // Two records that named no agent, from two tools, are two rows and not one: merging + // them would mix unattributable work with a tool's own measured main thread. it("keeps a main thread apart from a tool that could never have named one", () => { const built = report({ declaredTools: [ @@ -682,11 +633,8 @@ describe("buildCostReport — every breakdown reconciles", () => { expect(summed).toBe(built.totals.inputTokens); }); - // The one axis no host limit can empty — which is not the same as complete, and the - // difference is measured on `CostReportPromptRow`. Every other breakdown depends on a - // capture that may not have happened; this one depends on a field the reader resolves for - // itself by walking `parentUuid`, so what it cannot name is a chain it cannot walk or a - // record an older reader already stored without one. + // The one axis no host limit can empty - which is not the same as complete: the prompt + // is resolved by walking `parentUuid`, not by a capture that may not have happened. it("breaks the period down by the prompt that caused the work, largest first", () => { const built = report({ records: [ @@ -704,9 +652,8 @@ describe("buildCostReport — every breakdown reconciles", () => { ]); }); - // An opaque id alone is unreadable, so the row carries the earliest moment in its group - - // the one a person greps for in their own transcript. Earliest, never the first seen: the - // sink is append-ordered by read, not by turn. + // An opaque id alone is unreadable, so the row carries the earliest moment in its group. + // Earliest, never the first seen: the sink is append-ordered by read, not by turn. it("dates each prompt row by the earliest moment in that prompt, not the first record read", () => { const built = report({ records: [ @@ -718,9 +665,8 @@ describe("buildCostReport — every breakdown reconciles", () => { expect(built.byPrompts.map((row) => row.startedAt)).toEqual(["2026-08-18T09:00:00Z"]); }); - // A record whose tool cannot say which prompt caused it is its own row, never merged into - // one that named a prompt - the same rule `by_agent` and `by_model` follow for an absent - // key. Every host but Claude Code is in that row today. + // A record whose tool cannot say which prompt caused it is its own row, never merged + // into one that named a prompt - the same rule `by_agent` and `by_model` follow. it("leaves records that named no prompt undated rather than dating them from another prompt", () => { const built = report({ records: [ @@ -734,8 +680,7 @@ describe("buildCostReport — every breakdown reconciles", () => { }); // A remainder, not a prompt: sorting it among the prompts by size would rank a bucket - // drawn from many turns against single turns. `by_flow` places its own remainder the same - // way, and for the same reason. + // drawn from many turns against single turns. it("keeps the row for records that named no prompt last, even when it is the largest", () => { const built = report({ records: [request({ input_tokens: 900 }), request({ prompt_id: "p-1", input_tokens: 10 })], @@ -744,10 +689,8 @@ describe("buildCostReport — every breakdown reconciles", () => { expect(built.byPrompts.map((row) => row.prompt)).toEqual(["p-1", undefined]); }); - // Every counter, not only the one this session happens to look at: 99% of a real session's - // tokens are cache, so a guard summing `input_tokens` alone would pass while the counter - // carrying the money went missing. `requests` too, which is what a session-kind record - // leaking into a prompt group would break. + // Every counter, not only the one this session happens to look at: 99% of a real + // session's tokens are cache, and `requests` too, which a session record would break. it("reconciles the prompt breakdown to the same total as every other axis", () => { const built = report({ records: [ @@ -831,10 +774,7 @@ describe("buildCostReport — every breakdown reconciles", () => { }); // Every tool this report has ever seen runs at 90%-plus cache, so a weight blind to the - // two cache counters orders a costless breakdown by the sliver of its volume nobody reads - // it for - here, backwards. `heavy-cache` moves far less input/output than `light-cache`, - // but consumes forty times the total tokens once cache is counted: the honest "largest - // first" answer, and the one the report already prints beside the row. + // two cache counters orders a costless breakdown backwards. it("weighs a costless row by all four counters, cache included - not input and output alone", () => { const built = report({ records: [ @@ -858,7 +798,6 @@ describe("buildCostReport — every breakdown reconciles", () => { }); }); -// The default period is 2026-08-17..2026-08-21, five UTC days inclusive. describe("buildCostReport — by day and by project", () => { it("gives every day in the period a row, a gap included, and reconciles to the total", () => { const built = report({ @@ -912,8 +851,7 @@ describe("buildCostReport — by day and by project", () => { }); // `project_id: ""` is not a name - it is what a tool writes when it has none to give. - // Treating it as its own project row would print a row nobody can act on, and would - // disagree with the plugin's own `projectKeyOf`, which already reads it as unknown. + // Its own row would print something nobody can act on, and disagree with `projectKeyOf`. it("treats an empty-string project_id the same as no project at all", () => { const built = report({ records: [ @@ -931,12 +869,8 @@ describe("buildCostReport — by day and by project", () => { }); describe("buildCostReport — an unknown keeps its row, never a zero", () => { - // `bySteps` already has `unattributed` and `byProjects` already has an unknown row for - // exactly this reason. Both the Codex and OpenCode readers permit a request record with - // no model, so without this row `byModels` stopped reconciling to its own total with - // nothing naming the gap - the fixtures below carry no model on purpose, which is the - // whole point: the reconciliation test above never reaches this branch because every one - // of its fixtures carries one. + // Both the Codex and OpenCode readers permit a request record with no model, so without + // this row `byModels` stopped reconciling to its own total with nothing naming the gap. it("gives a record with no model its own row in byModels, and it still reconciles", () => { const built = report({ records: [ @@ -954,9 +888,8 @@ describe("buildCostReport — an unknown keeps its row, never a zero", () => { expect(total).toBe(built.totals.costMicroUsd); }); - // `JSON.stringify(NaN)` is `null`, which round-trips through `parseTelemetrySinkLine` as - // `null !== undefined` - the exact path that made a token-counter-style guard - // (`typeof value === "number"`) necessary for `cost_usd` too, not just `!== undefined`. + // `JSON.stringify(NaN)` is `null`, which round-trips as `null !== undefined` - which is + // why `cost_usd` needs a `typeof` guard rather than an `!== undefined` one. it("reads a non-numeric cost as unknown, never as a zero", () => { const damaged = parseTelemetrySinkLine( JSON.stringify({ ...request({ turn_id: "x" }), cost_usd: Number.NaN }) @@ -970,9 +903,7 @@ describe("buildCostReport — an unknown keeps its row, never a zero", () => { }); // `telemetrySinkRecordDayKey` answers `undefined` for a string merely shaped like a - // moment (see its own unit tests) - this is that answer reaching the report: the record - // stays in `totals` but invents no day row, rather than filing into a fragment nothing on - // the calendar matches. + // moment: the record stays in `totals` but invents no day row. it("gives a damaged moment no day row, while the total still holds it", () => { const damaged = parseTelemetrySinkLine( JSON.stringify({ @@ -1367,11 +1298,8 @@ describe("buildCostReport — by_flow reads the journal's own sequence, nothing expect(outside?.totals.requests).toBe(1); }); - // A session resumed after its context was compacted invokes nothing again, so no - // `step_start` hook fires and its journal opens no flow - while the transcript goes on - // stating the step on every record it produces. Measured on this machine: one such - // session, six `step_end` lines, no `step_start`, and 2,220 records in a 30-day period - // that `by_flow` reported as belonging to no flow at all. + // A session resumed after its context was compacted fires no `step_start`, so its journal + // opens no flow while the transcript goes on stating the step on every record. it("names the flow a record's own tool stated, where no interval covers it", () => { const records: readonly TelemetrySinkRecord[] = [ request({ @@ -1445,9 +1373,8 @@ describe("buildCostReport — by_flow reads the journal's own sequence, nothing expect(built.byFlows[0]?.attribution).toBe("unattributed"); }); - // An interval is the only thing that can say *which run*. A step the reader inferred from - // a moment says neither run nor, on its own, that a flow was ever orchestrated - so it - // opens no flow row, and only the tool's own statement does. + // An interval is the only thing that can say *which run*: a step inferred from a moment + // says neither run nor that a flow was ever orchestrated, so it opens no flow row. it("opens no flow for an orchestrating step the reader merely inferred", () => { const records: readonly TelemetrySinkRecord[] = [ request({ @@ -1597,7 +1524,9 @@ describe("buildCostReport — what it says about itself", () => { it("names no tool and no skill, by string literal", () => { const source = readFileSync( - fileURLToPath(new URL("../../../src/domain/models/cost-report.ts", import.meta.url)), + fileURLToPath( + new URL("../../../../src/contexts/telemetry/domain/cost-report.ts", import.meta.url) + ), "utf8" ); @@ -1610,9 +1539,8 @@ describe("buildCostReport — what it says about itself", () => { }); describe("buildCostReport — the same records, however they arrive", () => { - // A re-read appends, so the same session's lines sit in different orders on two - // machines, and nothing a consumer does controls it. Repetition alone would never catch - // a group that carries insertion order. + // A re-read appends, so the same session's lines sit in different orders on two machines. + // Repetition alone would never catch a group that carries insertion order. const RECORDS: readonly TelemetrySinkRecord[] = [ request({ turn_id: "a", @@ -1738,9 +1666,8 @@ describe("buildCostReport — any dimension filters as well as it groups", () => const byModel = narrowed({ filters: { model: "opus" } }); expect(byModel.byModels).toHaveLength(1); - // by_tool is a breakdown of every *declared* tool - a --tool filter has to narrow - // that list too, or every excluded tool would still print a row reading "nothing in - // this period", indistinguishable from one genuinely measured idle. + // by_tool is a breakdown of every *declared* tool - a --tool filter has to narrow that + // list too, or an excluded tool would print a row a measured idle is not told from. const byTool = narrowed({ filters: { tool: "codex" } }); expect(byTool.byTools).toHaveLength(1); expect(byTool.byTools[0]?.tool).toBe("codex"); @@ -1748,9 +1675,8 @@ describe("buildCostReport — any dimension filters as well as it groups", () => }); it("keeps a session-only figure under a step filter when a journal interval stamped one", () => { - // Unlike model, a step can land on a session record: `resolveStepAttribution` runs - // over every candidate regardless of kind, so a session record whose own moment falls - // inside a step interval carries `step` too. + // Unlike model, a step can land on a session record: `resolveStepAttribution` runs over + // every candidate regardless of kind. const sessionRecord: TelemetrySinkRecord = sessionMeasure({ vendor_id: "s-3", tool: "claude", @@ -1846,13 +1772,8 @@ describe("buildCostReport — any dimension filters as well as it groups", () => }); describe("buildCostReport — a line on disk holds whatever it holds, not what a type declares", () => { - // `parseTelemetrySinkLine` checks `sink_schema_version` and casts the rest, which is why - // every counter is read through a `typeof` guard rather than an `!== undefined` one. - // `active_time_s` was the one field that skipped it. /** A record carrying a field of the wrong type, built the only way one ever reaches this - * module: through the real parse, which checks `sink_schema_version` and casts the rest. - * Written as JSON rather than hand-cast, so the test exercises the route instead of - * asserting against a shape no file could produce. */ + * module: through the real parse, which checks `sink_schema_version` and casts the rest. */ function recordFromLine(overrides: Record): TelemetrySinkRecord { return parseTelemetrySinkLine(JSON.stringify({ ...sessionMeasure(), ...overrides })); } diff --git a/cli/tests/domain/models/flow-attribution.unit.test.ts b/cli/tests/contexts/telemetry/domain/flow-attribution.unit.test.ts similarity index 87% rename from cli/tests/domain/models/flow-attribution.unit.test.ts rename to cli/tests/contexts/telemetry/domain/flow-attribution.unit.test.ts index 64f05bff5..92517b360 100644 --- a/cli/tests/domain/models/flow-attribution.unit.test.ts +++ b/cli/tests/contexts/telemetry/domain/flow-attribution.unit.test.ts @@ -3,9 +3,9 @@ import { bareOrchestratingSkillNames, buildFlowIntervals, ORCHESTRATING_SKILLS, -} from "../../../src/domain/models/flow-attribution.js"; -import { momentFallsWithin } from "../../../src/domain/models/journal-intervals.js"; -import type { RunJournal } from "../../../src/domain/ports/run-journal-reader.js"; +} from "../../../../src/contexts/telemetry/domain/flow-attribution.js"; +import { momentFallsWithin } from "../../../../src/contexts/telemetry/domain/journal-intervals.js"; +import type { RunJournal } from "../../../../src/contexts/telemetry/domain/ports/run-journal-reader.js"; function journalOf( boundaries: RunJournal["boundaries"], @@ -123,9 +123,8 @@ describe("buildFlowIntervals — pure: journal lines -> bounded flow intervals", }); it("closes a flow opened by its bare name with the end the orchestrator declares in full", () => { - // Cursor and Codex write `01-sdlc` into step_start - the plugin never reaches the - // journal - while the end the skill echoes always carries it. Compared exactly, the - // declaration those hosts capture closed nothing at all. + // Cursor and Codex write `01-sdlc` into step_start, the plugin never reaching the + // journal, while the end the skill echoes always carries it. const bareOpener = { type: "step_start", at: "2026-08-17T10:00:00Z", @@ -148,9 +147,8 @@ describe("buildFlowIntervals — pure: journal lines -> bounded flow intervals", }); it("never closes a flow on a step_end naming some other skill", () => { - // A step run inside the orchestration ends; the orchestration does not. Distinguishing: - // something is witnessed after that end, so closing there and not closing there are two - // different numbers. + // A step run inside the orchestration ends; the orchestration does not. Something is + // witnessed after that end, so the two readings are different numbers. const intervals = buildFlowIntervals(journalOf([SDLC_OPENS, PLAN_ENDS], [], [WRITTEN_LATE])); expect(intervals[0]?.endMs).toBe(Date.parse(WRITTEN_LATE.at)); @@ -158,10 +156,8 @@ describe("buildFlowIntervals — pure: journal lines -> bounded flow intervals", }); it("does not close a flow at a turn_end - a pause is not the end of an orchestration", () => { - // The separating case: with `turn_end` last, the moment it would close at and the - // journal's own last witnessed moment are the same number, so a fixture ending on the - // pause proves nothing either way. Something witnessed *after* the pause is what tells - // the two rules apart — this end is 11:30, and was 11:00 while a `turn_end` closed one. + // With `turn_end` last, closing there and capping at the journal's end are the same + // number: only something witnessed after the pause tells the two rules apart. const intervals = buildFlowIntervals( journalOf([SDLC_OPENS, EARLIER_TURN_END], [], [WRITTEN_LATE]) ); @@ -171,9 +167,6 @@ describe("buildFlowIntervals — pure: journal lines -> bounded flow intervals", }); it("keeps work done after the pause inside the flow that was still running", () => { - // The consequence a person actually reads: the orchestration measured here paused at - // 06:02 and worked on for three more hours, and every one of those records used to fall - // outside its own flow. const intervals = buildFlowIntervals( journalOf([SDLC_OPENS, EARLIER_TURN_END], [], [WRITTEN_LATE]) ); @@ -212,7 +205,6 @@ describe("buildFlowIntervals — pure: journal lines -> bounded flow intervals", SDLC_OPENS.skill, SDLC_OPENS.skill, ]); - // The first closes on the second's own start, not on the pause between them. expect(intervals[0]?.endMs).toBe(Date.parse(secondSdlcRun.at)); expect(intervals[1]?.endMs).toBe(Date.parse(secondSdlcRun.at)); // unclosed - capped at its own start }); @@ -281,7 +273,7 @@ describe("buildFlowIntervals — pure: journal lines -> bounded flow intervals", it("touches no filesystem — the module imports none of Node's fs APIs", async () => { const source = await import("node:fs").then((fs) => fs.readFileSync( - new URL("../../../src/domain/models/flow-attribution.ts", import.meta.url), + new URL("../../../../src/contexts/telemetry/domain/flow-attribution.ts", import.meta.url), "utf-8" ) ); @@ -326,9 +318,6 @@ describe("buildFlowIntervals — a journal with no readable moment in it", () => }); describe("buildFlowIntervals — the limit the flow axis prints beside its figures", () => { - // Pinned as behaviour, not left to a doc comment: `cost-report-artefact.ts`'s own - // `flowLimits` tells a reader this happens, and a change here that stopped it happening - // would leave that sentence describing something the code no longer does. it("opens a flow on a bare 01-sdlc, whichever project's own skills/ directory named it", () => { const projectsOwnSkill = { type: "step_start", diff --git a/cli/tests/domain/formats/claude-code-transcript.unit.test.ts b/cli/tests/contexts/telemetry/domain/formats/claude-code-transcript.unit.test.ts similarity index 77% rename from cli/tests/domain/formats/claude-code-transcript.unit.test.ts rename to cli/tests/contexts/telemetry/domain/formats/claude-code-transcript.unit.test.ts index 940079cec..27b266fd2 100644 --- a/cli/tests/domain/formats/claude-code-transcript.unit.test.ts +++ b/cli/tests/contexts/telemetry/domain/formats/claude-code-transcript.unit.test.ts @@ -4,30 +4,22 @@ import { describe, expect, it } from "vitest"; import { createClaudeCodeTranscriptAccumulator, mapClaudeCodeTranscriptToSinkRecords, -} from "../../../src/domain/formats/claude-code-transcript.js"; +} from "../../../../../src/contexts/telemetry/domain/formats/claude-code-transcript.js"; const SID = "22222222-2222-4222-8222-222222222222"; -// Both fixtures are real, redacted excerpts captured 2026-08-20 — main.jsonl from Claude -// Code 2.1.229, subagent.jsonl from 2.1.232 — see the local-cost fixtures README-style -// header comment in claude-code-transcript.ts for the full measurement. +// Both fixtures are real, redacted session excerpts; the measurement behind them is the header +// comment in `claude-code-transcript.ts`. function loadFixture(relativePath: string): string { - const url = new URL(`../../fixtures/local-cost/${relativePath}`, import.meta.url); + const url = new URL(`../../../../fixtures/local-cost/${relativePath}`, import.meta.url); return readFileSync(fileURLToPath(url), "utf8"); } const MAIN_PATH = `.claude/projects/fake-project/${SID}.jsonl`; const SUBAGENT_PATH = `.claude/projects/fake-project/${SID}/subagents/agent-aa81cdef3bb58820c.jsonl`; -/** A billed call and the prompt that caused it never share a line. - * - * Measured on a real 810-record session: zero lines carry both `requestId` and `promptId`. - * Only `type: "user"` lines carry a `promptId` — 112 of them — and every one of the 209 - * lines bearing counters reaches one by following `parentUuid`, three hops in the median. - * - * The journal already writes that same identifier on `step_start` (Claude Code's - * `prompt_id`), so resolving it here is what lets a step be joined to a record exactly, - * instead of inferred from the moment each happened to fall on. */ +/** No transcript line carries both `requestId` and `promptId`: only `type: "user"` lines name a + * prompt, and a billed line reaches one by following `parentUuid`. */ function chain(lines: readonly Record[]): string { return lines.map((line) => JSON.stringify(line)).join("\n"); } @@ -74,19 +66,8 @@ describe("mapClaudeCodeTranscriptToSinkRecords — the prompt a billed call belo expect(record?.prompt_id).toBeUndefined(); }); - /** - * The skill a `Skill` call started inside this record's own prompt. - * - * `attributionSkill`, which the record's `step` already reads, is exact where it appears - * and sparse where it does not: measured on the one orchestrated session captured, - * 2026-09-04, inside the window `aidd-dev:01-plan` demonstrably ran, 142 billed lines - * carry counters and 20 carry that field. Its absence is not the tool saying no skill - * ran, so naming the skill a prompt invoked contradicts nothing it states. - * - * Stored rather than judged: which step a record belongs to is the reader's question, - * and this is the observation it answers from — the same fact the run journal writes as - * `step_start`'s `turn_id`, seen from the transcript instead. - */ + // `attributionSkill` is exact where it appears and sparse where it does not, so its absence + // is never the tool stating that no skill ran. it("names the skill a Skill call invoked inside the record's own prompt", () => { const content = chain([ { type: "user", uuid: "u1", promptId: "p-abc" }, @@ -107,9 +88,6 @@ describe("mapClaudeCodeTranscriptToSinkRecords — the prompt a billed call belo expect(record?.prompt_skill).toBe("aidd-dev:01-plan"); }); - // A record whose prompt started no skill states none, rather than borrowing the last one - // seen: two prompts are two prompts however their moments overlap, which is the whole - // reason this reads a prompt and not a moment. it("names no skill for a prompt that invoked none", () => { const content = chain([ { type: "user", uuid: "u1", promptId: "p-one" }, @@ -164,9 +142,8 @@ describe("mapClaudeCodeTranscriptToSinkRecords — the prompt a billed call belo expect(record?.prompt_skill).toBe("aidd-orchestrator:01-sdlc"); }); - // Only a `Skill` call names a step. Every other tool call is work done inside whatever - // step was already running, and reading one as a step start would name a skill for a - // prompt that never invoked any. + // Only a `Skill` call names a step; every other tool call is work inside the step already + // running, so reading one as a step start would name a skill the prompt never invoked. it("ignores a tool call that is not a Skill call", () => { const content = chain([ { type: "user", uuid: "u1", promptId: "p-abc" }, @@ -197,10 +174,8 @@ describe("mapClaudeCodeTranscriptToSinkRecords — the prompt a billed call belo expect(record?.prompt_id).toBeUndefined(); }); - // This one fails by *hanging*, not by going red: the walk is synchronous, so a cycle - // wedges the worker and no `--testTimeout` can cut it. Measured — removing the `seen` - // guard runs the suite past two minutes until it is killed. Worth stating, because a - // reader who saw only a green tick might take the guard for decoration. + // This one fails by hanging, not by going red: the walk is synchronous, so a cycle wedges the + // worker and no `--testTimeout` cuts it — removing the `seen` guard runs past two minutes. it("terminates on a chain that points back at itself", () => { const content = chain([ { type: "user", uuid: "u1", parentUuid: "a1", promptId: undefined }, @@ -217,10 +192,8 @@ describe("mapClaudeCodeTranscriptToSinkRecords", () => { it("yields one record per real assistant turn, by value, under the stored field names", () => { const records = mapClaudeCodeTranscriptToSinkRecords(loadFixture(MAIN_PATH)); - // The fixture holds a queue-operation, a user turn, a tool_result turn (none carry - // counters), one `` notice Claude Code wrote itself, and three real API - // calls — one of them logged as two JSONL lines (a `thinking` block then a `tool_use` - // block) sharing one `requestId` and `message.id`. + // The fixture holds a queue operation, a user turn and a tool_result turn (none carrying + // counters), a `` notice, and three real calls — one logged as two lines. expect(records).toHaveLength(3); expect(records[0]).toEqual({ kind: "request", @@ -271,9 +244,8 @@ describe("mapClaudeCodeTranscriptToSinkRecords", () => { effort: "high", event_timestamp: "2026-08-14T07:54:15.988Z", agent_name: "Explore", - // A real, unflagged fact this capture carries — task 1's own field, read straight - // off the transcript with no journal beside it. No `step_plugin`: this line carries - // no `attributionPlugin` at all, and one is never invented alongside a real skill. + // Read straight off the transcript with no journal beside it. No `step_plugin`: this + // line carries no `attributionPlugin`, and one is never invented beside a real skill. step: "probe-echo", input_tokens: 2, output_tokens: 1, @@ -283,10 +255,8 @@ describe("mapClaudeCodeTranscriptToSinkRecords", () => { ]); }); - // Task 1's own criterion: the field's absence is never read as "no skill ran" — it is - // simply not asserted at all. Built by removing the real fixture's own attributionSkill - // key rather than hand-writing a payload, so this exercises the same real line shape the - // presence test above does, differing only in the one field under test. + // Built by removing the real fixture's own attributionSkill key rather than hand-writing a + // payload, so this differs from the presence test above in that one field alone. it("carries no step at all when a line has no attributionSkill, never asserting none ran", () => { const withoutAttribution = loadFixture(SUBAGENT_PATH).replace( /"attributionSkill":\s*"[^"]*",?/, @@ -331,8 +301,8 @@ describe("mapClaudeCodeTranscriptToSinkRecords", () => { expect(records).toHaveLength(0); }); - // #686. The fixture's synthetic line is a captured one: a session-limit notice Claude - // Code composed itself, `model: ""`, four zero counters, its own `requestId`. + // The fixture's synthetic line is a captured one: a session-limit notice Claude Code composed + // itself, `model: ""`, four zero counters, its own `requestId`. it("yields no record for a message the tool marked ", () => { const records = mapClaudeCodeTranscriptToSinkRecords(loadFixture(MAIN_PATH)); @@ -345,9 +315,8 @@ describe("mapClaudeCodeTranscriptToSinkRecords", () => { ]); }); - // The filter is the marker, not the symptom: all-counters-zero on a real model is - // improbable, not impossible, and dropping it would be a real call lost with nothing - // downstream able to tell it was ever there. + // The filter is the marker, not the symptom: all-counters-zero on a real model is improbable + // rather than impossible, and dropping it would lose a real call with nothing able to tell. it("still yields a record for all-zero counters on a message that is not synthetic", () => { const line = JSON.stringify({ type: "assistant", @@ -433,11 +402,8 @@ describe("createClaudeCodeTranscriptAccumulator", () => { expect(accumulator.build()).toEqual(whole); }); - // Measured on 1,604 real transcripts: Claude Code writes a line when a message starts and - // again when it completes, sharing one message.id. 25,702 of 83,626 groups differ, and the - // last line's output_tokens is >= the first's in every one. Keeping the first kept the - // placeholder and discarded 37.4% of all output tokens. The shape below is a real capture, - // trimmed: same input and cache figures, output 3 -> 329. + // Claude Code writes a line when a message starts and again when it completes, sharing one + // `message.id`; keeping the first kept a placeholder and discarded 37.4% of output tokens. it("keeps the completed line for a message, not the placeholder that opened it", () => { const shared = { type: "assistant", diff --git a/cli/tests/domain/formats/codex-rollout.unit.test.ts b/cli/tests/contexts/telemetry/domain/formats/codex-rollout.unit.test.ts similarity index 75% rename from cli/tests/domain/formats/codex-rollout.unit.test.ts rename to cli/tests/contexts/telemetry/domain/formats/codex-rollout.unit.test.ts index 6a0abea2b..a82d82c14 100644 --- a/cli/tests/domain/formats/codex-rollout.unit.test.ts +++ b/cli/tests/contexts/telemetry/domain/formats/codex-rollout.unit.test.ts @@ -3,21 +3,19 @@ import { sep } from "node:path"; import { fileURLToPath } from "node:url"; import { describe, expect, it } from "vitest"; import { - CODEX_ROLLOUT_LOCATION, createCodexRolloutAccumulator, mapCodexRolloutToSinkRecords, -} from "../../../src/domain/formats/codex-rollout.js"; -import { journalRecord } from "../../helpers/telemetry-journal-hook.js"; +} from "../../../../../src/contexts/telemetry/domain/formats/codex-rollout.js"; +import { CODEX_ROLLOUT_LOCATION } from "../../../../../src/contexts/tools/domain/profiles/codex/codex-transcript-location.js"; +import { journalRecord } from "../../../../helpers/telemetry-journal-hook.js"; const TARGET_ID = "019fae6f-2009-7cd3-86b2-b8f83481b160"; const TARGET_PARENT = "019f69d0-9e1f-7951-86c9-ddb23cfd51f4"; -// Both fixtures are real, redacted rollout excerpts captured 2026-08-20 on Codex CLI -// 0.145.0-alpha.27 — target.jsonl is a resumed session (session_meta.id !== session_id), -// parent.jsonl is that resumed session's own parent (a fresh session, where the two agree). -// See codex-rollout.ts's header comment for the full measurement. +// Real, redacted rollout excerpts: target.jsonl is a resumed session (session_meta.id !== +// session_id), parent.jsonl its own parent, a fresh session where the two agree. function loadFixture(relativePath: string): string { - const url = new URL(`../../fixtures/local-cost/${relativePath}`, import.meta.url); + const url = new URL(`../../../../fixtures/local-cost/${relativePath}`, import.meta.url); return readFileSync(fileURLToPath(url), "utf8"); } @@ -28,10 +26,8 @@ describe("mapCodexRolloutToSinkRecords", () => { it("yields one record per turn, its counters summed from the increments, never the totals", () => { const records = mapCodexRolloutToSinkRecords(loadFixture(TARGET_PATH)); - // Real captured `last_token_usage` events for this turn: {22229,20224,0,231}, - // {24692,21248,0,206}, {27769,24320,0,390} (input, cached, cache_write, output). - // Summing `total_token_usage` instead (22229 → 46921 → 74690) would give an - // input figure over 8x too large for this one turn. + // Real captured `last_token_usage` increments for this turn; summing `total_token_usage` + // instead would give an input figure over 8x too large. expect(records).toHaveLength(2); expect(records[0]).toEqual({ kind: "request", @@ -71,18 +67,16 @@ describe("mapCodexRolloutToSinkRecords", () => { }); it("carries the model and effort from turn_context, not from the counted event", () => { - // token_count's own `info` has no model and no effort at all — if the mapper read - // either from there, this fixture (which never puts them there) would leave them - // undefined instead of "gpt-5.6-sol" / "high". + // `token_count`'s own `info` carries no model and no effort, so a mapper reading either + // from there would leave them undefined on this fixture. const records = mapCodexRolloutToSinkRecords(loadFixture(TARGET_PATH)); expect(records.every((r) => r.model === "gpt-5.6-sol" && r.effort === "high")).toBe(true); }); it("omits a counter never observed in any event of the turn, rather than summing a zero", () => { - // The parent fixture's events never carry cache_write_input_tokens at all (a real, - // older-CLI shape) — the resulting record must have no cache_creation_tokens key, - // not a fabricated 0. + // The parent fixture's events never carry cache_write_input_tokens at all, a real + // older-CLI shape: the record must have no key rather than a fabricated 0. const [record] = mapCodexRolloutToSinkRecords(loadFixture(PARENT_PATH)); expect(record).toEqual({ @@ -107,9 +101,8 @@ describe("mapCodexRolloutToSinkRecords", () => { expect(mapCodexRolloutToSinkRecords(moved)).toHaveLength(0); }); - // Without a moment, a Codex record cannot fall inside any step interval, so the journal - // — the only step source Codex has — could never attribute it. The rollout carries the - // moment on the `turn_context` line; taking it is what makes the fallback reachable. + // Without a moment a Codex record falls inside no step interval, and the journal is the + // only step source Codex has; the rollout carries that moment on its `turn_context` line. it("carries the turn's own start, so a journal interval can reach it", () => { const records = mapCodexRolloutToSinkRecords(loadFixture(TARGET_PATH)); @@ -148,11 +141,8 @@ describe("createCodexRolloutAccumulator", () => { describe("CODEX_ROLLOUT_LOCATION", () => { it("accepts a rollout for the id the journal hook derives from the same path", () => { - // The hook writes vendor_id; this location resolves the file to read. They agree only - // if both read the rollout's own id off the filename, and they live apart because - // hooks/ is copied verbatim by the framework build and can import nothing from cli/. - // Pinned here so a drift in either one turns this red rather than silently dropping - // every resumed session's figures from a report. + // The hook writes vendor_id and this location resolves the file to read; they agree only + // if both take the rollout's own id off the filename, and they can share no code. for (const path of [TARGET_PATH, PARENT_PATH]) { const derived = journalRecord.codexSessionIdFromTranscriptPath(path); @@ -164,9 +154,8 @@ describe("CODEX_ROLLOUT_LOCATION", () => { }); it("derives the resumed rollout's own id, never its parent's", () => { - // The trap: on a resumed session `session_meta.session_id` holds the parent's id, and a - // vendor_id written from it joins to nothing. 124 of 330 rollouts measured on one - // machine are resumed, so this is 38% of Codex sessions, not an edge case. + // On a resumed session `session_meta.session_id` holds the parent's id, so a vendor_id + // written from it joins to nothing — 38% of measured Codex sessions are resumed. expect(journalRecord.codexSessionIdFromTranscriptPath(TARGET_PATH)).toBe(TARGET_ID); expect(journalRecord.codexSessionIdFromTranscriptPath(TARGET_PATH)).not.toBe(TARGET_PARENT); }); @@ -190,13 +179,8 @@ describe("CODEX_ROLLOUT_LOCATION", () => { }); }); -// Measured on 400 real rollouts in ~/.codex/sessions on 2026-08-26: the last `token_count` -// of a turn is sometimes re-emitted verbatim — the same `last_token_usage` arrives twice -// while `total_token_usage` does not move. 291 of 16,415 events (1.8%) across 38 of the 400 -// rollouts. Summing every increment therefore over-counted this tool by ~0.9% on input and -// cache-read and ~1.2% on output. Reproduced from -// rollout-2026-08-05T09-42-34-019fd0df-c784-7c31-b470, whose shape this fixture reproduces: -// last=45800 / total=121055 arrives, then arrives again unchanged. +// Measured on real rollouts: the last `token_count` of a turn is sometimes re-emitted +// verbatim, the same `last_token_usage` twice while `total_token_usage` does not move. describe("a token_count re-emitted with an unmoved cumulative", () => { const usage = (input: number, cached: number, output: number) => ({ input_tokens: input, diff --git a/cli/tests/contexts/telemetry/domain/formats/commit-session-trailer.integration.test.ts b/cli/tests/contexts/telemetry/domain/formats/commit-session-trailer.integration.test.ts new file mode 100644 index 000000000..ca3c5d1cb --- /dev/null +++ b/cli/tests/contexts/telemetry/domain/formats/commit-session-trailer.integration.test.ts @@ -0,0 +1,74 @@ +import { execFileSync } from "node:child_process"; +import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { + SESSION_TRAILER_TOKEN, + sessionTrailerDelegateScript, +} from "../../../../../src/contexts/telemetry/domain/formats/commit-session-trailer.js"; + +/** The delegate as git actually invokes it: only running the generated script proves what a + * `case` branch does with each `message_source`, which asserting on its text cannot. */ +const SESSION = "55555555-5555-4555-8555-555555555555"; + +const created: string[] = []; + +afterEach(async () => { + await Promise.all(created.map((dir) => rm(dir, { recursive: true, force: true }))); + created.length = 0; +}); + +/** Every variable the delegate reads, stripped before a session is added back - this suite + * runs inside a real Claude Code session, so a bare `process.env` already carries one. */ +function withoutSessionVariables(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv { + const { CODEX_THREAD_ID, CLAUDE_CODE_SESSION_ID, ...rest } = env; + return rest; +} + +async function runDelegate( + message: string, + messageSource: string, + sessionEnv: NodeJS.ProcessEnv = {} +): Promise { + const dir = await mkdtemp(join(tmpdir(), "aidd-trailer-delegate-")); + created.push(dir); + const scriptPath = join(dir, "aidd-session-trailer.sh"); + const messagePath = join(dir, "MSG"); + await writeFile(scriptPath, sessionTrailerDelegateScript()); + await writeFile(messagePath, message); + + execFileSync("sh", [scriptPath, messagePath, messageSource], { + env: { ...withoutSessionVariables(process.env), ...sessionEnv }, + }); + + return readFile(messagePath, "utf8"); +} + +describe("the delegate's own case statement, run against each source git passes", () => { + it("trailers a merge a session resolved - that is session work, not a person authoring it", async () => { + const written = await runDelegate("merged", "merge", { CLAUDE_CODE_SESSION_ID: SESSION }); + + expect(written).toContain(`${SESSION_TRAILER_TOKEN}: ${SESSION}`); + }); + + it("trailers a squash a session produced, the same way", async () => { + const written = await runDelegate("squashed", "squash", { CLAUDE_CODE_SESSION_ID: SESSION }); + + expect(written).toContain(`${SESSION_TRAILER_TOKEN}: ${SESSION}`); + }); + + it("still writes nothing when no session made the commit", async () => { + const written = await runDelegate("by-hand", "message", {}); + + expect(written).not.toContain(SESSION_TRAILER_TOKEN); + }); + + it("never doubles a trailer a prior run already wrote", async () => { + const once = await runDelegate("merged", "merge", { CLAUDE_CODE_SESSION_ID: SESSION }); + + const twice = await runDelegate(once, "merge", { CLAUDE_CODE_SESSION_ID: SESSION }); + + expect(twice.split(SESSION_TRAILER_TOKEN).length - 1).toBe(1); + }); +}); diff --git a/cli/tests/contexts/telemetry/domain/formats/commit-session-trailer.unit.test.ts b/cli/tests/contexts/telemetry/domain/formats/commit-session-trailer.unit.test.ts new file mode 100644 index 000000000..00f9265b1 --- /dev/null +++ b/cli/tests/contexts/telemetry/domain/formats/commit-session-trailer.unit.test.ts @@ -0,0 +1,150 @@ +import { describe, expect, it } from "vitest"; +import { + SESSION_TRAILER_DELEGATE_FILE, + SESSION_TRAILER_TOKEN, + sessionTrailerDelegateScript, + sessionTrailerHookLine, + sessionTrailerHuskyLine, + sessionTrailerLefthookJob, + sessionTrailerManagerSnippet, +} from "../../../../../src/contexts/telemetry/domain/formats/commit-session-trailer.js"; + +describe("the line added to a repository's own prepare-commit-msg", () => { + it("forwards git's own arguments, so the delegate always gets the message file it needs", () => { + expect(sessionTrailerHookLine("/repo/.git/hooks/aidd-session-trailer.sh")).toBe( + 'sh "/repo/.git/hooks/aidd-session-trailer.sh" "$@"' + ); + }); + + it("quotes the path, so a checkout living under a directory with a space still runs", () => { + const line = sessionTrailerHookLine("/Users/a b/repo/.git/hooks/x.sh"); + + expect(line).toContain('"/Users/a b/repo/.git/hooks/x.sh"'); + }); + + // A hook is shell, run by the `sh` Git for Windows ships, and that shell does not resolve + // `C:\Users\…`: inside double quotes a backslash is an ordinary character. + it("writes a Windows path with forward slashes, which is the only form sh resolves", () => { + const line = sessionTrailerHookLine("C:\\Users\\a\\repo\\.git\\hooks\\x.sh"); + + expect(line).toBe('sh "C:/Users/a/repo/.git/hooks/x.sh" "$@"'); + expect(line).not.toContain("\\"); + }); + + it("leaves a POSIX path exactly as it was", () => { + expect(sessionTrailerHookLine("/repo/.git/hooks/x.sh")).toBe('sh "/repo/.git/hooks/x.sh" "$@"'); + }); +}); + +describe("the delegate a commit's message actually passes through", () => { + const script = sessionTrailerDelegateScript(); + + it("reads Codex's own variable before Claude Code's, the precedence session-anchor.ts measured", () => { + // Shell parameter expansion, not a JS placeholder: this literal is what the delegate has + // to contain, character for character, so it is asserted as written. + // biome-ignore lint/suspicious/noTemplateCurlyInString: the string is shell, not JS + expect(script).toContain('session_id="${CODEX_THREAD_ID:-${CLAUDE_CODE_SESSION_ID:-}}"'); + }); + + it("writes nothing when no session made the commit - an unknown is never a guess", () => { + expect(script).toContain('[ -n "$session_id" ] || exit 0'); + }); + + it("no longer branches on message_source - a merge or a squash is session work too", () => { + expect(script).not.toContain("merge | squash"); + }); + + it("writes the trailer once however often it runs, amend included", () => { + expect(script).toContain("--if-exists doNothing"); + expect(script).toContain(`--trailer "${SESSION_TRAILER_TOKEN}=$session_id"`); + }); + + it("never fails a commit: every path out of it exits zero", () => { + const exits = script.match(/exit \d+/gu) ?? []; + + expect(exits.length).toBeGreaterThan(0); + expect(exits.every((line) => line === "exit 0")).toBe(true); + }); + + // Runs on every commit in the repository, long after whatever installed it. Depending on + // node, or on this CLI still being on PATH, would make an uninstall break commits. + it("needs nothing but a shell and git - it runs neither node nor this CLI", () => { + const instructions = script + .split("\n") + .filter((line) => line.trim() !== "" && !line.trimStart().startsWith("#")); + + expect(script.startsWith("#!/bin/sh\n")).toBe(true); + expect(instructions.some((line) => /\bnode\b/u.test(line))).toBe(false); + expect(instructions.some((line) => /\baidd\b/u.test(line))).toBe(false); + expect(instructions.some((line) => line.includes("git interpret-trailers"))).toBe(true); + }); + + it("names the commands that install and remove it, where a person will look", () => { + expect(script).toContain("aidd telemetry on"); + expect(script).toContain("aidd telemetry off"); + }); +}); + +describe("what the delegate is called on disk", () => { + it("is named for what it does, and is a shell script", () => { + expect(SESSION_TRAILER_DELEGATE_FILE).toBe("aidd-session-trailer.sh"); + }); +}); + +/** + * Neither snippet may carry an absolute path: these files are committed and shared across + * machines, so a path baked in on one contributor's disk names nothing on another's. + */ +describe("the job printed for a repository lefthook already owns", () => { + const job = sessionTrailerLefthookJob(SESSION_TRAILER_DELEGATE_FILE); + + it("is the prepare-commit-msg job, keyed the way lefthook.yml expects", () => { + expect(job).toContain("prepare-commit-msg:"); + }); + + it("forwards the message-file and source arguments with lefthook's own placeholders", () => { + expect(job).toContain("{1} {2}"); + }); + + it("only calls the delegate when it is actually there", () => { + expect(job).toContain("[ -f"); + }); + + it("carries no absolute path — resolved fresh against this machine's own git dir instead", () => { + expect(job).not.toMatch(/\/(Users|home)\//u); + expect(job).toContain("$(git rev-parse --git-common-dir)"); + }); +}); + +describe("the line printed for a repository husky already owns", () => { + const line = sessionTrailerHuskyLine(SESSION_TRAILER_DELEGATE_FILE); + + it("forwards git's own arguments the way a plain hook does", () => { + expect(line).toContain('"$@"'); + }); + + it("only calls the delegate when it is actually there", () => { + expect(line).toContain("[ -f"); + }); + + it("carries no absolute path either", () => { + expect(line).not.toMatch(/\/(Users|home)\//u); + expect(line).toContain("$(git rev-parse --git-common-dir)"); + }); +}); + +describe("sessionTrailerManagerSnippet — one place naming both the file and its snippet", () => { + it("names lefthook.yml for lefthook, carrying the same job", () => { + const result = sessionTrailerManagerSnippet("lefthook", SESSION_TRAILER_DELEGATE_FILE); + + expect(result.targetFile).toBe("lefthook.yml"); + expect(result.snippet).toBe(sessionTrailerLefthookJob(SESSION_TRAILER_DELEGATE_FILE)); + }); + + it("names .husky/prepare-commit-msg for husky, carrying the same line", () => { + const result = sessionTrailerManagerSnippet("husky", SESSION_TRAILER_DELEGATE_FILE); + + expect(result.targetFile).toBe(".husky/prepare-commit-msg"); + expect(result.snippet).toBe(sessionTrailerHuskyLine(SESSION_TRAILER_DELEGATE_FILE)); + }); +}); diff --git a/cli/tests/contexts/telemetry/domain/formats/copilot-events.unit.test.ts b/cli/tests/contexts/telemetry/domain/formats/copilot-events.unit.test.ts new file mode 100644 index 000000000..320013f78 --- /dev/null +++ b/cli/tests/contexts/telemetry/domain/formats/copilot-events.unit.test.ts @@ -0,0 +1,122 @@ +import { readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { describe, expect, it } from "vitest"; +import { mapCopilotEventsToSinkRecords } from "../../../../../src/contexts/telemetry/domain/formats/copilot-events.js"; + +const SESSION = "33333333-3333-4333-8333-333333333333"; +const EMPTY_SESSION = "44444444-4444-4444-8444-444444444444"; +const CACHED_SESSION = "55555555-5555-4555-8555-555555555555"; + +// Both fixtures are real, redacted excerpts of a captured Copilot session file — every +// message and reasoning field stripped. +function loadFixture(relativePath: string): string { + const url = new URL(`../../../../fixtures/local-cost/${relativePath}`, import.meta.url); + return readFileSync(fileURLToPath(url), "utf8"); +} + +const FULL_PATH = `.copilot/session-state/${SESSION}/events.jsonl`; +const EMPTY_PATH = `.copilot/session-state/${EMPTY_SESSION}/events.jsonl`; +const CACHED_PATH = `.copilot/session-state/${CACHED_SESSION}/events.jsonl`; + +describe("mapCopilotEventsToSinkRecords", () => { + it("yields one kind: session record, from session.shutdown's own tokenDetails", () => { + const records = mapCopilotEventsToSinkRecords(loadFixture(FULL_PATH), SESSION); + + expect(records).toEqual([ + { + kind: "session", + vendor_id: SESSION, + vendor_field: "sessionId", + turn_id: "99ccf9e7-b3ac-4145-a622-31852ec698cb", + turn_field: "id", + event_timestamp: "2026-08-21T14:07:49.286Z", + input_tokens: 10, + output_tokens: 42, + cache_read_tokens: 0, + cache_creation_tokens: 21070, + }, + ]); + }); + + it("stamps the vendor id it was given, never one read off the file's own content", () => { + // The file only ever confirms it holds *a* session, never which one, so the caller's + // own answer is the one that must win. + const records = mapCopilotEventsToSinkRecords(loadFixture(FULL_PATH), "some-other-id"); + + expect(records[0]?.vendor_id).toBe("some-other-id"); + }); + + it("still yields a record from a truncated file with no session.start line at all", () => { + // A partial copy or a rotated file carries no session.start to fall back to; reading + // identity from the caller's own argument is what keeps the record from being dropped. + const noSessionStart = loadFixture(FULL_PATH) + .split("\n") + .filter((line) => !line.includes('"session.start"')) + .join("\n"); + + const records = mapCopilotEventsToSinkRecords(noSessionStart, SESSION); + + expect(records).toHaveLength(1); + expect(records[0]?.vendor_id).toBe(SESSION); + }); + + it("never reads modelMetrics.usage.inputTokens, which is inclusive of the cache figure", () => { + // Measured: 10 (tokenDetails.input) + 21070 (cache_write) = 21080 (usage.inputTokens). + const [record] = mapCopilotEventsToSinkRecords(loadFixture(FULL_PATH), SESSION); + + expect(record?.input_tokens).not.toBe(21080); + }); + + it("reads the four counters disjoint when the cached prompt is large, the case left open", () => { + // Measured: 9 + 42038 + 21404 = 63451 = usage.inputTokens. At cache_read 0 an input that + // excludes the cached prompt and one that includes it read alike; at 42038 they cannot. + const [record] = mapCopilotEventsToSinkRecords(loadFixture(CACHED_PATH), CACHED_SESSION); + + expect(record?.input_tokens).toBe(9); + expect(record?.cache_read_tokens).toBe(42038); + expect(record?.cache_creation_tokens).toBe(21404); + expect( + (record?.input_tokens ?? 0) + + (record?.cache_read_tokens ?? 0) + + (record?.cache_creation_tokens ?? 0) + ).toBe(63451); + }); + + it("never carries cost_usd — totalPremiumRequests is a multiplier, not a currency", () => { + const [record] = mapCopilotEventsToSinkRecords(loadFixture(FULL_PATH), SESSION); + + expect(record && "cost_usd" in record).toBe(false); + }); + + it("never names a model — currentModel is only ever the session's last model", () => { + const [record] = mapCopilotEventsToSinkRecords(loadFixture(FULL_PATH), SESSION); + + expect(record && "model" in record).toBe(false); + }); + + it("yields nothing, not a record of zeros, when shutdown carried no tokenDetails", () => { + const records = mapCopilotEventsToSinkRecords(loadFixture(EMPTY_PATH), EMPTY_SESSION); + + expect(records).toEqual([]); + }); + + it("yields nothing for a session that never shut down", () => { + const noShutdown = loadFixture(FULL_PATH) + .split("\n") + .filter((line) => !line.includes('"session.shutdown"')) + .join("\n"); + + expect(mapCopilotEventsToSinkRecords(noShutdown, SESSION)).toEqual([]); + }); + + it("turns red rather than storing a zero when tokenDetails' own field is renamed", () => { + const moved = loadFixture(FULL_PATH).replaceAll("tokenCount", "token_count"); + + expect(mapCopilotEventsToSinkRecords(moved, SESSION)).toEqual([]); + }); + + it("touches no filesystem — two strings in, an array out", () => { + expect(typeof mapCopilotEventsToSinkRecords).toBe("function"); + expect(mapCopilotEventsToSinkRecords.length).toBe(2); + }); +}); diff --git a/cli/tests/domain/formats/local-cost-fixtures.redaction.unit.test.ts b/cli/tests/contexts/telemetry/domain/formats/local-cost-fixtures.redaction.unit.test.ts similarity index 89% rename from cli/tests/domain/formats/local-cost-fixtures.redaction.unit.test.ts rename to cli/tests/contexts/telemetry/domain/formats/local-cost-fixtures.redaction.unit.test.ts index 2fa8ed2f8..f3f95c7cf 100644 --- a/cli/tests/domain/formats/local-cost-fixtures.redaction.unit.test.ts +++ b/cli/tests/contexts/telemetry/domain/formats/local-cost-fixtures.redaction.unit.test.ts @@ -5,11 +5,10 @@ import { describe, expect, it } from "vitest"; // Scans tests/fixtures/local-cost/ itself, not a named list of files — a fixture added // later by this test's own module or by a future tool reader is covered automatically. -const FIXTURES_DIR = fileURLToPath(new URL("../../fixtures/local-cost", import.meta.url)); +const FIXTURES_DIR = fileURLToPath(new URL("../../../../fixtures/local-cost", import.meta.url)); -// Keys no counter-bearing line ever needs: every one of these carried a real prompt, file -// path, credential-adjacent detail, or system-prompt-sized blob in the transcripts these -// fixtures were excerpted from. +// Keys no counter-bearing line ever needs: each carried a real prompt, file path, +// credential-adjacent detail or system-prompt-sized blob in the transcripts excerpted here. const FORBIDDEN_KEYS = [ "cwd", "workspace_roots", diff --git a/cli/tests/domain/formats/opencode-export.unit.test.ts b/cli/tests/contexts/telemetry/domain/formats/opencode-export.unit.test.ts similarity index 77% rename from cli/tests/domain/formats/opencode-export.unit.test.ts rename to cli/tests/contexts/telemetry/domain/formats/opencode-export.unit.test.ts index f32bf0e49..f0e430173 100644 --- a/cli/tests/domain/formats/opencode-export.unit.test.ts +++ b/cli/tests/contexts/telemetry/domain/formats/opencode-export.unit.test.ts @@ -1,29 +1,20 @@ import { readFileSync } from "node:fs"; import { fileURLToPath } from "node:url"; import { describe, expect, it } from "vitest"; -import { mapOpencodeExportToSinkRecords } from "../../../src/domain/formats/opencode-export.js"; +import { mapOpencodeExportToSinkRecords } from "../../../../../src/contexts/telemetry/domain/formats/opencode-export.js"; const SESSION_ID = "ses_test_read"; -// opencode-export.json is a real `opencode export --sanitize` capture (opencode -// 1.14.20, 2026-08-20), mechanically trimmed to `{info, messages: [{info}, ...]}` — `parts` -// dropped, since the mapper under test reads only `messages[].info` and `--sanitize` had -// already redacted everything `parts` still carried. No value inside `info` was hand-edited. +// A real `opencode export --sanitize` capture (opencode 1.14.20), mechanically trimmed to +// `{info, messages: [{info}, ...]}`. No value inside `info` was hand-edited. function loadFixture(name: string): unknown { - const url = new URL(`../../fixtures/telemetry-sink/${name}`, import.meta.url); + const url = new URL(`../../../../fixtures/telemetry-sink/${name}`, import.meta.url); return JSON.parse(readFileSync(fileURLToPath(url), "utf8")); } describe("mapOpencodeExportToSinkRecords", () => { - // The comparison opencode-export.ts's own header named as missing: a large `cache.read` - // beside `input`, for a provider that is not Anthropic. Captured live 2026-09-06 from - // `opencode export --sanitize`, opencode 1.14.20, providerID "opencode", modelID - // "ling-3.0-flash-fin-free" - three billed turns of one session, cache genuinely - // exercised across them. `input` falls from 28242 to 269 as `cache.read` climbs from - // 640 to 28928: an `input` that already counted the cached tokens could not shrink that - // way. OpenCode's own `total` confirms it by arithmetic on every turn - - // `total == input + output + reasoning + cache.read + cache.write` - which only holds if - // the counters are disjoint. + // Measured on a non-Anthropic capture: `input` falls from 28242 to 269 as `cache.read` + // climbs to 28928, and `total` only adds up if the counters are disjoint. it("reads a non-Anthropic provider's counters as disjoint, the comparison no capture held", () => { const records = mapOpencodeExportToSinkRecords( loadFixture("opencode-export-non-anthropic-cache.json"), @@ -48,9 +39,8 @@ describe("mapOpencodeExportToSinkRecords", () => { it("yields one record per billed message, by value, under the stored field names", () => { const records = mapOpencodeExportToSinkRecords(loadFixture("opencode-export.json"), SESSION_ID); - // The fixture holds 5 user turns (no `tokens`), 3 billed assistant turns (`tokens` with a - // `total`), and 1 assistant turn OpenCode created but never billed (`tokens` present, every - // counter 0, no `total`) — only the 3 billed turns are counted messages. + // The fixture holds 5 user turns, 3 billed assistant turns, and 1 assistant turn + // OpenCode created but never billed — only the billed ones are counted messages. expect(records).toHaveLength(3); expect(records).toEqual([ { diff --git a/cli/tests/contexts/telemetry/domain/journal-intervals.unit.test.ts b/cli/tests/contexts/telemetry/domain/journal-intervals.unit.test.ts new file mode 100644 index 000000000..556ff2317 --- /dev/null +++ b/cli/tests/contexts/telemetry/domain/journal-intervals.unit.test.ts @@ -0,0 +1,116 @@ +import { describe, expect, it } from "vitest"; +import { + buildClosedIntervals, + type IntervalClosure, + timed, +} from "../../../../src/contexts/telemetry/domain/journal-intervals.js"; + +interface Boundary { + readonly at: string; + readonly kind: "open" | "close" | "other"; +} + +interface Interval { + readonly startMs: number; + readonly endMs: number; + readonly closedBy: IntervalClosure; +} + +const isOpener = (boundary: Boundary): boundary is Boundary => boundary.kind === "open"; +const isCloser = (boundary: Boundary): boolean => boundary.kind === "close"; +const toInterval = ( + _opener: Boundary, + startMs: number, + endMs: number, + closedBy: IntervalClosure +): Interval => ({ startMs, endMs, closedBy }); + +describe("timed()", () => { + it("drops a boundary whose own at cannot be parsed, instead of leaving a mid-list gap", () => { + const result = timed([ + { at: "2026-01-01T00:00:00.000Z" }, + { at: "not-a-date" }, + { at: "2026-01-02T00:00:00.000Z" }, + ]); + + expect(result.map((entry) => entry.boundary.at)).toEqual([ + "2026-01-01T00:00:00.000Z", + "2026-01-02T00:00:00.000Z", + ]); + }); + + it("sorts by the parsed moment, whatever order the input carried", () => { + const result = timed([{ at: "2026-01-02T00:00:00.000Z" }, { at: "2026-01-01T00:00:00.000Z" }]); + + expect(result.map((entry) => entry.atMs)).toEqual([ + Date.parse("2026-01-01T00:00:00.000Z"), + Date.parse("2026-01-02T00:00:00.000Z"), + ]); + }); +}); + +describe("buildClosedIntervals — the periodEndMs cap", () => { + it("caps an unclosed interval's end at periodEndMs, never at a later moment the journal witnessed", () => { + const opensAt = Date.parse("2026-01-01T00:00:00.000Z"); + const periodEndMs = Date.parse("2026-01-02T00:00:00.000Z"); + const boundaries: readonly Boundary[] = [ + { at: "2026-01-01T00:00:00.000Z", kind: "open" }, + { at: "2026-01-05T00:00:00.000Z", kind: "other" }, + ]; + + const intervals = buildClosedIntervals(boundaries, periodEndMs, isOpener, isCloser, toInterval); + + expect(intervals).toEqual([{ startMs: opensAt, endMs: periodEndMs, closedBy: "journal-end" }]); + }); +}); + +describe("IntervalClosure — the three ways an interval's end is reached", () => { + it("closes on an explicit closer boundary", () => { + const boundaries: readonly Boundary[] = [ + { at: "2026-01-01T00:00:00.000Z", kind: "open" }, + { at: "2026-01-02T00:00:00.000Z", kind: "close" }, + ]; + + const intervals = buildClosedIntervals(boundaries, undefined, isOpener, isCloser, toInterval); + + expect(intervals).toEqual([ + { + startMs: Date.parse("2026-01-01T00:00:00.000Z"), + endMs: Date.parse("2026-01-02T00:00:00.000Z"), + closedBy: "boundary", + }, + ]); + }); + + it("closes on the next opener, even with no explicit closer in between", () => { + const boundaries: readonly Boundary[] = [ + { at: "2026-01-01T00:00:00.000Z", kind: "open" }, + { at: "2026-01-02T00:00:00.000Z", kind: "open" }, + ]; + + const intervals = buildClosedIntervals(boundaries, undefined, isOpener, isCloser, toInterval); + + expect(intervals[0]).toEqual({ + startMs: Date.parse("2026-01-01T00:00:00.000Z"), + endMs: Date.parse("2026-01-02T00:00:00.000Z"), + closedBy: "boundary", + }); + }); + + it("stays open to the journal's own last witnessed moment when nothing closes it", () => { + const boundaries: readonly Boundary[] = [ + { at: "2026-01-01T00:00:00.000Z", kind: "open" }, + { at: "2026-01-03T00:00:00.000Z", kind: "other" }, + ]; + + const intervals = buildClosedIntervals(boundaries, undefined, isOpener, isCloser, toInterval); + + expect(intervals).toEqual([ + { + startMs: Date.parse("2026-01-01T00:00:00.000Z"), + endMs: Date.parse("2026-01-03T00:00:00.000Z"), + closedBy: "journal-end", + }, + ]); + }); +}); diff --git a/cli/tests/domain/models/metrics-contract.unit.test.ts b/cli/tests/contexts/telemetry/domain/metrics-contract.unit.test.ts similarity index 78% rename from cli/tests/domain/models/metrics-contract.unit.test.ts rename to cli/tests/contexts/telemetry/domain/metrics-contract.unit.test.ts index 504739227..bbd09f1a4 100644 --- a/cli/tests/domain/models/metrics-contract.unit.test.ts +++ b/cli/tests/contexts/telemetry/domain/metrics-contract.unit.test.ts @@ -1,40 +1,38 @@ import { readFileSync } from "node:fs"; -import { fileURLToPath } from "node:url"; +import { join } from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; import { describe, expect, it } from "vitest"; +import { REPOSITORY_ROOT } from "../../../helpers/repository-root.js"; // Side-effect imports: the use-case resolves every AI tool's local-read declaration from -// the registry (it loops all of them, not just "claude"), so every tool must be registered -// for the local-read worked example to run at all. -import "../../../src/domain/tools/ai/claude.js"; -import "../../../src/domain/tools/ai/codex.js"; -import "../../../src/domain/tools/ai/copilot.js"; -import "../../../src/domain/tools/ai/cursor.js"; -import "../../../src/domain/tools/ai/opencode.js"; -import { ReadLocalCostUseCase } from "../../../src/application/use-cases/telemetry/read-local-cost-use-case.js"; -import type { LocalCostCandidateRecord } from "../../../src/domain/ports/session-cost-reader.js"; -import { NULL_PERSON_IDENTITY_READER } from "../../helpers/ports/in-memory-person-identity-reader.js"; -import { NULL_RUN_JOURNAL_READER } from "../../helpers/ports/in-memory-run-journal-reader.js"; -import { InMemoryTelemetrySink } from "../../helpers/ports/in-memory-telemetry-sink.js"; -import { StubTelemetryEvidenceReader } from "../../helpers/ports/stub-telemetry-evidence-reader.js"; +// the registry, so every tool must be registered for the worked example to run at all. +import "../../../../src/contexts/tools/domain/profiles/claude/profile.js"; +import "../../../../src/contexts/tools/domain/profiles/codex/profile.js"; +import "../../../../src/contexts/tools/domain/profiles/copilot/profile.js"; +import "../../../../src/contexts/tools/domain/profiles/cursor/profile.js"; +import "../../../../src/contexts/tools/domain/profiles/opencode/profile.js"; +import { ReadLocalCostUseCase } from "../../../../src/contexts/telemetry/application/read-local-cost-use-case.js"; +import type { LocalCostCandidateRecord } from "../../../../src/contexts/telemetry/domain/ports/session-cost-reader.js"; +import { NULL_PERSON_IDENTITY_READER } from "../../../helpers/ports/in-memory-person-identity-reader.js"; +import { NULL_RUN_JOURNAL_READER } from "../../../helpers/ports/in-memory-run-journal-reader.js"; +import { InMemoryTelemetrySink } from "../../../helpers/ports/in-memory-telemetry-sink.js"; +import { StubTelemetryEvidenceReader } from "../../../helpers/ports/stub-telemetry-evidence-reader.js"; -// This test is the honesty check the contract document promises its own readers: it never -// trusts a hand-maintained list of fields on either side, only the record's own interface +// Trusts no hand-maintained list of fields on either side: only the record's own interface // text and the document's own prose, both read fresh off disk. const RECORD_MODEL_URL = new URL( - "../../../src/domain/models/telemetry-sink-record.ts", + "../../../../src/contexts/telemetry/domain/telemetry-sink-record.ts", import.meta.url ); -const CONTRACT_DOC_URL = new URL( - "../../../../aidd_docs/product/metrics-contract.md", - import.meta.url +const CONTRACT_DOC_URL = pathToFileURL( + join(REPOSITORY_ROOT, "aidd_docs", "product", "metrics-contract.md") ); function readTextFile(url: URL): string { return readFileSync(fileURLToPath(url), "utf8"); } -/** Every field name on `TelemetrySinkRecord`, read straight off the interface's own text — - * never a hand-copied list, so an added or removed field is seen here without this file - * being told about it. */ +/** Every field name on `TelemetrySinkRecord`, read straight off the interface's own text, + * so an added or removed field is seen here without this file being told about it. */ function recordFieldNames(): readonly string[] { const source = readTextFile(RECORD_MODEL_URL); const start = source.indexOf("export interface TelemetrySinkRecord {"); diff --git a/cli/tests/domain/models/person-resolution.unit.test.ts b/cli/tests/contexts/telemetry/domain/person-resolution.unit.test.ts similarity index 81% rename from cli/tests/domain/models/person-resolution.unit.test.ts rename to cli/tests/contexts/telemetry/domain/person-resolution.unit.test.ts index 224044604..ee12d50e8 100644 --- a/cli/tests/domain/models/person-resolution.unit.test.ts +++ b/cli/tests/contexts/telemetry/domain/person-resolution.unit.test.ts @@ -3,14 +3,9 @@ import { resolvePerson, withAlsoMeAdded, withPersonIdAdopted, -} from "../../../src/domain/models/person-resolution.js"; -import type { PersonIdentity } from "../../../src/domain/ports/person-identity-reader.js"; - -/** - * One machine's own identity, carrying a display name and two identifiers it did not - * choose here — the Test Scope's own setup, reused across every case below rather than - * rebuilt per test. - */ +} from "../../../../src/contexts/telemetry/domain/person-resolution.js"; +import type { PersonIdentity } from "../../../../src/contexts/telemetry/domain/ports/person-identity-reader.js"; + function identityWithAlsoMe(): PersonIdentity { return { personId: "person-a", @@ -44,11 +39,8 @@ describe("resolvePerson", () => { expect(resolved.identities).toEqual(["nobody-claimed-this"]); }); - // The determinism fix. `person_id` is stamped when a record is stored, so whether a - // record carries one depends on when the identity was declared relative to when that - // record was read - not on the work. The sink has exactly one writer and every line in it - // is `local-read`, so a record that named nobody was read by this machine's own reader, - // and this machine has said who that is. + // `person_id` is stamped when a record is stored, so whether a record carries one depends on + // when the identity was declared. Every sink line is `local-read`, by this machine's reader. it("names an unstamped record after this machine's own declared identity", () => { const resolved = resolvePerson(identityWithAlsoMe(), undefined); @@ -147,12 +139,8 @@ describe("resolvePerson", () => { expect(bare.alsoMe).toEqual([]); }); - // A second person is not a value this shape can carry — the proof belongs to the - // compiler rather than to a run: `PersonIdentity` has no field for a second, distinct - // person's claim, so the attempt below does not compile. `@ts-expect-error` inverts - // that into an assertion: the day a roster field is added back, the directive has - // nothing to suppress and `tsc` fails on it. There is nothing here for a runtime check - // to guard, because there is no shape in which the failure could be constructed. + // The proof belongs to the compiler: `PersonIdentity` has no field for a second person's + // claim, and the day a roster field returns the directive below has nothing to suppress. it("admits no second person's claim, at compile time", () => { const identity: PersonIdentity = { personId: "person-a", @@ -165,9 +153,8 @@ describe("resolvePerson", () => { expect(identity).toBeDefined(); }); - // The sequence three supported verbs allow: add an identifier, later adopt it as your - // own. Before the invariant moved into the writers, `also_me` kept the newly canonical - // identifier and one row named it twice as its own evidence. + // Add an identifier, then adopt it: before the invariant moved into the writers, `also_me` + // kept the newly canonical identifier and one row named it twice as its own evidence. it("adopting an identifier already added onto this person does not list it as added onto them", () => { const linked = withAlsoMeAdded( { personId: "machine-a", origin: "minted", alsoMe: [] }, diff --git a/cli/tests/domain/models/report-period.unit.test.ts b/cli/tests/contexts/telemetry/domain/report-period.unit.test.ts similarity index 93% rename from cli/tests/domain/models/report-period.unit.test.ts rename to cli/tests/contexts/telemetry/domain/report-period.unit.test.ts index 643fd6d65..aa7911260 100644 --- a/cli/tests/domain/models/report-period.unit.test.ts +++ b/cli/tests/contexts/telemetry/domain/report-period.unit.test.ts @@ -1,11 +1,11 @@ import { readFileSync } from "node:fs"; import { fileURLToPath } from "node:url"; import { describe, expect, it } from "vitest"; -import { InvalidReportDayError, InvalidReportSpanError } from "../../../src/domain/errors.js"; import { DEFAULT_REPORT_DAYS, resolveReportPeriod, -} from "../../../src/domain/models/report-period.js"; +} from "../../../../src/contexts/telemetry/domain/report-period.js"; +import { InvalidReportDayError, InvalidReportSpanError } from "../../../../src/kernel/errors.js"; const TODAY = new Date("2026-08-21T09:30:00Z"); @@ -96,7 +96,9 @@ describe("resolveReportPeriod", () => { it("reads no clock of its own", () => { const source = readFileSync( - fileURLToPath(new URL("../../../src/domain/models/report-period.ts", import.meta.url)), + fileURLToPath( + new URL("../../../../src/contexts/telemetry/domain/report-period.ts", import.meta.url) + ), "utf8" ); diff --git a/cli/tests/domain/models/session-project.unit.test.ts b/cli/tests/contexts/telemetry/domain/session-project.unit.test.ts similarity index 90% rename from cli/tests/domain/models/session-project.unit.test.ts rename to cli/tests/contexts/telemetry/domain/session-project.unit.test.ts index 82f9a01e0..dbcb740ac 100644 --- a/cli/tests/domain/models/session-project.unit.test.ts +++ b/cli/tests/contexts/telemetry/domain/session-project.unit.test.ts @@ -1,9 +1,9 @@ import { describe, expect, it } from "vitest"; -import { resolveSessionProject } from "../../../src/domain/models/session-project.js"; import type { RunJournal, RunJournalSessionStart, -} from "../../../src/domain/ports/run-journal-reader.js"; +} from "../../../../src/contexts/telemetry/domain/ports/run-journal-reader.js"; +import { resolveSessionProject } from "../../../../src/contexts/telemetry/domain/session-project.js"; function sessionOf(overrides: Partial = {}): RunJournalSessionStart { return { diff --git a/cli/tests/domain/models/skill-name.unit.test.ts b/cli/tests/contexts/telemetry/domain/skill-name.unit.test.ts similarity index 85% rename from cli/tests/domain/models/skill-name.unit.test.ts rename to cli/tests/contexts/telemetry/domain/skill-name.unit.test.ts index 1d9bfd514..ddb00142c 100644 --- a/cli/tests/domain/models/skill-name.unit.test.ts +++ b/cli/tests/contexts/telemetry/domain/skill-name.unit.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { namesTheSameSkill } from "../../../src/domain/models/skill-name.js"; +import { namesTheSameSkill } from "../../../../src/contexts/telemetry/domain/skill-name.js"; describe("namesTheSameSkill — one skill, two hosts, two spellings", () => { it("holds for the identical spelling", () => { @@ -8,8 +8,8 @@ describe("namesTheSameSkill — one skill, two hosts, two spellings", () => { }); it("holds when only one side carries the plugin, whichever side that is", () => { - // The case this exists for: Cursor and Codex open a step as `01-plan`, and the end the - // skill echoes always says `aidd-dev:01-plan`. + // Cursor and Codex open a step as `01-plan`, while the end the skill echoes always says + // `aidd-dev:01-plan`. expect(namesTheSameSkill("01-plan", "aidd-dev:01-plan")).toBe(true); expect(namesTheSameSkill("aidd-dev:01-plan", "01-plan")).toBe(true); }); diff --git a/cli/tests/contexts/telemetry/domain/step-attribution.unit.test.ts b/cli/tests/contexts/telemetry/domain/step-attribution.unit.test.ts new file mode 100644 index 000000000..c367d26a3 --- /dev/null +++ b/cli/tests/contexts/telemetry/domain/step-attribution.unit.test.ts @@ -0,0 +1,408 @@ +import { readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { describe, expect, it } from "vitest"; +import type { RunJournal } from "../../../../src/contexts/telemetry/domain/ports/run-journal-reader.js"; +import { + attributeMoment, + buildStepIntervals, +} from "../../../../src/contexts/telemetry/domain/step-attribution.js"; + +function journalOf(...boundaries: RunJournal["boundaries"]): RunJournal { + return { boundaries, filesWritten: [], taskDeclarations: [] }; +} + +function journalWith( + boundaries: RunJournal["boundaries"], + filesWritten: RunJournal["filesWritten"] +): RunJournal { + return { boundaries, filesWritten, taskDeclarations: [] }; +} + +const A_START = { + type: "step_start", + at: "2026-08-20T10:00:00Z", + skill: "aidd-dev:02-implement", +} as const; +const B_START = { + type: "step_start", + at: "2026-08-20T10:05:00Z", + skill: "aidd-dev:06-test", +} as const; +const A_AGAIN = { + type: "step_start", + at: "2026-08-20T10:10:00Z", + skill: "aidd-dev:02-implement", +} as const; +const TURN_END = { type: "turn_end", at: "2026-08-20T10:15:00Z" } as const; + +describe("step-attribution — pure: journal lines + records -> intervals", () => { + it("maps a moment inside a step interval to that step, marked as derived", () => { + const intervals = buildStepIntervals(journalOf(A_START, TURN_END)); + + const attribution = attributeMoment(intervals, "2026-08-20T10:02:00Z"); + + expect(attribution).toEqual({ source: "journal-interval", step: "aidd-dev:02-implement" }); + }); + + // A `turn_end` is a pause, not the end of a step - the same rule `buildTaskIntervals` and + // `buildFlowIntervals` already read from this very journal. + it("runs a step past a pause, to the journal's own last witnessed moment", () => { + const intervals = buildStepIntervals( + journalWith( + [A_START, TURN_END], + [{ type: "file_written", at: "2026-08-20T11:00:00Z", path: "aidd_docs/note.md" }] + ) + ); + + expect(attributeMoment(intervals, "2026-08-20T10:30:00Z")).toEqual({ + source: "journal-interval", + step: "aidd-dev:02-implement", + }); + expect(intervals[0]?.endMs).toBe(Date.parse("2026-08-20T11:00:00Z")); + }); + + // No host emits when a skill's work finished (a `Skill` call's `tool_result` returns in a + // tenth of a second), so the skill declares its own end, and that end outranks any pause. + it("runs a step past every pause, to the end its own skill declared", () => { + const intervals = buildStepIntervals( + journalOf( + A_START, + TURN_END, + { type: "turn_end", at: "2026-08-20T10:20:00Z" }, + { type: "step_end", at: "2026-08-20T10:30:00Z", skill: "aidd-dev:02-implement" } + ) + ); + + const attribution = attributeMoment(intervals, "2026-08-20T10:25:00Z"); + + expect(attribution).toEqual({ source: "journal-interval", step: "aidd-dev:02-implement" }); + }); + + // A skill invoking a second one leaves two open intervals; an end for the inner skill + // must leave the outer one running. + it("closes only the step its own skill names", () => { + const intervals = buildStepIntervals( + journalOf(A_START, B_START, { + type: "step_end", + at: "2026-08-20T10:07:00Z", + skill: "aidd-dev:06-test", + }) + ); + + const outer = intervals.find((interval) => interval.skill === "aidd-dev:02-implement"); + const inner = intervals.find((interval) => interval.skill === "aidd-dev:06-test"); + expect(inner?.endMs).toBe(Date.parse("2026-08-20T10:07:00Z")); + expect(outer?.endMs).toBe(Date.parse("2026-08-20T10:05:00Z")); + }); + + // Cursor and Codex name a skill by its folder alone, while the end a skill echoes always + // carries the plugin: compared exactly, a declared end closes nothing on those hosts. + it("closes a step opened by its bare name with the end its skill declares in full", () => { + const bareStart = { + type: "step_start", + at: "2026-08-20T10:00:00Z", + skill: "02-implement", + } as const; + const intervals = buildStepIntervals( + journalOf( + bareStart, + { type: "turn_end", at: "2026-08-20T10:10:00Z" }, + { type: "step_end", at: "2026-08-20T10:30:00Z", skill: "aidd-dev:02-implement" } + ) + ); + + expect(intervals[0]?.endMs).toBe(Date.parse("2026-08-20T10:30:00Z")); + }); + + // The journal carries a moment later than the disagreeing end deliberately: with that end + // as its last line, the assertion could not tell a refused closer from a cap. + it("still refuses an end whose plugin disagrees with the one that opened the step", () => { + const intervals = buildStepIntervals( + journalWith( + [A_START, { type: "step_end", at: "2026-08-20T10:02:00Z", skill: "aidd-pm:02-implement" }], + [{ type: "file_written", at: "2026-08-20T10:20:00Z", path: "aidd_docs/note.md" }] + ) + ); + + expect(intervals[0]?.endMs).toBe(Date.parse("2026-08-20T10:20:00Z")); + }); + + // Read as a boundary all the same, it would truncate whatever interval was running - a + // step it has no claim on. + it("ignores an end for a skill this session never started", () => { + const intervals = buildStepIntervals( + journalWith( + [A_START, { type: "step_end", at: "2026-08-20T10:02:00Z", skill: "some-other:skill" }], + [{ type: "file_written", at: "2026-08-20T10:20:00Z", path: "aidd_docs/note.md" }] + ) + ); + + expect(attributeMoment(intervals, "2026-08-20T10:03:00Z")).toEqual({ + source: "journal-interval", + step: "aidd-dev:02-implement", + }); + }); + + it("closes an interval at the next step_start, not at the turn's end past it", () => { + const intervals = buildStepIntervals(journalOf(A_START, B_START, TURN_END)); + + expect(attributeMoment(intervals, "2026-08-20T10:04:59Z")).toEqual({ + source: "journal-interval", + step: "aidd-dev:02-implement", + }); + expect(attributeMoment(intervals, "2026-08-20T10:05:00Z")).toEqual({ + source: "journal-interval", + step: "aidd-dev:06-test", + }); + }); + + // A pause is not a closer, so what bounds the last step here is the journal's own last + // witnessed moment - which this journal's `turn_end` happens to be. + it("leaves nothing beyond the journal's last witnessed moment covered", () => { + const intervals = buildStepIntervals(journalOf(B_START, TURN_END)); + + expect(attributeMoment(intervals, "2026-08-20T10:14:59Z")).toMatchObject({ + source: "journal-interval", + }); + expect(attributeMoment(intervals, "2026-08-20T10:15:00Z")).toEqual({ + source: "unattributed", + }); + }); + + it("yields three intervals and two names from A, then B, then A", () => { + const intervals = buildStepIntervals(journalOf(A_START, B_START, A_AGAIN, TURN_END)); + + expect(intervals).toHaveLength(3); + expect(new Set(intervals.map((i) => i.skill))).toEqual( + new Set(["aidd-dev:02-implement", "aidd-dev:06-test"]) + ); + expect(attributeMoment(intervals, "2026-08-20T10:05:30Z")).toEqual({ + source: "journal-interval", + step: "aidd-dev:06-test", + }); + expect(attributeMoment(intervals, "2026-08-20T10:12:00Z")).toEqual({ + source: "journal-interval", + step: "aidd-dev:02-implement", + }); + }); + + it("reads a moment before the first boundary as unattributed, never folded into it", () => { + const intervals = buildStepIntervals(journalOf(A_START, TURN_END)); + + const attribution = attributeMoment(intervals, "2026-08-20T09:59:59Z"); + + expect(attribution).toEqual({ source: "unattributed" }); + }); + + it("reads a record with no moment at all as unattributed, never the first interval", () => { + const intervals = buildStepIntervals(journalOf(A_START, TURN_END)); + + expect(attributeMoment(intervals, undefined)).toEqual({ source: "unattributed" }); + }); + + it("does not let an unparseable boundary extend the step before it into the step after", () => { + const intervals = buildStepIntervals( + journalOf(A_START, { type: "turn_end", at: "not-a-date" }, B_START, TURN_END) + ); + + const attribution = attributeMoment(intervals, "2026-08-20T10:07:00Z"); + + expect(attribution).toEqual({ source: "journal-interval", step: "aidd-dev:06-test" }); + }); + + it("reads every moment as unattributed when the journal opened no step", () => { + const intervals = buildStepIntervals(journalOf(TURN_END)); + + expect(attributeMoment(intervals, "2026-08-20T10:00:00Z")).toEqual({ + source: "unattributed", + }); + }); + + // `nowIso()` stamps at second resolution and the walk's sort is stable, so lines sharing + // a moment keep file order: a step whose end shares its start's moment covers nothing. + it("closes a step at an end sharing its own start's moment, covering nothing", () => { + const intervals = buildStepIntervals( + journalWith( + [ + A_START, + { type: "step_end", at: A_START.at, skill: A_START.skill }, + { type: "turn_end", at: "2026-08-20T11:00:00Z" }, + ], + [{ type: "file_written", at: "2026-08-20T12:00:00Z", path: "aidd_docs/note.md" }] + ) + ); + + expect(intervals[0]?.endMs).toBe(Date.parse(A_START.at)); + expect(attributeMoment(intervals, A_START.at)).toEqual({ source: "unattributed" }); + }); + + // Reading the invoked skill's own `step_start` as the end of the orchestration credits an + // orchestration that ran for hours with the seconds before its first child. + it("does not let an invoked step close the orchestration that invoked it", () => { + const intervals = buildStepIntervals( + journalOf( + { type: "step_start", at: "2026-08-20T10:00:00Z", skill: "aidd-orchestrator:01-sdlc" }, + { type: "step_start", at: "2026-08-20T10:05:00Z", skill: "aidd-pm:04-spec" }, + { type: "turn_end", at: "2026-08-20T11:00:00Z" } + ) + ); + + const sdlc = intervals.find((interval) => interval.skill === "aidd-orchestrator:01-sdlc"); + expect(sdlc?.endMs).toBe(Date.parse("2026-08-20T11:00:00Z")); + }); + + // The invoked step is inside the orchestration, not beside it, so both intervals contain + // the same moment; the innermost answers, being the more specific claim. + it("attributes a moment inside both to the step, and one outside it to the orchestration", () => { + const intervals = buildStepIntervals( + journalOf( + { type: "step_start", at: "2026-08-20T10:00:00Z", skill: "aidd-orchestrator:01-sdlc" }, + { type: "step_start", at: "2026-08-20T10:05:00Z", skill: "aidd-pm:04-spec" }, + { type: "step_end", at: "2026-08-20T10:10:00Z", skill: "aidd-pm:04-spec" }, + { type: "turn_end", at: "2026-08-20T11:00:00Z" } + ) + ); + + expect(attributeMoment(intervals, "2026-08-20T10:02:00Z")).toEqual({ + source: "journal-interval", + step: "aidd-orchestrator:01-sdlc", + }); + expect(attributeMoment(intervals, "2026-08-20T10:07:00Z")).toEqual({ + source: "journal-interval", + step: "aidd-pm:04-spec", + }); + // Past the invoked step's own declared end, back inside the orchestration alone. + expect(attributeMoment(intervals, "2026-08-20T10:30:00Z")).toEqual({ + source: "journal-interval", + step: "aidd-orchestrator:01-sdlc", + }); + }); + + // An interval nothing closed ends at the journal's last witnessed moment, a bound and not + // a measurement, so where one sits inside another the enclosing one answers. + it("hands a moment to the orchestration when nothing ever closed the step inside it", () => { + const intervals = buildStepIntervals( + journalOf( + { type: "step_start", at: "2026-08-20T10:00:00Z", skill: "aidd-orchestrator:01-sdlc" }, + { type: "step_start", at: "2026-08-20T10:05:00Z", skill: "aidd-pm:04-spec" }, + { type: "turn_end", at: "2026-08-20T11:00:00Z" } + ) + ); + + expect(attributeMoment(intervals, "2026-08-20T10:30:00Z")).toEqual({ + source: "journal-interval", + step: "aidd-orchestrator:01-sdlc", + }); + }); + + // The first invoked step is closed by the second's own start, so its end is witnessed and + // it answers for what it covers; only the second is unclosed, and it is the one that yields. + it("keeps the earlier invoked step, and yields only the one nothing closed", () => { + const intervals = buildStepIntervals( + journalOf( + { type: "step_start", at: "2026-08-20T10:00:00Z", skill: "aidd-orchestrator:01-sdlc" }, + { type: "step_start", at: "2026-08-20T10:05:00Z", skill: "aidd-pm:04-spec" }, + { type: "step_start", at: "2026-08-20T10:20:00Z", skill: "aidd-dev:01-plan" }, + { type: "turn_end", at: "2026-08-20T11:00:00Z" } + ) + ); + + expect(attributeMoment(intervals, "2026-08-20T10:10:00Z")).toEqual({ + source: "journal-interval", + step: "aidd-pm:04-spec", + }); + expect(attributeMoment(intervals, "2026-08-20T10:30:00Z")).toEqual({ + source: "journal-interval", + step: "aidd-orchestrator:01-sdlc", + }); + }); + + // The yielding is between two intervals nothing closed, and no wider: here the + // orchestration states its own end, so the step inside runs past it and nothing encloses it. + it("keeps the innermost step when the orchestration around it states its own end", () => { + const intervals = buildStepIntervals( + journalOf( + { type: "step_start", at: "2026-08-20T10:00:00Z", skill: "aidd-orchestrator:01-sdlc" }, + { type: "step_start", at: "2026-08-20T10:10:00Z", skill: "aidd-pm:04-spec" }, + { type: "step_end", at: "2026-08-20T10:20:00Z", skill: "aidd-orchestrator:01-sdlc" }, + { type: "turn_end", at: "2026-08-20T11:00:00Z" } + ) + ); + + expect(attributeMoment(intervals, "2026-08-20T10:15:00Z")).toEqual({ + source: "journal-interval", + step: "aidd-pm:04-spec", + }); + }); + + // Nesting is declared, never inferred: only a skill `ORCHESTRATING_SKILLS` names invokes + // others. Two ordinary skills in a row are a sequence, and the second still ends the first. + it("still lets one ordinary step close another, which is a sequence and not a nesting", () => { + const intervals = buildStepIntervals(journalOf(A_START, B_START, TURN_END)); + + const first = intervals.find((interval) => interval.skill === A_START.skill); + expect(first?.endMs).toBe(Date.parse(B_START.at)); + }); + + // One orchestration does not nest inside another by default - the same rule + // `buildFlowIntervals` already applies to the wider concept. + it("lets one orchestration close another", () => { + const intervals = buildStepIntervals( + journalOf( + { type: "step_start", at: "2026-08-20T10:00:00Z", skill: "aidd-orchestrator:01-sdlc" }, + { type: "step_start", at: "2026-08-20T10:05:00Z", skill: "aidd-orchestrator:02-backlog" }, + { type: "turn_end", at: "2026-08-20T11:00:00Z" } + ) + ); + + const first = intervals.find((interval) => interval.skill === "aidd-orchestrator:01-sdlc"); + expect(first?.endMs).toBe(Date.parse("2026-08-20T10:05:00Z")); + }); + + it("touches no filesystem — the module imports none of Node's fs APIs", () => { + const url = new URL( + "../../../../src/contexts/telemetry/domain/step-attribution.ts", + import.meta.url + ); + const source = readFileSync(fileURLToPath(url), "utf8"); + + expect(source).not.toMatch(/from ["']node:fs/); + expect(source).not.toMatch(/require\(["']node:fs/); + }); +}); + +describe("buildStepIntervals — a step the session never closed", () => { + // An open interval is not the safer error: one captured session carries a single + // `vendor_id` spanning 22 days, so "everything afterward" is three weeks of foreign work. + it("caps a step nothing closed at the journal's own last witnessed moment", () => { + const intervals = buildStepIntervals( + journalWith( + [{ type: "step_start", at: "2026-08-17T10:00:00Z", skill: "aidd-dev:01-plan" }], + [{ type: "file_written", at: "2026-08-17T12:00:00Z", path: "aidd_docs/note.md" }] + ) + ); + + expect(intervals).toEqual([ + { + skill: "aidd-dev:01-plan", + startMs: Date.parse("2026-08-17T10:00:00Z"), + endMs: Date.parse("2026-08-17T12:00:00Z"), + closedBy: "journal-end", + }, + ]); + expect(attributeMoment(intervals, "2026-09-30T23:59:00Z")).toEqual({ source: "unattributed" }); + }); + + // The price of the cap: a journal whose only line is the opener has no later moment to cap + // at. `records-join` survives it - a record whose own tool named its step needs no interval. + it("covers nothing when the opener is the only moment the journal ever witnessed", () => { + const intervals = buildStepIntervals( + journalOf({ type: "step_start", at: "2026-08-17T10:00:00Z", skill: "aidd-dev:01-plan" }) + ); + + expect(intervals[0]?.endMs).toBe(Date.parse("2026-08-17T10:00:00Z")); + expect(attributeMoment(intervals, "2026-08-17T10:00:01Z")).toEqual({ + source: "unattributed", + }); + }); +}); diff --git a/cli/tests/contexts/telemetry/domain/task-attribution.unit.test.ts b/cli/tests/contexts/telemetry/domain/task-attribution.unit.test.ts new file mode 100644 index 000000000..4fb731108 --- /dev/null +++ b/cli/tests/contexts/telemetry/domain/task-attribution.unit.test.ts @@ -0,0 +1,291 @@ +import { readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { describe, expect, it } from "vitest"; +import { momentFallsWithin } from "../../../../src/contexts/telemetry/domain/journal-intervals.js"; +import type { RunJournal } from "../../../../src/contexts/telemetry/domain/ports/run-journal-reader.js"; +import { + buildTaskIntervals, + taskUnattributedReason, +} from "../../../../src/contexts/telemetry/domain/task-attribution.js"; + +function journalOf( + taskDeclarations: RunJournal["taskDeclarations"], + boundaries: RunJournal["boundaries"] = [], + filesWritten: RunJournal["filesWritten"] = [] +): RunJournal { + return { boundaries, filesWritten, taskDeclarations }; +} + +const WANTED = { + type: "task_declared", + at: "2026-08-17T10:00:00Z", + path: "aidd_docs/tasks/2026_08/wanted/spec.md", +} as const; +const OTHER = { + type: "task_declared", + at: "2026-08-17T10:10:00Z", + path: "aidd_docs/tasks/2026_08/other/spec.md", +} as const; +const TURN_END = { type: "turn_end", at: "2026-08-17T10:15:00Z" } as const; + +describe("task-attribution — pure: journal lines -> bounded intervals", () => { + it("closes a declared interval at the turn_end that follows it", () => { + const intervals = buildTaskIntervals(journalOf([WANTED], [TURN_END])); + + expect(intervals).toEqual([ + { path: WANTED.path, startMs: Date.parse(WANTED.at), endMs: Date.parse(TURN_END.at) }, + ]); + }); + + // A `turn_end` is a pause, not a change of subject. It stays a *witness*, so an interval + // with nothing after it still ends there; what changes is one with work after it. + it("keeps a declaration open across a turn_end, ending at the work that followed", () => { + const wrote = { + type: "file_written", + at: "2026-08-17T10:20:00Z", + path: "aidd_docs/tasks/2026_08/wanted/phase-1.md", + } as const; + const intervals = buildTaskIntervals(journalOf([WANTED], [TURN_END], [wrote])); + + expect(intervals).toEqual([ + { path: WANTED.path, startMs: Date.parse(WANTED.at), endMs: Date.parse(wrote.at) }, + ]); + }); + + it("closes a declaration at a later declaration, never at the turn's own end past it", () => { + const intervals = buildTaskIntervals(journalOf([WANTED, OTHER], [TURN_END])); + + expect(intervals).toEqual([ + { path: WANTED.path, startMs: Date.parse(WANTED.at), endMs: Date.parse(OTHER.at) }, + { path: OTHER.path, startMs: Date.parse(OTHER.at), endMs: Date.parse(TURN_END.at) }, + ]); + }); + + it("caps an unclosed declaration at its own moment, never at Infinity", () => { + // No turn_end at all - the session crashed right after declaring. + const intervals = buildTaskIntervals(journalOf([WANTED])); + + expect(intervals).toEqual([ + { path: WANTED.path, startMs: Date.parse(WANTED.at), endMs: Date.parse(WANTED.at) }, + ]); + expect(momentFallsWithin(intervals, "2026-08-17T10:30:00Z")).toBe(false); + }); + + it("caps an unclosed declaration at the last boundary the journal actually recorded", () => { + const laterStep = { + type: "step_start", + at: "2026-08-17T10:20:00Z", + skill: "aidd-dev:02-implement", + } as const; + const intervals = buildTaskIntervals(journalOf([WANTED], [laterStep])); + + // step_start is not one of the two kinds an interval closes on, but it is still the + // journal's own last recorded moment - the honest bound for a crash right after it. + expect(intervals[0].endMs).toBe(Date.parse(laterStep.at)); + }); + + it("never lets a step_start close a declared interval early - only task_declared and turn_end do", () => { + const stepBetween = { + type: "step_start", + at: "2026-08-17T10:05:00Z", + skill: "aidd-dev:02-implement", + } as const; + const intervals = buildTaskIntervals(journalOf([WANTED], [stepBetween, TURN_END])); + + expect(intervals).toEqual([ + { path: WANTED.path, startMs: Date.parse(WANTED.at), endMs: Date.parse(TURN_END.at) }, + ]); + }); + + it("declares no interval at all for a journal that never named a task", () => { + expect(buildTaskIntervals(journalOf([], [TURN_END]))).toEqual([]); + }); + + it("drops a task_declared line whose own `at` this reader cannot parse, the same as one that was never written", () => { + // `taskUnattributedReason` folds this into "no-declaration" beside a session that truly + // never declared, which is why that label reads "no *usable* declaration". + const unparseable = { + type: "task_declared", + at: "not-a-real-timestamp", + path: WANTED.path, + } as const; + + expect(buildTaskIntervals(journalOf([unparseable], [TURN_END]))).toEqual([]); + }); + + it("emits no interval for a declared path this reader cannot turn into an identity, but still lets it close the interval before it", () => { + // A `..` segment passes `task-declared.cjs`'s looser gate and names no task; dropped from + // `closers` too, it would silently widen WANTED's interval past its own declaration. + const climbing = { + type: "task_declared", + at: "2026-08-17T10:10:00Z", + path: "aidd_docs/tasks/2026_08/../../etc/passwd", + } as const; + + const intervals = buildTaskIntervals(journalOf([WANTED, climbing], [TURN_END])); + + expect(intervals).toEqual([ + { path: WANTED.path, startMs: Date.parse(WANTED.at), endMs: Date.parse(climbing.at) }, + ]); + }); + + it("reads a moment inside the interval as covered, and one outside as not", () => { + const intervals = buildTaskIntervals(journalOf([WANTED], [TURN_END])); + + expect(momentFallsWithin(intervals, "2026-08-17T10:05:00Z")).toBe(true); + expect(momentFallsWithin(intervals, "2026-08-17T09:59:59Z")).toBe(false); + expect(momentFallsWithin(intervals, "2026-08-17T10:15:00Z")).toBe(false); + }); + + it("reads a record with no moment, or an unparseable one, as not covered", () => { + const intervals = buildTaskIntervals(journalOf([WANTED], [TURN_END])); + + expect(momentFallsWithin(intervals, undefined)).toBe(false); + expect(momentFallsWithin(intervals, "not-a-date")).toBe(false); + }); + + it("touches no filesystem — the module imports none of Node's fs APIs", () => { + const url = new URL( + "../../../../src/contexts/telemetry/domain/task-attribution.ts", + import.meta.url + ); + const source = readFileSync(fileURLToPath(url), "utf8"); + + expect(source).not.toMatch(/from ["']node:fs/); + expect(source).not.toMatch(/require\(["']node:fs/); + }); + + it("widens an unclosed declaration's end to a written file the journal witnessed after it", () => { + const writtenAfter = { + type: "file_written", + at: "2026-08-17T10:40:00Z", + path: "x.md", + } as const; + const intervals = buildTaskIntervals(journalOf([WANTED], [], [writtenAfter])); + + expect(intervals).toEqual([ + { path: WANTED.path, startMs: Date.parse(WANTED.at), endMs: Date.parse(writtenAfter.at) }, + ]); + // After the declaration, before the write, no turn_end anywhere in sight - the + // ordinary state of a session still running. + expect(momentFallsWithin(intervals, "2026-08-17T10:20:00Z")).toBe(true); + }); + + it("never lets a written file reach further back than the interval's own last closer", () => { + const writtenBefore = { + type: "file_written", + at: "2026-08-17T09:00:00Z", + path: "x.md", + } as const; + const intervals = buildTaskIntervals(journalOf([WANTED], [TURN_END], [writtenBefore])); + + expect(intervals).toEqual([ + { path: WANTED.path, startMs: Date.parse(WANTED.at), endMs: Date.parse(TURN_END.at) }, + ]); + }); + + it("still never runs away: a written file does not turn the interval open-ended", () => { + const writtenAfter = { + type: "file_written", + at: "2026-08-17T10:40:00Z", + path: "x.md", + } as const; + const intervals = buildTaskIntervals(journalOf([WANTED], [], [writtenAfter])); + + // Long after the last thing the journal witnessed. + expect(momentFallsWithin(intervals, "2026-08-20T00:00:00Z")).toBe(false); + }); + + it("clamps an unclosed interval's end to the report's own period end, never past it", () => { + // A clock-skewed `file_written` far in the future still parses, and no record this reader + // places can fall past the period's own end, so capping there costs nothing real. + const farFuture = { + type: "file_written", + at: "9999-12-31T00:00:00Z", + path: "x.md", + } as const; + const periodEndMs = Date.parse("2026-08-18T00:00:00Z"); + + const intervals = buildTaskIntervals(journalOf([WANTED], [], [farFuture]), periodEndMs); + + expect(intervals).toEqual([ + { path: WANTED.path, startMs: Date.parse(WANTED.at), endMs: periodEndMs }, + ]); + expect(momentFallsWithin(intervals, "2040-01-01T00:00:00Z")).toBe(false); + }); + + it("leaves an unclosed interval's end exactly where a real closer put it, when that is well inside the period", () => { + // The clamp must never pull a legitimate end earlier - only a witnessed moment beyond + // the period end is capped. + const periodEndMs = Date.parse("2026-08-20T00:00:00Z"); + + const intervals = buildTaskIntervals(journalOf([WANTED], [TURN_END]), periodEndMs); + + expect(intervals).toEqual([ + { path: WANTED.path, startMs: Date.parse(WANTED.at), endMs: Date.parse(TURN_END.at) }, + ]); + }); +}); + +describe("taskUnattributedReason — which of four distinct facts applies", () => { + it("names no-declaration for a session whose journal never declared a task", () => { + expect(taskUnattributedReason([], "2026-08-17T10:00:00Z")).toBe("no-declaration"); + }); + + it("names precedes-declaration for a record before the session's only declaration", () => { + const intervals = buildTaskIntervals(journalOf([WANTED], [TURN_END])); + + expect(taskUnattributedReason(intervals, "2026-08-17T09:00:00Z")).toBe("precedes-declaration"); + }); + + // A resumed transcript carries turns billed days before the session that read them ever + // started, so the sink dates them before its journal witnessed anything. + it("names precedes-journal for a record older than everything its journal witnessed", () => { + const intervals = buildTaskIntervals(journalOf([WANTED], [TURN_END])); + const journalFromMs = Date.parse("2026-08-17T09:30:00Z"); + + expect(taskUnattributedReason(intervals, "2026-08-10T12:00:00Z", journalFromMs)).toBe( + "precedes-journal" + ); + }); + + it("still names precedes-declaration inside the span, before the first declaration", () => { + const intervals = buildTaskIntervals(journalOf([WANTED], [TURN_END])); + const journalFromMs = Date.parse("2026-08-17T09:30:00Z"); + + expect(taskUnattributedReason(intervals, "2026-08-17T09:45:00Z", journalFromMs)).toBe( + "precedes-declaration" + ); + }); + + // The coverage check runs first: it is the fact that explains why no declaration could + // have covered this record at all. + it("names precedes-journal, not no-declaration, when the journal declared nothing either", () => { + const journalFromMs = Date.parse("2026-08-17T09:30:00Z"); + + expect(taskUnattributedReason([], "2026-08-10T12:00:00Z", journalFromMs)).toBe( + "precedes-journal" + ); + }); + + it("never claims coverage for a journal that carries no readable moment", () => { + const intervals = buildTaskIntervals(journalOf([WANTED], [TURN_END])); + + expect(taskUnattributedReason(intervals, "2026-08-10T12:00:00Z", undefined)).toBe( + "precedes-declaration" + ); + }); + + it("names journal-silent for a record after the last declared interval's own end", () => { + const intervals = buildTaskIntervals(journalOf([WANTED], [TURN_END])); + + expect(taskUnattributedReason(intervals, "2026-08-17T11:00:00Z")).toBe("journal-silent"); + }); + + it("names journal-silent for a record with no moment, once a task was declared", () => { + const intervals = buildTaskIntervals(journalOf([WANTED], [TURN_END])); + + expect(taskUnattributedReason(intervals, undefined)).toBe("journal-silent"); + expect(taskUnattributedReason(intervals, "not-a-date")).toBe("journal-silent"); + }); +}); diff --git a/cli/tests/domain/models/task-backlog-link.unit.test.ts b/cli/tests/contexts/telemetry/domain/task-backlog-link.unit.test.ts similarity index 84% rename from cli/tests/domain/models/task-backlog-link.unit.test.ts rename to cli/tests/contexts/telemetry/domain/task-backlog-link.unit.test.ts index fd325d67a..27b445a7b 100644 --- a/cli/tests/domain/models/task-backlog-link.unit.test.ts +++ b/cli/tests/contexts/telemetry/domain/task-backlog-link.unit.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { taskFolderPathFromIdentity } from "../../../src/domain/models/task-backlog-link.js"; +import { taskFolderPathFromIdentity } from "../../../../src/contexts/telemetry/domain/task-backlog-link.js"; describe("taskFolderPathFromIdentity — the folder a task's identity resolves to", () => { it("resolves a forge-style identity to its own task folder", () => { diff --git a/cli/tests/domain/models/task-identity.unit.test.ts b/cli/tests/contexts/telemetry/domain/task-identity.unit.test.ts similarity index 84% rename from cli/tests/domain/models/task-identity.unit.test.ts rename to cli/tests/contexts/telemetry/domain/task-identity.unit.test.ts index 80adf987b..9937ceeee 100644 --- a/cli/tests/domain/models/task-identity.unit.test.ts +++ b/cli/tests/contexts/telemetry/domain/task-identity.unit.test.ts @@ -4,8 +4,8 @@ import { describe, expect, it } from "vitest"; import { taskIdentitiesFromWrittenPaths, taskIdentityFromWrittenPath, -} from "../../../src/domain/models/task-identity.js"; -import { journalFileWrites } from "../../helpers/telemetry-journal-hook.js"; +} from "../../../../src/contexts/telemetry/domain/task-identity.js"; +import { journalFileWrites } from "../../../helpers/telemetry-journal-hook.js"; const FOLDER_TASK = "aidd_docs/tasks/2026_08/2026_08_21_cost-reporter/plan.md"; const FILE_TASK = "aidd_docs/tasks/2026_08/2026_08_21_cost-reporter.md"; @@ -55,9 +55,8 @@ describe("taskIdentityFromWrittenPath", () => { }); it("resolves a task name that merely contains '..' as text, never mistaking it for a climb", () => { - // "2026_02_10_a..b" is a name, not a path segment of exactly "..": it climbs nothing, and - // both `file-writes.cjs` and `task-declared.cjs` journal it. Rejecting it on a bare - // substring check would read a real, journalled declaration as though none existed. + // "2026_02_10_a..b" is a name, not a segment of exactly "..": it climbs nothing, and both + // `file-writes.cjs` and `task-declared.cjs` journal it. expect(taskIdentityFromWrittenPath("aidd_docs/tasks/2026_02/2026_02_10_a..b/spec.md")).toBe( "2026_02/2026_02_10_a..b" ); @@ -65,7 +64,9 @@ describe("taskIdentityFromWrittenPath", () => { it("touches no filesystem — a string in, an identity or nothing out", () => { const source = readFileSync( - fileURLToPath(new URL("../../../src/domain/models/task-identity.ts", import.meta.url)), + fileURLToPath( + new URL("../../../../src/contexts/telemetry/domain/task-identity.ts", import.meta.url) + ), "utf8" ); @@ -96,10 +97,8 @@ describe("taskIdentitiesFromWrittenPaths", () => { }); it("names a task for exactly the paths the hook journals, and for no others", () => { - // The two live apart - hooks/ is copied verbatim by the framework build and can import - // nothing from cli/ - so they are pinned to each other here. A derivation stricter than - // the writer's gate would leave journalled lines resolving to nothing; a looser one - // would invent a task from a path no session was ever recorded as writing. + // `hooks/` is copied verbatim by the build and can import nothing from `cli/`, so the + // writer's gate and this derivation are pinned to each other here. const CANDIDATES = [ "aidd_docs/tasks/2026_08/2026_08_21_cost-reporter/plan.md", "aidd_docs/tasks/2026_08/2026_08_21_cost-reporter.md", diff --git a/cli/tests/domain/models/telemetry-claim.unit.test.ts b/cli/tests/contexts/telemetry/domain/telemetry-claim.unit.test.ts similarity index 80% rename from cli/tests/domain/models/telemetry-claim.unit.test.ts rename to cli/tests/contexts/telemetry/domain/telemetry-claim.unit.test.ts index efbfba5e0..6f25f54bb 100644 --- a/cli/tests/domain/models/telemetry-claim.unit.test.ts +++ b/cli/tests/contexts/telemetry/domain/telemetry-claim.unit.test.ts @@ -10,7 +10,8 @@ import { type TelemetryClaimToolRead, type TelemetryClaimVerdict, type TelemetryEvidence, -} from "../../../src/domain/models/telemetry-claim.js"; +} from "../../../../src/contexts/telemetry/domain/telemetry-claim.js"; +import { REPOSITORY_ROOT } from "../../../helpers/repository-root.js"; const RUNS_DIR_LABEL = "aidd_docs/runs"; @@ -27,15 +28,12 @@ function evidence(overrides: Partial = {}): TelemetryEvidence journals: [], toolReads: [], runsDirLabel: RUNS_DIR_LABEL, - // The conservative default every pre-existing test relies on: nothing here declares - // the recorder, so an empty journal reads as the failure it already did before this - // fact existed. A test for the declared branch sets this explicitly. + // Nothing declares the recorder by default, so an empty journal reads as the failure it + // already did. The declared branch sets this explicitly. recorderDeclared: false, - // Readable by default — a clean machine where the declaration itself was never in - // question. A test for the "could not be read" branch sets this to `false` explicitly. + // Readable by default; the "could not be read" branch sets this to `false` explicitly. recorderDeclarationReadable: true, - // Empty by default: every journal on disk states the schema this build reads, or states - // none. A test for the disagreement sets it explicitly. + // Empty by default: every journal on disk states the schema this build reads, or none. foreignSchemaVersions: [], ...overrides, }; @@ -98,20 +96,16 @@ describe("diagnoseTelemetryClaims — hook fired", () => { }) ); const hookFired = claim(declared, "hook-fired"); - // A run file existing at all is direct evidence the recorder ran, so this is a - // failure — not "no run file … yet, nothing to evaluate" (`--`), which the recorder's - // own declaration cannot turn a demonstrably-existing, unanchored file into. + // A run file existing at all is direct evidence the recorder ran, so this is a failure and + // not "no run file yet, nothing to evaluate". expect(hookFired?.verdict).toBe("fail"); expect(hookFired?.reason).toBe("anchorless-run-file"); expect(hookFired?.detail).not.toMatch(/nothing to evaluate/u); expect(hookFired?.detail).not.toMatch(/declared nowhere/u); }); - // A journal the reader refused for stating a schema it does not read leaves `journals` - // empty, which every other branch here reads as "no run file". Two of them would then be - // outright false about a file that demonstrably exists and whose header parsed perfectly: - // "declared nowhere" claims no file, and "none carry a readable session_start" blames a - // torn write. The version disagreement is the fact that is actually known. + // A refused journal leaves `journals` empty, which every other branch reads as "no run + // file"; the version disagreement is the fact that is actually known. it("names a journal written under another schema, never a torn write or a missing file", () => { const result = diagnoseTelemetryClaims( evidence({ currentSessionId: "s-1", foreignSchemaVersions: [3] }) @@ -124,9 +118,8 @@ describe("diagnoseTelemetryClaims — hook fired", () => { expect(hookFired?.detail).not.toMatch(/none carry a readable session_start/u); }); - // Ahead of the anchorless reading, deliberately: a build that cannot read a journal's - // schema cannot tell whether its session_start is missing or merely shaped differently, - // so blaming a torn write would be asserting what it just said it cannot see. + // A build that cannot read a journal's schema cannot tell whether its session_start is + // missing or merely shaped differently, so blaming a torn write asserts what it cannot see. it("prefers the schema disagreement over an anchorless file when both are present", () => { const result = diagnoseTelemetryClaims( evidence({ @@ -426,17 +419,10 @@ describe("diagnoseTelemetryClaims — the whole set", () => { }); }); -// The failure this guards against, verbatim from the plan: "A shape change whose consumer -// is not updated in the same commit is how the cost skill was halted by a version pin, -// twice." The consumer here is every file phase 3 named as stating the claim count in -// prose - `02-check/SKILL.md` and `actions/02-diagnose.md`, the second added after review -// found the first alone left the file phase 3 itself called out as "the one that hard-coded -// 'all six claims'" still unguarded (review.md, "one route, and every sentence about it -// true", finding 7). This reads both as text, the same way a person or an agent would, -// rather than trusting that whoever next changes the claim count remembers to touch every -// file that states it. +// A shape change whose consumer is not updated in the same commit is how the cost skill was +// halted twice, so this reads the skill's prose as text, the way a person or an agent would. describe("the diagnostic skill states the claims the command prints, in the number it prints them", () => { - const SKILL_DIR = resolve(process.cwd(), "..", "plugins", "aidd-telemetry", "skills", "02-check"); + const SKILL_DIR = resolve(REPOSITORY_ROOT, "plugins", "aidd-telemetry", "skills", "02-check"); const CLAIM_COUNT_FILES = [ resolve(SKILL_DIR, "SKILL.md"), resolve(SKILL_DIR, "actions", "02-diagnose.md"), @@ -454,9 +440,7 @@ describe("the diagnostic skill states the claims the command prints, in the numb }; // Every cardinal " claim(s)" mention in one file, not only the first: 02-diagnose.md - // states the count three times in prose, and a guard that stops at the first match would - // leave the second and third free to drift unnoticed, which is exactly how it went stale - // the first time. + // states the count three times, and stopping at the first would leave the rest free to drift. function statedClaimCounts(path: string): readonly number[] { const text = readFileSync(path, "utf8"); const matches = [ @@ -486,34 +470,12 @@ describe("the diagnostic skill states the claims the command prints, in the numb }); }); -// Extends the guard above to the account phase 2 added: two readings of the same absence -// ("no run file yet"), told apart only by the recorder's own declaration. A marker phrase -// is read from the live code's own detail text, then required in the skill's prose too — -// so a change on either side alone (the wording in `telemetry-claim.ts`, or the account in -// `02-diagnose.md`) fails this test, never only a change to both together. -/** - * A guard that actually bites when the skill's account of a verdict disagrees with the - * command's own reasons — the whole-file, phrase-presence-anywhere version of this guard - * (the one this replaces) proved unable to: inverting which token `02-diagnose.md`'s own - * step 6 pairs with which phrase left it green, because the *other* pairing survived - * unmutated elsewhere in the same file (the Test table). Two changes fix that: - * - * 1. Every account below is checked against `02-diagnose.md`'s own step 6 alone — the one - * paragraph that actually teaches the behaviour — not the whole file, so a stray - * correct mention elsewhere can no longer paper over a wrong one here. - * 2. The check ties the verdict token to the phrase *in the same sentence* (`reads - * \`TOKEN\`…PHRASE`, non-greedy up to the next period), so swapping which token a - * sentence names — even while keeping the phrase — fails, not just deleting the phrase - * outright. - * - * The account map itself is keyed by `NoRunFileReason`, `noRunFileClaim`'s own exhaustive - * reason union: adding a fifth branch there without adding a fifth entry here is a - * compile error (`Record` is exhaustive), not a runtime maybe. - */ +/** A marker phrase read from the live code's own detail text is required in `02-diagnose.md`'s + * step 6 alone, tied to its verdict token in the same sentence, so a change on either side + * alone fails; a missing `NoRunFileReason` entry is a compile error. */ describe("the diagnostic skill's account of every no-run-file reason matches the command's own reasons", () => { const DIAGNOSE_MD = resolve( - process.cwd(), - "..", + REPOSITORY_ROOT, "plugins", "aidd-telemetry", "skills", @@ -523,9 +485,8 @@ describe("the diagnostic skill's account of every no-run-file reason matches the ); const DIAGNOSE_TEXT = readFileSync(DIAGNOSE_MD, "utf8"); - // Step 6 alone — from its own header to the next numbered step — the one place that - // actually teaches which token pairs with which reason, not the Test table below it - // (a reference row restating the same fact is not an account of it). + // Step 6 alone, from its header to the next numbered step: the one place that teaches which + // token pairs with which reason, not the Test table restating it. const STEP_SIX_HEADER = "No run file yet is not always a failure"; const stepSixMatch = DIAGNOSE_TEXT.match( new RegExp(`${STEP_SIX_HEADER}[\\s\\S]*?\\n\\d+\\.\\s+\\*\\*`, "u") @@ -579,11 +540,8 @@ describe("the diagnostic skill's account of every no-run-file reason matches the }, }; - // The bullet mentioning `phrase`, bounded by the nearest period on either side — one - // bullet is exactly one sentence in `02-diagnose.md`'s own step 6, but not always in the - // same token-then-phrase order (most read "reads `TOKEN`, phrase"; the anchorless-run-file - // one reads "phrase … reads `TOKEN`"), so this bounds the clause instead of assuming an - // order, then the caller checks which token actually appears inside it. + // One bullet is one sentence in step 6, but not always in token-then-phrase order, so this + // bounds the clause by the nearest period instead of assuming one. function clauseContaining(text: string, phrase: string): string { const idx = text.toLowerCase().indexOf(phrase.toLowerCase()); if (idx === -1) throw new Error(`step 6 of ${DIAGNOSE_MD} never mentions "${phrase}"`); diff --git a/cli/tests/domain/models/telemetry-export-leftover.unit.test.ts b/cli/tests/contexts/telemetry/domain/telemetry-export-leftover.unit.test.ts similarity index 92% rename from cli/tests/domain/models/telemetry-export-leftover.unit.test.ts rename to cli/tests/contexts/telemetry/domain/telemetry-export-leftover.unit.test.ts index ca2d2c162..a12aee834 100644 --- a/cli/tests/domain/models/telemetry-export-leftover.unit.test.ts +++ b/cli/tests/contexts/telemetry/domain/telemetry-export-leftover.unit.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { findLeftoverExportKeys } from "../../../src/domain/models/telemetry-export-leftover.js"; +import { findLeftoverExportKeys } from "../../../../src/contexts/telemetry/domain/telemetry-export-leftover.js"; describe("findLeftoverExportKeys", () => { it("names every known export key present in the file's env block", () => { diff --git a/cli/tests/domain/models/telemetry-removal.unit.test.ts b/cli/tests/contexts/telemetry/domain/telemetry-removal.unit.test.ts similarity index 97% rename from cli/tests/domain/models/telemetry-removal.unit.test.ts rename to cli/tests/contexts/telemetry/domain/telemetry-removal.unit.test.ts index dfd7ef669..b21f7f68d 100644 --- a/cli/tests/domain/models/telemetry-removal.unit.test.ts +++ b/cli/tests/contexts/telemetry/domain/telemetry-removal.unit.test.ts @@ -2,7 +2,7 @@ import { describe, expect, it } from "vitest"; import { type TelemetryRemovalPreview, telemetryRemovalIsEmpty, -} from "../../../src/domain/models/telemetry-removal.js"; +} from "../../../../src/contexts/telemetry/domain/telemetry-removal.js"; function preview(overrides: Partial = {}): TelemetryRemovalPreview { return { diff --git a/cli/tests/contexts/telemetry/domain/telemetry-setup.unit.test.ts b/cli/tests/contexts/telemetry/domain/telemetry-setup.unit.test.ts new file mode 100644 index 000000000..e438da297 --- /dev/null +++ b/cli/tests/contexts/telemetry/domain/telemetry-setup.unit.test.ts @@ -0,0 +1,96 @@ +import { describe, expect, it } from "vitest"; +import { + buildTelemetryAllowedSetup, + detectHookManager, +} from "../../../../src/contexts/telemetry/domain/telemetry-setup.js"; + +const SWITCH_PATH = "/repo/.aidd/config.json"; + +describe("buildTelemetryAllowedSetup — whose choice this was", () => { + it("reads a project's own switch, turned on, as the project's decision", () => { + const setup = buildTelemetryAllowedSetup( + { path: SWITCH_PATH, enabled: true, readable: true }, + {} + ); + expect(setup).toEqual({ + allowed: true, + decidedBy: "project-switch", + location: SWITCH_PATH, + readable: true, + }); + }); + + it("reads a project never switched on as the project's own decision, not a refusal", () => { + const setup = buildTelemetryAllowedSetup( + { path: SWITCH_PATH, enabled: false, readable: true }, + {} + ); + expect(setup.allowed).toBe(false); + expect(setup.decidedBy).toBe("project-switch"); + }); + + it("reads AIDD_TELEMETRY=0 as this person's own refusal, whatever the project file says", () => { + const setup = buildTelemetryAllowedSetup( + { path: SWITCH_PATH, enabled: true, readable: true }, + { AIDD_TELEMETRY: "0" } + ); + expect(setup).toEqual({ + allowed: false, + decidedBy: "person-refusal", + location: "AIDD_TELEMETRY", + readable: true, + }); + }); + + it("never lets a damaged switch file masquerade as a refusal", () => { + const setup = buildTelemetryAllowedSetup( + { path: SWITCH_PATH, enabled: false, readable: false }, + {} + ); + expect(setup.decidedBy).toBe("project-switch"); + expect(setup.readable).toBe(false); + }); + + it("reads a person's refusal as always readable — an env var never fails to read", () => { + const setup = buildTelemetryAllowedSetup( + { path: SWITCH_PATH, enabled: false, readable: false }, + { AIDD_TELEMETRY: "0" } + ); + expect(setup.readable).toBe(true); + }); + + it("never treats an unset AIDD_TELEMETRY as a refusal", () => { + const setup = buildTelemetryAllowedSetup( + { path: SWITCH_PATH, enabled: true, readable: true }, + { AIDD_TELEMETRY: "" } + ); + expect(setup.decidedBy).toBe("project-switch"); + }); +}); + +/** From root marker files alone: a manager regenerates `prepare-commit-msg` from its own + * config on every install, so the hook's own contents say nothing by the time this runs. */ +describe("detectHookManager — which manager owns prepare-commit-msg, from the root alone", () => { + it("reads lefthook.yml as lefthook", () => { + expect(detectHookManager(["lefthook.yml"])).toBe("lefthook"); + }); + + it("reads every dot-prefixed and .yaml spelling lefthook itself accepts", () => { + expect(detectHookManager([".lefthook.yaml"])).toBe("lefthook"); + expect(detectHookManager(["lefthook.yaml"])).toBe("lefthook"); + expect(detectHookManager([".lefthook.yml"])).toBe("lefthook"); + }); + + it("reads .husky as husky", () => { + expect(detectHookManager([".husky"])).toBe("husky"); + }); + + it("prefers lefthook when both markers are present — a deterministic, documented tie-break", () => { + expect(detectHookManager(["lefthook.yml", ".husky"])).toBe("lefthook"); + }); + + it("names neither manager when no marker is present", () => { + expect(detectHookManager([])).toBeUndefined(); + expect(detectHookManager(["package.json", "README.md"])).toBeUndefined(); + }); +}); diff --git a/cli/tests/contexts/telemetry/domain/telemetry-sink-record.unit.test.ts b/cli/tests/contexts/telemetry/domain/telemetry-sink-record.unit.test.ts new file mode 100644 index 000000000..9d4356893 --- /dev/null +++ b/cli/tests/contexts/telemetry/domain/telemetry-sink-record.unit.test.ts @@ -0,0 +1,143 @@ +import { readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { describe, expect, it } from "vitest"; +import { + parseTelemetrySinkLine, + SINK_SCHEMA_VERSION, + type TelemetrySinkRecord, + telemetrySinkRecordDayKey, +} from "../../../../src/contexts/telemetry/domain/telemetry-sink-record.js"; +import { UnknownTelemetrySinkSchemaVersionError } from "../../../../src/kernel/errors.js"; + +describe("parseTelemetrySinkLine()", () => { + it("rejects an unknown sink_schema_version rather than guessing its shape", () => { + expect(() => + parseTelemetrySinkLine(JSON.stringify({ sink_schema_version: 999, kind: "request" })) + ).toThrow(UnknownTelemetrySinkSchemaVersionError); + }); + + // The literal version this schema moved past — v1 carried no `provenance`, so guessing + // one for it would be exactly the false "old route" default the field exists to forbid. + it("rejects the v1 shape specifically, not just an unrecognised number", () => { + expect(() => + parseTelemetrySinkLine( + JSON.stringify({ sink_schema_version: 1, kind: "request", vendor_id: "s-1" }) + ) + ).toThrow(UnknownTelemetrySinkSchemaVersionError); + }); + + it("parses a hand-written fixture the mapper never produced", () => { + const url = new URL("../../../fixtures/telemetry-sink/expected.jsonl", import.meta.url); + const lines = readFileSync(fileURLToPath(url), "utf8").trim().split("\n"); + const records = lines.map(parseTelemetrySinkLine); + + const requestLine = records.find((r) => r.kind === "request"); + expect(requestLine?.vendor_id).toBeTruthy(); + expect(requestLine?.vendor_field).toBeTruthy(); + expect(requestLine?.cost_usd).toBeGreaterThan(0); + expect(requestLine?.model).toBeTruthy(); + + const sessionLine = records.find((r) => r.kind === "session" && r.active_time_s !== undefined); + expect(sessionLine?.active_time_s).toBeGreaterThan(0); + expect(sessionLine?.turn_id).toBeUndefined(); + }); + + // The fixture carries `user_id` on purpose: the sink is append-only, so a line an earlier + // build wrote keeps the field forever and parsing must not choke on it. + it("parses a stored line that still carries the now-removed user_id, inertly", () => { + const url = new URL("../../../fixtures/telemetry-sink/expected.jsonl", import.meta.url); + const lines = readFileSync(fileURLToPath(url), "utf8").trim().split("\n"); + expect(lines.some((line) => line.includes("user_id"))).toBe(true); + + const records = lines.map(parseTelemetrySinkLine); + const legacy = records.find((r) => "user_id" in r); + // `in` narrows the parsed record to one that still carries the field, so the value + // below is read off a type - and the fixture losing the line fails here, loudly. + if (legacy === undefined || !("user_id" in legacy)) { + throw new Error("fixture no longer carries a user_id line"); + } + expect(legacy.user_id).toBe("user_example_hash_0000000000000000"); + }); + + it("carries provenance for both routes, on the same fixture", () => { + const url = new URL("../../../fixtures/telemetry-sink/expected.jsonl", import.meta.url); + const lines = readFileSync(fileURLToPath(url), "utf8").trim().split("\n"); + const records = lines.map(parseTelemetrySinkLine); + expect(records.some((r) => r.provenance === "export")).toBe(true); + expect(records.some((r) => r.provenance === "local-read")).toBe(true); + }); + + // This fixture predates cli_version entirely: an unknown version costs a field, never a + // figure, so every record on it must still be there to count. + it("parses a line written before cli_version existed, losing no figure to the gap", () => { + const url = new URL("../../../fixtures/telemetry-sink/expected.jsonl", import.meta.url); + const lines = readFileSync(fileURLToPath(url), "utf8").trim().split("\n"); + expect(lines.some((line) => line.includes("cli_version"))).toBe(false); + + const records = lines.map(parseTelemetrySinkLine); + expect(records).toHaveLength(lines.length); + for (const record of records) { + expect("cli_version" in record).toBe(false); + } + }); +}); + +describe("telemetrySinkRecordDayKey()", () => { + const BASE: TelemetrySinkRecord = { + sink_schema_version: SINK_SCHEMA_VERSION, + kind: "request", + provenance: "local-read", + tool: "claude", + vendor_id: "s-1", + vendor_field: "sessionId", + step_attribution: "unattributed", + }; + + it("answers the UTC day for a real moment, the fast path and the parsed one alike", () => { + expect(telemetrySinkRecordDayKey({ ...BASE, event_timestamp: "2026-08-18T01:00:00Z" })).toBe( + "2026-08-18" + ); + // No `Z` offset - the parsed path, not the sliced one. + expect( + telemetrySinkRecordDayKey({ ...BASE, event_timestamp: "2026-08-18T01:00:00+05:00" }) + ).toBe("2026-08-17"); + }); + + it("answers undefined for no moment at all", () => { + expect(telemetrySinkRecordDayKey({ ...BASE })).toBeUndefined(); + }); + + // A string merely shaped like a moment must not take the fast slice path: a record filed + // under a fragment nothing on the calendar matches stays in `totals` but leaves `byDays`. + it("answers undefined for a string merely shaped like a moment, never a sliced fragment", () => { + expect( + telemetrySinkRecordDayKey({ ...BASE, event_timestamp: "not-a-momentZ" }) + ).toBeUndefined(); + }); +}); + +describe("telemetrySinkRecordDayKey() — a line holds whatever it holds", () => { + const BASE: TelemetrySinkRecord = { + sink_schema_version: SINK_SCHEMA_VERSION, + kind: "request", + provenance: "local-read", + tool: "claude", + vendor_id: "s-1", + vendor_field: "sessionId", + step_attribution: "unattributed", + }; + + /** Built through the real parse, which checks `sink_schema_version` and casts the rest — + * the only way a record whose field is a string by convention alone ever gets here. */ + function recordFromLine(overrides: Record): TelemetrySinkRecord { + return parseTelemetrySinkLine(JSON.stringify({ ...BASE, ...overrides })); + } + + it("answers nothing for a moment stored as a number, never 1970", () => { + expect(telemetrySinkRecordDayKey(recordFromLine({ event_timestamp: 12_345 }))).toBeUndefined(); + }); + + it("answers nothing for a moment stored as null", () => { + expect(telemetrySinkRecordDayKey(recordFromLine({ event_timestamp: null }))).toBeUndefined(); + }); +}); diff --git a/cli/tests/domain/models/telemetry-sink-retention.unit.test.ts b/cli/tests/contexts/telemetry/domain/telemetry-sink-retention.unit.test.ts similarity index 95% rename from cli/tests/domain/models/telemetry-sink-retention.unit.test.ts rename to cli/tests/contexts/telemetry/domain/telemetry-sink-retention.unit.test.ts index 35413f24c..509acdcdd 100644 --- a/cli/tests/domain/models/telemetry-sink-retention.unit.test.ts +++ b/cli/tests/contexts/telemetry/domain/telemetry-sink-retention.unit.test.ts @@ -2,7 +2,7 @@ import { describe, expect, it } from "vitest"; import { DEFAULT_TELEMETRY_SINK_RETENTION_DAYS, decideTelemetrySinkRetention, -} from "../../../src/domain/models/telemetry-sink-retention.js"; +} from "../../../../src/contexts/telemetry/domain/telemetry-sink-retention.js"; describe("decideTelemetrySinkRetention()", () => { it("keeps the window's files and prunes the oldest, on real file names", () => { diff --git a/cli/tests/domain/models/telemetry-switch.unit.test.ts b/cli/tests/contexts/telemetry/domain/telemetry-switch.unit.test.ts similarity index 97% rename from cli/tests/domain/models/telemetry-switch.unit.test.ts rename to cli/tests/contexts/telemetry/domain/telemetry-switch.unit.test.ts index 20e64cdb7..3af967d4c 100644 --- a/cli/tests/domain/models/telemetry-switch.unit.test.ts +++ b/cli/tests/contexts/telemetry/domain/telemetry-switch.unit.test.ts @@ -9,8 +9,8 @@ import { resolveTelemetryEnabled, type TelemetrySwitch, telemetryConfigPath, -} from "../../../src/domain/models/telemetry-switch.js"; -import { journalRepo } from "../../helpers/telemetry-journal-hook.js"; +} from "../../../../src/contexts/telemetry/domain/telemetry-switch.js"; +import { journalRepo } from "../../../helpers/telemetry-journal-hook.js"; describe("telemetryConfigPath", () => { it("resolves .aidd/config.json under the project root", () => { diff --git a/cli/tests/infrastructure/adapters/copilot-cost-reader-adapter.integration.test.ts b/cli/tests/contexts/telemetry/infrastructure/copilot-cost-reader-adapter.integration.test.ts similarity index 75% rename from cli/tests/infrastructure/adapters/copilot-cost-reader-adapter.integration.test.ts rename to cli/tests/contexts/telemetry/infrastructure/copilot-cost-reader-adapter.integration.test.ts index 4c2f160bb..d918128db 100644 --- a/cli/tests/infrastructure/adapters/copilot-cost-reader-adapter.integration.test.ts +++ b/cli/tests/contexts/telemetry/infrastructure/copilot-cost-reader-adapter.integration.test.ts @@ -1,11 +1,10 @@ import { fileURLToPath } from "node:url"; import { describe, expect, it } from "vitest"; -import { CopilotCostReaderAdapter } from "../../../src/infrastructure/adapters/copilot-cost-reader-adapter.js"; +import { CopilotCostReaderAdapter } from "../../../../src/contexts/telemetry/infrastructure/copilot-cost-reader-adapter.js"; -// tests/fixtures/local-cost mirrors a real $HOME: .copilot/session-state//events.jsonl -// sits exactly where a real machine would write it, so pointing homeDir at this directory -// exercises the same path this reader builds against a real installation. -const HOME_DIR = fileURLToPath(new URL("../../fixtures/local-cost", import.meta.url)).replace( +// The fixture mirrors a real $HOME: `.copilot/session-state//events.jsonl` sits where a +// real machine writes it, so homeDir here exercises the path built against an installation. +const HOME_DIR = fileURLToPath(new URL("../../../fixtures/local-cost", import.meta.url)).replace( /\/$/, "" ); diff --git a/cli/tests/contexts/telemetry/infrastructure/git-adapter-telemetry-project-id.integration.test.ts b/cli/tests/contexts/telemetry/infrastructure/git-adapter-telemetry-project-id.integration.test.ts new file mode 100644 index 000000000..9fca023d0 --- /dev/null +++ b/cli/tests/contexts/telemetry/infrastructure/git-adapter-telemetry-project-id.integration.test.ts @@ -0,0 +1,40 @@ +import { execFileSync } from "node:child_process"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { journalRepo } from "../../../helpers/telemetry-journal-hook.js"; + +// git exports GIT_DIR into every process it spawns, and the journal hook runs from inside +// one: without the strip, a hook-started session tags every record with the wrong project. +describe("the journal hook does not follow a leaked GIT_DIR", () => { + const created: string[] = []; + const savedGitDir = process.env.GIT_DIR; + + afterEach(() => { + if (savedGitDir === undefined) delete process.env.GIT_DIR; + else process.env.GIT_DIR = savedGitDir; + for (const dir of created) rmSync(dir, { recursive: true, force: true }); + created.length = 0; + }); + + function makeRepo(remoteUrl: string): string { + const dir = mkdtempSync(join(tmpdir(), "aidd-gitdir-")); + created.push(dir); + const env = Object.fromEntries( + Object.entries(process.env).filter(([key]) => !key.startsWith("GIT_")) + ); + execFileSync("git", ["init", "-q", "."], { cwd: dir, env }); + execFileSync("git", ["remote", "add", "origin", remoteUrl], { cwd: dir, env }); + return dir; + } + + it("reads the remote of the repository at cwd, not the one GIT_DIR names", () => { + const elsewhere = makeRepo("git@github.com:leaked/elsewhere.git"); + const here = makeRepo("git@github.com:expected/here.git"); + + process.env.GIT_DIR = join(elsewhere, ".git"); + + expect(journalRepo.deriveProjectId(here)).toBe("expected/here"); + }); +}); diff --git a/cli/tests/infrastructure/adapters/hook-trust-reader-adapter.integration.test.ts b/cli/tests/contexts/telemetry/infrastructure/hook-trust-reader-adapter.integration.test.ts similarity index 80% rename from cli/tests/infrastructure/adapters/hook-trust-reader-adapter.integration.test.ts rename to cli/tests/contexts/telemetry/infrastructure/hook-trust-reader-adapter.integration.test.ts index 8079c481c..1d8ce1536 100644 --- a/cli/tests/infrastructure/adapters/hook-trust-reader-adapter.integration.test.ts +++ b/cli/tests/contexts/telemetry/infrastructure/hook-trust-reader-adapter.integration.test.ts @@ -2,17 +2,11 @@ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, describe, expect, it } from "vitest"; -import { HookTrustReaderAdapter } from "../../../src/infrastructure/adapters/hook-trust-reader-adapter.js"; +import { HookTrustReaderAdapter } from "../../../../src/contexts/telemetry/infrastructure/hook-trust-reader-adapter.js"; /** - * Codex is the one host that will decline to run a hook and say nothing about it, so this - * read is what lets `aidd telemetry check` tell "the hook is dead" apart from "the hook was - * never approved". Both leave an empty journal; only one is something a person can fix, and - * naming the wrong one sends them looking in the wrong place. - * - * Tested against a real `config.toml` rather than a double because the whole method is a - * parse of a file Codex writes in its own shape — a double would only assert this test's - * idea of that shape. + * Codex is the one host that declines to run a hook and says nothing about it, so this read + * is what tells "the hook is dead" apart from "the hook was never approved". */ const created: string[] = []; const savedHome = process.env.HOME; @@ -24,8 +18,6 @@ afterEach(() => { created.length = 0; }); -/** A throwaway home, with `config.toml` written into it when `content` is given and left - * absent when it is not. */ function codexHome(content?: string): string { const home = mkdtempSync(join(tmpdir(), "aidd-hook-trust-")); created.push(home); @@ -80,9 +72,8 @@ describe("reading whether Codex has been told it may run the recorder's hook", ( expect(await new HookTrustReaderAdapter().read()).toMatchObject({ trusted: false }); }); - // Unreadable is not untrusted: the first says nothing is known, the second is a fact - // about Codex's own state, and a check that conflated them would name a cause that was - // never established. + // Unreadable is not untrusted: the first says nothing is known, the second is a fact about + // Codex's own state. it("reads an absent config as unreadable, naming the path and the reason", async () => { const home = codexHome(); diff --git a/cli/tests/infrastructure/adapters/opencode-cost-reader-adapter.integration.test.ts b/cli/tests/contexts/telemetry/infrastructure/opencode-cost-reader-adapter.integration.test.ts similarity index 83% rename from cli/tests/infrastructure/adapters/opencode-cost-reader-adapter.integration.test.ts rename to cli/tests/contexts/telemetry/infrastructure/opencode-cost-reader-adapter.integration.test.ts index aebbb0881..ced459539 100644 --- a/cli/tests/infrastructure/adapters/opencode-cost-reader-adapter.integration.test.ts +++ b/cli/tests/contexts/telemetry/infrastructure/opencode-cost-reader-adapter.integration.test.ts @@ -3,17 +3,16 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { fileURLToPath } from "node:url"; import { afterEach, describe, expect, it } from "vitest"; -import { OpencodeExportError } from "../../../src/domain/errors.js"; -import { OpencodeCostReaderAdapter } from "../../../src/infrastructure/adapters/opencode-cost-reader-adapter.js"; +import { OpencodeCostReaderAdapter } from "../../../../src/contexts/telemetry/infrastructure/opencode-cost-reader-adapter.js"; +import { OpencodeExportError } from "../../../../src/kernel/errors.js"; const SESSION_ID = "ses_test_read"; const FIXTURE_PATH = fileURLToPath( - new URL("../../fixtures/telemetry-sink/opencode-export.json", import.meta.url) + new URL("../../../fixtures/telemetry-sink/opencode-export.json", import.meta.url) ); /** Installs a real, executable `opencode` stand-in on an isolated PATH — no mock of - * `child_process`, so absent/failing/slow/well-behaved all exercise the real spawn and - * real timeout machinery a mock would paper over. */ + * `child_process`, so the real spawn and timeout machinery is exercised. */ function installStandIn(scriptBody: string): { restore: () => void } { const dir = mkdtempSync(join(tmpdir(), "aidd-opencode-bin-")); writeFileSync(join(dir, "opencode"), scriptBody, { mode: 0o755 }); @@ -85,12 +84,8 @@ describe("OpencodeCostReaderAdapter", () => { }); }); - // installStandIn writes a #!/bin/sh script with no file extension and chmods it - // executable - Windows resolves an executable by PATHEXT/extension, not a shebang line - // or the POSIX execute bit, so the stand-in never launches there (same gap - // countGitInvocations hit in the plugin's own suite, and telemetry-multi-tool.e2e.test.ts - // hits in cli/tests/e2e/). Every test below that calls installStandIn is skipped on - // win32 rather than left to fail for the wrong reason. + // The stand-in is a `#!/bin/sh` file with no extension: Windows resolves an executable by + // PATHEXT, not a shebang or the POSIX execute bit, so it never launches there. const skipOnWindows = process.platform === "win32"; it.skipIf(skipOnWindows)( @@ -103,7 +98,7 @@ describe("OpencodeCostReaderAdapter", () => { expect(sessionFound).toBe(true); // The fixture's fourth assistant message carries no `total` — never billed — so it - // yields no record; see opencode-export.unit.test.ts for that boundary directly. + // yields no record. expect(records).toHaveLength(3); expect(records[0]).toMatchObject({ kind: "request", diff --git a/cli/tests/infrastructure/adapters/person-identity-adapter.integration.test.ts b/cli/tests/contexts/telemetry/infrastructure/person-identity-adapter.integration.test.ts similarity index 81% rename from cli/tests/infrastructure/adapters/person-identity-adapter.integration.test.ts rename to cli/tests/contexts/telemetry/infrastructure/person-identity-adapter.integration.test.ts index 3185dfcba..c2ac367b3 100644 --- a/cli/tests/infrastructure/adapters/person-identity-adapter.integration.test.ts +++ b/cli/tests/contexts/telemetry/infrastructure/person-identity-adapter.integration.test.ts @@ -2,21 +2,10 @@ import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; -import { PersonIdentityAdapter } from "../../../src/infrastructure/adapters/person-identity-adapter.js"; - -/** - * `PersonIdentityAdapter` on real disk. - * - * The first block is the guarantee "the deletion path" review found broken: `filePath` is - * resolved once, at construction, and `forget(path)` acts on the exact `path` it is handed, - * never re-resolving `HOME` at removal time. - * - * The second block exists because this file used to claim the rest was "already covered - * indirectly through `PersonIdentityUseCase`'s own tests". It was not: those construct an - * `InMemoryPersonIdentityStore`, so nothing exercised what this adapter actually writes to - * disk or reads back — which is what decides whose records are whose. Every write here goes - * through the file and is read back through it. - */ +import { PersonIdentityAdapter } from "../../../../src/contexts/telemetry/infrastructure/person-identity-adapter.js"; + +/** On real disk: every write here goes through the file and is read back through it, since + * what this adapter stores is what decides whose records are whose. */ describe("PersonIdentityAdapter.forget — resolved once, acts on the path it is handed", () => { let previousHome: string | undefined; const homes: string[] = []; @@ -57,11 +46,8 @@ describe("PersonIdentityAdapter.forget — resolved once, acts on the path it is await expect(adapter.forget(adapter.filePath)).resolves.toBe(false); }); - // Finding 1: `HOME` relocated between the moment a person is shown `filePath` (the - // preview) and the moment `forget` runs (the removal) used to reach the relocated - // profile instead of the one shown, because the old `identityFilePath()` re-read `HOME` - // on every call. `filePath` is now frozen at construction, and this proves `forget` - // never asks `HOME` again either — it acts on whatever `path` it is handed. + // `filePath` is frozen at construction and `forget` never asks `HOME` again, so a + // relocation between the preview and the removal cannot redirect it. it("acts on the path it is handed, immune to HOME being relocated afterwards", async () => { const realHome = await freshHome(); process.env.HOME = realHome; @@ -171,9 +157,8 @@ describe("PersonIdentityAdapter — what it writes, and what it reads back", () expect(after.alsoMe).toEqual([]); }); - // The whole reason two reads exist: `read` is for every consumer that must not fail over - // one damaged file, `readStrict` is for the one caller that has to tell "nobody chose" - // apart from "could not be read". + // `read` is for every consumer that must not fail over one damaged file; `readStrict` for + // the one caller that has to tell "nobody chose" apart from "could not be read". it("reads a damaged file as nothing, and refuses it strictly", async () => { const adapter = await adapterInFreshHome(); await writeFile(adapter.filePath, "{ not json"); diff --git a/cli/tests/infrastructure/adapters/person-identity-location.unit.test.ts b/cli/tests/contexts/telemetry/infrastructure/person-identity-location.unit.test.ts similarity index 78% rename from cli/tests/infrastructure/adapters/person-identity-location.unit.test.ts rename to cli/tests/contexts/telemetry/infrastructure/person-identity-location.unit.test.ts index 49fa023bd..d099b4d01 100644 --- a/cli/tests/infrastructure/adapters/person-identity-location.unit.test.ts +++ b/cli/tests/contexts/telemetry/infrastructure/person-identity-location.unit.test.ts @@ -2,19 +2,10 @@ import { mkdtempSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, describe, expect, it } from "vitest"; -import { PersonIdentityAdapter } from "../../../src/infrastructure/adapters/person-identity-adapter.js"; +import { PersonIdentityAdapter } from "../../../../src/contexts/telemetry/infrastructure/person-identity-adapter.js"; -/** - * Where a person's own identity file lands, pinned on any platform rather than only on a - * Windows runner — the same reason `telemetry-sink-location.unit.test.ts` exists for the - * sink. `identityDir()` is private to the adapter, so this drives it through the one public - * surface that exposes it: `filePath`. - * - * Also pins the rule that gives this file its whole reason to exist as its own port rather - * than reusing the sink's: `AIDD_USER_CONFIG_DIR` is a location a repository, a team, or a - * CI can point at, and reaching an identity through it would not be this person's own - * choice — unlike the sink, which honours it deliberately. - */ +/** `identityDir()` is private to the adapter, so this drives it through `filePath` on every + * platform. An identity reached through a location a team or a CI points at is not one's own. */ function withPlatform(platform: NodeJS.Platform, run: () => T): T { const original = Object.getOwnPropertyDescriptor(process, "platform"); Object.defineProperty(process, "platform", { value: platform, configurable: true }); diff --git a/cli/tests/infrastructure/adapters/run-journal-file-written.integration.test.ts b/cli/tests/contexts/telemetry/infrastructure/run-journal-file-written.integration.test.ts similarity index 82% rename from cli/tests/infrastructure/adapters/run-journal-file-written.integration.test.ts rename to cli/tests/contexts/telemetry/infrastructure/run-journal-file-written.integration.test.ts index 4057c71b0..1c93d46a5 100644 --- a/cli/tests/infrastructure/adapters/run-journal-file-written.integration.test.ts +++ b/cli/tests/contexts/telemetry/infrastructure/run-journal-file-written.integration.test.ts @@ -4,13 +4,12 @@ import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; -import { RunJournalReaderAdapter } from "../../../src/infrastructure/adapters/run-journal-reader-adapter.js"; -import { environmentWithoutGitVariables } from "../../../src/infrastructure/git-environment.js"; -import { journalFileWrites } from "../../helpers/telemetry-journal-hook.js"; +import { RunJournalReaderAdapter } from "../../../../src/contexts/telemetry/infrastructure/run-journal-reader-adapter.js"; +import { environmentWithoutGitVariables } from "../../../../src/runtime/git/git-environment.js"; +import { journalFileWrites } from "../../../helpers/telemetry-journal-hook.js"; -// The line phase 2's task derivation rests on, exercised against the hook that writes it -// and the adapter that reads it — nothing between them is stubbed. Without this, a change -// to either side would leave every task in a report empty and no test would notice. +// The line task derivation rests on, exercised against the hook that writes it and the adapter +// that reads it with nothing stubbed between: a change to either side empties every task. const RUN_ID = "01ARZ3NDEKTSV4RRFFQ69G5FAV"; const SESSION_ID = "22222222-2222-4222-8222-222222222222"; const TASK_FILE = "aidd_docs/tasks/2026_08/2026_08_21_cost-reporter/plan.md"; @@ -95,9 +94,8 @@ describe("file_written, from the hook that writes it to the reader that reads it }); it("covers Claude Code alone, which a report has to print as a limit rather than assume away", () => { - // Not an aspiration: Copilot and Cursor were never captured writing a readable path, - // and Codex's writes live inside an apply_patch command string. A host absent here - // yields sessions attributable to a period and a step, never to a task. + // Copilot and Cursor were never captured writing a readable path, and Codex's writes live + // inside an apply_patch command string; a host absent here is never attributable to a task. expect(Object.keys(journalFileWrites.WRITTEN_PATH_EXTRACTOR_BY_HOST)).toEqual(["claude-code"]); }); }); diff --git a/cli/tests/infrastructure/adapters/run-journal-reader-adapter.integration.test.ts b/cli/tests/contexts/telemetry/infrastructure/run-journal-reader-adapter.integration.test.ts similarity index 85% rename from cli/tests/infrastructure/adapters/run-journal-reader-adapter.integration.test.ts rename to cli/tests/contexts/telemetry/infrastructure/run-journal-reader-adapter.integration.test.ts index 1efa5be7b..02dd3ed67 100644 --- a/cli/tests/infrastructure/adapters/run-journal-reader-adapter.integration.test.ts +++ b/cli/tests/contexts/telemetry/infrastructure/run-journal-reader-adapter.integration.test.ts @@ -6,12 +6,11 @@ import { READABLE_JOURNAL_SCHEMA_VERSION, RunJournalReaderAdapter, sanitizePathSegment, -} from "../../../src/infrastructure/adapters/run-journal-reader-adapter.js"; -import { journalRecord, journalRepo } from "../../helpers/telemetry-journal-hook.js"; +} from "../../../../src/contexts/telemetry/infrastructure/run-journal-reader-adapter.js"; +import { journalRecord, journalRepo } from "../../../helpers/telemetry-journal-hook.js"; -// A real-shaped ULID (26 Crockford-base32 characters), matching what -// plugins/aidd-telemetry/hooks/lib/record.cjs's generateUlid mints — the adapter splits a -// run file's name on this fixed length, never on "__", so the id itself must be genuine. +// A real-shaped ULID (26 Crockford-base32 characters): the adapter splits a run file's name +// on that fixed length, never on "__", so the id itself must be genuine. const RUN_ID = "01ARZ3NDEKTSV4RRFFQ69G5FAV"; const SESSION_ID = "22222222-2222-4222-8222-222222222222"; @@ -90,12 +89,8 @@ describe("RunJournalReaderAdapter", () => { ]); }); - // The hook that writes a journal anchors at `git rev-parse --show-toplevel` - // (plugins/aidd-telemetry/hooks/lib/repo.cjs), so a session started anywhere inside a - // checkout writes into ONE directory at its root. A reader anchored at the process - // working directory instead finds that directory only when the command happens to be run - // from the root - and answers "this session declared no task" when it is not, which is a - // claim about the work rather than about the read. + // The writing hook anchors at the repository root, so a reader anchored at the working + // directory answers "this session declared no task" — a claim about the work, not the read. it("anchors at the repository root, so a subdirectory finds the journal the hook wrote", async () => { await mkdir(join(projectRoot, ".git"), { recursive: true }); await writeFile( @@ -112,10 +107,8 @@ describe("RunJournalReaderAdapter", () => { ]); }); - // A linked worktree's `.git` is a FILE holding `gitdir: …`, not a directory. Accepting - // only a directory would leave every worktree anchored at the process working directory - - // and this repository is developed in worktrees, so the case is the common one, not a - // corner. + // A linked worktree's `.git` is a FILE holding `gitdir: …`, not a directory, so accepting + // only a directory leaves every worktree anchored at the process working directory. it("accepts a linked worktree, whose .git is a file rather than a directory", async () => { await writeFile(join(projectRoot, ".git"), "gitdir: /elsewhere/.git/worktrees/w\n"); await writeFile( @@ -168,10 +161,8 @@ describe("RunJournalReaderAdapter", () => { }); }); -// Duplicated on purpose, not shared at runtime — see the adapter's own doc comment for -// why. This is what proves the duplication stays honest: if repo.cjs's regex ever moves, -// this test turns red before a session id merely fails to match its own journal file, -// silently, with every other test still green. +// Duplicated on purpose, not shared at runtime: this keeps the copy honest, so the hook's +// regex moving turns this red rather than leaving a session id silently unmatched. describe("sanitizePathSegment — agrees with the journal hook's own function", () => { it.each([ "22222222-2222-4222-8222-222222222222", @@ -368,10 +359,8 @@ describe("RunJournalReaderAdapter.deleteRunFile — confined to the directory it await expect(adapter.deleteRunFile(runsDir, "never-existed.jsonl")).resolves.toBeUndefined(); }); - // Finding 4: `deleteRunFile("../../VICTIM.txt")` used to delete outside the runs - // directory — `join` normalises `..` away visually but still deletes wherever the - // normalised path lands. Confinement must be a property of this method, not an accident - // of `readdir` yielding bare components. + // `join` normalises `..` away visually but still deletes wherever the normalised path + // lands, so confinement must be a property of this method, not of who calls it. it("refuses a relative walk out of the directory it is handed, rather than deleting outside it", async () => { // aidd_docs/runs -> .. -> aidd_docs -> .. -> projectRoot: "../../VICTIM.txt" lands here. const victimPath = join(projectRoot, "VICTIM.txt"); @@ -389,12 +378,8 @@ describe("RunJournalReaderAdapter.deleteRunFile — confined to the directory it await expect(adapter.deleteRunFile(runsDir, ".")).rejects.toThrow(); }); - // Finding 1: `AIDD_RUNS_DIR` relocated between the moment a person is shown `runsDir` - // (the preview) and the moment `deleteRunFile` runs (the removal) used to reach the - // relocated directory instead of the one shown, because the old `deleteRunFile` re-read - // `resolveRunsDir()` on every call. `runsDir` is now frozen at construction, and - // `deleteRunFile` takes `dir` as an explicit argument — this proves it acts on whatever - // `dir` it is handed, never on a live re-resolution of `AIDD_RUNS_DIR`. + // `runsDir` is frozen at construction and `deleteRunFile` takes `dir` explicitly, so + // `AIDD_RUNS_DIR` moving between the preview and the removal cannot redirect it. it("acts on the dir it is handed, immune to AIDD_RUNS_DIR being relocated afterwards", async () => { await writeFile(join(runsDir, "shown.jsonl"), "shown\n"); const adapter = new RunJournalReaderAdapter(projectRoot); @@ -414,9 +399,8 @@ describe("RunJournalReaderAdapter.deleteRunFile — confined to the directory it }); }); -/** The hook stamps `schema_version` on every `session_start` it writes, and until now this - * reader dropped it — so a journal written under a schema whose line shapes had changed was - * read as if it were this one, which is a silent misreading rather than a refusal. */ +/** The hook stamps `schema_version` on every `session_start`: a journal written under a + * schema whose line shapes changed must be refused, not read as if it were this one. */ describe("RunJournalReaderAdapter — the schema a journal states it was written under", () => { let projectRoot: string; let runsDir: string; @@ -495,10 +479,8 @@ describe("RunJournalReaderAdapter — the schema a journal states it was written expect(await adapter.read(SESSION_ID)).toBeNull(); }); - // Absence is not a stated disagreement. Every journal on disk before this reader looked at - // the field was read without it, and refusing them now would drop attribution this reader - // has always been able to give - the fault "an unknown is never a zero" names, applied to - // the reader rather than to a figure. + // Absence is not a stated disagreement: every journal written before this field existed was + // read without it, and refusing them now would drop attribution this reader can still give. it("still reads a journal that states no schema at all", async () => { await writeJournal(header({}), { type: "step_start", @@ -513,9 +495,8 @@ describe("RunJournalReaderAdapter — the schema a journal states it was written expect(journal?.boundaries).toHaveLength(1); }); - // What a refusal must not cost: the fact that a run file is there. Dropped silently, the - // diagnostic reads "none carry a readable session_start" about a file whose header it read - // perfectly well, and prints a torn write as the cause of a version disagreement. + // Dropped silently, the diagnostic reads "none carry a readable session_start" about a file + // whose header it read perfectly well, blaming a torn write for a version disagreement. it("still names the schema of every journal it refused", async () => { await writeJournal(header({ schema_version: READABLE_JOURNAL_SCHEMA_VERSION + 1 })); const adapter = new RunJournalReaderAdapter(projectRoot); diff --git a/cli/tests/infrastructure/adapters/run-journal-task-declared.integration.test.ts b/cli/tests/contexts/telemetry/infrastructure/run-journal-task-declared.integration.test.ts similarity index 82% rename from cli/tests/infrastructure/adapters/run-journal-task-declared.integration.test.ts rename to cli/tests/contexts/telemetry/infrastructure/run-journal-task-declared.integration.test.ts index 74a1f1201..c852ad31e 100644 --- a/cli/tests/infrastructure/adapters/run-journal-task-declared.integration.test.ts +++ b/cli/tests/contexts/telemetry/infrastructure/run-journal-task-declared.integration.test.ts @@ -4,15 +4,13 @@ import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; -import { buildTaskIntervals } from "../../../src/domain/models/task-attribution.js"; -import { RunJournalReaderAdapter } from "../../../src/infrastructure/adapters/run-journal-reader-adapter.js"; -import { environmentWithoutGitVariables } from "../../../src/infrastructure/git-environment.js"; -import { journalTaskDeclared } from "../../helpers/telemetry-journal-hook.js"; - -// A ticket declared on a host whose payload names no path at all - the gap #663 left and -// this deliverable closes. Exercised against the real hook and the real reader, nothing -// stubbed between them: without this, a change to either side would leave every declared -// task unreadable and no test would notice. +import { buildTaskIntervals } from "../../../../src/contexts/telemetry/domain/task-attribution.js"; +import { RunJournalReaderAdapter } from "../../../../src/contexts/telemetry/infrastructure/run-journal-reader-adapter.js"; +import { environmentWithoutGitVariables } from "../../../../src/runtime/git/git-environment.js"; +import { journalTaskDeclared } from "../../../helpers/telemetry-journal-hook.js"; + +// A ticket declared on a host whose payload names no path at all, exercised against the real +// hook and the real reader with nothing stubbed between them. const RUN_ID = "01ARZ3NDEKTSV4RRFFQ69G5FAV"; const SESSION_ID = "22222222-2222-4222-8222-222222222222"; const TASK_PATH = "aidd_docs/tasks/2026_08/2026_08_21_cost-reporter/spec.md"; @@ -49,9 +47,8 @@ describe("task_declared, from the hook that writes it to the reader that reads i await rm(projectRoot, { recursive: true, force: true }); }); - // Codex has no write-path field on any tool and no distinct "Read" tool either - a Bash - // command reading the plan is the only shape it ever sends, exactly as its step detection - // already relies on. + // Codex has no write-path field on any tool and no distinct "Read" tool: a Bash command + // reading the plan is the only shape it ever sends. function readViaBashPayload(): Record { return { tool_name: "Bash", diff --git a/cli/tests/infrastructure/adapters/task-backlog-adapter.integration.test.ts b/cli/tests/contexts/telemetry/infrastructure/task-backlog-adapter.integration.test.ts similarity index 88% rename from cli/tests/infrastructure/adapters/task-backlog-adapter.integration.test.ts rename to cli/tests/contexts/telemetry/infrastructure/task-backlog-adapter.integration.test.ts index dbb49ea47..28107c4bb 100644 --- a/cli/tests/infrastructure/adapters/task-backlog-adapter.integration.test.ts +++ b/cli/tests/contexts/telemetry/infrastructure/task-backlog-adapter.integration.test.ts @@ -3,7 +3,7 @@ import { mkdir, mkdtemp, readdir, readFile, rm, writeFile } from "node:fs/promis import { tmpdir } from "node:os"; import { join, relative } from "node:path"; import { afterEach, describe, expect, it } from "vitest"; -import { TaskBacklogAdapter } from "../../../src/infrastructure/adapters/task-backlog-adapter.js"; +import { TaskBacklogAdapter } from "../../../../src/contexts/telemetry/infrastructure/task-backlog-adapter.js"; const TASK_FOLDER = "aidd_docs/tasks/2026_08/2026_08_21_cost-reporter/"; @@ -25,9 +25,8 @@ async function writeLink(root: string, body: string): Promise { await writeFile(join(folder, "backlog-link.json"), body, "utf8"); } -/** Every file under `dir`, hashed by its own bytes — the whole set, not only the files a - * caller already knows about, so a file the read path *created* is caught exactly as a - * file it modified would be. */ +/** Every file under `dir`, hashed by its own bytes — the whole set, so a file the read path + * *created* is caught exactly as a file it modified would be. */ async function snapshot(dir: string): Promise> { const files = new Map(); const walk = async (current: string): Promise => { @@ -95,12 +94,8 @@ describe("TaskBacklogAdapter — reads a declaration without ever writing one", ); }); - // The task folder path this reader is handed is repository-relative, because the journal - // line it came from was written relative to the repository root. Joining it to the - // process working directory instead finds nothing from a subdirectory - and "nothing" is - // spelled `{ kind: "none" }`, "this task declares no backlog item", which is a claim about - // the task rather than about the read. Introduced the moment the journal reader started - // anchoring at the root: `by_task` names the task, `by_backlog` says it declares nothing. + // The path handed in is repository-relative, and joining it to the process working + // directory finds nothing - spelled `{ kind: "none" }`, a claim about the task, not the read. it("anchors at the repository root, so a subdirectory reads the same declaration", async () => { const root = await freshProject(); await mkdir(join(root, ".git"), { recursive: true }); diff --git a/cli/tests/contexts/telemetry/infrastructure/task-backlog-skill-shape.integration.test.ts b/cli/tests/contexts/telemetry/infrastructure/task-backlog-skill-shape.integration.test.ts new file mode 100644 index 000000000..d0e649d1f --- /dev/null +++ b/cli/tests/contexts/telemetry/infrastructure/task-backlog-skill-shape.integration.test.ts @@ -0,0 +1,93 @@ +import { readFileSync } from "node:fs"; +import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { TaskBacklogAdapter } from "../../../../src/contexts/telemetry/infrastructure/task-backlog-adapter.js"; +import { REPOSITORY_ROOT } from "../../../helpers/repository-root.js"; + +/** Each skill's own fenced example is fed through the real adapter over a real temp folder, + * never a stand-in parser, so a field renamed on either side fails here. */ +const REPO_ROOT = REPOSITORY_ROOT; +const SPEC_SKILL_MD = join( + REPO_ROOT, + "plugins", + "aidd-pm", + "skills", + "04-spec", + "actions", + "01-build.md" +); +const PLAN_SKILL_MD = join( + REPO_ROOT, + "plugins", + "aidd-dev", + "skills", + "01-plan", + "actions", + "04-plan.md" +); + +/** The first fenced json block: the literal example a skill tells an agent to write, + * tolerant of a numbered-list item's own indentation. `null` when none is found. */ +function fencedJsonExample(markdown: string): string | null { + const match = /^[ \t]*```json\r?\n([\s\S]*?)\r?\n[ \t]*```/mu.exec(markdown); + return match?.[1] ?? null; +} + +const tempDirs: string[] = []; + +afterEach(async () => { + for (const dir of tempDirs.splice(0)) await rm(dir, { recursive: true, force: true }); +}); + +async function projectWithLink(json: string): Promise<{ root: string; taskFolder: string }> { + const root = await mkdtemp(join(tmpdir(), "aidd-backlog-skill-shape-")); + tempDirs.push(root); + const taskFolder = "aidd_docs/tasks/2026_08/2026_08_21_example/"; + await mkdir(join(root, taskFolder), { recursive: true }); + await writeFile(join(root, taskFolder, "backlog-link.json"), json, "utf8"); + return { root, taskFolder }; +} + +describe.each([ + ["aidd-pm:04-spec", SPEC_SKILL_MD], + ["aidd-dev:01-plan", PLAN_SKILL_MD], +])("%s's own backlog-link.json example matches what the reader accepts", (_skill, path) => { + it("names a fenced JSON example at all (guards against a no-op extraction)", () => { + const markdown = readFileSync(path, "utf8"); + expect(fencedJsonExample(markdown)).not.toBeNull(); + }); + + it("parses through the real TaskBacklogAdapter as a declared item", async () => { + const markdown = readFileSync(path, "utf8"); + const example = fencedJsonExample(markdown); + if (example === null) throw new Error("no fenced json example to test"); + + const { root, taskFolder } = await projectWithLink(`${example}\n`); + const adapter = new TaskBacklogAdapter(root); + + const declaration = await adapter.read(taskFolder); + + expect(declaration.kind).toBe("declared"); + if (declaration.kind === "declared") { + expect(declaration.link.backlog).toBe("owner/repo#123"); + expect(declaration.link.writtenAt.length).toBeGreaterThan(0); + expect(declaration.link.writtenBy.length).toBeGreaterThan(0); + } + }); +}); + +describe("both skills agree with each other, not only with the reader", () => { + it("write the identical field names, so neither can drift from the other unnoticed", () => { + const specExample = fencedJsonExample(readFileSync(SPEC_SKILL_MD, "utf8")); + const planExample = fencedJsonExample(readFileSync(PLAN_SKILL_MD, "utf8")); + expect(specExample).not.toBeNull(); + expect(planExample).not.toBeNull(); + + const fieldNames = (json: string): readonly string[] => + Object.keys(JSON.parse(json) as Record).sort(); + + expect(fieldNames(specExample as string)).toEqual(fieldNames(planExample as string)); + }); +}); diff --git a/cli/tests/infrastructure/adapters/telemetry-evidence-adapter.integration.test.ts b/cli/tests/contexts/telemetry/infrastructure/telemetry-evidence-adapter.integration.test.ts similarity index 83% rename from cli/tests/infrastructure/adapters/telemetry-evidence-adapter.integration.test.ts rename to cli/tests/contexts/telemetry/infrastructure/telemetry-evidence-adapter.integration.test.ts index eda47528a..128dfe1f7 100644 --- a/cli/tests/infrastructure/adapters/telemetry-evidence-adapter.integration.test.ts +++ b/cli/tests/contexts/telemetry/infrastructure/telemetry-evidence-adapter.integration.test.ts @@ -4,27 +4,16 @@ import { dirname, join } from "node:path"; import { afterEach, describe, expect, it } from "vitest"; // Every tool, because `enabledPluginsCandidates` walks the whole registry: a partial // registration throws before a single assertion is reached. -import "../../../src/domain/tools/ai/claude.js"; -import "../../../src/domain/tools/ai/codex.js"; -import "../../../src/domain/tools/ai/copilot.js"; -import "../../../src/domain/tools/ai/cursor.js"; -import "../../../src/domain/tools/ai/opencode.js"; -import { TelemetryEvidenceAdapter } from "../../../src/infrastructure/adapters/telemetry-evidence-adapter.js"; +import "../../../../src/contexts/tools/domain/profiles/claude/profile.js"; +import "../../../../src/contexts/tools/domain/profiles/codex/profile.js"; +import "../../../../src/contexts/tools/domain/profiles/copilot/profile.js"; +import "../../../../src/contexts/tools/domain/profiles/cursor/profile.js"; +import "../../../../src/contexts/tools/domain/profiles/opencode/profile.js"; +import { TelemetryEvidenceAdapter } from "../../../../src/contexts/telemetry/infrastructure/telemetry-evidence-adapter.js"; /** - * The adapter every other telemetry command asks first. - * - * It answers whether measurement is allowed here, whether anything is declared to do the - * recording, and what a tool's own settings file still carries — so a wrong answer here - * does not produce a wrong figure, it produces no figures at all, or figures nobody asked - * to be collected. That is why it is tested against real files rather than a double: every - * one of its answers is a read of a path this build also writes, and the two must not - * drift. - * - * `HOME` is pointed at a throwaway directory for every case, because - * `readRecorderDeclaration` checks the user-scope Claude settings file as one of its - * locations. Left at the real value, a developer who happens to have the plugin enabled - * globally would see these pass for the wrong reason. + * `HOME` is a throwaway directory for every case: `readRecorderDeclaration` checks the + * user-scope Claude settings file, which a developer may have the plugin enabled in. */ const created: string[] = []; const savedHome = process.env.HOME; @@ -139,6 +128,7 @@ describe("whether anything is declared to do the recording", () => { hooks: [ { type: "command", + // biome-ignore lint/suspicious/noTemplateCurlyInString: Claude Code resolves this placeholder, the settings file carries it verbatim command: "node ${CLAUDE_PLUGIN_ROOT}/hooks/journal.cjs session-start", }, ], @@ -205,6 +195,23 @@ describe("a payload that matched no known host", () => { expect(await adapter().readUnrecognisedPayload(root)).toBeNull(); }); + + // The hook writing this file anchors at the repository root, never at the directory a + // session started from, so a reader must walk up rather than join onto `projectRoot`. + it("finds the file from a subdirectory of the repository, not only from its root", async () => { + const root = project(); + mkdirSync(join(root, ".git"), { recursive: true }); + write( + join(root, "aidd_docs", "runs", "_unrecognised.jsonl"), + `${JSON.stringify({ type: "unrecognised_payload", at: "2026-03-02T08:00:00Z" })}\n` + ); + const subdirectory = join(root, "packages", "app"); + mkdirSync(subdirectory, { recursive: true }); + + expect(await adapter().readUnrecognisedPayload(subdirectory)).toEqual({ + at: "2026-03-02T08:00:00Z", + }); + }); }); describe("an export a deleted command left behind in a tool's own settings", () => { diff --git a/cli/tests/infrastructure/adapters/telemetry-sink-adapter.integration.test.ts b/cli/tests/contexts/telemetry/infrastructure/telemetry-sink-adapter.integration.test.ts similarity index 86% rename from cli/tests/infrastructure/adapters/telemetry-sink-adapter.integration.test.ts rename to cli/tests/contexts/telemetry/infrastructure/telemetry-sink-adapter.integration.test.ts index 770968862..bc1266786 100644 --- a/cli/tests/infrastructure/adapters/telemetry-sink-adapter.integration.test.ts +++ b/cli/tests/contexts/telemetry/infrastructure/telemetry-sink-adapter.integration.test.ts @@ -1,12 +1,12 @@ -import { appendFile, chmod, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { appendFile, chmod, mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; -import type { TelemetrySinkRecord } from "../../../src/domain/models/telemetry-sink-record.js"; -import { decideTelemetrySinkRetention } from "../../../src/domain/models/telemetry-sink-retention.js"; -import type { TelemetrySinkPeriodRead } from "../../../src/domain/ports/telemetry-sink.js"; -import { TelemetrySinkAdapter } from "../../../src/infrastructure/adapters/telemetry-sink-adapter.js"; -import { InMemoryTelemetrySink } from "../../helpers/ports/in-memory-telemetry-sink.js"; +import type { TelemetrySinkPeriodRead } from "../../../../src/contexts/telemetry/domain/ports/telemetry-sink.js"; +import type { TelemetrySinkRecord } from "../../../../src/contexts/telemetry/domain/telemetry-sink-record.js"; +import { decideTelemetrySinkRetention } from "../../../../src/contexts/telemetry/domain/telemetry-sink-retention.js"; +import { TelemetrySinkAdapter } from "../../../../src/contexts/telemetry/infrastructure/telemetry-sink-adapter.js"; +import { InMemoryTelemetrySink } from "../../../helpers/ports/in-memory-telemetry-sink.js"; const RECORD: TelemetrySinkRecord = { sink_schema_version: 2, @@ -93,6 +93,17 @@ describe("TelemetrySinkAdapter", () => { expect(records.every((r) => r.vendor_id === "s-1")).toBe(true); }); + it("tolerates a day file that cannot be read, the same way a full read already does", async () => { + const adapter = new TelemetrySinkAdapter(userConfigDir); + await adapter.ensureWritable(); + await adapter.appendRecord(RECORD, new Date("2026-08-15T10:00:00Z")); + // `listDayFiles` filters by name alone, so a directory matching the pattern is listed + // and then fails to read — the deterministic stand-in for a file deleted mid-scan. + await mkdir(join(adapter.rootDir, "2026-08-16.jsonl")); + + await expect(adapter.readRecordsForVendor("s-1")).resolves.toHaveLength(1); + }); + it("skips a torn final line rather than failing the whole scan", async () => { const adapter = new TelemetrySinkAdapter(userConfigDir); await adapter.ensureWritable(); @@ -103,9 +114,8 @@ describe("TelemetrySinkAdapter", () => { expect(records).toHaveLength(1); }); - // chmod-based permission denial is meaningless for root (common in CI containers) and - // for Windows ACLs — this project's CI matrix has neither, but the guard keeps the test - // honest instead of silently passing on a platform where chmod doesn't block writes. + // chmod blocks no write for root or behind Windows ACLs, where this would pass without + // testing anything. it.skipIf(process.platform === "win32" || process.getuid?.() === 0)( "fails ensureWritable at startup with a message naming the path, when the directory cannot be written", async () => { @@ -125,10 +135,8 @@ describe("TelemetrySinkAdapter.readRecordsInPeriod", () => { let userConfigDir: string; let adapter: TelemetrySinkAdapter; - // Every fixture below is appended on one day and stamped with another. That gap is the - // whole point: a session read locally days after it ran lands in today's day file while - // its records carry their own, older moments, and a report asking what last week cost - // means the moment — not the day we happened to hear about it. + // Every fixture is appended on one day and stamped with another: a session read days + // later lands in today's file while its records carry their own, older moments. const STORED_ON = new Date("2026-08-21T09:00:00Z"); beforeEach(async () => { @@ -158,7 +166,7 @@ describe("TelemetrySinkAdapter.readRecordsInPeriod", () => { await append("july", "2026-07-29"); await append("august", "2026-08-18"); - // Both were appended on 2026-08-21, so both live in the same day file. + // Both were appended on the same day, so both live in one day file. expect(await adapter.listDayFiles()).toEqual(["2026-08-21.jsonl"]); expect((await period("2026-07-01", "2026-07-31")).records.map((r) => r.vendor_id)).toEqual([ "july", @@ -232,7 +240,7 @@ describe("TelemetrySinkAdapter.readRecordsInPeriod", () => { it("places a moment written with a non-UTC offset on the day it actually happened", async () => { await adapter.appendRecord( - // 2026-08-18T01:00+05:00 is 2026-08-17T20:00Z — the 17th, not the 18th. + // 01:00+05:00 on the 18th is 20:00Z on the 17th. { ...RECORD, vendor_id: "offset", event_timestamp: "2026-08-18T01:00:00+05:00" }, STORED_ON ); @@ -266,9 +274,8 @@ describe("TelemetrySinkAdapter.readRecordsInPeriod", () => { }); describe("the real sink and its in-memory double place a record on the same day", () => { - // A double that buckets differently from the adapter it stands for lets phase 2's - // aggregation tests agree with the double and disagree with production. These are the - // four shapes the two could diverge on. + // A double that buckets differently from the adapter it stands for would let aggregation + // tests agree with the double and disagree with production. Four shapes could diverge. const MOMENTS: readonly (string | undefined)[] = [ "2026-08-17T10:00:00.000Z", "2026-08-18T01:00:00+05:00", @@ -317,8 +324,8 @@ describe("the real sink and its in-memory double place a record on the same day" expect(ids(fromDouble)).toEqual(ids(fromAdapter)); }); - // Finding 4's confinement, mirrored for the sink: `deleteDayFile` shares the same - // `isBareFileName` check as `RunJournalReaderAdapter.deleteRunFile`. + // `deleteDayFile` shares the `isBareFileName` check `RunJournalReaderAdapter.deleteRunFile` + // applies. it("refuses a relative walk out of the directory it is handed, rather than deleting outside it", async () => { const adapter = new TelemetrySinkAdapter(userConfigDir); await adapter.ensureWritable(); diff --git a/cli/tests/contexts/telemetry/infrastructure/telemetry-sink-location.unit.test.ts b/cli/tests/contexts/telemetry/infrastructure/telemetry-sink-location.unit.test.ts new file mode 100644 index 000000000..b457fd632 --- /dev/null +++ b/cli/tests/contexts/telemetry/infrastructure/telemetry-sink-location.unit.test.ts @@ -0,0 +1,257 @@ +import { chmodSync, mkdirSync, mkdtempSync, readFileSync, rmSync, statSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { + defaultConfigDir, + TelemetrySinkAdapter, +} from "../../../../src/contexts/telemetry/infrastructure/telemetry-sink-adapter.js"; +import { AuthStorage } from "../../../../src/runtime/auth/auth-storage.js"; +import { sandboxedEnv, sinkDirIn } from "../../../e2e/helpers.js"; +import { REPOSITORY_ROOT } from "../../../helpers/repository-root.js"; + +/** + * `defaultConfigDir` reads `process.platform` on every call, so faking it here pins the rule + * on any machine. `%APPDATA%` is where a Windows application keeps this, `.config` is not. + */ +const PLUGIN_README = join(REPOSITORY_ROOT, "plugins", "aidd-telemetry", "README.md"); + +function withPlatform(platform: NodeJS.Platform, run: () => T): T { + const original = Object.getOwnPropertyDescriptor(process, "platform"); + Object.defineProperty(process, "platform", { value: platform, configurable: true }); + try { + return run(); + } finally { + if (original) Object.defineProperty(process, "platform", original); + } +} + +const previousAppData = process.env.APPDATA; +const previousHome = process.env.HOME; +const temporaryHomes: string[] = []; + +/** A home with no `.config/aidd/telemetry`, so the legacy-data fallback does not fire: a + * machine that already journalled there keeps landing there, only a fresh one gets `%APPDATA%`. */ +function freshHome(): string { + const home = mkdtempSync(join(tmpdir(), "aidd-sink-location-")); + temporaryHomes.push(home); + process.env.HOME = home; + return home; +} + +afterEach(() => { + if (previousAppData === undefined) delete process.env.APPDATA; + else process.env.APPDATA = previousAppData; + if (previousHome === undefined) delete process.env.HOME; + else process.env.HOME = previousHome; + for (const home of temporaryHomes.splice(0)) rmSync(home, { recursive: true, force: true }); +}); + +describe("where the figures land by default", () => { + it("a POSIX machine keeps them under the OS user's own .config", () => { + const home = freshHome(); + + expect(withPlatform("linux", defaultConfigDir)).toBe(join(home, ".config", "aidd")); + }); + + it("a fresh Windows machine keeps them under %APPDATA%, never under .config", () => { + freshHome(); + process.env.APPDATA = join("C:", "Users", "someone", "AppData", "Roaming"); + + expect(withPlatform("win32", defaultConfigDir)).toBe(join(process.env.APPDATA, "aidd")); + }); + + it("Windows without APPDATA falls back rather than inventing a path", () => { + const home = freshHome(); + delete process.env.APPDATA; + + expect(withPlatform("win32", defaultConfigDir)).toBe(join(home, ".config", "aidd")); + }); + + it("the plugin README states the exact default the code writes", () => { + // Forward slashes rather than `join`, which yields `~\\.config\\aidd` on Windows: the + // prose reads the same on every platform, only the code follows the host's separator. + const documented = "~/.config/aidd/telemetry"; + const text = readFileSync(PLUGIN_README, "utf8"); + + expect(text).toContain(documented); + // The variable the adapter honours, not the older one a reader is only told to avoid. + expect(text).toContain("AIDD_TELEMETRY_DIR"); + }); +}); + +/** + * `sandboxedEnv` sets `AIDD_USER_CONFIG_DIR` and the adapter honours it ahead of + * `defaultConfigDir()`, so a sandboxed sink sits under the fake home on every platform. + */ +describe("a sandboxed run's sink, agreed between the helper and the adapter", () => { + const previousUserConfigDir = process.env.AIDD_USER_CONFIG_DIR; + + afterEach(() => { + if (previousUserConfigDir === undefined) delete process.env.AIDD_USER_CONFIG_DIR; + else process.env.AIDD_USER_CONFIG_DIR = previousUserConfigDir; + }); + + for (const platform of ["linux", "win32"] as const) { + it(`agrees on ${platform}, whichever platform this suite runs on`, () => { + const home = freshHome(); + const env = sandboxedEnv(home); + const previousPlatformAppData = process.env.APPDATA; + process.env.APPDATA = env.APPDATA; + process.env.AIDD_USER_CONFIG_DIR = env.AIDD_USER_CONFIG_DIR; + try { + const fromAdapter = withPlatform(platform, () => new TelemetrySinkAdapter().rootDir); + const fromHelper = withPlatform(platform, () => sinkDirIn(home)); + + expect(fromHelper).toBe(fromAdapter); + } finally { + if (previousPlatformAppData === undefined) delete process.env.APPDATA; + else process.env.APPDATA = previousPlatformAppData; + } + }); + } +}); + +/** + * `AIDD_USER_CONFIG_DIR` also names where `auth.json` — a GitHub token — is written, so + * sharing the figures through it shared the token. The measurement has its own name. + */ +describe("where the figures land, and what does not follow them there", () => { + const previousTelemetryDir = process.env.AIDD_TELEMETRY_DIR; + const previousUserConfigDir = process.env.AIDD_USER_CONFIG_DIR; + + afterEach(() => { + for (const [key, value] of [ + ["AIDD_TELEMETRY_DIR", previousTelemetryDir], + ["AIDD_USER_CONFIG_DIR", previousUserConfigDir], + ] as const) { + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + } + }); + + it("puts the figures exactly where AIDD_TELEMETRY_DIR names, not in a subdirectory of it", () => { + // This variable names the directory the day files sit in; `AIDD_USER_CONFIG_DIR` names + // the directory above it. + const shared = mkdtempSync(join(tmpdir(), "aidd-shared-figures-")); + try { + process.env.AIDD_TELEMETRY_DIR = shared; + delete process.env.AIDD_USER_CONFIG_DIR; + + expect(new TelemetrySinkAdapter().rootDir).toBe(shared); + } finally { + rmSync(shared, { recursive: true, force: true }); + } + }); + + it("leaves the token where it was when the figures are shared", () => { + const shared = mkdtempSync(join(tmpdir(), "aidd-shared-figures-")); + const home = mkdtempSync(join(tmpdir(), "aidd-home-")); + try { + delete process.env.AIDD_USER_CONFIG_DIR; + const tokenBefore = new AuthStorage().userConfigPath(); + + process.env.AIDD_TELEMETRY_DIR = shared; + + expect(new TelemetrySinkAdapter().rootDir).toBe(shared); + expect(new AuthStorage().userConfigPath()).toBe(tokenBefore); + } finally { + rmSync(shared, { recursive: true, force: true }); + rmSync(home, { recursive: true, force: true }); + } + }); + + it("still honours the older variable, so a setup that predates the split keeps working", () => { + const older = mkdtempSync(join(tmpdir(), "aidd-legacy-config-")); + try { + delete process.env.AIDD_TELEMETRY_DIR; + process.env.AIDD_USER_CONFIG_DIR = older; + + expect(new TelemetrySinkAdapter().rootDir).toBe(join(older, "telemetry")); + } finally { + rmSync(older, { recursive: true, force: true }); + } + }); + + it("prefers the name given to the figures when both are set", () => { + const shared = mkdtempSync(join(tmpdir(), "aidd-shared-figures-")); + const older = mkdtempSync(join(tmpdir(), "aidd-legacy-config-")); + try { + process.env.AIDD_TELEMETRY_DIR = shared; + process.env.AIDD_USER_CONFIG_DIR = older; + + expect(new TelemetrySinkAdapter().rootDir).toBe(shared); + } finally { + rmSync(shared, { recursive: true, force: true }); + rmSync(older, { recursive: true, force: true }); + } + }); +}); + +/** + * The directory's mode decides who may list a person's working days. A default location is + * tightened; one they named themselves is left as made, since sharing is what naming it is for. + */ +describe("who may list the days a person worked", () => { + const previousTelemetryDir = process.env.AIDD_TELEMETRY_DIR; + const previousUserConfigDir = process.env.AIDD_USER_CONFIG_DIR; + const previousHome = process.env.HOME; + + afterEach(() => { + for (const [key, value] of [ + ["AIDD_TELEMETRY_DIR", previousTelemetryDir], + ["AIDD_USER_CONFIG_DIR", previousUserConfigDir], + ["HOME", previousHome], + ] as const) { + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + } + }); + + function modeOf(dir: string): string { + return (statSync(dir).mode & 0o777).toString(8); + } + + it.skipIf(process.platform === "win32")( + "tightens a default location to this person alone", + async () => { + const home = mkdtempSync(join(tmpdir(), "aidd-tighten-home-")); + try { + delete process.env.AIDD_TELEMETRY_DIR; + delete process.env.AIDD_USER_CONFIG_DIR; + process.env.HOME = home; + + const sink = new TelemetrySinkAdapter(); + await sink.ensureWritable(); + + expect(modeOf(sink.rootDir)).toBe("700"); + } finally { + rmSync(home, { recursive: true, force: true }); + } + } + ); + + it.skipIf(process.platform === "win32")( + "leaves a location a person named themselves exactly as they made it", + async () => { + const home = mkdtempSync(join(tmpdir(), "aidd-tighten-home-")); + const shared = join(mkdtempSync(join(tmpdir(), "aidd-tighten-shared-")), "figures"); + try { + process.env.HOME = home; + delete process.env.AIDD_USER_CONFIG_DIR; + process.env.AIDD_TELEMETRY_DIR = shared; + mkdirSync(shared, { recursive: true }); + chmodSync(shared, 0o755); + + const sink = new TelemetrySinkAdapter(); + await sink.ensureWritable(); + + // Untouched: a directory a team shares must stay listable by the team. + expect(modeOf(shared)).toBe("755"); + } finally { + rmSync(home, { recursive: true, force: true }); + rmSync(shared, { recursive: true, force: true }); + } + } + ); +}); diff --git a/cli/tests/infrastructure/adapters/transcript-cost-reader-adapter.integration.test.ts b/cli/tests/contexts/telemetry/infrastructure/transcript-cost-reader-adapter.integration.test.ts similarity index 76% rename from cli/tests/infrastructure/adapters/transcript-cost-reader-adapter.integration.test.ts rename to cli/tests/contexts/telemetry/infrastructure/transcript-cost-reader-adapter.integration.test.ts index 2c4c2515e..d9a3e20b1 100644 --- a/cli/tests/infrastructure/adapters/transcript-cost-reader-adapter.integration.test.ts +++ b/cli/tests/contexts/telemetry/infrastructure/transcript-cost-reader-adapter.integration.test.ts @@ -1,19 +1,14 @@ import { fileURLToPath } from "node:url"; import { describe, expect, it } from "vitest"; -import { - CLAUDE_CODE_TRANSCRIPT_LOCATION, - createClaudeCodeTranscriptAccumulator, -} from "../../../src/domain/formats/claude-code-transcript.js"; -import { - CODEX_ROLLOUT_LOCATION, - createCodexRolloutAccumulator, -} from "../../../src/domain/formats/codex-rollout.js"; -import { TranscriptCostReaderAdapter } from "../../../src/infrastructure/adapters/transcript-cost-reader-adapter.js"; +import { createClaudeCodeTranscriptAccumulator } from "../../../../src/contexts/telemetry/domain/formats/claude-code-transcript.js"; +import { createCodexRolloutAccumulator } from "../../../../src/contexts/telemetry/domain/formats/codex-rollout.js"; +import { TranscriptCostReaderAdapter } from "../../../../src/contexts/telemetry/infrastructure/transcript-cost-reader-adapter.js"; +import { CLAUDE_CODE_TRANSCRIPT_LOCATION } from "../../../../src/contexts/tools/domain/profiles/claude/claude-transcript-location.js"; +import { CODEX_ROLLOUT_LOCATION } from "../../../../src/contexts/tools/domain/profiles/codex/codex-transcript-location.js"; -// The fixtures tree under tests/fixtures/local-cost mirrors a real $HOME: `.claude/projects` -// and `.codex/sessions` sit exactly where each tool would write them, so pointing `homeDir` -// at this directory exercises the same directory walk and file naming a real machine would. -const HOME_DIR = fileURLToPath(new URL("../../fixtures/local-cost", import.meta.url)).replace( +// The fixtures tree mirrors a real $HOME, each tool's directory exactly where it would +// write it, so `homeDir` here exercises the same walk and file naming a real machine does. +const HOME_DIR = fileURLToPath(new URL("../../../fixtures/local-cost", import.meta.url)).replace( /\/$/, "" ); diff --git a/cli/tests/contexts/tools/domain/build-hooks-support-declaration.unit.test.ts b/cli/tests/contexts/tools/domain/build-hooks-support-declaration.unit.test.ts new file mode 100644 index 000000000..8649c2308 --- /dev/null +++ b/cli/tests/contexts/tools/domain/build-hooks-support-declaration.unit.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, it } from "vitest"; +import type { + ArtifactContract, + ToolBuildContract, +} from "../../../../src/contexts/tools/domain/build-contract.js"; +import { buildClaudeFlatContract } from "../../../../src/contexts/tools/domain/profiles/claude/build.js"; +import { claude } from "../../../../src/contexts/tools/domain/profiles/claude/profile.js"; +import { buildCodexFlatContract } from "../../../../src/contexts/tools/domain/profiles/codex/build.js"; +import { codex } from "../../../../src/contexts/tools/domain/profiles/codex/profile.js"; +import { buildCopilotFlatContract } from "../../../../src/contexts/tools/domain/profiles/copilot/build.js"; +import { copilot } from "../../../../src/contexts/tools/domain/profiles/copilot/profile.js"; +import { buildCursorFlatContract } from "../../../../src/contexts/tools/domain/profiles/cursor/build.js"; +import { cursor } from "../../../../src/contexts/tools/domain/profiles/cursor/profile.js"; +import { buildOpencodeFlatContract } from "../../../../src/contexts/tools/domain/profiles/opencode/build.js"; +import { opencode } from "../../../../src/contexts/tools/domain/profiles/opencode/profile.js"; + +interface HooksDeclaringTool { + readonly toolId: string; + readonly capabilities: { readonly plugins: { readonly acceptsHooks: boolean } }; +} + +/** + * A tool declares whether it runs a delivered hook once, on its own `acceptsHooks`. A build + * contract hard-coding its own answer would sit on a route no declaration can reach. + */ +const FLAT_CONTRACTS: ReadonlyArray<[HooksDeclaringTool, () => ToolBuildContract]> = [ + [claude, buildClaudeFlatContract], + [cursor, buildCursorFlatContract], + [copilot, buildCopilotFlatContract], + [codex, buildCodexFlatContract], + [opencode, buildOpencodeFlatContract], +]; + +function isSupported(artifact: ArtifactContract): boolean { + return artifact.supported; +} + +describe("the flat build contract's hooks support", () => { + it("matches the tool's own acceptsHooks declaration, for every flat-mode tool", () => { + let examined = 0; + for (const [tool, buildContract] of FLAT_CONTRACTS) { + examined++; + const declared = tool.capabilities.plugins.acceptsHooks; + const delivered = isSupported(buildContract().artifacts.hooks); + expect(delivered, tool.toolId).toBe(declared); + } + // A tool list that stopped naming any flat-mode tool would pass by never reaching + // the assertion above, which is the failure shape this file exists to catch. + expect(examined).not.toBe(0); + }); +}); diff --git a/cli/tests/domain/capabilities/agents-capability.unit.test.ts b/cli/tests/contexts/tools/domain/capabilities/agents-capability.unit.test.ts similarity index 97% rename from cli/tests/domain/capabilities/agents-capability.unit.test.ts rename to cli/tests/contexts/tools/domain/capabilities/agents-capability.unit.test.ts index aad33f03e..5b736f58b 100644 --- a/cli/tests/domain/capabilities/agents-capability.unit.test.ts +++ b/cli/tests/contexts/tools/domain/capabilities/agents-capability.unit.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { AgentsCapability } from "../../../src/domain/capabilities/agents-capability.js"; +import { AgentsCapability } from "../../../../../src/contexts/tools/domain/capabilities/agents-capability.js"; describe("AgentsCapability", () => { const markdownParams = { diff --git a/cli/tests/domain/capabilities/commands-capability.unit.test.ts b/cli/tests/contexts/tools/domain/capabilities/commands-capability.unit.test.ts similarity index 94% rename from cli/tests/domain/capabilities/commands-capability.unit.test.ts rename to cli/tests/contexts/tools/domain/capabilities/commands-capability.unit.test.ts index 27553b84a..a4682f7ba 100644 --- a/cli/tests/domain/capabilities/commands-capability.unit.test.ts +++ b/cli/tests/contexts/tools/domain/capabilities/commands-capability.unit.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { CommandsCapability } from "../../../src/domain/capabilities/commands-capability.js"; +import { CommandsCapability } from "../../../../../src/contexts/tools/domain/capabilities/commands-capability.js"; const stubParams = { buildInstallPath: (fileName: string): string | null => `stub/${fileName}`, diff --git a/cli/tests/domain/capabilities/hooks-capability.unit.test.ts b/cli/tests/contexts/tools/domain/capabilities/hooks-capability.unit.test.ts similarity index 91% rename from cli/tests/domain/capabilities/hooks-capability.unit.test.ts rename to cli/tests/contexts/tools/domain/capabilities/hooks-capability.unit.test.ts index f7ecd712a..06fb67507 100644 --- a/cli/tests/domain/capabilities/hooks-capability.unit.test.ts +++ b/cli/tests/contexts/tools/domain/capabilities/hooks-capability.unit.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { HooksCapability } from "../../../src/domain/capabilities/hooks-capability.js"; +import { HooksCapability } from "../../../../../src/contexts/tools/domain/capabilities/hooks-capability.js"; describe("HooksCapability", () => { const params = { outputPath: ".codex/hooks.json" }; diff --git a/cli/tests/domain/capabilities/mcp-capability.unit.test.ts b/cli/tests/contexts/tools/domain/capabilities/mcp-capability.unit.test.ts similarity index 97% rename from cli/tests/domain/capabilities/mcp-capability.unit.test.ts rename to cli/tests/contexts/tools/domain/capabilities/mcp-capability.unit.test.ts index 009fc05b3..7922c7070 100644 --- a/cli/tests/domain/capabilities/mcp-capability.unit.test.ts +++ b/cli/tests/contexts/tools/domain/capabilities/mcp-capability.unit.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { McpCapability } from "../../../src/domain/capabilities/mcp-capability.js"; +import { McpCapability } from "../../../../../src/contexts/tools/domain/capabilities/mcp-capability.js"; const sampleMcpJson = JSON.stringify({ mcpServers: { diff --git a/cli/tests/domain/capabilities/plugins-capability.unit.test.ts b/cli/tests/contexts/tools/domain/capabilities/plugins-capability.unit.test.ts similarity index 88% rename from cli/tests/domain/capabilities/plugins-capability.unit.test.ts rename to cli/tests/contexts/tools/domain/capabilities/plugins-capability.unit.test.ts index 55587546a..ec22d508d 100644 --- a/cli/tests/domain/capabilities/plugins-capability.unit.test.ts +++ b/cli/tests/contexts/tools/domain/capabilities/plugins-capability.unit.test.ts @@ -1,10 +1,11 @@ import { describe, expect, it } from "vitest"; -import { PluginsCapability } from "../../../src/domain/capabilities/plugins-capability.js"; +import { PluginsCapability } from "../../../../../src/contexts/tools/domain/capabilities/plugins-capability.js"; const MARKETPLACE_SETTINGS = { settingsPath: ".claude/settings.json", settingsKey: "extraKnownMarketplaces", - toEntry: () => null, + toEntryKey: () => null, + marketplacesSettingsPath: null, }; describe("PluginsCapability", () => { @@ -93,6 +94,27 @@ describe("PluginsCapability", () => { it("hooksUnsupportedReason is null", () => { expect(cap.hooksUnsupportedReason).toBeNull(); }); + + it("flatHooksLoaderEntry is null when not declared", () => { + expect(cap.flatHooksLoaderEntry).toBeNull(); + }); + }); + + describe("flat mode, hooks accepted, with a loader entry", () => { + const cap = new PluginsCapability({ + mode: "flat", + acceptsHooks: true, + flatHooksDir: ".test-tool/hooks/", + flatHooksLoaderEntry: { dir: ".test-tool/plugin/", baseName: "test-tool-plugin.js" }, + flatNamespacePrefix: "aidd-", + }); + + it("exposes flatHooksLoaderEntry", () => { + expect(cap.flatHooksLoaderEntry).toEqual({ + dir: ".test-tool/plugin/", + baseName: "test-tool-plugin.js", + }); + }); }); describe("unsupported mode", () => { diff --git a/cli/tests/domain/capabilities/rules-capability.unit.test.ts b/cli/tests/contexts/tools/domain/capabilities/rules-capability.unit.test.ts similarity index 83% rename from cli/tests/domain/capabilities/rules-capability.unit.test.ts rename to cli/tests/contexts/tools/domain/capabilities/rules-capability.unit.test.ts index a2c749222..e99f8a1b3 100644 --- a/cli/tests/domain/capabilities/rules-capability.unit.test.ts +++ b/cli/tests/contexts/tools/domain/capabilities/rules-capability.unit.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { RulesCapability } from "../../../src/domain/capabilities/rules-capability.js"; +import { RulesCapability } from "../../../../../src/contexts/tools/domain/capabilities/rules-capability.js"; const stubParams = { buildInstallPath: (fileName: string): string | null => `stub/${fileName}`, @@ -17,12 +17,8 @@ describe("RulesCapability", () => { }); }); - // Where a rule *lands* is not `buildOutputPath` — that answers where the framework's own - // source form goes. An installed tree holds the converted file, and the one thing that - // knows its shape is `buildInstallPath`, which is a closure per tool: a template for - // three of them, `toMdc` for Cursor, a delegated handler for Copilot. Asking it with a - // sentinel keeps the answer where the knowledge is, instead of a reader parsing a path - // string back apart and becoming a second copy of it. + // Where a rule lands is `buildInstallPath`, not `buildOutputPath`, and it is a closure per tool. + // Asking it with a sentinel keeps the answer where the knowledge is, not in a path parser here. describe("installedLocation", () => { it("answers the directory and the extension an installed rule actually carries", () => { const cap = new RulesCapability({ diff --git a/cli/tests/domain/capabilities/settings-capability.unit.test.ts b/cli/tests/contexts/tools/domain/capabilities/settings-capability.unit.test.ts similarity index 97% rename from cli/tests/domain/capabilities/settings-capability.unit.test.ts rename to cli/tests/contexts/tools/domain/capabilities/settings-capability.unit.test.ts index 2775642ee..1099f47cc 100644 --- a/cli/tests/domain/capabilities/settings-capability.unit.test.ts +++ b/cli/tests/contexts/tools/domain/capabilities/settings-capability.unit.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { SettingsCapability } from "../../../src/domain/capabilities/settings-capability.js"; +import { SettingsCapability } from "../../../../../src/contexts/tools/domain/capabilities/settings-capability.js"; describe("SettingsCapability", () => { describe("constructor", () => { diff --git a/cli/tests/domain/capabilities/skills-capability.unit.test.ts b/cli/tests/contexts/tools/domain/capabilities/skills-capability.unit.test.ts similarity index 96% rename from cli/tests/domain/capabilities/skills-capability.unit.test.ts rename to cli/tests/contexts/tools/domain/capabilities/skills-capability.unit.test.ts index 87a817973..034af43f7 100644 --- a/cli/tests/domain/capabilities/skills-capability.unit.test.ts +++ b/cli/tests/contexts/tools/domain/capabilities/skills-capability.unit.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { SkillsCapability } from "../../../src/domain/capabilities/skills-capability.js"; +import { SkillsCapability } from "../../../../../src/contexts/tools/domain/capabilities/skills-capability.js"; const stubCallbacks = { buildInstallPath: (fileName: string): string | null => `stub/${fileName}`, diff --git a/cli/tests/domain/formats/agent-frontmatter-strip.unit.test.ts b/cli/tests/contexts/tools/domain/formats/agent-frontmatter-strip.unit.test.ts similarity index 97% rename from cli/tests/domain/formats/agent-frontmatter-strip.unit.test.ts rename to cli/tests/contexts/tools/domain/formats/agent-frontmatter-strip.unit.test.ts index c9e2172d3..0503162b0 100644 --- a/cli/tests/domain/formats/agent-frontmatter-strip.unit.test.ts +++ b/cli/tests/contexts/tools/domain/formats/agent-frontmatter-strip.unit.test.ts @@ -4,7 +4,7 @@ import { CURSOR_AGENT_FRONTMATTER_KEYS, stripAgentFrontmatter, stripCursorAgentFrontmatter, -} from "../../../src/domain/formats/agent-frontmatter-strip.js"; +} from "../../../../../src/contexts/tools/domain/formats/agent-frontmatter-strip.js"; describe("stripAgentFrontmatter", () => { describe("keeps allowlisted keys", () => { diff --git a/cli/tests/domain/formats/cursor-hooks-project-merge.unit.test.ts b/cli/tests/contexts/tools/domain/formats/cursor-hooks-project-merge.unit.test.ts similarity index 97% rename from cli/tests/domain/formats/cursor-hooks-project-merge.unit.test.ts rename to cli/tests/contexts/tools/domain/formats/cursor-hooks-project-merge.unit.test.ts index 0b0164bcf..7e0f9ba06 100644 --- a/cli/tests/domain/formats/cursor-hooks-project-merge.unit.test.ts +++ b/cli/tests/contexts/tools/domain/formats/cursor-hooks-project-merge.unit.test.ts @@ -4,7 +4,7 @@ import { cursorProjectHooksScriptPath, mergeCursorProjectHooksJson, unmergeCursorProjectHooksJson, -} from "../../../src/domain/formats/cursor-hooks-project-merge.js"; +} from "../../../../../src/contexts/tools/domain/formats/cursor-hooks-project-merge.js"; // biome-ignore lint/suspicious/noTemplateCurlyInString: intentionally testing Claude hook placeholder substitution const PLUGIN_ROOT_VAR = "${CLAUDE_PLUGIN_ROOT}"; diff --git a/cli/tests/domain/formats/flat-hooks-merge.unit.test.ts b/cli/tests/contexts/tools/domain/formats/flat-hooks-merge.unit.test.ts similarity index 89% rename from cli/tests/domain/formats/flat-hooks-merge.unit.test.ts rename to cli/tests/contexts/tools/domain/formats/flat-hooks-merge.unit.test.ts index a4517ed0c..3fc669cec 100644 --- a/cli/tests/domain/formats/flat-hooks-merge.unit.test.ts +++ b/cli/tests/contexts/tools/domain/formats/flat-hooks-merge.unit.test.ts @@ -5,9 +5,7 @@ import { mergeClaudeSettingsHooks, mergeCodexFrameworkHooksJson, mergeCursorFlatHooks, -} from "../../../src/domain/formats/flat-hooks-merge.js"; - -// ── mergeClaudeSettingsHooks ────────────────────────────────────────────────── +} from "../../../../../src/contexts/tools/domain/formats/flat-hooks-merge.js"; describe("mergeClaudeSettingsHooks", () => { it("merges plugin hooks into empty settings.json", () => { @@ -76,8 +74,6 @@ describe("mergeClaudeSettingsHooks", () => { }); }); -// ── flattenCopilotHooksShape ────────────────────────────────────────────────── - describe("flattenCopilotHooksShape", () => { it("flattens nested Claude matcher-group to flat entries", () => { const input = JSON.stringify({ @@ -137,8 +133,6 @@ describe("flattenCopilotHooksShape", () => { }); }); -// ── mergeCursorFlatHooks ────────────────────────────────────────────────────── - describe("mergeCursorFlatHooks", () => { it("maps SessionStart → sessionStart", () => { const plugin = JSON.stringify({ @@ -167,10 +161,8 @@ describe("mergeCursorFlatHooks", () => { expect(result.hooks).toHaveProperty("beforeSubmitPrompt"); }); - // #680: the only reason a Cursor session closes a turn headlessly. Measured 2026-08-22: - // an interactive session fires `stop` and a headless one fires `sessionEnd` instead, - // never both from one run - so subscribing to `stop` alone journals nothing headless, - // in silence. This test fails if either name stops being emitted. + // An interactive Cursor session fires `stop` and a headless one fires `sessionEnd`, never + // both from one run - so subscribing to `stop` alone journals nothing headless, in silence. it("maps Stop → both stop and sessionEnd, because Cursor fires one or the other", () => { const command = "node ./.cursor/hooks/plugin/turn-end.js"; const plugin = JSON.stringify({ @@ -265,17 +257,12 @@ describe("mergeCursorFlatHooks", () => { }); }); -// ── mergeCodexFrameworkHooksJson ────────────────────────────────────────────── - describe("mergeCodexFrameworkHooksJson", () => { - // #707: Codex has no `Stop` event. Its vocabulary, read out of the 0.149.0 binary and - // confirmed by a live `codex exec` run with all four subscribed, is SessionStart / - // SessionEnd / PostToolUse / PreToolUse and friends - SessionStart and SessionEnd fired, - // Stop never did. Subscribing to Stop alone journals a session_start with nothing after - // it, in silence. This test fails if that mapping is dropped. + // Codex has no `Stop` event: its vocabulary is SessionStart / SessionEnd / PostToolUse / + // PreToolUse, so subscribing to Stop journals a session_start with nothing after it. it("maps Stop to SessionEnd, the event Codex actually delivers", () => { - // Split literal, the same way claude-root-path-rewrite.ts writes one: biome's - // noTemplateCurlyInString cannot tell a plugin-root token from a botched template. + // Split literal, the way a plugin-root token is written: biome's noTemplateCurlyInString + // cannot tell one from a botched template. const command = `node $${"{PLUGIN_ROOT}"}/hooks/journal.cjs turn-end`; const plugin = JSON.stringify({ hooks: { Stop: [{ hooks: [{ type: "command", command }] }] }, @@ -377,8 +364,6 @@ describe("mergeCodexFrameworkHooksJson", () => { }); }); -// ── hookCommandsForEvent ──────────────────────────────────────────────────────── - describe("hookCommandsForEvent", () => { it("reads a command out of Claude's nested matcher-group shape", () => { const content = JSON.stringify({ diff --git a/cli/tests/domain/formats/opencode-mcp-merge.unit.test.ts b/cli/tests/contexts/tools/domain/formats/opencode-mcp-merge.unit.test.ts similarity index 96% rename from cli/tests/domain/formats/opencode-mcp-merge.unit.test.ts rename to cli/tests/contexts/tools/domain/formats/opencode-mcp-merge.unit.test.ts index ef7f23317..df7076b69 100644 --- a/cli/tests/domain/formats/opencode-mcp-merge.unit.test.ts +++ b/cli/tests/contexts/tools/domain/formats/opencode-mcp-merge.unit.test.ts @@ -3,8 +3,8 @@ import { buildOpencodeFlatConfig, mergeOpencodeMcp, unmergeOpencodeMcp, -} from "../../../src/domain/formats/opencode-mcp-merge.js"; -import { DeterministicHasher } from "../../helpers/ports/deterministic-hasher.js"; +} from "../../../../../src/contexts/tools/domain/formats/opencode-mcp-merge.js"; +import { DeterministicHasher } from "../../../../helpers/ports/deterministic-hasher.js"; const hasher = new DeterministicHasher(); @@ -140,8 +140,8 @@ describe("mergeOpencodeMcp", () => { }); describe("tolerates a JSONC user-owned opencode.json", () => { - // Regression for #295: a user opencode.json with comments / trailing commas - // crashed `aidd setup` with "Expected double-quoted property name in JSON". + // A user opencode.json may carry comments and trailing commas, which a strict `JSON.parse` + // refuses with "Expected double-quoted property name in JSON". const JSONC_EXISTING = `{ "$schema": "https://opencode.ai/config.json", // user-authored comment diff --git a/cli/tests/domain/formats/vscode-mcp-merge.unit.test.ts b/cli/tests/contexts/tools/domain/formats/vscode-mcp-merge.unit.test.ts similarity index 97% rename from cli/tests/domain/formats/vscode-mcp-merge.unit.test.ts rename to cli/tests/contexts/tools/domain/formats/vscode-mcp-merge.unit.test.ts index b24da2d6e..45c3fa5f7 100644 --- a/cli/tests/domain/formats/vscode-mcp-merge.unit.test.ts +++ b/cli/tests/contexts/tools/domain/formats/vscode-mcp-merge.unit.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { mergeVscodeMcp } from "../../../src/domain/formats/vscode-mcp-merge.js"; +import { mergeVscodeMcp } from "../../../../../src/contexts/tools/domain/formats/vscode-mcp-merge.js"; const PLUGIN_SERVER = { command: "node", args: ["server.js"] }; diff --git a/cli/tests/contexts/tools/domain/host-plugin-registration.unit.test.ts b/cli/tests/contexts/tools/domain/host-plugin-registration.unit.test.ts new file mode 100644 index 000000000..532bd1317 --- /dev/null +++ b/cli/tests/contexts/tools/domain/host-plugin-registration.unit.test.ts @@ -0,0 +1,101 @@ +import { describe, expect, it } from "vitest"; +import { + buildHostRegistration, + type HostRegistrationEvidence, +} from "../../../../src/contexts/tools/domain/host-plugin-registration.js"; + +const REGISTRY = "/home/dev/.claude/plugins/installed_plugins.json"; + +function evidence(overrides: Partial = {}): HostRegistrationEvidence { + return { + tool: "claude", + plugins: [{ name: "aidd-telemetry", marketplace: "aidd-framework" }], + reading: { + location: REGISTRY, + refs: new Map([["aidd-telemetry@aidd-framework", { enabled: true }]]), + }, + ...overrides, + }; +} + +function only(input: HostRegistrationEvidence) { + const entry = buildHostRegistration([input]).entries[0]; + if (entry === undefined) throw new Error("expected exactly one entry"); + return entry; +} + +describe("what a host's own registry says about a plugin AIDD installed", () => { + it("is registered when the registry carries its ref", () => { + expect(only(evidence()).answer).toBe("registered"); + }); + + // The declaration is perfectly good and the host drops it anyway, because the host + // consults its own registry and nothing else. + it("is not registered when the registry was read and lacks the ref", () => { + const entry = only(evidence({ reading: { location: REGISTRY, refs: new Map() } })); + + expect(entry.answer).toBe("not-registered"); + expect(entry.detail).toContain(REGISTRY); + expect(entry.detail).toContain("orphaned"); + }); + + // Folding this into `registered` would report a plugin that will not load as one that will. + it("tells a disabled registration from an absent one", () => { + const reading = { + location: REGISTRY, + refs: new Map([["aidd-telemetry@aidd-framework", { enabled: false }]]), + }; + + expect(only(evidence({ reading })).answer).toBe("registered-disabled"); + }); + + it("is unanswerable when the registry could not be read, never `not-registered`", () => { + const reading = { location: REGISTRY, unreadable: "ENOENT" }; + const entry = only(evidence({ reading })); + + expect(entry.answer).toBe("unanswerable"); + expect(entry.detail).toContain("ENOENT"); + }); + + it("is unanswerable for a host nothing here knows how to ask", () => { + expect(only(evidence({ reading: undefined })).answer).toBe("unanswerable"); + }); + + /** + * Two silences, two different things for a person to do: a tool driving its own CLI keeps a + * registry somebody could measure, one declaring no native activation has none to look for. + */ + it("says a declared registry is unmeasured, not that none exists", () => { + const entry = only(evidence({ reading: undefined, declaresNativeActivation: true })); + + expect(entry.detail).toContain("has established its shape"); + }); + + it("says a host declaring no registry has none to read", () => { + const entry = only(evidence({ reading: undefined, declaresNativeActivation: false })); + + expect(entry.detail).toContain("declares no plugin registry"); + }); + + // Every measured host keys its registry on `@`, so a plugin with no + // marketplace recorded is unanswerable at the source, not a lookup that came back empty. + it("is unanswerable when no ref can be built at all, and names no ref", () => { + const entry = only(evidence({ plugins: [{ name: "hand-copied" }] })); + + expect(entry.answer).toBe("unanswerable"); + expect(entry.ref).toBeUndefined(); + }); + + it("gives every plugin its own entry, across tools", () => { + const result = buildHostRegistration([ + evidence(), + evidence({ plugins: [{ name: "aidd-dev", marketplace: "aidd-framework" }] }), + ]); + + expect(result.entries.map((e) => e.plugin)).toEqual(["aidd-telemetry", "aidd-dev"]); + }); + + it("reports no entry for a project with nothing installed", () => { + expect(buildHostRegistration([])).toEqual({ entries: [] }); + }); +}); diff --git a/cli/tests/contexts/tools/domain/marketplace-catalog.unit.test.ts b/cli/tests/contexts/tools/domain/marketplace-catalog.unit.test.ts new file mode 100644 index 000000000..a3b9a9d53 --- /dev/null +++ b/cli/tests/contexts/tools/domain/marketplace-catalog.unit.test.ts @@ -0,0 +1,263 @@ +import { describe, expect, it } from "vitest"; +import type { PluginPresence } from "../../../../src/contexts/tools/domain/build-contract.js"; +import { + buildClaudeStyleCatalogEntry, + buildClaudeStyleMarketplace, + synthesizeClaudeStyleManifest, +} from "../../../../src/contexts/tools/domain/marketplace-catalog.js"; + +const EMPTY_PRESENCE: PluginPresence = { + hasAgents: false, + agentsList: [], + skillsList: [], + hasHooksJson: false, + hasMcpJson: false, +}; + +const FULL_PRESENCE: PluginPresence = { + hasAgents: true, + agentsList: ["implementer.md", "planner.md", "reviewer.md"], + skillsList: ["commit", "plan"], + hasHooksJson: true, + hasMcpJson: true, +}; + +const BASE_SOURCE = { + name: "aidd-dev", + description: "AI Driven Dev plugin", + version: "1.2.3", + author: "Baptiste", + homepage: "https://example.com", + repository: "https://github.com/ai-driven-dev/aidd", + license: "MIT", + keywords: ["ai", "dev"], +}; + +describe("synthesizeClaudeStyleManifest", () => { + describe("passthrough fields", () => { + it("preserves name, description, version, author, homepage, repository, license, keywords", () => { + const result = synthesizeClaudeStyleManifest(BASE_SOURCE, EMPTY_PRESENCE, { + agentsField: true, + hooksField: true, + }); + expect(result.name).toBe("aidd-dev"); + expect(result.description).toBe("AI Driven Dev plugin"); + expect(result.version).toBe("1.2.3"); + expect(result.author).toBe("Baptiste"); + expect(result.homepage).toBe("https://example.com"); + expect(result.repository).toBe("https://github.com/ai-driven-dev/aidd"); + expect(result.license).toBe("MIT"); + expect(result.keywords).toEqual(["ai", "dev"]); + }); + + it("omits fields absent from source", () => { + const result = synthesizeClaudeStyleManifest({ name: "test" }, EMPTY_PRESENCE, { + agentsField: true, + hooksField: true, + }); + expect(result.description).toBeUndefined(); + expect(result.version).toBeUndefined(); + expect(result.author).toBeUndefined(); + }); + }); + + describe("agents field", () => { + it("includes agents as ./agents/*.md file paths when agentsField:true and agents present", () => { + const result = synthesizeClaudeStyleManifest(BASE_SOURCE, FULL_PRESENCE, { + agentsField: true, + hooksField: true, + }); + expect(result.agents).toEqual([ + "./agents/implementer.md", + "./agents/planner.md", + "./agents/reviewer.md", + ]); + }); + + it("omits agents when agentsField:true but no agents present", () => { + const result = synthesizeClaudeStyleManifest(BASE_SOURCE, EMPTY_PRESENCE, { + agentsField: true, + hooksField: true, + }); + expect(result.agents).toBeUndefined(); + }); + + it("omits agents when agentsField:false even if hasAgents:true", () => { + const result = synthesizeClaudeStyleManifest(BASE_SOURCE, FULL_PRESENCE, { + agentsField: false, + hooksField: true, + }); + expect(result.agents).toBeUndefined(); + }); + }); + + describe("conditional fields", () => { + it("includes skills array when skillsList is non-empty", () => { + const result = synthesizeClaudeStyleManifest(BASE_SOURCE, FULL_PRESENCE, { + agentsField: true, + hooksField: true, + }); + expect(result.skills).toEqual(["./skills/commit", "./skills/plan"]); + }); + + it("omits skills when skillsList is empty", () => { + const result = synthesizeClaudeStyleManifest(BASE_SOURCE, EMPTY_PRESENCE, { + agentsField: true, + hooksField: true, + }); + expect(result.skills).toBeUndefined(); + }); + + it("includes hooks when hasHooksJson:true", () => { + const result = synthesizeClaudeStyleManifest(BASE_SOURCE, FULL_PRESENCE, { + agentsField: true, + hooksField: true, + }); + expect(result.hooks).toBe("./hooks/hooks.json"); + }); + + it("omits hooks when hasHooksJson:false", () => { + const result = synthesizeClaudeStyleManifest(BASE_SOURCE, EMPTY_PRESENCE, { + agentsField: true, + hooksField: true, + }); + expect(result.hooks).toBeUndefined(); + }); + + it("omits hooks when the tool loads that path by convention and refuses a manifest naming it", () => { + const result = synthesizeClaudeStyleManifest(BASE_SOURCE, FULL_PRESENCE, { + agentsField: true, + hooksField: false, + }); + + expect(result.hooks).toBeUndefined(); + }); + + it("includes mcpServers when hasMcpJson:true", () => { + const result = synthesizeClaudeStyleManifest(BASE_SOURCE, FULL_PRESENCE, { + agentsField: true, + hooksField: true, + }); + expect(result.mcpServers).toBe("./.mcp.json"); + }); + + it("omits mcpServers when hasMcpJson:false", () => { + const result = synthesizeClaudeStyleManifest(BASE_SOURCE, EMPTY_PRESENCE, { + agentsField: true, + hooksField: true, + }); + expect(result.mcpServers).toBeUndefined(); + }); + }); + + describe("manifestDir variants", () => { + it("accepts .cursor-plugin as manifestDir (field set unchanged)", () => { + const result = synthesizeClaudeStyleManifest(BASE_SOURCE, FULL_PRESENCE, { + agentsField: true, + hooksField: true, + }); + expect(result.agents).toEqual([ + "./agents/implementer.md", + "./agents/planner.md", + "./agents/reviewer.md", + ]); + expect(result.name).toBe("aidd-dev"); + }); + + it("accepts .plugin as manifestDir (field set unchanged)", () => { + const result = synthesizeClaudeStyleManifest(BASE_SOURCE, FULL_PRESENCE, { + agentsField: true, + hooksField: true, + }); + expect(result.agents).toEqual([ + "./agents/implementer.md", + "./agents/planner.md", + "./agents/reviewer.md", + ]); + }); + }); + + describe("key insertion order", () => { + it("emits keys in deterministic order: name, description, version, author, ..., agents, skills, hooks, mcpServers", () => { + const result = synthesizeClaudeStyleManifest(BASE_SOURCE, FULL_PRESENCE, { + agentsField: true, + hooksField: true, + }); + const keys = Object.keys(result); + const agentsIdx = keys.indexOf("agents"); + const skillsIdx = keys.indexOf("skills"); + const hooksIdx = keys.indexOf("hooks"); + const mcpIdx = keys.indexOf("mcpServers"); + expect(agentsIdx).toBeLessThan(skillsIdx); + expect(skillsIdx).toBeLessThan(hooksIdx); + expect(hooksIdx).toBeLessThan(mcpIdx); + expect(keys.indexOf("name")).toBe(0); + }); + }); +}); + +describe("buildClaudeStyleMarketplace", () => { + const ENTRIES = [ + { name: "aidd-dev", source: "./plugins/aidd-dev", description: "Dev", version: "1.0.0" }, + ]; + + it("emits name, plugins as required fields", () => { + const result = buildClaudeStyleMarketplace( + { name: "aidd-framework", owner: { name: "AIDD" } }, + ENTRIES + ); + expect(result.name).toBe("aidd-framework"); + expect(result.plugins).toEqual(ENTRIES); + }); + + it("includes version and description when present", () => { + const result = buildClaudeStyleMarketplace( + { name: "aidd-fw", version: "2.0.0", description: "Full", owner: { name: "X" } }, + ENTRIES + ); + expect(result.version).toBe("2.0.0"); + expect(result.description).toBe("Full"); + }); + + it("omits version and description when absent", () => { + const result = buildClaudeStyleMarketplace({ name: "fw", owner: { name: "X" } }, ENTRIES); + expect(result.version).toBeUndefined(); + expect(result.description).toBeUndefined(); + }); + + it("includes owner when present", () => { + const owner = { name: "AIDD" }; + const result = buildClaudeStyleMarketplace({ name: "fw", owner }, ENTRIES); + expect(result.owner).toEqual(owner); + }); +}); + +describe("buildClaudeStyleCatalogEntry", () => { + it("builds entry with name, source, description, version", () => { + const entry = buildClaudeStyleCatalogEntry("aidd-dev", "AI Dev plugin", "1.0.0", undefined); + expect(entry.name).toBe("aidd-dev"); + expect(entry.source).toBe("./plugins/aidd-dev"); + expect(entry.description).toBe("AI Dev plugin"); + expect(entry.version).toBe("1.0.0"); + }); + + it("passes through strict and recommended when present", () => { + const entry = buildClaudeStyleCatalogEntry("aidd-dev", "desc", "1.0.0", { + strict: true, + recommended: false, + }); + expect(entry.strict).toBe(true); + expect(entry.recommended).toBe(false); + }); + + it("omits strict and recommended when absent", () => { + const entry = buildClaudeStyleCatalogEntry("aidd-dev", "desc", "1.0.0", undefined); + expect(entry.strict).toBeUndefined(); + expect(entry.recommended).toBeUndefined(); + }); + + it("only includes strict when it is boolean (not string/number)", () => { + const entry = buildClaudeStyleCatalogEntry("aidd-dev", "desc", "1.0.0", { strict: true }); + expect(typeof entry.strict).toBe("boolean"); + }); +}); diff --git a/cli/tests/contexts/tools/domain/marketplace-source-conflict.unit.test.ts b/cli/tests/contexts/tools/domain/marketplace-source-conflict.unit.test.ts new file mode 100644 index 000000000..52342b76d --- /dev/null +++ b/cli/tests/contexts/tools/domain/marketplace-source-conflict.unit.test.ts @@ -0,0 +1,123 @@ +import { describe, expect, it } from "vitest"; +import { + describePluginDiff, + marketplaceSourceConflict, + pluginSetDifference, +} from "../../../../src/contexts/tools/domain/marketplace-source-conflict.js"; + +const LOCATION = "/home/.claude/plugins/known_marketplaces.json"; + +const IDENTITY_A = { name: "probe-mkt", pluginNames: ["sample-plugin"] }; +const IDENTITY_A_SAME = { name: "probe-mkt", pluginNames: ["sample-plugin"] }; +const IDENTITY_B = { name: "probe-mkt", pluginNames: ["other-plugin"] }; + +describe("marketplaceSourceConflict", () => { + it("is not a conflict when the registry could not be read", () => { + const reading = { location: LOCATION, unreadable: "ENOENT" }; + + expect( + marketplaceSourceConflict(reading, "probe-mkt", "/src/B", undefined, IDENTITY_A) + ).toBeUndefined(); + }); + + it("is not a conflict when the name is absent from an otherwise readable registry", () => { + const reading = { location: LOCATION, entries: new Map() }; + + expect( + marketplaceSourceConflict(reading, "probe-mkt", "/src/B", undefined, IDENTITY_A) + ).toBeUndefined(); + }); + + it("is not a conflict when the same catalog is registered from a different, resolved path — two projects sharing one build", () => { + const reading = { location: LOCATION, entries: new Map([["probe-mkt", "/src/A"]]) }; + + expect( + marketplaceSourceConflict(reading, "probe-mkt", "/src/B", IDENTITY_A, IDENTITY_A_SAME) + ).toBeUndefined(); + }); + + it("is not a conflict when the registered source no longer resolves to a readable catalog — a dead entry a re-add repairs", () => { + const reading = { location: LOCATION, entries: new Map([["probe-mkt", "/gone"]]) }; + + expect( + marketplaceSourceConflict(reading, "probe-mkt", "/src/B", undefined, IDENTITY_A) + ).toBeUndefined(); + }); + + it("is a conflict when a different catalog is registered under the same name, and carries both identities", () => { + const reading = { location: LOCATION, entries: new Map([["probe-mkt", "/src/A"]]) }; + + const conflict = marketplaceSourceConflict( + reading, + "probe-mkt", + "/src/B", + IDENTITY_A, + IDENTITY_B + ); + + expect(conflict).toEqual({ + name: "probe-mkt", + registeredSource: "/src/A", + requestedSource: "/src/B", + registeredIdentity: IDENTITY_A, + requestedIdentity: IDENTITY_B, + location: LOCATION, + }); + }); + + // Identity is a catalog's declared name plus its plugin set, never its version, and each + // case below isolates one component so a mutation collapsing it fails here specifically. + describe("identity: declared name plus plugin set, never version", () => { + const reading = { location: LOCATION, entries: new Map([["probe-mkt", "/src/A"]]) }; + + it("is a conflict when only the plugin set differs", () => { + const a = { name: "probe-mkt", pluginNames: ["sample-plugin"] }; + const b = { name: "probe-mkt", pluginNames: ["other-plugin"] }; + + expect(marketplaceSourceConflict(reading, "probe-mkt", "/src/B", a, b)).toBeDefined(); + }); + + it("is a conflict when only the declared name differs", () => { + const a = { name: "probe-mkt", pluginNames: ["sample-plugin"] }; + const b = { name: "renamed-mkt", pluginNames: ["sample-plugin"] }; + + expect(marketplaceSourceConflict(reading, "probe-mkt", "/src/B", a, b)).toBeDefined(); + }); + + it("is not a conflict when the plugin set is the same but listed in a different order", () => { + const a = { name: "probe-mkt", pluginNames: ["a-plugin", "b-plugin"] }; + const b = { name: "probe-mkt", pluginNames: ["b-plugin", "a-plugin"] }; + + expect(marketplaceSourceConflict(reading, "probe-mkt", "/src/B", a, b)).toBeUndefined(); + }); + + it("is not a conflict when only a version field differs — an upgrade under the same name and plugin set, not a different catalog", () => { + const a = { name: "probe-mkt", pluginNames: ["sample-plugin"], version: "1.0.0" }; + const b = { name: "probe-mkt", pluginNames: ["sample-plugin"], version: "2.0.0" }; + + expect(marketplaceSourceConflict(reading, "probe-mkt", "/src/B", a, b)).toBeUndefined(); + }); + }); +}); + +describe("pluginSetDifference / describePluginDiff", () => { + it("names what was added and what was removed", () => { + const registered = { name: "probe-mkt", pluginNames: ["kept-plugin", "removed-plugin"] }; + const requested = { name: "probe-mkt", pluginNames: ["kept-plugin", "added-plugin"] }; + + const diff = pluginSetDifference(registered, requested); + + expect(diff).toEqual({ added: ["added-plugin"], removed: ["removed-plugin"] }); + expect(describePluginDiff(diff)).toBe("differ (+added-plugin, -removed-plugin)"); + }); + + it("falls back to naming the declared name when the plugin sets already match", () => { + const registered = { name: "probe-mkt", pluginNames: ["sample-plugin"] }; + const requested = { name: "renamed-mkt", pluginNames: ["sample-plugin"] }; + + const diff = pluginSetDifference(registered, requested); + + expect(diff).toEqual({ added: [], removed: [] }); + expect(describePluginDiff(diff)).toBe("match, but the declared name differs"); + }); +}); diff --git a/cli/tests/contexts/tools/domain/mcp-exclusion.unit.test.ts b/cli/tests/contexts/tools/domain/mcp-exclusion.unit.test.ts new file mode 100644 index 000000000..dfec36814 --- /dev/null +++ b/cli/tests/contexts/tools/domain/mcp-exclusion.unit.test.ts @@ -0,0 +1,82 @@ +import { describe, expect, it } from "vitest"; +import { transformFor } from "../../../../src/contexts/tools/domain/mcp-exclusion.js"; + +function makeConfig(servers: Record): string { + return JSON.stringify({ mcpServers: servers }, null, 2); +} + +describe("transformFor()", () => { + it("returns undefined for linux", () => { + expect(transformFor("linux")).toBeUndefined(); + }); + + it("returns undefined for darwin", () => { + expect(transformFor("darwin")).toBeUndefined(); + }); + + it("returns a transform for win32", () => { + expect(transformFor("win32")).toBeDefined(); + }); + + describe("win32 transform", () => { + // biome-ignore lint/style/noNonNullAssertion: win32 is asserted defined in the test above + const transform = transformFor("win32")!; + + it("transforms npx without existing args", () => { + const result = JSON.parse(transform(makeConfig({ server: { command: "npx", args: [] } }))); + expect(result.mcpServers.server.command).toBe("cmd"); + expect(result.mcpServers.server.args).toEqual(["/c", "npx"]); + }); + + it("transforms npx with existing args", () => { + const result = JSON.parse( + transform(makeConfig({ server: { command: "npx", args: ["-y", "some-pkg"] } })) + ); + expect(result.mcpServers.server.command).toBe("cmd"); + expect(result.mcpServers.server.args).toEqual(["/c", "npx", "-y", "some-pkg"]); + }); + + it("transforms uvx command", () => { + const result = JSON.parse(transform(makeConfig({ server: { command: "uvx" } }))); + expect(result.mcpServers.server.command).toBe("uvx.exe"); + }); + + it("transforms uv command", () => { + const result = JSON.parse( + transform(makeConfig({ server: { command: "uv", args: ["run", "mcp"] } })) + ); + expect(result.mcpServers.server.command).toBe("uv.exe"); + expect(result.mcpServers.server.args).toEqual(["run", "mcp"]); + }); + + it("leaves node command unchanged", () => { + const result = JSON.parse( + transform(makeConfig({ server: { command: "node", args: ["server.js"] } })) + ); + expect(result.mcpServers.server.command).toBe("node"); + }); + + it("leaves docker command unchanged", () => { + const result = JSON.parse( + transform(makeConfig({ server: { command: "docker", args: ["run", "img"] } })) + ); + expect(result.mcpServers.server.command).toBe("docker"); + }); + + it("leaves http server entries unchanged", () => { + const result = JSON.parse( + transform(makeConfig({ server: { url: "http://localhost:3000" } })) + ); + expect(result.mcpServers.server).toEqual({ url: "http://localhost:3000" }); + }); + + it("handles empty mcpServers", () => { + const result = JSON.parse(transform(JSON.stringify({ mcpServers: {} }))); + expect(result.mcpServers).toEqual({}); + }); + + it("throws on invalid JSON", () => { + expect(() => transform("not-json")).toThrow(); + }); + }); +}); diff --git a/cli/tests/contexts/tools/domain/plugin-enablement-is-machine-global.unit.test.ts b/cli/tests/contexts/tools/domain/plugin-enablement-is-machine-global.unit.test.ts new file mode 100644 index 000000000..92d51e9cd --- /dev/null +++ b/cli/tests/contexts/tools/domain/plugin-enablement-is-machine-global.unit.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, it } from "vitest"; +import "../../../../src/contexts/tools/domain/profiles/claude/profile.js"; +import "../../../../src/contexts/tools/domain/profiles/codex/profile.js"; +import "../../../../src/contexts/tools/domain/profiles/copilot/profile.js"; +import "../../../../src/contexts/tools/domain/profiles/cursor/profile.js"; +import { pluginEnablementIsMachineGlobal } from "../../../../src/contexts/tools/domain/registry.js"; + +describe("pluginEnablementIsMachineGlobal", () => { + it("is true for a tool declaring no scopeArgs at all (codex)", () => { + expect(pluginEnablementIsMachineGlobal("codex")).toBe(true); + }); + + it("is true for a tool declaring no scopeArgs at all (copilot)", () => { + expect(pluginEnablementIsMachineGlobal("copilot")).toBe(true); + }); + + it("is false for a tool declaring scopeArgs per scope (claude)", () => { + expect(pluginEnablementIsMachineGlobal("claude")).toBe(false); + }); + + it("is true for a tool with no native activation at all (cursor)", () => { + // No `NativeActivation` declared, so `scopeArgs` is vacuously `undefined` — never asked + // of a ref in practice, since a caller reaches here only for a tool that has one. + expect(pluginEnablementIsMachineGlobal("cursor")).toBe(true); + }); +}); diff --git a/cli/tests/contexts/tools/domain/plugin-root-token-declaration.unit.test.ts b/cli/tests/contexts/tools/domain/plugin-root-token-declaration.unit.test.ts new file mode 100644 index 000000000..066ef9544 --- /dev/null +++ b/cli/tests/contexts/tools/domain/plugin-root-token-declaration.unit.test.ts @@ -0,0 +1,98 @@ +import { describe, expect, it } from "vitest"; +import { buildClaudeContract } from "../../../../src/contexts/tools/domain/profiles/claude/build.js"; +import { buildCodexContract } from "../../../../src/contexts/tools/domain/profiles/codex/build.js"; +import { buildCopilotMarketplaceContract } from "../../../../src/contexts/tools/domain/profiles/copilot/build.js"; +import { buildCursorContract } from "../../../../src/contexts/tools/domain/profiles/cursor/build.js"; +import { rewritePluginRootToken } from "../../../../src/contexts/translate/domain/formats/plugin-root-token-rewrite.js"; +import { AI_TOOL_IDS, type AiToolId } from "../../../../src/kernel/tool.js"; +import "../../../../src/contexts/tools/domain/profiles/claude/profile.js"; +import "../../../../src/contexts/tools/domain/profiles/codex/profile.js"; +import "../../../../src/contexts/tools/domain/profiles/copilot/profile.js"; +import "../../../../src/contexts/tools/domain/profiles/cursor/profile.js"; +import "../../../../src/contexts/tools/domain/profiles/opencode/profile.js"; +import type { PluginsCapability } from "../../../../src/contexts/tools/domain/capabilities/plugins-capability.js"; +import { getAiToolConfig } from "../../../../src/contexts/tools/domain/registry.js"; + +/** A hook whose command names a variable the host does not expand installs cleanly, runs on + * every event, and silently does nothing, so the variable each tool expands is declared beside + * the rest of what that tool supports. These hold the two install routes to that declaration. */ + +const CONTRACTS: ReadonlyArray<[AiToolId, () => { pluginRootToken?: string | null }]> = [ + ["claude", buildClaudeContract], + ["cursor", buildCursorContract], + ["copilot", buildCopilotMarketplaceContract], + ["codex", buildCodexContract], +]; + +function pluginsOf(tool: AiToolId): PluginsCapability | undefined { + const capabilities = getAiToolConfig(tool).capabilities as { plugins?: PluginsCapability }; + return capabilities.plugins; +} + +describe("which variable a tool expands to its installed plugin's directory", () => { + it("declares one for every tool that hosts a plugin as its own directory", () => { + let examined = 0; + for (const tool of AI_TOOL_IDS) { + const plugins = pluginsOf(tool); + if (plugins?.mode !== "native") continue; + examined++; + expect(plugins.pluginRootToken, tool).toBeTruthy(); + } + // A tool list that stopped naming any native-mode tool would pass by never reaching + // the assertion above, which is the failure shape this whole file exists to catch. + expect(examined).not.toBe(0); + }); + + it("declares none for a tool with no plugin directory to point at", () => { + let examined = 0; + for (const tool of AI_TOOL_IDS) { + const plugins = pluginsOf(tool); + if (!plugins || plugins.mode === "native") continue; + examined++; + expect(plugins.pluginRootToken, tool).toBeNull(); + } + expect(examined).not.toBe(0); + }); + + // A tool that declares the variable it expands and still does not receive the hooks that would + // use it is the state this forbids. + it("pairs the declaration with actually receiving hooks", () => { + let examined = 0; + for (const tool of AI_TOOL_IDS) { + const plugins = pluginsOf(tool); + if (plugins?.mode !== "native") continue; + examined++; + expect(Boolean(plugins.pluginRootToken), tool).toBe(plugins.acceptsHooks); + } + expect(examined).not.toBe(0); + }); + + it("names a variable a host can expand, never a path", () => { + let examined = 0; + for (const tool of AI_TOOL_IDS) { + const token = pluginsOf(tool)?.pluginRootToken; + if (token === null || token === undefined) continue; + examined++; + expect(token, tool).toMatch(/^\$\{[A-Z_]+\}$/u); + } + expect(examined).not.toBe(0); + }); +}); + +describe("the route that builds a marketplace bundle", () => { + // Two places naming the same variable is how they start disagreeing, and the failure + // would be silent on the side nobody looks at. + it("substitutes the token the tool itself declared", () => { + for (const [tool, buildContract] of CONTRACTS) { + expect(buildContract().pluginRootToken, tool).toBe(pluginsOf(tool)?.pluginRootToken); + } + }); + + it("leaves a command alone for the tool whose variable is the one authors write", () => { + const authored = `node ${pluginsOf("claude")?.pluginRootToken}/hooks/journal.cjs`; + + const token = buildClaudeContract().pluginRootToken; + + expect(rewritePluginRootToken(authored, token ?? "")).toBe(authored); + }); +}); diff --git a/cli/tests/contexts/tools/domain/profiles/claude.unit.test.ts b/cli/tests/contexts/tools/domain/profiles/claude.unit.test.ts new file mode 100644 index 000000000..fc9d6cc55 --- /dev/null +++ b/cli/tests/contexts/tools/domain/profiles/claude.unit.test.ts @@ -0,0 +1,220 @@ +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; +import { claude } from "../../../../../src/contexts/tools/domain/profiles/claude/profile.js"; + +describe("claude", () => { + describe("capabilities.mcp", () => { + it("outputs to .mcp.json", () => { + expect(claude.capabilities.mcp.params.outputPath).toBe(".mcp.json"); + }); + + it("consumes the mcp config name", () => { + expect(claude.capabilities.mcp.consumes).toContain("mcp"); + }); + + it("does not consume unknown config names", () => { + expect(claude.capabilities.mcp.consumes).not.toContain("vscodeDir"); + }); + + it("mcp config preserves user customizations during update", () => { + expect(claude.capabilities.mcp.params.mergeStrategy ?? "user-prime").toBe("user-prime"); + }); + }); + + describe("capabilities.rules.convertFrontmatter()", () => { + it("preserves paths: list when already in Claude format", () => { + const fm = { paths: ["src/**/*.ts"] }; + const result = claude.capabilities.rules?.convertFrontmatter(fm); + expect(result).toEqual({ paths: ["src/**/*.ts"] }); + }); + + it("strips extra fields when paths key is present", () => { + const fm = { paths: ["src/**/*.ts"], description: "extra", alwaysApply: false }; + const result = claude.capabilities.rules?.convertFrontmatter(fm); + expect(result).toEqual({ paths: ["src/**/*.ts"] }); + }); + + it("converts cursor-style globs to paths", () => { + const fm = { globs: ["src/**/*.ts"], alwaysApply: false, description: "desc" }; + const result = claude.capabilities.rules?.convertFrontmatter(fm); + expect(result).toEqual({ paths: ["src/**/*.ts"] }); + }); + + it("returns empty frontmatter for always-apply rules (no paths field = unconditional load)", () => { + const fm = { description: "desc", alwaysApply: true }; + const result = claude.capabilities.rules?.convertFrontmatter(fm); + expect(result).toEqual({}); + }); + + it("keeps description when alwaysApply is false and no paths are specified", () => { + const fm = { description: "Apply when editing command files.", alwaysApply: false }; + const result = claude.capabilities.rules?.convertFrontmatter(fm); + expect(result).toEqual({ description: "Apply when editing command files." }); + }); + + it("returns empty frontmatter for a rule whose paths list is empty", () => { + const result = claude.capabilities.rules?.convertFrontmatter({ paths: [] }); + expect(result).toStrictEqual({}); + }); + + it("returns empty frontmatter for a rule that opts out of always-apply and names no description", () => { + const result = claude.capabilities.rules?.convertFrontmatter({ alwaysApply: false }); + expect(result).toStrictEqual({}); + }); + }); + + describe("capabilities.skills.buildInstallPath()", () => { + it("builds path under .claude/skills/ without the tool suffix", () => { + expect(claude.capabilities.skills.buildInstallPath("commit.claude.md")).toBe( + ".claude/skills/commit.md" + ); + }); + }); + + it("names the one config file Claude reads, and where it goes", () => { + expect(claude.configOutputPaths).toStrictEqual({ "settings.json": ".claude/settings.json" }); + }); + + describe("rewriteContent()", () => { + it("routes a numbered command folder under commands/aidd//, with or without the @ prefix", () => { + expect( + claude.rewriteContent?.( + "Run .claude/commands/04_code/implement.md, then @.claude/commands/02_context/plan.md.\n" + ) + ).toBe( + "Run .claude/commands/aidd/04/implement.md, then @.claude/commands/aidd/02/plan.md.\n" + ); + }); + }); + + describe("capabilities.agents.convertFrontmatter()", () => { + it("strips extra fields for agents sections — only name and description", () => { + const fm = { name: "alexia", description: "Agent", model: "opus" }; + const result = claude.capabilities.agents.convertFrontmatter(fm); + expect(result).toEqual({ name: "alexia", description: "Agent" }); + }); + }); + + describe("capabilities.commands.convertFrontmatter()", () => { + it("prefixes name with aidd:{phase}:", () => { + const fm = { name: "implement", description: "Implement a plan" }; + const result = claude.capabilities.commands?.convertFrontmatter(fm, "04_code/implement.md"); + expect(result).toEqual({ name: "aidd:04:implement", description: "Implement a plan" }); + }); + + it("preserves argument-hint when present", () => { + const fm = { name: "implement", description: "Implement a plan", "argument-hint": "task" }; + const result = claude.capabilities.commands?.convertFrontmatter(fm, "04_code/implement.md"); + expect(result).toEqual({ + name: "aidd:04:implement", + description: "Implement a plan", + "argument-hint": "task", + }); + }); + }); + + describe("capabilities.agents.buildInstallPath()", () => { + it("builds path for agents section", () => { + const path = claude.capabilities.agents.buildInstallPath("code-reviewer.md"); + expect(path).toBe(".claude/agents/code-reviewer.md"); + }); + }); + + describe("capabilities.rules.buildInstallPath()", () => { + it("builds path for rules section with subdirectory", () => { + const path = claude.capabilities.rules?.buildInstallPath("01-standards/naming.md"); + expect(path).toBe(".claude/rules/01-standards/naming.md"); + }); + }); + + describe("capabilities.commands.buildInstallPath()", () => { + it("builds commands path with aidd brand prefix and phase number", () => { + const path = claude.capabilities.commands?.buildInstallPath("04_code/implement.md"); + expect(path).toBe(".claude/commands/aidd/04/implement.md"); + }); + + it("handles two-digit phase in commands", () => { + const path = claude.capabilities.commands?.buildInstallPath( + "02_context/create_user_stories.md" + ); + expect(path).toBe(".claude/commands/aidd/02/create_user_stories.md"); + }); + }); + + describe("capabilities.plugins", () => { + it("has a plugins capability", () => { + expect("plugins" in claude.capabilities).toBe(true); + }); + + it("is native mode", () => { + expect(claude.capabilities.plugins.mode).toBe("native"); + }); + + it("uses .claude/plugins/ as plugins directory", () => { + expect(claude.capabilities.plugins.pluginsDir).toBe(".claude/plugins/"); + }); + + it("uses plugin.json as plugin manifest path", () => { + expect(claude.capabilities.plugins.pluginManifestRelativePath).toBe("plugin.json"); + }); + + it("pluginOutputDir returns correct path for a plugin name", () => { + expect(claude.capabilities.plugins.pluginOutputDir("my-plugin")).toBe( + ".claude/plugins/my-plugin/" + ); + }); + + it("declares its own marketplace registry and plugin cache root, for clean's own purge", () => { + const activation = claude.capabilities.plugins.nativeActivation; + expect(activation?.marketplaceRegistry?.("/home/tester")).toBe( + join("/home/tester", ".claude", "plugins", "known_marketplaces.json") + ); + expect(activation?.pluginCacheDir?.("/home/tester")).toBe( + join("/home/tester", ".claude", "plugins", "cache") + ); + }); + + it("declares where claude's own user-scope settings file lives, for --scope user", () => { + const activation = claude.capabilities.plugins.nativeActivation; + expect(activation?.userSettingsPath?.("/home/tester", () => undefined)).toBe( + join("/home/tester", ".claude", "settings.json") + ); + }); + + it("drives the claude binary at local scope, with the verbs claude uses and nothing else", () => { + // Exhaustive, not `toMatchObject`: a field added by mistake must fail here. + expect(claude.capabilities.plugins.nativeActivation).toStrictEqual({ + binary: "claude", + scopeArgs: { project: ["--scope", "local"], user: ["--scope", "user"] }, + enableVerb: "install", + disableVerb: "uninstall", + upgradeVerb: "update", + pluginArgs: ["--yes"], + marketplaceRegistry: expect.any(Function), + pluginCacheDir: expect.any(Function), + userSettingsPath: expect.any(Function), + }); + }); + + it("registers a marketplace in the file claude writes itself, and enables plugins in the tracked one", () => { + const settings = claude.capabilities.plugins.marketplaceSettings; + + expect({ + settingsPath: settings?.settingsPath, + settingsKey: settings?.settingsKey, + marketplacesSettingsPath: settings?.marketplacesSettingsPath, + enabledPluginsKey: settings?.enabledPluginsKey, + entryKey: settings?.toEntryKey?.({ + name: "aidd-framework", + source: { kind: "local", path: "/abs/cache" }, + }), + }).toStrictEqual({ + settingsPath: ".claude/settings.json", + settingsKey: "extraKnownMarketplaces", + marketplacesSettingsPath: ".claude/settings.local.json", + enabledPluginsKey: "enabledPlugins", + entryKey: "aidd-framework", + }); + }); + }); +}); diff --git a/cli/tests/contexts/tools/domain/profiles/claude/build.unit.test.ts b/cli/tests/contexts/tools/domain/profiles/claude/build.unit.test.ts new file mode 100644 index 000000000..5c82e47bd --- /dev/null +++ b/cli/tests/contexts/tools/domain/profiles/claude/build.unit.test.ts @@ -0,0 +1,288 @@ +import { describe, expect, it } from "vitest"; +import type { + ArtifactContract, + ArtifactSource, +} from "../../../../../../src/contexts/tools/domain/build-contract.js"; +import { + buildClaudeContract, + buildClaudeFlatContract, +} from "../../../../../../src/contexts/tools/domain/profiles/claude/build.js"; +import { InMemoryFileAdapter } from "../../../../../helpers/ports/in-memory-file-adapter.js"; + +// Built, not written literally: biome reads a string holding "${...}" as a lost template. +const ROOT = "$" + "{CLAUDE_PLUGIN_ROOT}"; + +const AGENT_SOURCE = [ + "---", + "name: planner", + "description: Plans the work", + "model: opus", + "---", + `Read @${ROOT}/skills/01-plan/SKILL.md`, + `Ask @${ROOT}/agents/reviewer.md`, + `Run @${ROOT}/hooks/journal.cjs`, + "", +].join("\n"); + +const HOOKS_JSON = JSON.stringify({ + hooks: { Stop: [{ hooks: [{ type: "command", command: "node journal.cjs" }] }] }, +}); + +function supported(artifact: ArtifactContract): Extract { + if (!artifact.supported) throw new Error("artifact is declared unsupported"); + return artifact; +} + +function sourceOf(artifact: ArtifactContract): ArtifactSource | null { + return artifact.supported ? artifact.source : null; +} + +describe("buildClaudeContract()", () => { + it("declares Claude's own plugin manifest path, schema and root token", () => { + const contract = buildClaudeContract(); + + expect({ + pluginRootToken: contract.pluginRootToken, + manifestFileRelative: contract.manifestFileRelative, + manifestSchemaName: contract.manifestSchemaName, + }).toStrictEqual({ + pluginRootToken: ROOT, + manifestFileRelative: ".claude-plugin/plugin.json", + manifestSchemaName: "plugin-manifest", + }); + }); + + it("synthesizes a manifest naming the agents and skills, and never the hooks file Claude finds itself", () => { + const manifest = buildClaudeContract().synthesizeManifest?.( + { name: "aidd-dev", description: "Development loop", version: "1.2.3" }, + { + hasAgents: true, + agentsList: ["planner.md"], + skillsList: ["01-plan"], + hasHooksJson: true, + hasMcpJson: true, + } + ); + + expect(manifest).toStrictEqual({ + name: "aidd-dev", + description: "Development loop", + version: "1.2.3", + agents: ["./agents/planner.md"], + skills: ["./skills/01-plan"], + mcpServers: "./.mcp.json", + }); + }); + + it("sources skills, agents, mcp and hooks from the plugin tree, and neither rules nor commands", () => { + const { artifacts } = buildClaudeContract(); + + expect({ + skills: sourceOf(artifacts.skills), + agents: sourceOf(artifacts.agents), + mcp: sourceOf(artifacts.mcp), + hooks: sourceOf(artifacts.hooks), + rules: artifacts.rules, + commands: artifacts.commands, + }).toStrictEqual({ + skills: { kind: "fullTree", srcDir: "skills" }, + agents: { kind: "filteredTree", srcDir: "agents", inputExt: ".md" }, + mcp: { kind: "configFile", srcPath: ".mcp.json" }, + hooks: { kind: "hooksBundle", jsonPath: "hooks/hooks.json", scriptDir: "hooks" }, + rules: { supported: false }, + commands: { supported: false }, + }); + }); + + it("keeps every artifact at the plugin-relative path it came from", () => { + const { artifacts } = buildClaudeContract(); + + expect({ + skill: supported(artifacts.skills).path("aidd-dev", "skills/01-plan/SKILL.md"), + agent: supported(artifacts.agents).path("aidd-dev", "agents/planner.md"), + mcp: supported(artifacts.mcp).path("aidd-dev", ".mcp.json"), + hook: supported(artifacts.hooks).path("aidd-dev", "hooks/journal.cjs"), + }).toStrictEqual({ + skill: "skills/01-plan/SKILL.md", + agent: "agents/planner.md", + mcp: ".mcp.json", + hook: "hooks/journal.cjs", + }); + }); + + it("keeps an agent's whole frontmatter and links its plugin-root references from agents/", () => { + const transform = supported(buildClaudeContract().artifacts.agents).transform; + + expect(transform?.(AGENT_SOURCE, "aidd-dev", "planner.md")).toBe( + [ + "---", + "name: 'planner'", + "description: 'Plans the work'", + "model: 'opus'", + "---", + "Read [SKILL.md](../skills/01-plan/SKILL.md)", + "Ask [reviewer.md](./reviewer.md)", + "Run [journal.cjs](../hooks/journal.cjs)", + "", + ].join("\n") + ); + }); + + it("builds the catalog Claude reads, at .claude-plugin/marketplace.json", async () => { + const built = await buildClaudeContract().buildMarketplaceCatalog?.( + { + name: "aidd", + version: "1.0.0", + description: "AI Driven Dev", + owner: { name: "AIDD" }, + plugins: [], + }, + [{ name: "aidd-dev", source: "./plugins/aidd-dev" }], + new InMemoryFileAdapter() + ); + + expect(built).toStrictEqual({ + catalog: { + name: "aidd", + version: "1.0.0", + description: "AI Driven Dev", + owner: { name: "AIDD" }, + plugins: [{ name: "aidd-dev", source: "./plugins/aidd-dev" }], + }, + schemaName: "claude-marketplace", + destRelPath: ".claude-plugin/marketplace.json", + }); + }); + + it("builds a catalog entry from the plugin manifest already written to the output tree", async () => { + const fs = new InMemoryFileAdapter({ + "/out/plugins/aidd-dev/.claude-plugin/plugin.json": JSON.stringify({ + version: "1.2.3", + description: "Development loop", + }), + }); + + const entry = await buildClaudeContract().buildMarketplaceEntry?.( + "aidd-dev", + "/src/plugins/aidd-dev", + "/out", + undefined, + fs + ); + + expect(entry).toStrictEqual({ + name: "aidd-dev", + source: "./plugins/aidd-dev", + description: "Development loop", + version: "1.2.3", + }); + }); +}); + +describe("buildClaudeFlatContract()", () => { + it("sources the same trees as the plugin contract, and neither rules nor commands", () => { + const { artifacts } = buildClaudeFlatContract(); + + expect({ + skills: sourceOf(artifacts.skills), + agents: sourceOf(artifacts.agents), + mcp: sourceOf(artifacts.mcp), + hooks: sourceOf(artifacts.hooks), + rules: artifacts.rules, + commands: artifacts.commands, + }).toStrictEqual({ + skills: { kind: "fullTree", srcDir: "skills" }, + agents: { kind: "filteredTree", srcDir: "agents", inputExt: ".md" }, + mcp: { kind: "configFile", srcPath: ".mcp.json" }, + hooks: { kind: "hooksBundle", jsonPath: "hooks/hooks.json", scriptDir: "hooks" }, + rules: { supported: false }, + commands: { supported: false }, + }); + }); + + it("prefixes a flat skill's own folder with the plugin name, and renames the skill itself", () => { + const skills = supported(buildClaudeFlatContract().artifacts.skills); + + expect({ + path: skills.path("aidd-dev", "skills/01-plan/SKILL.md"), + rewriteSkillName: skills.rewriteSkillName, + }).toStrictEqual({ + path: ".claude/skills/aidd-dev-01-plan/SKILL.md", + rewriteSkillName: true, + }); + }); + + it("prefixes a flat agent's file with the plugin name and keeps it markdown", () => { + const agents = supported(buildClaudeFlatContract().artifacts.agents); + + expect(agents.path("aidd-dev", "agents/planner.md")).toBe(".claude/agents/aidd-dev-planner.md"); + }); + + it("lands a plugin's hooks declaration beside Claude's hooks and its scripts under the plugin's own folder", () => { + const hooks = supported(buildClaudeFlatContract().artifacts.hooks); + + expect({ + declaration: hooks.path("aidd-dev", "hooks/aidd-dev.hooks.json"), + script: hooks.path("aidd-dev", "hooks/journal.cjs"), + mergeDest: hooks.hooksMergeDest?.("/out"), + }).toStrictEqual({ + declaration: ".claude/hooks/aidd-dev.hooks.json", + script: ".claude/hooks/aidd-dev/journal.cjs", + mergeDest: "/out/.claude/settings.json", + }); + }); + + it("appends a plugin's hooks to the settings file, leaving every other setting alone", () => { + const hooks = supported(buildClaudeFlatContract().artifacts.hooks); + + expect(hooks.hooksMerge?.(JSON.stringify({ model: "opus" }), HOOKS_JSON)).toStrictEqual({ + content: `${JSON.stringify( + { + model: "opus", + hooks: { Stop: [{ hooks: [{ type: "command", command: "node journal.cjs" }] }] }, + }, + null, + 2 + )}\n`, + warnings: [], + }); + }); + + it("adds a plugin's mcp servers to the workspace .mcp.json under mcpServers", () => { + const mcp = supported(buildClaudeFlatContract().artifacts.mcp); + + expect({ + path: mcp.path("aidd-dev", ".mcp.json"), + mcpServersKey: mcp.mcpServersKey, + mergeDest: mcp.mergeDest?.("/out"), + merged: mcp.merge?.(null, { "aidd-dev-context": { command: "node" } }, false), + }).toStrictEqual({ + path: ".mcp.json", + mcpServersKey: "mcpServers", + mergeDest: "/out/.mcp.json", + merged: { + mergedContent: + '{\n "mcpServers": {\n "aidd-dev-context": {\n "command": "node"\n }\n }\n}\n', + collisions: [], + }, + }); + }); + + it("renames a flat agent after its plugin and points its references at their flat destinations", () => { + const transform = supported(buildClaudeFlatContract().artifacts.agents).transform; + + expect(transform?.(AGENT_SOURCE, "aidd-dev", "planner.md")).toBe( + [ + "---", + "name: 'aidd-dev-planner'", + "description: 'Plans the work'", + "model: 'opus'", + "---", + "Read [SKILL.md](../skills/aidd-dev-01-plan/SKILL.md)", + "Ask [reviewer.md](./aidd-dev-reviewer.md)", + "Run [journal.cjs](../../hooks/journal.cjs)", + "", + ].join("\n") + ); + }); +}); diff --git a/cli/tests/contexts/tools/domain/profiles/codex.unit.test.ts b/cli/tests/contexts/tools/domain/profiles/codex.unit.test.ts new file mode 100644 index 000000000..3fc7c5d4e --- /dev/null +++ b/cli/tests/contexts/tools/domain/profiles/codex.unit.test.ts @@ -0,0 +1,408 @@ +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; +import { + mergeCodexConfigToml, + stripCodexSkillFrontmatter, +} from "../../../../../src/contexts/tools/domain/profiles/codex/build.js"; +import { + codex, + mergeCodexHooksJson, + rewriteCodexContent, +} from "../../../../../src/contexts/tools/domain/profiles/codex/profile.js"; +import { getToolConfig } from "../../../../../src/contexts/tools/domain/registry.js"; +import { serializeFrontmatter } from "../../../../../src/kernel/markdown.js"; + +describe("codex", () => { + it("has toolId codex", () => { + expect(codex.toolId).toBe("codex"); + }); + + it("has .codex/ directory", () => { + expect(codex.directory).toBe(".codex/"); + }); + + it("has .codex.md tool suffix", () => { + expect(codex.toolSuffix).toBe(".codex.md"); + }); + + it("has signalDir pointing at .codex/commands", () => { + expect(codex.signalDir).toBe(".codex/commands"); + }); + + it("is registered in the tool registry", () => { + const config = getToolConfig("codex"); + expect(config.toolId).toBe("codex"); + }); + + describe("capabilities.skills.buildInstallPath()", () => { + it("builds path under .agents/skills/aidd-{name}/SKILL.md", () => { + const path = codex.capabilities.skills.buildInstallPath("my-skill/SKILL.md"); + expect(path).toBe(".agents/skills/aidd-my-skill/SKILL.md"); + }); + + it("strips .codex.md tool suffix", () => { + const path = codex.capabilities.skills.buildInstallPath("my-skill.codex.md"); + expect(path).toBe(".agents/skills/aidd-my-skill/SKILL.md"); + }); + + it("strips plain .md suffix from skill name", () => { + const path = codex.capabilities.skills.buildInstallPath("my-skill.md"); + expect(path).toBe(".agents/skills/aidd-my-skill/SKILL.md"); + }); + + it("keeps a skill name that carries no extension at all", () => { + const path = codex.capabilities.skills.buildInstallPath("my-skill"); + expect(path).toBe(".agents/skills/aidd-my-skill/SKILL.md"); + }); + }); + + it("names the one config file Codex reads, and where it goes", () => { + expect(codex.configOutputPaths).toStrictEqual({ "config.toml": ".codex/config.toml" }); + }); + + describe("capabilities.agents.buildInstallPath()", () => { + it("builds .toml path under .codex/agents/", () => { + const path = codex.capabilities.agents.buildInstallPath("alexia.md"); + expect(path).toBe(".codex/agents/alexia.toml"); + }); + }); + + describe("capabilities.mcp", () => { + it("outputs to .codex/config.toml", () => { + expect(codex.capabilities.mcp.params.outputPath).toBe(".codex/config.toml"); + }); + + it("consumes the mcp config name", () => { + expect(codex.capabilities.mcp.consumes).toContain("mcp"); + }); + + it("uses user-prime merge strategy", () => { + expect(codex.capabilities.mcp.params.mergeStrategy ?? "user-prime").toBe("user-prime"); + }); + + it("uses mcp_servers as entry section", () => { + expect(codex.capabilities.mcp.params.entrySection).toBe("mcp_servers"); + }); + + it("writes its config in TOML", () => { + expect(codex.capabilities.mcp.params.format).toBe("toml"); + }); + }); + + describe("capabilities.hooks", () => { + it("outputs to .codex/hooks.json", () => { + expect(codex.capabilities.hooks.buildOutputPath()).toBe(".codex/hooks.json"); + }); + + it("consumes the codex-hooks config name", () => { + expect(codex.capabilities.hooks.consumes).toContain("codex-hooks"); + }); + + it("uses user-prime merge strategy", () => { + expect(codex.capabilities.hooks.getMergeStrategy()).toBe("user-prime"); + }); + + it("uses SessionStart as entry section", () => { + expect(codex.capabilities.hooks.getEntrySection()).toBe("SessionStart"); + }); + + it("returns null entry section for unknown config names", () => { + const cap = [codex.capabilities.mcp, codex.capabilities.hooks].find((c) => + c.consumes.includes("unknown") + ); + expect(cap).toBeUndefined(); + }); + }); + + describe("capabilities.commands.buildInstallPath()", () => { + it("maps phase-prefixed path to .codex/commands/aidd// subfolder", () => { + const path = codex.capabilities.commands.buildInstallPath("04_code/implement.md"); + expect(path).toBe(".codex/commands/aidd/04/implement.md"); + }); + + it("maps top-level file to .codex/commands/aidd/ without phase", () => { + const path = codex.capabilities.commands.buildInstallPath("commit.md"); + expect(path).toBe(".codex/commands/aidd/commit.md"); + }); + }); + + describe("capabilities.commands.convertFrontmatter()", () => { + it("prefixes name with aidd:: and strips extra fields", () => { + const fm = { name: "implement", description: "Implement", model: "sonnet" }; + const result = codex.capabilities.commands.convertFrontmatter(fm, "04_code/implement.md"); + expect(result).toEqual({ name: "aidd:04:implement", description: "Implement" }); + }); + }); + + describe("capabilities.rules.buildInstallPath()", () => { + it("builds path for rules under .codex/rules/", () => { + const path = codex.capabilities.rules.buildInstallPath("01-standards/naming.md"); + expect(path).toBe(".codex/rules/01-standards/naming.md"); + }); + + it("strips .codex.md tool suffix from rules path", () => { + const path = codex.capabilities.rules.buildInstallPath("01-standards/naming.codex.md"); + expect(path).toBe(".codex/rules/01-standards/naming.md"); + }); + }); + + describe("capabilities.rules.convertFrontmatter()", () => { + it("passes frontmatter through unchanged", () => { + const fm = { paths: ["src/**/*.ts"], description: "TS rules" }; + const result = codex.capabilities.rules.convertFrontmatter(fm); + expect(result).toEqual(fm); + }); + }); + + describe("capabilities.plugins", () => { + it("declares native codex CLI activation, with the verbs codex uses, and nothing else", () => { + const activation = codex.capabilities.plugins.nativeActivation; + // Exhaustive, not `toMatchObject`: a field added by mistake must fail here rather than + // escape. `pluginCacheDir` is `expect.any(Function)`, since a function matches no literal. + expect(activation).toEqual({ + binary: "codex", + upgradeVerb: "upgrade", + enableVerb: "add", + disableVerb: "remove", + pluginCacheDir: expect.any(Function), + userSettingsPath: expect.any(Function), + }); + }); + + it("declares its own plugin cache root, so clean can purge the empty shell codex leaves behind", () => { + const pluginCacheDir = codex.capabilities.plugins.nativeActivation?.pluginCacheDir; + expect(pluginCacheDir?.("/home/tester")).toBe( + join("/home/tester", ".codex", "plugins", "cache") + ); + }); + + describe("nativeActivation.userSettingsPath()", () => { + const environment = (vars: Record) => (name: string) => vars[name]; + + it("falls back to ~/.codex/config.toml when CODEX_HOME is unset", () => { + const userSettingsPath = codex.capabilities.plugins.nativeActivation?.userSettingsPath; + expect(userSettingsPath?.("/home/tester", environment({}))).toBe( + join("/home/tester", ".codex", "config.toml") + ); + }); + + it("follows CODEX_HOME when a real machine has it set — the real codex binary reads there, not ~/.codex", () => { + const userSettingsPath = codex.capabilities.plugins.nativeActivation?.userSettingsPath; + expect( + userSettingsPath?.("/home/tester", environment({ CODEX_HOME: "/somewhere/else" })) + ).toBe(join("/somewhere/else", "config.toml")); + }); + }); + + it("does not write a project-local marketplace settings file", () => { + expect(codex.capabilities.plugins.marketplaceSettings).toBeNull(); + }); + + it("keeps the marketplace translation mode", () => { + expect(codex.capabilities.plugins.translationMode).toBe("marketplace"); + }); + + it("warns that Codex runs no hook it has not been told to trust, and how to tell it", () => { + expect(codex.capabilities.plugins.hooksTrustNotice).toBe( + "Codex will not run this plugin's hooks until each one is trusted — approve the prompt " + + "once in an interactive session, or pass --dangerously-bypass-hook-trust to codex exec " + + "for a headless run. Until then, a session leaves no run journal and nothing says why." + ); + }); + }); +}); + +describe("rewriteCodexContent()", () => { + it("sends a skill reference to the agents directory Codex scans, under its aidd- prefix", () => { + expect(rewriteCodexContent("Read .codex/skills/01-plan/SKILL.md\n")).toBe( + "Read .agents/skills/aidd-01-plan/SKILL.md\n" + ); + }); + + it("routes a numbered command folder under commands/aidd//, with or without the @ prefix", () => { + expect( + rewriteCodexContent( + "Run .codex/commands/04_code/implement.md, then @.codex/commands/02-plan/plan.md.\n" + ) + ).toBe("Run .codex/commands/aidd/04/implement.md, then @.codex/commands/aidd/02/plan.md.\n"); + }); +}); + +describe("mergeCodexHooksJson()", () => { + const AIDD_ENTRY = { + matcher: "startup|resume", + hooks: [ + { + type: "command", + command: "node .aidd/scripts/update_memory.cjs", + statusMessage: "Syncing AIDD memory...", + timeout: 30, + }, + ], + }; + + it("subscribes the memory refresh to a fresh session, on startup and on resume", () => { + expect(mergeCodexHooksJson("")).toBe(JSON.stringify({ SessionStart: [AIDD_ENTRY] }, null, 2)); + }); + + it("keeps a user's own hooks and appends the memory refresh after them", () => { + const existing = JSON.stringify({ + PreToolUse: [{ hooks: [{ type: "command", command: "user.sh" }] }], + SessionStart: [{ hooks: [{ type: "command", command: "user-start.sh" }] }], + }); + + expect(mergeCodexHooksJson(existing)).toBe( + JSON.stringify( + { + PreToolUse: [{ hooks: [{ type: "command", command: "user.sh" }] }], + SessionStart: [{ hooks: [{ type: "command", command: "user-start.sh" }] }, AIDD_ENTRY], + }, + null, + 2 + ) + ); + }); + + it("adds nothing on a second run over its own output", () => { + const once = mergeCodexHooksJson(""); + expect(mergeCodexHooksJson(once)).toBe(once); + }); + + it("starts over from a file it cannot read rather than failing the install", () => { + expect(mergeCodexHooksJson("{ not json")).toBe( + JSON.stringify({ SessionStart: [AIDD_ENTRY] }, null, 2) + ); + }); +}); + +const MCP_PAYLOAD = ` +[mcp_servers.playwright] +command = "npx" +args = ["-y", "@anthropic-ai/mcp-playwright"] +`; + +describe("mergeCodexConfigToml", () => { + it("writes full payload into empty file", () => { + const result = mergeCodexConfigToml("", MCP_PAYLOAD); + expect(result).toContain("mcp_servers"); + expect(result).toContain("playwright"); + expect(result).toContain("project_doc_max_bytes = 262144"); + expect(result).toContain("hooks = true"); + }); + + it("preserves user keys not managed by AIDD", () => { + const existing = ` +[user_section] +custom_key = "user value" +`; + const result = mergeCodexConfigToml(existing, MCP_PAYLOAD); + expect(result).toContain('custom_key = "user value"'); + expect(result).toContain("playwright"); + }); + + it("is idempotent on second run", () => { + const first = mergeCodexConfigToml("", MCP_PAYLOAD); + const second = mergeCodexConfigToml(first, MCP_PAYLOAD); + expect(second).toContain("playwright"); + const mcpCount = (second.match(/\[mcp_servers\.playwright\]/g) ?? []).length; + expect(mcpCount).toBe(1); + }); + + it("existing MCP server wins on conflict (user-prime)", () => { + const existing = ` +[mcp_servers.playwright] +command = "user-command" +`; + const result = mergeCodexConfigToml(existing, MCP_PAYLOAD); + expect(result).toContain('command = "user-command"'); + expect(result).not.toContain('"npx"'); + }); + + it("preserves user project_doc_max_bytes when above minimum", () => { + const existing = `project_doc_max_bytes = 999999`; + const result = mergeCodexConfigToml(existing, MCP_PAYLOAD); + expect(result).toContain("project_doc_max_bytes = 999999"); + expect(result).not.toContain("262144"); + }); + + it("sets minimum project_doc_max_bytes when absent", () => { + const result = mergeCodexConfigToml("", MCP_PAYLOAD); + expect(result).toContain("project_doc_max_bytes = 262144"); + }); + + it("ensures hooks feature when absent", () => { + const result = mergeCodexConfigToml("", MCP_PAYLOAD); + expect(result).toContain("hooks = true"); + }); + + it("preserves user codex_hooks value when already set", () => { + const existing = ` +[features] +codex_hooks = false +`; + const result = mergeCodexConfigToml(existing, MCP_PAYLOAD); + expect(result).toContain("codex_hooks = false"); + expect(result).not.toContain("hooks = true"); + }); + + it("does NOT emit [[skills.config]] — discovery is by placement", () => { + const result = mergeCodexConfigToml("", MCP_PAYLOAD); + expect(result).not.toContain(".agents/skills"); + expect(result).not.toContain("skills.config"); + }); + + it("preserves existing skills.config if user has one", () => { + const existing = ` +[skills.config] +path = ".agents/skills" +enabled = true +`; + const result = mergeCodexConfigToml(existing, MCP_PAYLOAD); + expect(result).toContain(".agents/skills"); + }); +}); + +/** + * Codex is the only target that re-serialises skill frontmatter instead of passing the file + * through, so these pin the transform itself rather than the golden that recorded its output. + */ +describe("a skill's frontmatter, rewritten for Codex", () => { + const rebuild = (fm: Record) => + serializeFrontmatter(stripCodexSkillFrontmatter(fm), "body\n"); + + it("keeps the three fields Codex reads", () => { + expect( + stripCodexSkillFrontmatter({ + name: "aidd-dev:01:plan", + description: "Plan things", + allowed_tools: ["Read"], + }) + ).toEqual({ name: "aidd-dev:01:plan", description: "Plan things", allowed_tools: ["Read"] }); + }); + + it("drops the fields it does not, rather than passing them through", () => { + // `model` is the one the framework ships and Codex has no use for. + expect(stripCodexSkillFrontmatter({ name: "n", description: "d", model: "opus" })).toEqual({ + name: "n", + description: "d", + }); + }); + + it("omits a field the source never set", () => { + expect(stripCodexSkillFrontmatter({ description: "d" })).toEqual({ description: "d" }); + }); + + it("quotes a value whose colon would otherwise make the frontmatter unreadable", () => { + // Not cosmetic: a description containing ": " makes `js-yaml` refuse the source with "bad + // indentation of a mapping entry". Re-serialising with quotes is what makes it parse. + expect(rebuild({ name: "aidd-context:03:context-generate", description: "Do a: thing" })).toBe( + "---\nname: 'aidd-context:03:context-generate'\ndescription: 'Do a: thing'\n---\nbody\n" + ); + }); + + it("escapes a quote in the value rather than closing the string early", () => { + expect(rebuild({ description: "it's here" })).toBe( + "---\ndescription: 'it''s here'\n---\nbody\n" + ); + }); +}); diff --git a/cli/tests/contexts/tools/domain/profiles/codex/build.unit.test.ts b/cli/tests/contexts/tools/domain/profiles/codex/build.unit.test.ts new file mode 100644 index 000000000..eb4316145 --- /dev/null +++ b/cli/tests/contexts/tools/domain/profiles/codex/build.unit.test.ts @@ -0,0 +1,315 @@ +import { describe, expect, it } from "vitest"; +import type { + ArtifactContract, + ArtifactSource, +} from "../../../../../../src/contexts/tools/domain/build-contract.js"; +import { + buildCodexContract, + buildCodexFlatContract, +} from "../../../../../../src/contexts/tools/domain/profiles/codex/build.js"; +import { InMemoryFileAdapter } from "../../../../../helpers/ports/in-memory-file-adapter.js"; + +const AGENT_SOURCE = [ + "---", + "name: planner", + "description: Plans the work", + "model: opus", + "---", + "Plan before building.", + "", +].join("\n"); + +const HOOKS_JSON = JSON.stringify({ + hooks: { Stop: [{ hooks: [{ type: "command", command: "node journal.cjs" }] }] }, +}); + +function supported(artifact: ArtifactContract): Extract { + if (!artifact.supported) throw new Error("artifact is declared unsupported"); + return artifact; +} + +function sourceOf(artifact: ArtifactContract): ArtifactSource | null { + return artifact.supported ? artifact.source : null; +} + +describe("buildCodexContract()", () => { + it("declares Codex's own plugin manifest path, schema and root token", () => { + const contract = buildCodexContract(); + + expect({ + pluginRootToken: contract.pluginRootToken, + manifestFileRelative: contract.manifestFileRelative, + manifestSchemaName: contract.manifestSchemaName, + }).toStrictEqual({ + pluginRootToken: "$" + "{PLUGIN_ROOT}", + manifestFileRelative: ".codex-plugin/plugin.json", + manifestSchemaName: "codex-plugin-manifest", + }); + }); + + it("synthesizes a manifest pointing skills at one directory, and naming no agents", () => { + const manifest = buildCodexContract().synthesizeManifest?.( + { + name: "aidd-dev", + description: "Development loop", + version: "1.2.3", + license: "MIT", + keywords: ["aidd"], + }, + { + hasAgents: true, + agentsList: ["planner.md"], + skillsList: ["01-plan"], + hasHooksJson: true, + hasMcpJson: true, + } + ); + + expect(manifest).toStrictEqual({ + name: "aidd-dev", + description: "Development loop", + version: "1.2.3", + license: "MIT", + keywords: ["aidd"], + skills: "./skills", + hooks: "./hooks/hooks.json", + mcpServers: "./.mcp.json", + }); + }); + + it("omits from the manifest every field the source plugin never declared", () => { + const manifest = buildCodexContract().synthesizeManifest?.( + { name: "aidd-dev" }, + { + hasAgents: false, + agentsList: [], + skillsList: [], + hasHooksJson: false, + hasMcpJson: false, + } + ); + + expect(manifest).toStrictEqual({ name: "aidd-dev" }); + }); + + it("sources skills, agents, mcp and hooks from the plugin tree, and neither rules nor commands", () => { + const { artifacts } = buildCodexContract(); + + expect({ + skills: sourceOf(artifacts.skills), + agents: sourceOf(artifacts.agents), + mcp: sourceOf(artifacts.mcp), + hooks: sourceOf(artifacts.hooks), + rules: artifacts.rules, + commands: artifacts.commands, + }).toStrictEqual({ + skills: { kind: "fullTree", srcDir: "skills" }, + agents: { kind: "filteredTree", srcDir: "agents", inputExt: ".md" }, + mcp: { kind: "configFile", srcPath: ".mcp.json" }, + hooks: { kind: "hooksBundle", jsonPath: "hooks/hooks.json", scriptDir: "hooks" }, + rules: { supported: false }, + commands: { supported: false }, + }); + }); + + it("stages an agent as TOML beside the plugin tree and leaves every other artifact where it was", () => { + const { artifacts } = buildCodexContract(); + + expect({ + skill: supported(artifacts.skills).path("aidd-dev", "skills/01-plan/SKILL.md"), + agent: supported(artifacts.agents).path("aidd-dev", "agents/planner.md"), + mcp: supported(artifacts.mcp).path("aidd-dev", ".mcp.json"), + hook: supported(artifacts.hooks).path("aidd-dev", "hooks/journal.cjs"), + }).toStrictEqual({ + skill: "skills/01-plan/SKILL.md", + agent: "codex-agents/planner.toml", + mcp: ".mcp.json", + hook: "hooks/journal.cjs", + }); + }); + + it("keeps only the three frontmatter fields Codex reads in a skill", () => { + const transform = supported(buildCodexContract().artifacts.skills).transform; + + expect( + transform?.( + "---\nname: plan\ndescription: Plan it\nmodel: opus\n---\nBody.\n", + "aidd-dev", + "SKILL.md" + ) + ).toBe("---\nname: 'plan'\ndescription: 'Plan it'\n---\nBody.\n"); + }); + + it("names an agent after its own frontmatter when the plugin tree keeps them apart", () => { + const transform = supported(buildCodexContract().artifacts.agents).transform; + + expect(transform?.(AGENT_SOURCE, "aidd-dev", "planner.md")).toBe( + 'name = "planner"\ndescription = "Plans the work"\ndeveloper_instructions = "Plan before building.\\n"\n' + ); + }); + + it("renames the events of a plugin's hooks declaration, and copies every other hook file byte for byte", () => { + const transform = supported(buildCodexContract().artifacts.hooks).transform; + + expect({ + declaration: transform?.(HOOKS_JSON, "aidd-dev", "hooks.json"), + script: transform?.("#!/usr/bin/env node\n", "aidd-dev", "journal.cjs"), + }).toStrictEqual({ + declaration: `${JSON.stringify( + { hooks: { SessionEnd: [{ hooks: [{ type: "command", command: "node journal.cjs" }] }] } }, + null, + 2 + )}\n`, + script: "#!/usr/bin/env node\n", + }); + }); + + it("builds the catalog Codex discovers, at .agents/plugins/marketplace.json", async () => { + const built = await buildCodexContract().buildMarketplaceCatalog?.( + { name: "aidd", displayName: "AI Driven Dev", plugins: [] }, + [{ name: "aidd-dev" }], + new InMemoryFileAdapter() + ); + + expect(built).toStrictEqual({ + catalog: { + name: "aidd", + interface: { displayName: "AI Driven Dev" }, + plugins: [{ name: "aidd-dev" }], + }, + schemaName: "codex-marketplace", + destRelPath: ".agents/plugins/marketplace.json", + }); + }); + + it("builds a catalog entry carrying the installation, authentication and category Codex requires", async () => { + const entry = await buildCodexContract().buildMarketplaceEntry?.( + "aidd-dev", + "/src/plugins/aidd-dev", + "/out", + { name: "aidd-dev" }, + new InMemoryFileAdapter() + ); + + expect(entry).toStrictEqual({ + name: "aidd-dev", + source: { source: "local", path: "./plugins/aidd-dev" }, + policy: { installation: "AVAILABLE", authentication: "ON_USE" }, + category: "Developer Tools", + }); + }); +}); + +describe("buildCodexFlatContract()", () => { + it("writes no manifest and no marketplace of its own", () => { + const contract = buildCodexFlatContract(); + + expect({ + manifestFileRelative: contract.manifestFileRelative, + synthesizeManifest: contract.synthesizeManifest, + manifestSchemaName: contract.manifestSchemaName, + buildMarketplaceCatalog: contract.buildMarketplaceCatalog, + buildMarketplaceEntry: contract.buildMarketplaceEntry, + }).toStrictEqual({ + manifestFileRelative: null, + synthesizeManifest: null, + manifestSchemaName: null, + buildMarketplaceCatalog: null, + buildMarketplaceEntry: null, + }); + }); + + it("sources skills, agents and hooks from the plugin tree, and leaves mcp to the config artifact", () => { + const { artifacts } = buildCodexFlatContract(); + + expect({ + skills: sourceOf(artifacts.skills), + agents: sourceOf(artifacts.agents), + hooks: sourceOf(artifacts.hooks), + mcp: artifacts.mcp, + rules: artifacts.rules, + commands: artifacts.commands, + }).toStrictEqual({ + skills: { kind: "fullTree", srcDir: "skills" }, + agents: { kind: "filteredTree", srcDir: "agents", inputExt: ".md" }, + hooks: { kind: "hooksBundle", jsonPath: "hooks/hooks.json", scriptDir: "hooks" }, + mcp: { supported: false }, + rules: { supported: false }, + commands: { supported: false }, + }); + }); + + it("prefixes a flat skill's own folder with the plugin name, and renames the skill itself", () => { + const skills = supported(buildCodexFlatContract().artifacts.skills); + + expect({ + path: skills.path("aidd-dev", "skills/01-plan/SKILL.md"), + rewriteSkillName: skills.rewriteSkillName, + }).toStrictEqual({ + path: ".agents/skills/aidd-dev-01-plan/SKILL.md", + rewriteSkillName: true, + }); + }); + + it("prefixes a flat agent's TOML with the plugin name, in Codex's own agents directory", () => { + const agents = supported(buildCodexFlatContract().artifacts.agents); + + expect(agents.path("aidd-dev", "agents/planner.md")).toBe( + ".codex/agents/aidd-dev-planner.toml" + ); + }); + + it("names a flat agent after its plugin, whatever its own frontmatter says", () => { + const transform = supported(buildCodexFlatContract().artifacts.agents).transform; + + expect(transform?.(AGENT_SOURCE, "aidd-dev", "planner.md")).toBe( + 'name = "aidd-dev-planner"\ndescription = "Plans the work"\ndeveloper_instructions = "Plan before building.\\n"\n' + ); + }); + + it("nests a plugin's hook scripts under its own folder and merges its declaration into .codex/hooks.json", () => { + const hooks = supported(buildCodexFlatContract().artifacts.hooks); + + expect({ + script: hooks.path("aidd-dev", "hooks/journal.cjs"), + mergeDest: hooks.hooksMergeDest?.("/out"), + merged: hooks.hooksMerge?.(null, HOOKS_JSON), + }).toStrictEqual({ + script: ".codex/hooks/aidd-dev/journal.cjs", + mergeDest: "/out/.codex/hooks.json", + merged: { + content: `${JSON.stringify( + { + hooks: { SessionEnd: [{ hooks: [{ type: "command", command: "node journal.cjs" }] }] }, + }, + null, + 2 + )}\n`, + warnings: [], + }, + }); + }); + + it("writes one config.toml holding every built plugin's mcp servers, under its plugin prefix", async () => { + const fs = new InMemoryFileAdapter({ + "/src/plugins/aidd-dev/.mcp.json": JSON.stringify({ + mcpServers: { context: { command: "node" } }, + }), + }); + + const written = await buildCodexFlatContract().emitConfigArtifact?.( + ["aidd-dev", "aidd-pm"], + "/out", + "/src", + fs, + { validate: () => undefined }, + { loadConfigAsset: () => ({}), loadSchema: () => ({}) } + ); + + expect({ written, config: fs.getFile("/out/.codex/config.toml") }).toStrictEqual({ + written: 1, + config: + 'project_doc_max_bytes = 262144\n\n[mcp_servers.aidd-dev-context]\ncommand = "node"\n\n[features]\nhooks = true\n', + }); + }); +}); diff --git a/cli/tests/domain/formats/codex-agent-toml.unit.test.ts b/cli/tests/contexts/tools/domain/profiles/codex/codex-agent-toml.unit.test.ts similarity index 97% rename from cli/tests/domain/formats/codex-agent-toml.unit.test.ts rename to cli/tests/contexts/tools/domain/profiles/codex/codex-agent-toml.unit.test.ts index c69bb36c3..6ec6f14b2 100644 --- a/cli/tests/domain/formats/codex-agent-toml.unit.test.ts +++ b/cli/tests/contexts/tools/domain/profiles/codex/codex-agent-toml.unit.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; -import { codexAgentMarkdownToToml } from "../../../src/domain/formats/codex-agent-toml.js"; -import { parseToml } from "../../../src/domain/formats/toml.js"; +import { codexAgentMarkdownToToml } from "../../../../../../src/contexts/tools/domain/profiles/codex/codex-agent-toml.js"; +import { parseToml } from "../../../../../../src/contexts/tools/domain/profiles/codex/toml.js"; describe("codexAgentMarkdownToToml()", () => { describe("name resolution (D-16)", () => { diff --git a/cli/tests/domain/formats/toml.unit.test.ts b/cli/tests/contexts/tools/domain/profiles/codex/toml.unit.test.ts similarity index 91% rename from cli/tests/domain/formats/toml.unit.test.ts rename to cli/tests/contexts/tools/domain/profiles/codex/toml.unit.test.ts index 7b165bc27..5a73dd8c8 100644 --- a/cli/tests/domain/formats/toml.unit.test.ts +++ b/cli/tests/contexts/tools/domain/profiles/codex/toml.unit.test.ts @@ -1,5 +1,8 @@ import { describe, expect, it } from "vitest"; -import { parseToml, stringifyToml } from "../../../src/domain/formats/toml.js"; +import { + parseToml, + stringifyToml, +} from "../../../../../../src/contexts/tools/domain/profiles/codex/toml.js"; describe("parseToml()", () => { it("parses a simple TOML string into an object", () => { diff --git a/cli/tests/contexts/tools/domain/profiles/copilot.unit.test.ts b/cli/tests/contexts/tools/domain/profiles/copilot.unit.test.ts new file mode 100644 index 000000000..95cc7a7b0 --- /dev/null +++ b/cli/tests/contexts/tools/domain/profiles/copilot.unit.test.ts @@ -0,0 +1,331 @@ +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; +import { copilot } from "../../../../../src/contexts/tools/domain/profiles/copilot/profile.js"; + +describe("copilot", () => { + describe("capabilities.rules.convertFrontmatter()", () => { + it("converts paths: list to applyTo: comma-joined string", () => { + const result = copilot.capabilities.rules?.convertFrontmatter({ + paths: ["src/**/*.ts"], + }); + expect(result).toHaveProperty("applyTo", "src/**/*.ts"); + expect(result).not.toHaveProperty("paths"); + }); + + it("returns empty frontmatter when paths is empty", () => { + const result = copilot.capabilities.rules?.convertFrontmatter({ paths: [] }); + expect(result).toEqual({}); + }); + + it("returns empty frontmatter when no paths or globs (always apply)", () => { + const result = copilot.capabilities.rules?.convertFrontmatter({}); + expect(result).toEqual({}); + }); + }); + + describe("capabilities.agents.convertFrontmatter()", () => { + it("strips extra fields for agents sections — only name and description", () => { + const fm = { name: "alexia", description: "Agent", model: "opus" }; + const result = copilot.capabilities.agents.convertFrontmatter(fm); + expect(result).toEqual({ name: "alexia", description: "Agent" }); + }); + }); + + describe("capabilities.mcp", () => { + it("maps mcp to .vscode/mcp.json", () => { + expect(copilot.capabilities.mcp.params.outputPath).toBe(".vscode/mcp.json"); + }); + + it("consumes the mcp config name", () => { + expect(copilot.capabilities.mcp.consumes).toContain("mcp"); + }); + }); + + describe("capabilities.settings", () => { + const settings = Array.isArray(copilot.capabilities.settings) + ? copilot.capabilities.settings[0] + : copilot.capabilities.settings; + + it("writes to .vscode/settings.json", () => { + expect(settings.params.outputPath).toBe(".vscode/settings.json"); + }); + + it("uses framework-prime merge strategy", () => { + expect(settings.getMergeStrategy()).toBe("framework-prime"); + }); + + it("references vscode-settings.json asset file (not hardcoded staticContent)", () => { + expect(settings.staticContentAssetFile).toBe("vscode-settings.json"); + expect(settings.staticContent).toBeUndefined(); + }); + + it("does not consume framework signals (content is CLI-owned)", () => { + expect(settings.consumes).toHaveLength(0); + }); + + it("declares requiresTool: vscode (gate merge to IDE-present context)", () => { + expect(settings.requiresTool).toBe("vscode"); + }); + }); + + describe("capabilities.commands.buildInstallPath()", () => { + it("flattens commands: prefixes with phase number", () => { + const path = copilot.capabilities.commands?.buildInstallPath("04_code/implement.md"); + expect(path).toBe(".github/prompts/04-implement.prompt.md"); + }); + + it("flattens commands: converts underscores to hyphens in filename", () => { + const path = copilot.capabilities.commands?.buildInstallPath("00_behavior/auto_accept.md"); + expect(path).toBe(".github/prompts/00-auto-accept.prompt.md"); + }); + + it("handles top-level commands file without subdirectory", () => { + const path = copilot.capabilities.commands?.buildInstallPath("commit.md"); + expect(path).toBe(".github/prompts/commit.prompt.md"); + }); + }); + + describe("capabilities.rules.buildInstallPath()", () => { + it("flattens rules: prefixes with category number, strips file numeric prefix", () => { + const path = copilot.capabilities.rules?.buildInstallPath("01-standards/1-mermaid.md"); + expect(path).toBe(".github/instructions/01-mermaid.instructions.md"); + }); + + it("flattens rules: no numeric prefix in filename — unchanged", () => { + const path = copilot.capabilities.rules?.buildInstallPath("01-standards/naming.md"); + expect(path).toBe(".github/instructions/01-naming.instructions.md"); + }); + + it("flattens rules: strips .copilot tool suffix from filename", () => { + const path = copilot.capabilities.rules?.buildInstallPath( + "04-tooling/ide-mapping.copilot.md" + ); + expect(path).toBe(".github/instructions/04-ide-mapping.instructions.md"); + }); + + it("returns null for .gitkeep files", () => { + expect(copilot.capabilities.rules?.buildInstallPath("00-architecture/.gitkeep")).toBeNull(); + }); + }); + + describe("capabilities.agents.buildInstallPath()", () => { + it("adds .agent.md extension", () => { + const path = copilot.capabilities.agents.buildInstallPath("code-reviewer.md"); + expect(path).toBe(".github/agents/code-reviewer.agent.md"); + }); + + it("returns null for .gitkeep files", () => { + expect(copilot.capabilities.agents.buildInstallPath(".gitkeep")).toBeNull(); + }); + }); + + describe("capabilities.skills.buildInstallPath()", () => { + it("preserves directory structure without flattening", () => { + const path = copilot.capabilities.skills.buildInstallPath("commit/SKILL.md"); + expect(path).toBe(".github/skills/commit/SKILL.md"); + }); + }); + + describe("capabilities.rules.convertFrontmatter() — alwaysApply", () => { + it("returns empty frontmatter when alwaysApply is false without patterns and no description", () => { + expect(copilot.capabilities.rules?.convertFrontmatter({ alwaysApply: false })).toEqual({}); + }); + + it("keeps description when alwaysApply is false and no patterns are specified", () => { + expect( + copilot.capabilities.rules?.convertFrontmatter({ + description: "Apply when editing command files.", + alwaysApply: false, + }) + ).toEqual({ description: "Apply when editing command files." }); + }); + + it("converts globs + alwaysApply: false from framework to applyTo", () => { + expect( + copilot.capabilities.rules?.convertFrontmatter({ + globs: ["{{TOOLS}}/rules/**/*.md"], + alwaysApply: false, + }) + ).toEqual({ applyTo: "{{TOOLS}}/rules/**/*.md" }); + }); + }); + + describe("capabilities.plugins", () => { + it("has a plugins capability", () => { + expect("plugins" in copilot.capabilities).toBe(true); + }); + + it("is native mode", () => { + expect(copilot.capabilities.plugins.mode).toBe("native"); + }); + + it("uses .github/plugins/ as plugins directory", () => { + expect(copilot.capabilities.plugins.pluginsDir).toBe(".github/plugins/"); + }); + + it("uses plugin.json as plugin manifest path", () => { + expect(copilot.capabilities.plugins.pluginManifestRelativePath).toBe("plugin.json"); + }); + + it("pluginOutputDir returns correct path for a plugin name", () => { + expect(copilot.capabilities.plugins.pluginOutputDir("my-plugin")).toBe( + ".github/plugins/my-plugin/" + ); + }); + }); + + describe("capabilities.plugins.marketplaceSettings", () => { + const ms = copilot.capabilities.plugins.marketplaceSettings; + + it("has marketplaceSettings configured", () => { + expect(ms).not.toBeNull(); + }); + + it("writes to .github/copilot/settings.json", () => { + expect(ms?.settingsPath).toBe(".github/copilot/settings.json"); + }); + + it("uses extraKnownMarketplaces as settings key", () => { + expect(ms?.settingsKey).toBe("extraKnownMarketplaces"); + }); + + it("uses enabledPlugins as enabled plugins key", () => { + expect(ms?.enabledPluginsKey).toBe("enabledPlugins"); + }); + + describe("the key a marketplace is recorded under", () => { + it("keys a github marketplace by its name", () => { + expect( + ms?.toEntryKey({ + name: "aidd-framework", + source: { kind: "github", repo: "ai-driven-dev/framework" }, + }) + ).toBe("aidd-framework"); + }); + + it("keys a local marketplace by its name too — the source decides only whether there is a key", () => { + expect( + ms?.toEntryKey({ name: "my-marketplace", source: { kind: "local", path: "/dev/aidd" } }) + ).toBe("my-marketplace"); + }); + + it("returns null for unsupported source kind (npm)", () => { + const result = ms?.toEntryKey({ + name: "my-plugin", + source: { kind: "npm", package: "my-plugin" }, + }); + expect(result).toBeNull(); + }); + + it("returns null for unsupported source kind (url)", () => { + const result = ms?.toEntryKey({ + name: "my-plugin", + source: { kind: "url", url: "https://example.com/plugin.zip" }, + }); + expect(result).toBeNull(); + }); + }); + }); +}); + +/** What a reference to another framework file becomes once installed for Copilot. No other gate + * sees it: `translate` never calls `rewriteContent`, and the byte-frozen golden cell is claude, + * whose rewrite is the identity. The whole rewritten string is asserted, never a fragment. */ +describe("a reference to another framework file, installed for Copilot", () => { + const rewrite = (content: string) => copilot.rewriteContent(content); + + describe("an @-reference, which becomes a link", () => { + it("points an agent reference at the installed .agent.md file", () => { + expect(rewrite("See @{{TOOLS}}/agents/executor.md for details")).toBe( + "See [.github/agents/executor.agent.md](../../.github/agents/executor.agent.md) for details" + ); + }); + + it("points a command reference at the flattened prompt file", () => { + expect(rewrite("Run @{{TOOLS}}/commands/01-plan/02_step.md now")).toBe( + "Run [.github/prompts/01-02-step.prompt.md](../../.github/prompts/01-02-step.prompt.md) now" + ); + }); + + it("points a rule reference at the instructions file, numeric prefix stripped", () => { + expect(rewrite("Read @{{TOOLS}}/rules/1-style.md")).toBe( + "Read [.github/instructions/style.instructions.md](../../.github/instructions/style.instructions.md)" + ); + }); + + it("keeps a skill reference's directory structure, which Copilot does not flatten", () => { + expect(rewrite("Read @{{TOOLS}}/skills/01-plan/SKILL.md")).toBe( + "Read [.github/skills/01-plan/SKILL.md](../../.github/skills/01-plan/SKILL.md)" + ); + }); + + it("points a docs reference into the project's docs directory", () => { + expect(rewrite("Read @{{DOCS}}/memory/testing.md")).toBe( + "Read [aidd_docs/memory/testing.md](../../aidd_docs/memory/testing.md)" + ); + }); + + it("gives a section nobody declared a prefixed path rather than dropping the link", () => { + expect(rewrite("Unknown @{{TOOLS}}/hooks/thing.js")).toBe( + "Unknown [.github/hooks/thing.js](../../.github/hooks/thing.js)" + ); + }); + + it("resolves a reference to a section directory to that directory", () => { + expect(rewrite("Everything under @{{TOOLS}}/agents/ applies")).toBe( + "Everything under [.github/agents/](../../.github/agents/) applies" + ); + }); + }); + + describe("a plain path reference, which stays plain text", () => { + // Frontmatter cannot hold a markdown link, so the form without the @ replaces the + // directory prefix and nothing else. + it("replaces the agents prefix and leaves the filename alone", () => { + expect(rewrite("Path: {{TOOLS}}/agents/executor.md")).toBe( + "Path: .github/agents/executor.md" + ); + }); + + it("flattens a command path, because the installed file is flattened", () => { + expect(rewrite("Path: {{TOOLS}}/commands/01-plan/02_step.md")).toBe( + "Path: .github/prompts/01-02-step.prompt.md" + ); + }); + + it("replaces the rules and skills prefixes in one pass", () => { + expect(rewrite("At {{TOOLS}}/rules/1-style.md and {{TOOLS}}/skills/x/SKILL.md")).toBe( + "At .github/instructions/1-style.md and .github/skills/x/SKILL.md" + ); + }); + + it("replaces a bare tools or docs prefix for a section it does not know", () => { + expect(rewrite("Bare {{TOOLS}}/other/thing.md and {{DOCS}}/x.md")).toBe( + "Bare .github/other/thing.md and aidd_docs/x.md" + ); + }); + + it("resolves the plugins path a pinned release still ships", () => { + // A real placeholder-carrying line, from framework-real's 00-sdlc skill. + expect(rewrite("validator: `{{TOOLS}}/plugins/aidd-pm/skills/05-spec/x.yml`")).toBe( + "validator: `.github/plugins/aidd-pm/skills/05-spec/x.yml`" + ); + }); + }); + + describe("content with nothing to rewrite", () => { + it("is returned unchanged", () => { + const content = "# A heading\n\nProse with a [link](https://example.com) and `code`.\n"; + expect(rewrite(content)).toBe(content); + }); + }); + + describe("capabilities.plugins", () => { + it("declares where copilot's own user-scope settings file lives, for --scope user", () => { + const activation = copilot.capabilities.plugins.nativeActivation; + expect(activation?.userSettingsPath?.("/home/tester", () => undefined)).toBe( + join("/home/tester", ".copilot", "settings.json") + ); + }); + }); +}); diff --git a/cli/tests/contexts/tools/domain/profiles/copilot/build.unit.test.ts b/cli/tests/contexts/tools/domain/profiles/copilot/build.unit.test.ts new file mode 100644 index 000000000..88b3e8c4a --- /dev/null +++ b/cli/tests/contexts/tools/domain/profiles/copilot/build.unit.test.ts @@ -0,0 +1,298 @@ +import { describe, expect, it } from "vitest"; +import type { + ArtifactContract, + ArtifactSource, +} from "../../../../../../src/contexts/tools/domain/build-contract.js"; +import { + buildCopilotFlatContract, + buildCopilotMarketplaceContract, +} from "../../../../../../src/contexts/tools/domain/profiles/copilot/build.js"; +import { InMemoryFileAdapter } from "../../../../../helpers/ports/in-memory-file-adapter.js"; + +// Built, not written literally: biome reads a string holding "${...}" as a lost template. +const ROOT = "$" + "{CLAUDE_PLUGIN_ROOT}"; + +const AGENT_SOURCE = [ + "---", + "name: planner", + "description: Plans the work", + "model: opus", + "color: blue", + "---", + `Read @${ROOT}/skills/01-plan/SKILL.md`, + `Ask @${ROOT}/agents/reviewer.md`, + `Run @${ROOT}/hooks/journal.cjs`, + "", +].join("\n"); + +const HOOKS_JSON = JSON.stringify({ + hooks: { + SessionStart: [{ hooks: [{ type: "command", command: "node journal.cjs", timeout: 30 }] }], + }, +}); + +function supported(artifact: ArtifactContract): Extract { + if (!artifact.supported) throw new Error("artifact is declared unsupported"); + return artifact; +} + +function sourceOf(artifact: ArtifactContract): ArtifactSource | null { + return artifact.supported ? artifact.source : null; +} + +describe("buildCopilotMarketplaceContract()", () => { + it("declares Copilot's own plugin manifest path and root token, and validates it against no schema", () => { + const contract = buildCopilotMarketplaceContract(); + + expect({ + pluginRootToken: contract.pluginRootToken, + manifestFileRelative: contract.manifestFileRelative, + manifestSchemaName: contract.manifestSchemaName, + }).toStrictEqual({ + pluginRootToken: "$" + "{PLUGIN_ROOT}", + manifestFileRelative: ".plugin/plugin.json", + manifestSchemaName: null, + }); + }); + + it("synthesizes a manifest naming the agents, skills, hooks file and mcp servers a plugin ships", () => { + const manifest = buildCopilotMarketplaceContract().synthesizeManifest?.( + { name: "aidd-dev", description: "Development loop", version: "1.2.3" }, + { + hasAgents: true, + agentsList: ["planner.md"], + skillsList: ["01-plan"], + hasHooksJson: true, + hasMcpJson: true, + } + ); + + expect(manifest).toStrictEqual({ + name: "aidd-dev", + description: "Development loop", + version: "1.2.3", + agents: ["./agents/planner.md"], + skills: ["./skills/01-plan"], + hooks: "./hooks/hooks.json", + mcpServers: "./.mcp.json", + }); + }); + + it("sources skills, agents, mcp and hooks from the plugin tree, and neither rules nor commands", () => { + const { artifacts } = buildCopilotMarketplaceContract(); + + expect({ + skills: sourceOf(artifacts.skills), + agents: sourceOf(artifacts.agents), + mcp: sourceOf(artifacts.mcp), + hooks: sourceOf(artifacts.hooks), + rules: artifacts.rules, + commands: artifacts.commands, + }).toStrictEqual({ + skills: { kind: "fullTree", srcDir: "skills" }, + agents: { kind: "filteredTree", srcDir: "agents", inputExt: ".md" }, + mcp: { kind: "configFile", srcPath: ".mcp.json" }, + hooks: { kind: "hooksBundle", jsonPath: "hooks/hooks.json", scriptDir: "hooks" }, + rules: { supported: false }, + commands: { supported: false }, + }); + }); + + it("keeps every artifact at the plugin-relative path it came from", () => { + const { artifacts } = buildCopilotMarketplaceContract(); + + expect({ + skill: supported(artifacts.skills).path("aidd-dev", "skills/01-plan/SKILL.md"), + agent: supported(artifacts.agents).path("aidd-dev", "agents/planner.md"), + mcp: supported(artifacts.mcp).path("aidd-dev", ".mcp.json"), + hook: supported(artifacts.hooks).path("aidd-dev", "hooks/journal.cjs"), + }).toStrictEqual({ + skill: "skills/01-plan/SKILL.md", + agent: "agents/planner.md", + mcp: ".mcp.json", + hook: "hooks/journal.cjs", + }); + }); + + it("keeps an agent's whole frontmatter and links its plugin-root references from agents/", () => { + const transform = supported(buildCopilotMarketplaceContract().artifacts.agents).transform; + + expect(transform?.(AGENT_SOURCE, "aidd-dev", "planner.md")).toBe( + [ + "---", + "name: 'planner'", + "description: 'Plans the work'", + "model: 'opus'", + "color: 'blue'", + "---", + "Read [SKILL.md](../skills/01-plan/SKILL.md)", + "Ask [reviewer.md](./reviewer.md)", + "Run [journal.cjs](../hooks/journal.cjs)", + "", + ].join("\n") + ); + }); + + it("builds the OpenPlugin catalog, at .plugin/marketplace.json, with the plugin root beside it", async () => { + const built = await buildCopilotMarketplaceContract().buildMarketplaceCatalog?.( + { + name: "aidd", + version: "1.0.0", + description: "AI Driven Dev", + owner: { name: "AIDD" }, + plugins: [], + }, + [{ name: "aidd-dev", source: "aidd-dev" }], + new InMemoryFileAdapter() + ); + + expect(built).toStrictEqual({ + catalog: { + name: "aidd", + metadata: { + description: "AI Driven Dev", + version: "1.0.0", + pluginRoot: "./plugins", + }, + owner: { name: "AIDD" }, + plugins: [{ name: "aidd-dev", source: "aidd-dev" }], + }, + schemaName: "marketplace", + destRelPath: ".plugin/marketplace.json", + }); + }); + + it("builds a catalog entry naming the plugin as its own source, from the manifest already written", async () => { + const fs = new InMemoryFileAdapter({ + "/out/plugins/aidd-dev/.plugin/plugin.json": JSON.stringify({ + version: "1.2.3", + description: "Development loop", + }), + }); + + const entry = await buildCopilotMarketplaceContract().buildMarketplaceEntry?.( + "aidd-dev", + "/src/plugins/aidd-dev", + "/out", + undefined, + fs + ); + + expect(entry).toStrictEqual({ + name: "aidd-dev", + source: "aidd-dev", + description: "Development loop", + version: "1.2.3", + }); + }); +}); + +describe("buildCopilotFlatContract()", () => { + it("sources the same trees as the plugin contract, and neither rules nor commands", () => { + const { artifacts } = buildCopilotFlatContract(); + + expect({ + skills: sourceOf(artifacts.skills), + agents: sourceOf(artifacts.agents), + mcp: sourceOf(artifacts.mcp), + hooks: sourceOf(artifacts.hooks), + rules: artifacts.rules, + commands: artifacts.commands, + }).toStrictEqual({ + skills: { kind: "fullTree", srcDir: "skills" }, + agents: { kind: "filteredTree", srcDir: "agents", inputExt: ".md" }, + mcp: { kind: "configFile", srcPath: ".mcp.json" }, + hooks: { kind: "hooksBundle", jsonPath: "hooks/hooks.json", scriptDir: "hooks" }, + rules: { supported: false }, + commands: { supported: false }, + }); + }); + + it("prefixes a flat skill's own folder with the plugin name, and renames the skill itself", () => { + const skills = supported(buildCopilotFlatContract().artifacts.skills); + + expect({ + path: skills.path("aidd-dev", "skills/01-plan/SKILL.md"), + rewriteSkillName: skills.rewriteSkillName, + }).toStrictEqual({ + path: ".github/skills/aidd-dev-01-plan/SKILL.md", + rewriteSkillName: true, + }); + }); + + it("gives a flat agent the plugin prefix and the .agent.md extension Copilot discovers", () => { + const agents = supported(buildCopilotFlatContract().artifacts.agents); + + expect({ ext: agents.ext, path: agents.path("aidd-dev", "agents/planner.md") }).toStrictEqual({ + ext: ".agent.md", + path: ".github/agents/aidd-dev-planner.agent.md", + }); + }); + + it("lands a plugin's hooks declaration beside the other hooks and its scripts under the plugin's own folder", () => { + const hooks = supported(buildCopilotFlatContract().artifacts.hooks); + + expect({ + declaration: hooks.path("aidd-dev", "hooks/aidd-dev.hooks.json"), + script: hooks.path("aidd-dev", "hooks/journal.cjs"), + }).toStrictEqual({ + declaration: ".github/hooks/aidd-dev.hooks.json", + script: ".github/hooks/aidd-dev/journal.cjs", + }); + }); + + it("flattens a plugin's hooks declaration into the one-entry-per-event shape Copilot reads", () => { + const hooks = supported(buildCopilotFlatContract().artifacts.hooks); + + expect(hooks.hooksTransform?.(HOOKS_JSON)).toBe( + `${JSON.stringify( + { + version: 1, + hooks: { + SessionStart: [{ type: "command", command: "node journal.cjs", timeout: 30 }], + }, + }, + null, + 2 + )}\n` + ); + }); + + it("adds a plugin's mcp servers to the workspace .vscode/mcp.json under servers", () => { + const mcp = supported(buildCopilotFlatContract().artifacts.mcp); + + expect({ + path: mcp.path("aidd-dev", ".mcp.json"), + mcpServersKey: mcp.mcpServersKey, + mergeDest: mcp.mergeDest?.("/out"), + merged: mcp.merge?.(null, { "aidd-dev-context": { command: "node" } }, false), + }).toStrictEqual({ + path: ".vscode/mcp.json", + mcpServersKey: "servers", + mergeDest: "/out/.vscode/mcp.json", + merged: { + mergedContent: + '{\n "servers": {\n "aidd-dev-context": {\n "command": "node"\n }\n }\n}\n', + collisions: [], + }, + }); + }); + + it("renames a flat agent after its plugin, drops what Copilot cannot read, and points its references at their flat destinations", () => { + const transform = supported(buildCopilotFlatContract().artifacts.agents).transform; + + expect(transform?.(AGENT_SOURCE, "aidd-dev", "planner.md")).toBe( + [ + "---", + "name: 'aidd-dev-planner'", + "description: 'Plans the work'", + "model: 'opus'", + "---", + "Read [SKILL.md](../skills/aidd-dev-01-plan/SKILL.md)", + "Ask [reviewer.md](./aidd-dev-reviewer.agent.md)", + "Run [journal.cjs](../../hooks/journal.cjs)", + "", + ].join("\n") + ); + }); +}); diff --git a/cli/tests/contexts/tools/domain/profiles/cursor.unit.test.ts b/cli/tests/contexts/tools/domain/profiles/cursor.unit.test.ts new file mode 100644 index 000000000..c97ef2758 --- /dev/null +++ b/cli/tests/contexts/tools/domain/profiles/cursor.unit.test.ts @@ -0,0 +1,172 @@ +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; +import { cursor } from "../../../../../src/contexts/tools/domain/profiles/cursor/profile.js"; + +describe("cursor", () => { + describe("capabilities.rules.convertFrontmatter()", () => { + it("converts paths: to globs: as a JSON inline string and adds alwaysApply: false", () => { + const result = cursor.capabilities.rules?.convertFrontmatter({ + paths: ["src/**/*.ts", "tests/**/*.ts"], + }); + expect(result).toStrictEqual({ + globs: '["src/**/*.ts", "tests/**/*.ts"]', + alwaysApply: false, + }); + }); + + it("keeps the description ahead of the globs it applies to", () => { + const result = cursor.capabilities.rules?.convertFrontmatter({ + description: "Apply when editing sources.", + paths: ["src/**/*.ts"], + }); + expect(result).toStrictEqual({ + description: "Apply when editing sources.", + globs: '["src/**/*.ts"]', + alwaysApply: false, + }); + }); + + it("returns empty frontmatter for rules without paths (always apply)", () => { + const result = cursor.capabilities.rules?.convertFrontmatter({ + description: "desc", + alwaysApply: true, + }); + expect(result).toStrictEqual({}); + }); + + it("returns empty frontmatter for a rule whose paths list is empty", () => { + const result = cursor.capabilities.rules?.convertFrontmatter({ paths: [] }); + expect(result).toStrictEqual({}); + }); + + it("returns empty frontmatter for a rule that opts out of always-apply and names no description", () => { + const result = cursor.capabilities.rules?.convertFrontmatter({ alwaysApply: false }); + expect(result).toStrictEqual({}); + }); + + it("keeps description and alwaysApply false when no globs are specified", () => { + const result = cursor.capabilities.rules?.convertFrontmatter({ + description: "Apply when editing command files.", + alwaysApply: false, + }); + expect(result).toEqual({ + description: "Apply when editing command files.", + alwaysApply: false, + }); + }); + }); + + describe("capabilities.agents.convertFrontmatter()", () => { + it("strips extra fields for agents sections — only name and description", () => { + const fm = { name: "alexia", description: "Agent", model: "opus" }; + const result = cursor.capabilities.agents.convertFrontmatter(fm); + expect(result).toEqual({ name: "alexia", description: "Agent" }); + }); + }); + + describe("capabilities.commands.convertFrontmatter()", () => { + it("prefixes name with aidd:: and strips extra fields", () => { + const fm = { name: "implement", description: "Implement", model: "sonnet" }; + const result = cursor.capabilities.commands?.convertFrontmatter(fm, "04_code/implement.md"); + expect(result).toEqual({ name: "aidd:04:implement", description: "Implement" }); + }); + }); + + describe("capabilities.commands.buildInstallPath()", () => { + it("maps phase-prefixed path to aidd// subfolder", () => { + const path = cursor.capabilities.commands?.buildInstallPath("04_code/implement.md"); + expect(path).toBe(".cursor/commands/aidd/04/implement.md"); + }); + + it("maps top-level file to aidd/ subfolder without phase", () => { + const path = cursor.capabilities.commands?.buildInstallPath("commit.md"); + expect(path).toBe(".cursor/commands/aidd/commit.md"); + }); + }); + + describe("capabilities.rules.buildInstallPath()", () => { + it("builds path for rules section with .mdc extension", () => { + const path = cursor.capabilities.rules?.buildInstallPath("01-standards/naming.md"); + expect(path).toBe(".cursor/rules/01-standards/naming.mdc"); + }); + + it("leaves a rule already written in Cursor's own extension untouched", () => { + const path = cursor.capabilities.rules?.buildInstallPath("01-standards/naming.mdc"); + expect(path).toBe(".cursor/rules/01-standards/naming.mdc"); + }); + }); + + describe("rewriteContent()", () => { + it("routes a numbered command folder under commands/aidd//, with or without the @ prefix", () => { + const rewritten = cursor.rewriteContent?.( + "Run .cursor/commands/04_code/implement.md, then @.cursor/commands/02-plan/plan.md.\n" + ); + expect(rewritten).toBe( + "Run .cursor/commands/aidd/04/implement.md, then @.cursor/commands/aidd/02/plan.md.\n" + ); + }); + + it("gives a referenced rule Cursor's .mdc extension", () => { + const rewritten = cursor.rewriteContent?.("Read @.cursor/rules/01-standards/naming.md\n"); + expect(rewritten).toBe("Read @.cursor/rules/01-standards/naming.mdc\n"); + }); + }); + + describe("capabilities.agents.buildInstallPath()", () => { + it("keeps .md extension for agents", () => { + const path = cursor.capabilities.agents.buildInstallPath("code-reviewer.md"); + expect(path).toBe(".cursor/agents/code-reviewer.md"); + }); + }); + + describe("capabilities.skills.buildInstallPath()", () => { + it("builds path under .cursor/skills/ without tool suffix", () => { + const path = cursor.capabilities.skills.buildInstallPath("commit/SKILL.md"); + expect(path).toBe(".cursor/skills/commit/SKILL.md"); + }); + + it("strips .cursor.md tool suffix from skill name", () => { + const path = cursor.capabilities.skills.buildInstallPath("commit.cursor.md"); + expect(path).toBe(".cursor/skills/commit.md"); + }); + }); + + describe("capabilities.plugins", () => { + it("has a plugins capability", () => { + expect("plugins" in cursor.capabilities).toBe(true); + }); + + it("is native mode", () => { + expect(cursor.capabilities.plugins.mode).toBe("native"); + }); + + it("pluginsDir is empty string (base-relative path prefix)", () => { + expect(cursor.capabilities.plugins.pluginsDir).toBe(""); + }); + + it("pluginManifestRelativePath is null (no manifest file written into plugin dir)", () => { + expect(cursor.capabilities.plugins.pluginManifestRelativePath).toBeNull(); + }); + + it("installScope is user", () => { + expect(cursor.capabilities.plugins.installScope).toBe("user"); + }); + + it("acceptsHooks is true (Cursor auto-discovers hooks.json at plugin root)", () => { + expect(cursor.capabilities.plugins.acceptsHooks).toBe(true); + }); + + it("acceptsMcp is true (Cursor auto-discovers mcp.json at plugin root)", () => { + expect(cursor.capabilities.plugins.acceptsMcp).toBe(true); + }); + + it("marketplaceSettings is null", () => { + expect(cursor.capabilities.plugins.marketplaceSettings).toBeNull(); + }); + + it("resolvePluginsBaseDir returns ~/.cursor/plugins/local resolved from given homedir", () => { + const result = cursor.capabilities.plugins.resolvePluginsBaseDir("/proj", "/home/user"); + expect(result).toBe(join("/home/user", ".cursor", "plugins", "local")); + }); + }); +}); diff --git a/cli/tests/contexts/tools/domain/profiles/cursor/build.unit.test.ts b/cli/tests/contexts/tools/domain/profiles/cursor/build.unit.test.ts new file mode 100644 index 000000000..ff8a39507 --- /dev/null +++ b/cli/tests/contexts/tools/domain/profiles/cursor/build.unit.test.ts @@ -0,0 +1,270 @@ +import { describe, expect, it } from "vitest"; +import type { + ArtifactContract, + ArtifactSource, +} from "../../../../../../src/contexts/tools/domain/build-contract.js"; +import { + buildCursorContract, + buildCursorFlatContract, +} from "../../../../../../src/contexts/tools/domain/profiles/cursor/build.js"; +import { InMemoryFileAdapter } from "../../../../../helpers/ports/in-memory-file-adapter.js"; + +// Built, not written literally: biome reads a string holding "${...}" as a lost template. +const ROOT = "$" + "{CLAUDE_PLUGIN_ROOT}"; + +const AGENT_SOURCE = [ + "---", + "name: planner", + "description: Plans the work", + "model: opus", + "color: blue", + "---", + `Read @${ROOT}/skills/01-plan/SKILL.md`, + `Ask @${ROOT}/agents/reviewer.md`, + `Run @${ROOT}/hooks/journal.cjs`, + "", +].join("\n"); + +function supported(artifact: ArtifactContract): Extract { + if (!artifact.supported) throw new Error("artifact is declared unsupported"); + return artifact; +} + +function sourceOf(artifact: ArtifactContract): ArtifactSource | null { + return artifact.supported ? artifact.source : null; +} + +describe("buildCursorContract()", () => { + it("declares Cursor's own plugin manifest path, schema and root token", () => { + const contract = buildCursorContract(); + + expect({ + pluginRootToken: contract.pluginRootToken, + manifestFileRelative: contract.manifestFileRelative, + manifestSchemaName: contract.manifestSchemaName, + }).toStrictEqual({ + pluginRootToken: "$" + "{CURSOR_PLUGIN_ROOT}", + manifestFileRelative: ".cursor-plugin/plugin.json", + manifestSchemaName: "plugin-manifest", + }); + }); + + it("synthesizes a manifest naming the agents, skills, hooks file and mcp servers a plugin ships", () => { + const manifest = buildCursorContract().synthesizeManifest?.( + { name: "aidd-dev", description: "Development loop", version: "1.2.3" }, + { + hasAgents: true, + agentsList: ["planner.md"], + skillsList: ["01-plan"], + hasHooksJson: true, + hasMcpJson: true, + } + ); + + expect(manifest).toStrictEqual({ + name: "aidd-dev", + description: "Development loop", + version: "1.2.3", + agents: ["./agents/planner.md"], + skills: ["./skills/01-plan"], + hooks: "./hooks/hooks.json", + mcpServers: "./.mcp.json", + }); + }); + + it("sources skills, agents, mcp and hooks from the plugin tree, and neither rules nor commands", () => { + const { artifacts } = buildCursorContract(); + + expect({ + skills: sourceOf(artifacts.skills), + agents: sourceOf(artifacts.agents), + mcp: sourceOf(artifacts.mcp), + hooks: sourceOf(artifacts.hooks), + rules: artifacts.rules, + commands: artifacts.commands, + }).toStrictEqual({ + skills: { kind: "fullTree", srcDir: "skills" }, + agents: { kind: "filteredTree", srcDir: "agents", inputExt: ".md" }, + mcp: { kind: "configFile", srcPath: ".mcp.json" }, + hooks: { kind: "hooksBundle", jsonPath: "hooks/hooks.json", scriptDir: "hooks" }, + rules: { supported: false }, + commands: { supported: false }, + }); + }); + + it("keeps every artifact at the plugin-relative path it came from", () => { + const { artifacts } = buildCursorContract(); + + expect({ + skill: supported(artifacts.skills).path("aidd-dev", "skills/01-plan/SKILL.md"), + agent: supported(artifacts.agents).path("aidd-dev", "agents/planner.md"), + mcp: supported(artifacts.mcp).path("aidd-dev", ".mcp.json"), + hook: supported(artifacts.hooks).path("aidd-dev", "hooks/journal.cjs"), + }).toStrictEqual({ + skill: "skills/01-plan/SKILL.md", + agent: "agents/planner.md", + mcp: ".mcp.json", + hook: "hooks/journal.cjs", + }); + }); + + it("keeps an agent's supported frontmatter and links its plugin-root references from agents/", () => { + const transform = supported(buildCursorContract().artifacts.agents).transform; + + expect(transform?.(AGENT_SOURCE, "aidd-dev", "planner.md")).toBe( + [ + "---", + "name: 'planner'", + "description: 'Plans the work'", + "model: 'opus'", + "---", + "Read [SKILL.md](../skills/01-plan/SKILL.md)", + "Ask [reviewer.md](./reviewer.md)", + "Run [journal.cjs](../hooks/journal.cjs)", + "", + ].join("\n") + ); + }); + + it("builds the catalog Cursor reads, at .cursor-plugin/marketplace.json", async () => { + const built = await buildCursorContract().buildMarketplaceCatalog?.( + { + name: "aidd", + version: "1.0.0", + description: "AI Driven Dev", + owner: { name: "AIDD" }, + plugins: [], + }, + [{ name: "aidd-dev", source: "./plugins/aidd-dev" }], + new InMemoryFileAdapter() + ); + + expect(built).toStrictEqual({ + catalog: { + name: "aidd", + version: "1.0.0", + description: "AI Driven Dev", + owner: { name: "AIDD" }, + plugins: [{ name: "aidd-dev", source: "./plugins/aidd-dev" }], + }, + schemaName: "claude-marketplace", + destRelPath: ".cursor-plugin/marketplace.json", + }); + }); + + it("builds a catalog entry from the plugin manifest already written to the output tree", async () => { + const fs = new InMemoryFileAdapter({ + "/out/plugins/aidd-dev/.cursor-plugin/plugin.json": JSON.stringify({ + version: "1.2.3", + description: "Development loop", + }), + }); + + const entry = await buildCursorContract().buildMarketplaceEntry?.( + "aidd-dev", + "/src/plugins/aidd-dev", + "/out", + undefined, + fs + ); + + expect(entry).toStrictEqual({ + name: "aidd-dev", + source: "./plugins/aidd-dev", + description: "Development loop", + version: "1.2.3", + }); + }); +}); + +describe("buildCursorFlatContract()", () => { + it("sources the same trees as the plugin contract, and neither rules nor commands", () => { + const { artifacts } = buildCursorFlatContract(); + + expect({ + skills: sourceOf(artifacts.skills), + agents: sourceOf(artifacts.agents), + mcp: sourceOf(artifacts.mcp), + hooks: sourceOf(artifacts.hooks), + rules: artifacts.rules, + commands: artifacts.commands, + }).toStrictEqual({ + skills: { kind: "fullTree", srcDir: "skills" }, + agents: { kind: "filteredTree", srcDir: "agents", inputExt: ".md" }, + mcp: { kind: "configFile", srcPath: ".mcp.json" }, + hooks: { kind: "hooksBundle", jsonPath: "hooks/hooks.json", scriptDir: "hooks" }, + rules: { supported: false }, + commands: { supported: false }, + }); + }); + + it("prefixes a flat skill's own folder with the plugin name, and renames the skill itself", () => { + const skills = supported(buildCursorFlatContract().artifacts.skills); + + expect({ + path: skills.path("aidd-dev", "skills/01-plan/SKILL.md"), + rewriteSkillName: skills.rewriteSkillName, + }).toStrictEqual({ + path: ".cursor/skills/aidd-dev-01-plan/SKILL.md", + rewriteSkillName: true, + }); + }); + + it("prefixes a flat agent's file with the plugin name and keeps it markdown", () => { + const agents = supported(buildCursorFlatContract().artifacts.agents); + + expect(agents.path("aidd-dev", "agents/planner.md")).toBe(".cursor/agents/aidd-dev-planner.md"); + }); + + it("lands a plugin's hooks declaration beside Cursor's hooks and its scripts under the plugin's own folder", () => { + const hooks = supported(buildCursorFlatContract().artifacts.hooks); + + expect({ + declaration: hooks.path("aidd-dev", "hooks/aidd-dev.hooks.json"), + script: hooks.path("aidd-dev", "hooks/journal.cjs"), + mergeDest: hooks.hooksMergeDest?.("/out"), + }).toStrictEqual({ + declaration: ".cursor/hooks/aidd-dev.hooks.json", + script: ".cursor/hooks/aidd-dev/journal.cjs", + mergeDest: "/out/.cursor/hooks.json", + }); + }); + + it("adds a plugin's mcp servers to the workspace .cursor/mcp.json under mcpServers", () => { + const mcp = supported(buildCursorFlatContract().artifacts.mcp); + + expect({ + path: mcp.path("aidd-dev", ".mcp.json"), + mcpServersKey: mcp.mcpServersKey, + mergeDest: mcp.mergeDest?.("/out"), + merged: mcp.merge?.(null, { "aidd-dev-context": { command: "node" } }, false), + }).toStrictEqual({ + path: ".cursor/mcp.json", + mcpServersKey: "mcpServers", + mergeDest: "/out/.cursor/mcp.json", + merged: { + mergedContent: + '{\n "mcpServers": {\n "aidd-dev-context": {\n "command": "node"\n }\n }\n}\n', + collisions: [], + }, + }); + }); + + it("renames a flat agent after its plugin and points its references at their flat destinations", () => { + const transform = supported(buildCursorFlatContract().artifacts.agents).transform; + + expect(transform?.(AGENT_SOURCE, "aidd-dev", "planner.md")).toBe( + [ + "---", + "name: 'aidd-dev-planner'", + "description: 'Plans the work'", + "model: 'opus'", + "---", + "Read [SKILL.md](../skills/aidd-dev-01-plan/SKILL.md)", + "Ask [reviewer.md](./aidd-dev-reviewer.md)", + "Run [journal.cjs](../../hooks/journal.cjs)", + "", + ].join("\n") + ); + }); +}); diff --git a/cli/tests/domain/tools/ai/opencode.unit.test.ts b/cli/tests/contexts/tools/domain/profiles/opencode.unit.test.ts similarity index 80% rename from cli/tests/domain/tools/ai/opencode.unit.test.ts rename to cli/tests/contexts/tools/domain/profiles/opencode.unit.test.ts index f8381b65a..5d1b921ee 100644 --- a/cli/tests/domain/tools/ai/opencode.unit.test.ts +++ b/cli/tests/contexts/tools/domain/profiles/opencode.unit.test.ts @@ -1,13 +1,14 @@ import { describe, expect, it } from "vitest"; -import { OpencodeDualConfigError } from "../../../../src/domain/errors.js"; -import { FileHash } from "../../../../src/domain/models/file.js"; -import type { FileReader } from "../../../../src/domain/ports/file-reader.js"; -import { opencode } from "../../../../src/domain/tools/ai/opencode.js"; +import { opencode } from "../../../../../src/contexts/tools/domain/profiles/opencode/profile.js"; +import { OpencodeDualConfigError } from "../../../../../src/kernel/errors.js"; +import { FileHash } from "../../../../../src/kernel/file.js"; +import type { FileReader } from "../../../../../src/kernel/ports/file-reader.js"; function makeFs(existingPaths: string[]): FileReader { return { fileExists: async (path: string) => existingPaths.some((p) => path.endsWith(p)), isExecutable: async () => false, + realpath: async (path: string) => path, readFile: async () => "", readFileHash: async () => new FileHash("00000000000000000000000000000000"), listDirectory: async () => [], @@ -34,17 +35,6 @@ describe("opencode", () => { const result = opencode.capabilities.agents.convertFrontmatter(fm); expect(result).toEqual({ description: "Act like the user", mode: "subagent" }); }); - - it("does not carry OpenCode specific fields back to canonical format", () => { - // claude → opencode drops name (filename is the name in OpenCode), adds mode: subagent. - // opencode → claude reverse strips mode and cannot recover name from frontmatter alone. - const claudeFm = { name: "alexia", description: "Act like the user" }; - const opencodeFm = opencode.capabilities.agents.convertFrontmatter(claudeFm); - const canonical = opencode.capabilities.agents.reverseConvertFrontmatter(opencodeFm); - expect(canonical).not.toHaveProperty("name"); - expect(canonical).not.toHaveProperty("mode"); - expect(canonical).toEqual({ description: "Act like the user" }); - }); }); describe("capabilities.commands.buildInstallPath()", () => { @@ -73,20 +63,6 @@ describe("opencode", () => { }); }); - describe("capabilities.commands.reverseConvertFrontmatter()", () => { - it("strips aidd:: prefix from name", () => { - const fm = { name: "aidd:04:implement", description: "Implement a plan" }; - const result = opencode.capabilities.commands?.reverseConvertFrontmatter(fm); - expect(result).toEqual({ name: "implement", description: "Implement a plan" }); - }); - - it("preserves name unchanged when prefix is absent", () => { - const fm = { name: "implement", description: "Implement a plan" }; - const result = opencode.capabilities.commands?.reverseConvertFrontmatter(fm); - expect(result).toEqual({ name: "implement", description: "Implement a plan" }); - }); - }); - describe("capabilities.rules.buildInstallPath()", () => { it("builds path under .opencode/rules/", () => { const path = opencode.capabilities.rules?.buildInstallPath("01-standards/naming.md"); @@ -322,32 +298,44 @@ describe("opencode", () => { it("pluginOutputDir returns null", () => { expect(opencode.capabilities.plugins.pluginOutputDir("my-plugin")).toBeNull(); }); - }); - describe("detectUserFileSectionKey()", () => { - it("detects agents section", () => { - const key = opencode.detectUserFileSectionKey(".opencode/agents/alexia.md"); - expect(key).toEqual({ section: "agents", key: "alexia.md" }); + it("namespaces a plugin's hook scripts under .opencode/hooks/", () => { + expect(opencode.capabilities.plugins.flatHooksDir).toBe(".opencode/hooks/"); }); - it("detects commands section and strips aidd/ prefix", () => { - const key = opencode.detectUserFileSectionKey(".opencode/commands/aidd/04/implement.md"); - expect(key).toEqual({ section: "commands", key: "04/implement.md" }); + it("declares opencode-plugin.js as the loader's own module, landing in .opencode/plugin/", () => { + expect(opencode.capabilities.plugins.flatHooksLoaderEntry).toEqual({ + dir: ".opencode/plugin/", + baseName: "opencode-plugin.js", + }); }); + }); - it("detects rules section", () => { - const key = opencode.detectUserFileSectionKey(".opencode/rules/01-standards/naming.md"); - expect(key).toEqual({ section: "rules", key: "01-standards/naming.md" }); + describe("buildContracts.flat().artifacts.hooks.path()", () => { + const hooksArtifact = opencode.buildContracts?.flat?.().artifacts.hooks; + if (hooksArtifact === undefined || !hooksArtifact.supported) { + throw new Error("expected opencode's flat hooks artifact to be supported"); + } + const path = hooksArtifact.path; + + it("namespaces a plain hook script under .opencode/hooks//", () => { + expect(path("aidd-context", "hooks/update_memory.js")).toBe( + ".opencode/hooks/aidd-context/update_memory.js" + ); }); - it("detects skills section", () => { - const key = opencode.detectUserFileSectionKey(".opencode/skills/my-skill/SKILL.md"); - expect(key).toEqual({ section: "skills", key: "my-skill/SKILL.md" }); + it("renames a plugin's own opencode-plugin.js flat into .opencode/plugin/.js", () => { + expect(path("aidd-telemetry", "hooks/opencode-plugin.js")).toBe( + ".opencode/plugin/aidd-telemetry.js" + ); }); - it("returns null for unrecognised paths", () => { - expect(opencode.detectUserFileSectionKey("opencode.json")).toBeNull(); - expect(opencode.detectUserFileSectionKey("AGENTS.md")).toBeNull(); + it("keeps two plugins' same-named hook script from colliding", () => { + const a = path("plugin-a", "hooks/x.js"); + const b = path("plugin-b", "hooks/x.js"); + expect(a).toBe(".opencode/hooks/plugin-a/x.js"); + expect(b).toBe(".opencode/hooks/plugin-b/x.js"); + expect(a).not.toBe(b); }); }); }); diff --git a/cli/tests/contexts/tools/domain/profiles/opencode/build.unit.test.ts b/cli/tests/contexts/tools/domain/profiles/opencode/build.unit.test.ts new file mode 100644 index 000000000..730ee5250 --- /dev/null +++ b/cli/tests/contexts/tools/domain/profiles/opencode/build.unit.test.ts @@ -0,0 +1,237 @@ +import { describe, expect, it } from "vitest"; +import type { + ArtifactContract, + ArtifactSource, +} from "../../../../../../src/contexts/tools/domain/build-contract.js"; +import { + buildOpencodeFlatContract, + transformMcpToOpencode, +} from "../../../../../../src/contexts/tools/domain/profiles/opencode/build.js"; +import { InMemoryFileAdapter } from "../../../../../helpers/ports/in-memory-file-adapter.js"; + +// Built, not written literally: biome reads a string holding "${...}" as a lost template. +const ROOT = "$" + "{CLAUDE_PLUGIN_ROOT}"; + +const AGENT_SOURCE = [ + "---", + "name: planner", + "description: Plans the work", + "---", + `Read @${ROOT}/skills/01-plan/SKILL.md`, + `Ask @${ROOT}/agents/reviewer.md`, + `Run @${ROOT}/hooks/journal.cjs`, + "", +].join("\n"); + +function supported(artifact: ArtifactContract): Extract { + if (!artifact.supported) throw new Error("artifact is declared unsupported"); + return artifact; +} + +function sourceOf(artifact: ArtifactContract): ArtifactSource | null { + return artifact.supported ? artifact.source : null; +} + +describe("transformMcpToOpencode()", () => { + it("turns a command server into a local one, its arguments folded into the command line", () => { + const converted = transformMcpToOpencode( + JSON.stringify({ + mcpServers: { context: { command: "npx", args: ["-y", "server"], env: { KEY: "v" } } }, + }) + ); + + expect(JSON.parse(converted)).toStrictEqual({ + mcp: { + context: { + type: "local", + command: ["npx", "-y", "server"], + enabled: true, + environment: { KEY: "v" }, + }, + }, + }); + }); + + it("turns a url server into a remote one, and a disabled server into a disabled one", () => { + const converted = transformMcpToOpencode( + JSON.stringify({ mcpServers: { hosted: { url: "https://example.test", disabled: true } } }) + ); + + expect(JSON.parse(converted)).toStrictEqual({ + mcp: { hosted: { type: "remote", url: "https://example.test", enabled: false } }, + }); + }); + + it("refuses a server that names neither a command nor a url", () => { + expect(() => transformMcpToOpencode(JSON.stringify({ mcpServers: { broken: {} } }))).toThrow( + /broken/ + ); + }); + + it("refuses a config that is not a JSON object", () => { + expect(() => transformMcpToOpencode("[]")).toThrow("MCP config must be a JSON object"); + expect(() => transformMcpToOpencode("{ not json")).toThrow(/Cannot parse MCP config/); + }); +}); + +describe("buildOpencodeFlatContract()", () => { + it("writes no manifest and no marketplace of its own", () => { + const contract = buildOpencodeFlatContract(); + + expect({ + manifestFileRelative: contract.manifestFileRelative, + synthesizeManifest: contract.synthesizeManifest, + manifestSchemaName: contract.manifestSchemaName, + buildMarketplaceCatalog: contract.buildMarketplaceCatalog, + buildMarketplaceEntry: contract.buildMarketplaceEntry, + }).toStrictEqual({ + manifestFileRelative: null, + synthesizeManifest: null, + manifestSchemaName: null, + buildMarketplaceCatalog: null, + buildMarketplaceEntry: null, + }); + }); + + it("sources skills, agents and hooks from the plugin tree, and leaves mcp to the config artifact", () => { + const { artifacts } = buildOpencodeFlatContract(); + + expect({ + skills: sourceOf(artifacts.skills), + agents: sourceOf(artifacts.agents), + hooks: sourceOf(artifacts.hooks), + mcp: artifacts.mcp, + rules: artifacts.rules, + commands: artifacts.commands, + }).toStrictEqual({ + skills: { kind: "fullTree", srcDir: "skills" }, + agents: { kind: "filteredTree", srcDir: "agents", inputExt: ".md" }, + hooks: { kind: "hooksBundle", jsonPath: "hooks/hooks.json", scriptDir: "hooks" }, + mcp: { supported: false }, + rules: { supported: false }, + commands: { supported: false }, + }); + }); + + it("nests a plugin's whole skills tree under its own folder, and renames the skill itself", () => { + const skills = supported(buildOpencodeFlatContract().artifacts.skills); + + expect({ + path: skills.path("aidd-dev", "skills/01-plan/SKILL.md"), + rewriteSkillName: skills.rewriteSkillName, + }).toStrictEqual({ + path: ".opencode/skills/aidd-dev/01-plan/SKILL.md", + rewriteSkillName: true, + }); + }); + + it("prefixes a flat agent's file with the plugin name and keeps it markdown", () => { + const agents = supported(buildOpencodeFlatContract().artifacts.agents); + + expect(agents.path("aidd-dev", "agents/planner.md")).toBe( + ".opencode/agents/aidd-dev-planner.md" + ); + }); + + it("declares an agent a subagent, renames it after its plugin, and points its references at their flat destinations", () => { + const transform = supported(buildOpencodeFlatContract().artifacts.agents).transform; + + expect(transform?.(AGENT_SOURCE, "aidd-dev", "planner.md")).toBe( + [ + "---", + "name: 'aidd-dev-planner'", + "description: 'Plans the work'", + "mode: 'subagent'", + "---", + "Read [SKILL.md](../skills/aidd-dev/01-plan/SKILL.md)", + "Ask [reviewer.md](./aidd-dev-reviewer.md)", + "Run [journal.cjs](../../hooks/journal.cjs)", + "", + ].join("\n") + ); + }); + + it("delivers a plugin's own OpenCode module where the loader scans, and every other script apart", () => { + const hooks = supported(buildOpencodeFlatContract().artifacts.hooks); + + expect({ + loaderEntry: hooks.path("aidd-dev", "hooks/opencode-plugin.js"), + script: hooks.path("aidd-dev", "hooks/journal.cjs"), + skipHooksJson: hooks.skipHooksJson, + bridgePath: hooks.hooksBridge?.path("aidd-dev"), + skipIfSourceHas: hooks.hooksBridge?.skipIfSourceHas, + }).toStrictEqual({ + loaderEntry: ".opencode/plugin/aidd-dev.js", + script: ".opencode/hooks/aidd-dev/journal.cjs", + skipHooksJson: true, + bridgePath: ".opencode/plugin/aidd-dev-hooks.js", + skipIfSourceHas: "opencode-plugin.js", + }); + }); + + it("generates no bridge for a plugin whose hooks name no event OpenCode delivers", () => { + const hooks = supported(buildOpencodeFlatContract().artifacts.hooks); + + expect( + hooks.hooksBridge?.generate( + JSON.stringify({ hooks: { Notification: [{ hooks: [{ command: "x" }] }] } }), + "aidd-dev" + ) + ).toBeNull(); + }); + + it("writes one opencode.json holding the bundled base, the user's own keys and every plugin's mcp servers", async () => { + const fs = new InMemoryFileAdapter({ + "/src/plugins/aidd-dev/.mcp.json": JSON.stringify({ + mcpServers: { context: { command: "node" } }, + }), + "/out/opencode.json": JSON.stringify({ theme: "dark" }), + }); + + const written = await buildOpencodeFlatContract().emitConfigArtifact?.( + ["aidd-dev"], + "/out", + "/src", + fs, + { validate: () => undefined }, + { + loadConfigAsset: () => ({ $schema: "https://opencode.ai/config.json" }), + loadSchema: () => ({}), + } + ); + + expect({ + written, + config: JSON.parse(fs.getFile("/out/opencode.json") ?? "null"), + }).toStrictEqual({ + written: 1, + config: { + $schema: "https://opencode.ai/config.json", + theme: "dark", + mcp: { + "aidd-dev-context": { type: "local", command: ["node"], enabled: true }, + }, + }, + }); + }); + + it("writes into the jsonc config when the project already keeps one", async () => { + const fs = new InMemoryFileAdapter({ + "/out/opencode.jsonc": JSON.stringify({ theme: "dark" }), + }); + + await buildOpencodeFlatContract().emitConfigArtifact?.( + [], + "/out", + "/src", + fs, + { validate: () => undefined }, + { loadConfigAsset: () => ({}), loadSchema: () => ({}) } + ); + + expect({ + jsonc: fs.has("/out/opencode.jsonc"), + json: fs.has("/out/opencode.json"), + }).toStrictEqual({ jsonc: true, json: false }); + }); +}); diff --git a/cli/tests/contexts/tools/domain/profiles/opencode/opencode-hooks-bridge-mapping.integration.test.ts b/cli/tests/contexts/tools/domain/profiles/opencode/opencode-hooks-bridge-mapping.integration.test.ts new file mode 100644 index 000000000..7c752800e --- /dev/null +++ b/cli/tests/contexts/tools/domain/profiles/opencode/opencode-hooks-bridge-mapping.integration.test.ts @@ -0,0 +1,127 @@ +// The mapping exists only as generated text a real ESM module must expose as a property of its +// factory, so proving it reaches one means writing and importing that file — integration, not unit. +import { execFileSync } from "node:child_process"; +import { mkdtemp, readFile, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { pathToFileURL } from "node:url"; +import { afterEach, describe, expect, it } from "vitest"; +import { generateOpencodeHooksBridge } from "../../../../../../src/contexts/tools/domain/profiles/opencode/opencode-hooks-bridge.js"; +import { REPOSITORY_ROOT } from "../../../../../helpers/repository-root.js"; + +const FIXTURES_DIR = join(REPOSITORY_ROOT, "scripts", "__tests__", "fixtures"); + +async function loadFixture(name: string): Promise { + return JSON.parse(await readFile(join(FIXTURES_DIR, name), "utf8")); +} + +// Built rather than written as a literal "${CLAUDE_PLUGIN_ROOT}" string: biome reads a plain +// string holding "${...}" as a forgotten template literal. +const ROOT = "$" + "{CLAUDE_PLUGIN_ROOT}"; + +const HOOKS_JSON = JSON.stringify({ + hooks: { + Stop: [{ hooks: [{ type: "command", command: `node ${ROOT}/hooks/turn.js` }] }], + PostToolUse: [ + { matcher: "bash", hooks: [{ type: "command", command: `node ${ROOT}/hooks/on-tool.js` }] }, + ], + }, +}); + +const tempDirs: string[] = []; +afterEach(async () => { + for (const dir of tempDirs.splice(0)) execFileSync("rm", ["-rf", dir]); +}); + +async function importGeneratedModule(): Promise<{ + stopCallsFor: (event: unknown, directory: string) => unknown[]; + postToolUseCallsFor: (event: unknown, directory: string) => unknown[]; +}> { + const generated = generateOpencodeHooksBridge(HOOKS_JSON, "aidd-sample"); + if (generated === null) throw new Error("expected a generated module"); + const dir = await mkdtemp(join(tmpdir(), "aidd-opencode-bridge-mapping-")); + tempDirs.push(dir); + const modulePath = join(dir, "bridge.mjs"); + await writeFile(modulePath, generated, "utf8"); + const mod: Record = await import(pathToFileURL(modulePath).href); + const factory = mod.AiddSampleHooks; + if (!isBridgeFactory(factory)) { + throw new Error( + "the generated bridge exports no AiddSampleHooks factory carrying the mapping seams" + ); + } + return factory; +} + +interface BridgeFactory { + stopCallsFor: (event: unknown, directory: string) => unknown[]; + postToolUseCallsFor: (event: unknown, directory: string) => unknown[]; +} + +/** The factory is a function carrying its two pure mapping seams as properties. */ +function isBridgeFactory(value: unknown): value is BridgeFactory { + if (typeof value !== "function") return false; + const stop = Reflect.get(value, "stopCallsFor"); + const post = Reflect.get(value, "postToolUseCallsFor"); + return typeof stop === "function" && typeof post === "function"; +} + +describe("the generated bridge's own mapping, called directly (no spawn)", () => { + it("session.idle produces the Stop hook's call, session id and cwd carried through", async () => { + const { stopCallsFor } = await importGeneratedModule(); + const idle = await loadFixture("opencode-session-idle.json"); + + const calls = stopCallsFor(idle, "/home/user/project"); + + expect(calls).toEqual([ + { + script: "turn.js", + args: [], + payload: { + hook_event_name: "Stop", + session_id: "ses_aaaaaaaaaaaaaaaaaaaaaaaaaa", + cwd: "/home/user/project", + }, + }, + ]); + }); + + it("an event other than session.idle produces no Stop call", async () => { + const { stopCallsFor } = await importGeneratedModule(); + + expect(stopCallsFor({ type: "session.created" }, "/home/user/project")).toEqual([]); + }); + + it("a completed tool part matching the matcher produces the PostToolUse hook's call", async () => { + const { postToolUseCallsFor } = await importGeneratedModule(); + const part = await loadFixture("opencode-tool-part-completed.json"); + + // The captured fixture's own tool is "read"; retarget it at this test's matcher without + // inventing a second capture — the part's shape is what is verified, not the tool name. + const retargeted = JSON.parse(JSON.stringify(part).replace('"tool":"read"', '"tool":"bash"')); + + const calls = postToolUseCallsFor(retargeted, "/home/user/project"); + + expect(calls).toEqual([ + { + script: "on-tool.js", + args: [], + payload: { + hook_event_name: "PostToolUse", + session_id: "ses_aaaaaaaaaaaaaaaaaaaaaaaaaa", + cwd: "/home/user/project", + tool_name: "bash", + tool_input: (retargeted as { properties: { part: { state: { input: unknown } } } }) + .properties.part.state.input, + }, + }, + ]); + }); + + it("a completed tool part whose tool the matcher excludes produces no call", async () => { + const { postToolUseCallsFor } = await importGeneratedModule(); + const part = await loadFixture("opencode-tool-part-completed.json"); // tool: "read" + + expect(postToolUseCallsFor(part, "/home/user/project")).toEqual([]); + }); +}); diff --git a/cli/tests/contexts/tools/domain/profiles/opencode/opencode-hooks-bridge.unit.test.ts b/cli/tests/contexts/tools/domain/profiles/opencode/opencode-hooks-bridge.unit.test.ts new file mode 100644 index 000000000..e63967f37 --- /dev/null +++ b/cli/tests/contexts/tools/domain/profiles/opencode/opencode-hooks-bridge.unit.test.ts @@ -0,0 +1,158 @@ +import { describe, expect, it } from "vitest"; +import { generateOpencodeHooksBridge } from "../../../../../../src/contexts/tools/domain/profiles/opencode/opencode-hooks-bridge.js"; + +// Built rather than written as a literal "${CLAUDE_PLUGIN_ROOT}" string: biome reads a plain +// string holding "${...}" as a forgotten template literal. +const ROOT = "$" + "{CLAUDE_PLUGIN_ROOT}"; + +const THREE_EVENT_HOOKS_JSON = JSON.stringify({ + hooks: { + SessionStart: [ + { hooks: [{ type: "command", command: `node ${ROOT}/hooks/update_memory.js` }] }, + ], + Stop: [{ hooks: [{ type: "command", command: `node ${ROOT}/hooks/journal.cjs turn-end` }] }], + PostToolUse: [ + { + matcher: "Bash", + hooks: [{ type: "command", command: `node ${ROOT}/hooks/journal.cjs tool-used` }], + }, + ], + }, +}); + +describe("generateOpencodeHooksBridge", () => { + it("generates a bridge module for a hooks.json naming all three mapped events", () => { + const generated = generateOpencodeHooksBridge(THREE_EVENT_HOOKS_JSON, "aidd-sample"); + + expect(generated).toMatchInlineSnapshot(` + "// Generated by aidd from plugins/aidd-sample/hooks/hooks.json - do not edit by hand. + // OpenCode's plugin loader scans no "hooks" family and this profile writes no hooks.json + // (build.ts's skipHooksJson, translated here rather than skipped) - this file is the only + // trigger this plugin's declared hooks have on OpenCode. See opencode-hooks-bridge.ts for + // the mapping this generator applies and the measurements behind it. + import { spawn } from "node:child_process"; + import { fileURLToPath } from "node:url"; + + // Never process.execPath: OpenCode ships as its own standalone binary (see + // plugins/aidd-telemetry/hooks/opencode-plugin.js:29-30) - that path names \`opencode\` + // itself, not a Node runtime able to run this plugin's own hook scripts. + const HOOKS_DIR = fileURLToPath(new URL("../hooks/aidd-sample/", import.meta.url)); + + const SESSION_START = [{"script":"update_memory.js","args":[]}]; + const STOP = [{"script":"journal.cjs","args":["turn-end"]}]; + const POST_TOOL_USE = [{"script":"journal.cjs","args":["tool-used"],"matcher":"Bash"}]; + + // Asynchronous on purpose, unlike opencode-plugin.js's own spawnSync: that file spawns at + // most one script per event, this one can spawn one per matching hook across every mapped + // event, and blocking OpenCode's event loop once per hook multiplies the cost its own + // comment already accepts for a single call. A failed spawn (ENOENT, a killed timeout) + // must not throw past this function - both listeners below, plus the caller's own + // try/catch, exist because a spawn that never launches can still throw on the stdin write. + function runHook(script, args, payload, directory) { + const child = spawn("node", [HOOKS_DIR + script, ...args], { + cwd: directory, + stdio: ["pipe", "ignore", "ignore"], + timeout: 5000, + }); + child.on("error", () => {}); + child.stdin.on("error", () => {}); + child.stdin.end(JSON.stringify(payload)); + } + + /** Pure: \`session.idle\` -> every Stop hook's own {script, args, payload} - or \`[]\` for + * any other event. Exported as a property (never a second named export - F6) so this + * generated module's own mapping can be asserted without spawning anything, the same seam + * \`AiddTelemetry.journalCallFor\` already gives opencode-plugin.js. */ + function stopCallsFor(event, directory) { + if (event?.type !== "session.idle") return []; + const sessionId = event.properties?.sessionID; + return STOP.map((hook) => ({ + script: hook.script, + args: hook.args, + payload: { hook_event_name: "Stop", session_id: sessionId ?? null, cwd: directory }, + })); + } + + /** Pure: \`message.part.updated\` for a completed tool part -> every PostToolUse hook whose + * matcher (absent, or an exact / pipe-separated tool name) allows this tool - or \`[]\` for + * any other event, an incomplete part, or one naming no tool. */ + function postToolUseCallsFor(event, directory) { + if (event?.type !== "message.part.updated") return []; + const part = event.properties?.part; + if (part?.type !== "tool" || part.state?.status !== "completed") return []; + const toolName = part.tool; + const sessionId = event.properties?.sessionID; + const matches = (matcher) => !matcher || matcher.split("|").includes(toolName); + return POST_TOOL_USE.filter((hook) => matches(hook.matcher)).map((hook) => ({ + script: hook.script, + args: hook.args, + payload: { + hook_event_name: "PostToolUse", + session_id: sessionId ?? null, + cwd: directory, + tool_name: toolName, + tool_input: part.state.input, + }, + })); + } + + export const AiddSampleHooks = async (input) => { + // SessionStart's own approximation (module doc comment above): fired once here, never + // per session. Silent on purpose, the same rule journal.cjs's own main() and + // opencode-plugin.js's own event handler both state: a measurement or a memory refresh + // that breaks OpenCode's own startup is worse than one that never ran. + try { + for (const hook of SESSION_START) { + runHook( + hook.script, + hook.args, + { hook_event_name: "SessionStart", session_id: null, cwd: input.directory }, + input.directory + ); + } + } catch { + // Silent on purpose - see above. + } + return { + event: async ({ event }) => { + try { + const calls = [ + ...stopCallsFor(event, input.directory), + ...postToolUseCallsFor(event, input.directory), + ]; + for (const call of calls) { + runHook(call.script, call.args, call.payload, input.directory); + } + } catch { + // Silent on purpose - see above. + } + }, + }; + }; + + AiddSampleHooks.stopCallsFor = stopCallsFor; + AiddSampleHooks.postToolUseCallsFor = postToolUseCallsFor; + " + `); + }); + + it("returns null for a hooks.json naming only an unmapped event", () => { + const hooksJson = JSON.stringify({ + hooks: { PreToolUse: [{ hooks: [{ type: "command", command: `${ROOT}/hooks/check.sh` }] }] }, + }); + + expect(generateOpencodeHooksBridge(hooksJson, "aidd-test")).toBeNull(); + }); + + it("returns null for a hooks.json with no hooks at all", () => { + expect(generateOpencodeHooksBridge(JSON.stringify({ hooks: {} }), "aidd-test")).toBeNull(); + }); + + it("drops a hook whose command does not invoke node against its own hooks/ script", () => { + const hooksJson = JSON.stringify({ + hooks: { Stop: [{ hooks: [{ type: "command", command: `${ROOT}/hooks/check.sh` }] }] }, + }); + + expect(generateOpencodeHooksBridge(hooksJson, "aidd-test")).toBeNull(); + }); +}); diff --git a/cli/tests/domain/tools/ide/vscode.unit.test.ts b/cli/tests/contexts/tools/domain/profiles/vscode.unit.test.ts similarity index 94% rename from cli/tests/domain/tools/ide/vscode.unit.test.ts rename to cli/tests/contexts/tools/domain/profiles/vscode.unit.test.ts index 863ee4ca3..f964366ba 100644 --- a/cli/tests/domain/tools/ide/vscode.unit.test.ts +++ b/cli/tests/contexts/tools/domain/profiles/vscode.unit.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { vscodeToolConfig } from "../../../../src/domain/tools/ide/vscode.js"; +import { vscodeToolConfig } from "../../../../../src/contexts/tools/domain/profiles/vscode/profile.js"; describe("vscodeToolConfig", () => { describe("settings capabilities", () => { diff --git a/cli/tests/contexts/tools/domain/registry-conformance.unit.test.ts b/cli/tests/contexts/tools/domain/registry-conformance.unit.test.ts new file mode 100644 index 000000000..052803d28 --- /dev/null +++ b/cli/tests/contexts/tools/domain/registry-conformance.unit.test.ts @@ -0,0 +1,409 @@ +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; +// Side-effect imports: registering every shipped tool is what makes this suite meaningful. +// A tool missing here would silently escape conformance, so the list must stay complete. +import "../../../../src/contexts/tools/domain/profiles/claude/profile.js"; +import "../../../../src/contexts/tools/domain/profiles/codex/profile.js"; +import "../../../../src/contexts/tools/domain/profiles/copilot/profile.js"; +import "../../../../src/contexts/tools/domain/profiles/cursor/profile.js"; +import "../../../../src/contexts/tools/domain/profiles/opencode/profile.js"; +import "../../../../src/contexts/tools/domain/profiles/vscode/profile.js"; +import type { ToolBuildContract } from "../../../../src/contexts/tools/domain/build-contract.js"; +import type { AiTool } from "../../../../src/contexts/tools/domain/contracts.js"; +import { hasRules } from "../../../../src/contexts/tools/domain/contracts.js"; +import { + frameworkBuildModeFor, + getAllRegisteredTools, + getToolConfig, + isAiTool, + journalHostToAiToolId, + machineLocalFilesOf, + projectHooksFileOf, + userMachineLocalFilesOf, +} from "../../../../src/contexts/tools/domain/registry.js"; +import { + buildTargetModesOf, + frameworkBuildTargetModes, +} from "../../../../src/contexts/translate/domain/build-target.js"; +import { + distributionProbesOf, + marketplaceProbes, +} from "../../../../src/contexts/translate/domain/plugin-format.js"; +import type { TelemetryLocalRead } from "../../../../src/kernel/measurement.js"; +import { AI_TOOL_IDS, type ToolId } from "../../../../src/kernel/tool.js"; +import { journalHost } from "../../../helpers/telemetry-journal-hook.js"; + +/** Every assertion iterates the registry rather than a hardcoded list, so adding a tool file + * subjects it to all of them instead of letting it misbehave at runtime. */ + +const registeredAiTools: [string, AiTool][] = [ + ...getAllRegisteredTools().entries(), +].flatMap(([id, config]) => + isAiTool(config) ? [[id as string, config] as [string, AiTool]] : [] +); + +describe("AiTool contract conformance", () => { + it("the registry actually contains tools (guards against a no-op suite)", () => { + expect(registeredAiTools.length).toBeGreaterThan(0); + }); + + describe.each(registeredAiTools)("%s", (toolId, tool) => { + it("has a well-formed AiTool shape", () => { + expect(tool.kind, `${toolId}: kind must be "ai"`).toBe("ai"); + expect(tool.toolId, `${toolId}: toolId must match its registry key`).toBe(toolId); + expect( + typeof tool.directory === "string" && tool.directory.length > 0, + `${toolId}: directory must be a non-empty string` + ).toBe(true); + expect(tool.directory.endsWith("/"), `${toolId}: directory must end with "/"`).toBe(true); + expect( + typeof tool.toolSuffix === "string" && tool.toolSuffix.startsWith("."), + `${toolId}: toolSuffix must be a string starting with "."` + ).toBe(true); + expect( + tool.signalDir === null || typeof tool.signalDir === "string", + `${toolId}: signalDir must be a string or null` + ).toBe(true); + expect( + typeof tool.capabilities === "object" && tool.capabilities !== null, + `${toolId}: capabilities must be an object` + ).toBe(true); + }); + + it("implements every required content method", () => { + for (const method of ["rewriteContent"] as const) { + expect(typeof tool[method], `${toolId}: ${method} must be a function`).toBe("function"); + } + }); + + it("is declared in AI_TOOL_IDS", () => { + expect( + (AI_TOOL_IDS as readonly string[]).includes(toolId), + `${toolId} is registered but missing from AI_TOOL_IDS (kernel/tool.ts)` + ).toBe(true); + }); + + it("is reachable by at least one framework build target/mode", () => { + expect( + frameworkBuildTargetModes().some((entry) => entry.target === toolId), + `${toolId} is registered but declares no buildContracts — 'aidd translate --to ${toolId}' would be rejected` + ).toBe(true); + }); + + it("is ingestible when it declares a plugins capability", () => { + const declaresPlugins = "plugins" in (tool.capabilities as object); + if (!declaresPlugins) return; + expect( + marketplaceProbes().some((probe) => probe.format === toolId), + `${toolId} declares a plugins capability but its profile declares no marketplace probe — its native marketplace would never be detected` + ).toBe(true); + }); + + // A project-local `marketplaceSettings` declaration was measured to load nothing in + // Claude: the runtime reads its own user-global registry, which `nativeActivation` drives. + it("drives native CLI activation when its plugins capability declares marketplaceSettings", () => { + const caps = tool.capabilities as { + plugins?: { marketplaceSettings?: unknown; nativeActivation?: unknown }; + }; + if (caps.plugins?.marketplaceSettings == null) return; + expect( + caps.plugins.nativeActivation, + `${toolId} declares marketplaceSettings without nativeActivation — its settings.json declaration is never registered with the runtime that resolves plugins` + ).not.toBeNull(); + }); + + // Same shape guard for local-read: the type system requires `telemetryLocalRead` to + // exist, but not that its `kind` is one of the two this union defines. + it("declares its local-read shape as declared or explicitly unsupported", () => { + const kinds: readonly TelemetryLocalRead["kind"][] = ["declared", "unsupported"]; + expect( + kinds, + `${toolId} declares an unrecognized telemetryLocalRead kind: ${tool.telemetryLocalRead.kind}` + ).toContain(tool.telemetryLocalRead.kind); + if (tool.telemetryLocalRead.kind === "unsupported") { + expect( + tool.telemetryLocalRead.reason.length, + `${toolId}: telemetryLocalRead.reason must not be empty` + ).toBeGreaterThan(0); + } + }); + }); +}); + +// Cursor's local-read reason is a measured fact, not a guess; Copilot is read at session +// rather than request granularity. +describe("telemetryLocalRead — exact declarations, phase 2 of local-cost-read", () => { + const EXPECTED: Record = { + claude: { kind: "declared" }, + codex: { kind: "declared" }, + opencode: { kind: "declared" }, + copilot: { kind: "declared" }, + cursor: { kind: "unsupported", reason: "token count" }, + }; + + it.each(Object.entries(EXPECTED))("%s", (toolId, expected) => { + const tool = registeredAiTools.find(([id]) => id === toolId)?.[1]; + if (!tool) throw new Error(`${toolId} is not registered`); + + const shape = tool.telemetryLocalRead; + expect(shape.kind).toBe(expected.kind); + if (shape.kind === "unsupported" && expected.reason) { + expect(shape.reason).toContain(expected.reason); + } + }); + + it("covers exactly the five registered AI tools — no tool escapes this check", () => { + expect(Object.keys(EXPECTED).sort()).toEqual(registeredAiTools.map(([id]) => id).sort()); + }); +}); + +describe("no parallel list references an unregistered tool", () => { + it("every AI_TOOL_IDS entry resolves to a registered AI tool", () => { + for (const id of AI_TOOL_IDS) { + const config = getToolConfig(id); + expect(isAiTool(config), `AI_TOOL_IDS lists "${id}" but its config is not an AI tool`).toBe( + true + ); + } + }); + + it("every host the journal hook writes for is claimed by exactly one tool declaration", () => { + // These declarations relate the hook's own host name to a toolId, so a host the hook + // writes for and nothing declares joins to nothing, silently. + for (const host of journalHost.DECLARED_HOSTS) { + expect( + journalHostToAiToolId(host), + `the journal hook writes for host "${host}", which no registered AI tool declares as its telemetryJournalHost` + ).not.toBeNull(); + } + }); + + it("declares no journal host the hook does not write for", () => { + for (const [toolId, config] of registeredAiTools) { + const declared = config.telemetryJournalHost; + if (declared === undefined) continue; + expect( + journalHost.DECLARED_HOSTS.has(declared), + `"${toolId}" declares telemetryJournalHost "${declared}", which the journal hook never writes` + ).toBe(true); + } + }); + + it("resolves an unknown host to null rather than to a nearby tool", () => { + expect(journalHostToAiToolId("not-a-host")).toBeNull(); + }); + + it("declares task attributability exactly where journal attribution is possible at all", () => { + // A declared task carries no per-host gate the way a written path or a step does, so + // attributability collapses to whether a host reaches the journal hook at all. + for (const [toolId, config] of registeredAiTools) { + const host = config.telemetryJournalHost; + const hookReachesToolUse = host !== undefined; + + expect( + config.telemetryTaskAttributable, + `"${toolId}" declares telemetryTaskAttributable ${config.telemetryTaskAttributable}, but the journal hook ${hookReachesToolUse ? "does" : "never"} dispatch a tool-used event for host "${host}"` + ).toBe(hookReachesToolUse); + } + }); + + it("declares what its local-read route supplies, for every tool", () => { + for (const [toolId, config] of registeredAiTools) { + const declaration = config.telemetryLocalRead; + if (declaration.kind !== "declared") continue; + expect( + declaration.supplies, + `"${toolId}" declares a telemetryLocalRead route without saying what it supplies` + ).toBeDefined(); + } + }); +}); + +/** A real contract that builds nothing. `buildTargetModesOf` reads which keys a profile + * declares, never what a contract holds, so the emptiest valid one says exactly that. */ +const unsupportedContract: ToolBuildContract = { + manifestFileRelative: null, + synthesizeManifest: null, + manifestSchemaName: null, + artifacts: { + skills: { supported: false }, + agents: { supported: false }, + mcp: { supported: false }, + hooks: { supported: false }, + rules: { supported: false }, + commands: { supported: false }, + }, + buildMarketplaceCatalog: null, + buildMarketplaceEntry: null, +}; + +/** A profile reduced to the two fields each derivation reads. */ +function fakeTool(overrides: Partial>): AiTool { + return { + kind: "ai", + toolId: "claude", + displayName: "Fake", + directory: ".fake/", + toolSuffix: ".md", + signalDir: null, + capabilities: {}, + telemetryLocalRead: { kind: "unsupported", reason: "a stub reads nothing" }, + telemetryTaskAttributable: false, + rewriteContent: (content) => content, + ...overrides, + }; +} + +function registryOf(...tools: AiTool[]): ReadonlyMap> { + return new Map(tools.map((tool) => [tool.toolId, tool])); +} + +describe("buildTargetModesOf()", () => { + it("gives a tool one pair per contract it declares, and none for what it omits", () => { + const contract = () => unsupportedContract; + const modes = buildTargetModesOf( + registryOf( + fakeTool({ toolId: "claude", buildContracts: { marketplace: contract, flat: contract } }), + fakeTool({ toolId: "opencode", buildContracts: { flat: contract } }) + ) + ); + expect(modes).toEqual([ + { target: "claude", mode: "marketplace" }, + { target: "claude", mode: "flat" }, + { target: "opencode", mode: "flat" }, + ]); + }); + + it("excludes a registered tool that declares no build contract at all", () => { + expect(buildTargetModesOf(registryOf(fakeTool({ toolId: "cursor" })))).toEqual([]); + }); +}); + +describe("distributionProbesOf()", () => { + // Order is behaviour: the reader takes the first probe that resolves, and a bare + // `plugin.json` at the root is satisfied by almost any directory. + it("puts the deepest path first and a bare filename last", () => { + const probes = distributionProbesOf( + registryOf( + fakeTool({ toolId: "claude", distributionProbes: { manifest: ["plugin.json"] } }), + fakeTool({ + toolId: "copilot", + distributionProbes: { manifest: [".plugin/plugin.json", ".a/b/plugin.json"] }, + }) + ), + "manifest" + ); + expect(probes.map((probe) => probe.relativePath)).toEqual([ + ".a/b/plugin.json", + ".plugin/plugin.json", + "plugin.json", + ]); + }); + + it("reads the kind it was asked for, and nothing from a profile that declares none", () => { + const tools = registryOf( + fakeTool({ toolId: "claude", distributionProbes: { marketplace: ["m.json"] } }), + fakeTool({ toolId: "cursor" }) + ); + expect(distributionProbesOf(tools, "marketplace")).toEqual([ + { format: "claude", relativePath: "m.json" }, + ]); + expect(distributionProbesOf(tools, "manifest")).toEqual([]); + }); +}); + +describe("frameworkBuildModeFor()", () => { + it("gives a flat tool a flat build", () => { + expect(frameworkBuildModeFor("opencode")).toBe("flat"); + }); + + it("gives a native tool a marketplace build", () => { + expect(frameworkBuildModeFor("claude")).toBe("marketplace"); + }); + + it("reads every tool's mode from its profile, never from its name", () => { + for (const toolId of AI_TOOL_IDS) { + const config = getToolConfig(toolId); + if (!isAiTool(config)) continue; + const caps = config.capabilities as { plugins?: { mode?: string } }; + const expected = caps.plugins?.mode === "flat" ? "flat" : "marketplace"; + expect(frameworkBuildModeFor(toolId), toolId).toBe(expected); + } + }); + + it("defaults an IDE tool with no plugins capability to marketplace", () => { + // vscode is `kind: "ide"` and declares no `plugins` capability at all — the one + // shipped tool that exercises the "no capability" branch, every AI tool declares one. + expect(frameworkBuildModeFor("vscode")).toBe("marketplace"); + }); +}); + +describe("machineLocalFilesOf()", () => { + // `status` skips these files by comparing the path a profile declares against the one it + // builds from the tool directory, so a path declared any other way stops being skipped. + it("declares every machine-local file project-relative, inside its own tool directory", () => { + for (const toolId of AI_TOOL_IDS) { + const config = getToolConfig(toolId); + if (!isAiTool(config)) continue; + for (const relativePath of machineLocalFilesOf(toolId)) { + expect(relativePath.startsWith(config.directory), `${toolId}: ${relativePath}`).toBe(true); + } + } + }); + + it("returns claude's .claude/settings.local.json", () => { + expect(machineLocalFilesOf("claude")).toContain(".claude/settings.local.json"); + }); + + // Its content is project-relative and shareable, unlike the absolute-path content this + // function keeps out of the gitignore; `projectHooksFileOf` carries that file instead. + it("does not carry cursor's project hooks file", () => { + expect(machineLocalFilesOf("cursor")).not.toContain(".cursor/hooks.json"); + }); +}); + +describe("userMachineLocalFilesOf()", () => { + it("returns claude's user-scope settings file, absolute under the given homedir", () => { + expect(userMachineLocalFilesOf("claude", "/home/tester", () => undefined)).toEqual([ + join("/home/tester", ".claude", "settings.json"), + ]); + }); + + it("returns nothing for a tool whose profile declares no userSettingsPath", () => { + expect(userMachineLocalFilesOf("cursor", "/home/tester", () => undefined)).toEqual([]); + }); +}); + +describe("projectHooksFileOf()", () => { + it("returns .cursor/hooks.json for cursor", () => { + expect(projectHooksFileOf("cursor")).toBe(".cursor/hooks.json"); + }); + + it("returns undefined for a tool with nothing merged into a project hooks file", () => { + expect(projectHooksFileOf("claude")).toBeUndefined(); + }); +}); + +/** Pinned as a table rather than described, so a tool whose install path moves — or a sixth + * tool added with rules — fails here instead of drifting away from the installer quietly. */ +describe("every tool says where its own installed rules live", () => { + const EXPECTED: Readonly> = { + claude: { directory: ".claude/rules/", extension: ".md" }, + codex: { directory: ".codex/rules/", extension: ".md" }, + copilot: { directory: ".github/instructions/", extension: ".instructions.md" }, + cursor: { directory: ".cursor/rules/", extension: ".mdc" }, + opencode: { directory: ".opencode/rules/", extension: ".md" }, + }; + + it("answers the directory and extension each one actually installs into", () => { + const answered = Object.fromEntries( + AI_TOOL_IDS.map((id) => { + const tool = getToolConfig(id); + const rules = isAiTool(tool) && hasRules(tool) ? tool.capabilities.rules : undefined; + return [id, rules?.installedLocation() ?? null]; + }) + ); + + expect(answered).toEqual(EXPECTED); + }); +}); diff --git a/cli/tests/contexts/tools/domain/telemetry-route-supply.unit.test.ts b/cli/tests/contexts/tools/domain/telemetry-route-supply.unit.test.ts new file mode 100644 index 000000000..2c9191acb --- /dev/null +++ b/cli/tests/contexts/tools/domain/telemetry-route-supply.unit.test.ts @@ -0,0 +1,153 @@ +import { readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { describe, expect, it } from "vitest"; +import "../../../../src/contexts/tools/domain/profiles/claude/profile.js"; +import "../../../../src/contexts/tools/domain/profiles/codex/profile.js"; +import "../../../../src/contexts/tools/domain/profiles/copilot/profile.js"; +import "../../../../src/contexts/tools/domain/profiles/cursor/profile.js"; +import "../../../../src/contexts/tools/domain/profiles/opencode/profile.js"; +import { mapClaudeCodeTranscriptToSinkRecords } from "../../../../src/contexts/telemetry/domain/formats/claude-code-transcript.js"; +import { mapCodexRolloutToSinkRecords } from "../../../../src/contexts/telemetry/domain/formats/codex-rollout.js"; +import { mapCopilotEventsToSinkRecords } from "../../../../src/contexts/telemetry/domain/formats/copilot-events.js"; +import { mapOpencodeExportToSinkRecords } from "../../../../src/contexts/telemetry/domain/formats/opencode-export.js"; +import type { TelemetrySinkRecord } from "../../../../src/contexts/telemetry/domain/telemetry-sink-record.js"; +import { getAiToolConfig } from "../../../../src/contexts/tools/domain/registry.js"; +import type { TelemetryRouteSupply } from "../../../../src/kernel/measurement.js"; +import { AI_TOOL_IDS, type AiToolId } from "../../../../src/kernel/tool.js"; + +/** A declaration is checked against what the captures measured, never the documentation, so + * a route claiming an amount its reader never sets fails here. Local read is the only route. */ +type Route = "local"; + +function fixture(relativePath: string): string { + return readFileSync( + fileURLToPath(new URL(`../../../fixtures/${relativePath}`, import.meta.url)), + { + encoding: "utf8", + } + ); +} + +const CLAUDE_SESSION = "22222222-2222-4222-8222-222222222222"; +const CODEX_SESSION = "019fae6f-2009-7cd3-86b2-b8f83481b160"; +const COPILOT_SESSION = "33333333-3333-4333-8333-333333333333"; + +/** Whatever a capture yields, reduced to the four facts a route declares. */ +function observe(records: readonly Partial[]): TelemetryRouteSupply { + const some = (has: (record: Partial) => boolean) => records.some(has); + return { + tokenCounters: some( + (record) => + record.input_tokens !== undefined || + record.output_tokens !== undefined || + record.cache_read_tokens !== undefined || + record.cache_creation_tokens !== undefined + ), + amount: some((record) => record.cost_usd !== undefined), + toolStatedStep: some((record) => record.step !== undefined), + agentName: some((record) => record.agent_name !== undefined), + }; +} + +const CAPTURES: ReadonlyMap TelemetryRouteSupply> = new Map([ + [ + // Both files, because both are this session's local read, and only the subagent's own + // carries the field the tool uses to name the running skill. + "claude:local", + () => + observe([ + ...mapClaudeCodeTranscriptToSinkRecords( + fixture(`local-cost/.claude/projects/fake-project/${CLAUDE_SESSION}.jsonl`) + ), + ...mapClaudeCodeTranscriptToSinkRecords( + fixture( + `local-cost/.claude/projects/fake-project/${CLAUDE_SESSION}/subagents/agent-aa81cdef3bb58820c.jsonl` + ) + ), + ]), + ], + [ + "codex:local", + () => + observe( + mapCodexRolloutToSinkRecords( + fixture( + `local-cost/.codex/sessions/2026/07/29/rollout-2026-07-29T17-12-26-${CODEX_SESSION}.jsonl` + ) + ) + ), + ], + [ + "copilot:local", + () => + observe( + mapCopilotEventsToSinkRecords( + fixture(`local-cost/.copilot/session-state/${COPILOT_SESSION}/events.jsonl`), + COPILOT_SESSION + ) + ), + ], + [ + "opencode:local", + () => + observe( + mapOpencodeExportToSinkRecords( + JSON.parse(fixture("telemetry-sink/opencode-export.json")), + "ses_probe" + ) + ), + ], +]); + +function declarationOf(tool: AiToolId) { + return getAiToolConfig(tool).telemetryLocalRead; +} + +const route: Route = "local"; + +describe("what a route declares it supplies, against what its reader actually produces", () => { + for (const tool of AI_TOOL_IDS) { + const declaration = declarationOf(tool); + if (declaration.kind !== "declared") continue; + const capture = CAPTURES.get(`${tool}:${route}`); + + if (!capture) { + it(`${tool} declares a ${route} route with no capture, so it may claim nothing`, () => { + // A declared route nobody ever captured carries an identifier and nothing else; + // letting it claim a capability would document a guess as a fact. + expect(declaration.supplies).toEqual({ + tokenCounters: false, + amount: false, + toolStatedStep: false, + agentName: false, + }); + }); + continue; + } + + it(`${tool}'s ${route} route supplies exactly what it declares`, () => { + expect(capture()).toEqual(declaration.supplies); + }); + } + + // Pins `TelemetryLocalRead` at exactly the two kinds a profile can state, so a third + // variant no profile ever produces cannot survive as a branch nothing reaches. + it("declares only 'declared' or 'unsupported' — a route never left unmeasured", () => { + for (const tool of AI_TOOL_IDS) { + expect(declarationOf(tool).kind).toMatch(/^(declared|unsupported)$/u); + } + }); + + it("has a capture for every route that claims to supply anything", () => { + for (const tool of AI_TOOL_IDS) { + const declaration = declarationOf(tool); + if (declaration.kind !== "declared") continue; + const claimsSomething = Object.values(declaration.supplies).some(Boolean); + + expect( + !claimsSomething || CAPTURES.has(`${tool}:${route}`), + `"${tool}" claims its ${route} route supplies something, with no capture to check it against` + ).toBe(true); + } + }); +}); diff --git a/cli/tests/domain/models/tool-config.unit.test.ts b/cli/tests/contexts/tools/domain/tool-config.unit.test.ts similarity index 88% rename from cli/tests/domain/models/tool-config.unit.test.ts rename to cli/tests/contexts/tools/domain/tool-config.unit.test.ts index b745c56a3..7c037aaa1 100644 --- a/cli/tests/domain/models/tool-config.unit.test.ts +++ b/cli/tests/contexts/tools/domain/tool-config.unit.test.ts @@ -1,16 +1,15 @@ import { describe, expect, it } from "vitest"; -import { stripToolSuffix } from "../../../src/domain/formats/command.js"; -import type { AiTool } from "../../../src/domain/tools/contracts.js"; +import type { AiTool } from "../../../../src/contexts/tools/domain/contracts.js"; +import { stripToolSuffix } from "../../../../src/contexts/tools/domain/formats/command.js"; import { - type AiToolId, assertToolIdsMatchCategory, getAllRegisteredTools, getToolConfig, registerTool, - type ToolId, toolIdsForCategory, - VALID_TOOL_IDS, -} from "../../../src/domain/tools/registry.js"; +} from "../../../../src/contexts/tools/domain/registry.js"; +import type { AiToolId, ToolId } from "../../../../src/kernel/tool.js"; +import { VALID_TOOL_IDS } from "../../../../src/kernel/tool.js"; const makeStubConfig = (toolId: AiToolId, toolSuffix: string): AiTool => ({ kind: "ai", @@ -19,12 +18,10 @@ const makeStubConfig = (toolId: AiToolId, toolSuffix: string): AiTool = toolSuffix, signalDir: `.${toolId}/commands`, displayName: toolId, - telemetryLocalRead: { kind: "unmeasured" }, + telemetryLocalRead: { kind: "unsupported", reason: "a stub reads nothing" }, telemetryTaskAttributable: false, capabilities: {}, rewriteContent: (content: string) => content, - reverseRewriteContent: (content: string) => content, - detectUserFileSectionKey: () => null, }); describe("VALID_TOOL_IDS", () => { diff --git a/cli/tests/contexts/tools/infrastructure/executable-on-path.unit.test.ts b/cli/tests/contexts/tools/infrastructure/executable-on-path.unit.test.ts new file mode 100644 index 000000000..f6ee37d5b --- /dev/null +++ b/cli/tests/contexts/tools/infrastructure/executable-on-path.unit.test.ts @@ -0,0 +1,77 @@ +import { describe, expect, it } from "vitest"; +import { + candidateExecutableNames, + type ExecutableLookup, + resolveExecutableOnPath, + runsThroughShell, + windowsCommandLine, +} from "../../../../src/contexts/tools/infrastructure/executable-on-path.js"; + +function lookup( + platform: NodeJS.Platform, + present: readonly string[], + pathExt?: string +): ExecutableLookup { + const files = new Set(present); + return { + platform, + pathExt, + pathEnv: platform === "win32" ? "C:\\tools;C:\\other" : "/usr/local/bin:/usr/bin", + isExecutable: (path) => files.has(path), + }; +} + +describe("finding a tool's own CLI on PATH", () => { + it("on Windows, a bare command stands for the shims npm and installers actually put there", () => { + expect(candidateExecutableNames("claude", "win32", ".EXE;.CMD")).toEqual([ + "claude", + "claude.EXE", + "claude.exe", + "claude.CMD", + "claude.cmd", + ]); + }); + + it("everywhere else, the bare name is the only spelling", () => { + expect(candidateExecutableNames("claude", "linux", ".COM;.EXE")).toEqual(["claude"]); + }); + + it("resolves a Windows machine whose PATH holds only claude.cmd", () => { + const found = resolveExecutableOnPath( + "claude", + lookup("win32", ["C:\\other\\claude.cmd"], ".EXE;.CMD") + ); + expect(found).toBe("C:\\other\\claude.cmd"); + }); + + it("falls back to Windows' own default extensions when PATHEXT is unset", () => { + const found = resolveExecutableOnPath("codex", lookup("win32", ["C:\\tools\\codex.CMD"])); + expect(found).toBe("C:\\tools\\codex.CMD"); + }); + + it("answers nothing when no spelling is executable anywhere on PATH", () => { + expect(resolveExecutableOnPath("claude", lookup("linux", ["/opt/claude"]))).toBeUndefined(); + }); + + it("runs a batch shim through the interpreter and anything else directly", () => { + expect(runsThroughShell("C:\\tools\\claude.cmd")).toBe(true); + expect(runsThroughShell("C:\\tools\\claude.BAT")).toBe(true); + expect(runsThroughShell("C:\\tools\\claude.exe")).toBe(false); + expect(runsThroughShell("/usr/bin/claude")).toBe(false); + }); + + it("quotes only the arguments the interpreter would otherwise split or interpret", () => { + expect( + windowsCommandLine("C:\\tools\\claude.cmd", [ + "plugin", + "marketplace", + "add", + "C:\\Users\\Jane Doe\\.aidd\\cache", + "--scope", + "local", + ]) + ).toBe( + 'C:\\tools\\claude.cmd plugin marketplace add "C:\\Users\\Jane Doe\\.aidd\\cache" --scope local' + ); + }); +}); diff --git a/cli/tests/contexts/tools/infrastructure/host-marketplace-registry-reader-adapter.integration.test.ts b/cli/tests/contexts/tools/infrastructure/host-marketplace-registry-reader-adapter.integration.test.ts new file mode 100644 index 000000000..6129034b6 --- /dev/null +++ b/cli/tests/contexts/tools/infrastructure/host-marketplace-registry-reader-adapter.integration.test.ts @@ -0,0 +1,119 @@ +import { mkdir, mkdtemp, realpath, rm, symlink, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +// hostMarketplaceRegistryReaders iterates every AI_TOOL_IDS entry, so every profile +// must be registered here — not just claude's, whose reader this file exercises. +import "../../../../src/contexts/tools/domain/profiles/claude/profile.js"; +import "../../../../src/contexts/tools/domain/profiles/codex/profile.js"; +import "../../../../src/contexts/tools/domain/profiles/copilot/profile.js"; +import "../../../../src/contexts/tools/domain/profiles/cursor/profile.js"; +import "../../../../src/contexts/tools/domain/profiles/opencode/profile.js"; +import { nativeActivationOf } from "../../../../src/contexts/tools/domain/registry.js"; +import { hostMarketplaceRegistryReaders } from "../../../../src/contexts/tools/infrastructure/host-marketplace-registry-reader-adapter.js"; + +let home: string; + +beforeEach(async () => { + // realpath'd once here so every fixture path already matches the reader's own realpath: + // macOS aliases its own tmpdir under a symlink (`/var` -> `/private/var`). + home = await realpath(await mkdtemp(join(tmpdir(), "aidd-host-marketplace-registry-"))); +}); + +afterEach(async () => { + await rm(home, { recursive: true, force: true }); +}); + +/** Read off claude's own profile, never a literal, so this path cannot drift from + * `profile.ts`'s own `marketplaceRegistry`. */ +function registryPath(): string { + const resolver = nativeActivationOf("claude")?.marketplaceRegistry; + if (resolver === undefined) throw new Error("claude's profile declares no marketplaceRegistry"); + return resolver(home); +} + +async function write(content: string): Promise { + const path = registryPath(); + await mkdir(dirname(path), { recursive: true }); + await writeFile(path, content, "utf8"); +} + +function reader() { + const found = hostMarketplaceRegistryReaders(home).get("claude"); + if (found === undefined) throw new Error("no reader declared for claude"); + return found; +} + +describe("Claude Code's own known_marketplaces.json", () => { + it("reads the name and the installLocation it resolves to", async () => { + const target = join(home, "srcA"); + await mkdir(target, { recursive: true }); + await write( + JSON.stringify({ + "probe-mkt": { + source: { source: "directory", path: target }, + installLocation: target, + lastUpdated: "2026-09-07T00:00:00.000Z", + }, + }) + ); + + const reading = await reader().read(); + + expect(reading.entries?.get("probe-mkt")).toBe(target); + }); + + it("resolves an installLocation reached through a symlink to its real target", async () => { + const realTarget = join(home, "real-src"); + const linked = join(home, "linked-src"); + await mkdir(realTarget, { recursive: true }); + await symlink(realTarget, linked); + await write( + JSON.stringify({ "probe-mkt": { source: {}, installLocation: linked, lastUpdated: "x" } }) + ); + + const reading = await reader().read(); + + // Two writes of "the same" directory, one straight and one through the link, must compare + // equal once both go through realpath. + expect(reading.entries?.get("probe-mkt")).toBe(realTarget); + }); + + it("says a registry that has never existed is absent, never unreadable", async () => { + const reading = await reader().read(); + + expect(reading.entries).toBeUndefined(); + expect(reading.absent).toBe(true); + expect(reading.unreadable).toBeUndefined(); + }); + + it("says a registry path it cannot read for any other reason is unreadable, never absent", async () => { + // A directory where the registry file should be: ENOENT never fires, EISDIR does — the + // shape ENOENT-only handling would wrongly report as absent. + await mkdir(registryPath(), { recursive: true }); + + const reading = await reader().read(); + + expect(reading.entries).toBeUndefined(); + expect(reading.absent).toBeUndefined(); + expect(reading.unreadable).toBeDefined(); + }); + + it("reads an empty registry as an empty answer, not as unreadable", async () => { + await write(JSON.stringify({})); + + const reading = await reader().read(); + + expect(reading.entries?.size).toBe(0); + expect(reading.unreadable).toBeUndefined(); + }); + + it("reads malformed JSON as unreadable, never as carrying no marketplaces", async () => { + await write("// managed automatically\n{ not json"); + + const reading = await reader().read(); + + expect(reading.entries).toBeUndefined(); + expect(reading.unreadable).toBeDefined(); + }); +}); diff --git a/cli/tests/contexts/tools/infrastructure/host-plugin-registry-reader-adapter.integration.test.ts b/cli/tests/contexts/tools/infrastructure/host-plugin-registry-reader-adapter.integration.test.ts new file mode 100644 index 000000000..3bd5298b8 --- /dev/null +++ b/cli/tests/contexts/tools/infrastructure/host-plugin-registry-reader-adapter.integration.test.ts @@ -0,0 +1,313 @@ +import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { hostPluginRegistryReaders } from "../../../../src/contexts/tools/infrastructure/host-plugin-registry-reader-adapter.js"; +import type { AiToolId } from "../../../../src/kernel/tool.js"; + +/** + * Fixtures carry the recorded shape only: a real machine's file holds hashed experiment + * keys, absolute project paths and somebody's marketplaces, none of it publishable. + */ +const PROJECT = "/repo/mine"; + +let home: string; + +beforeEach(async () => { + home = await mkdtemp(join(tmpdir(), "aidd-host-registry-")); +}); + +afterEach(async () => { + await rm(home, { recursive: true, force: true }); +}); + +async function write(relative: string, content: string): Promise { + const path = join(home, relative); + await mkdir(join(path, ".."), { recursive: true }); + await writeFile(path, content, "utf8"); +} + +function readerFor(tool: AiToolId) { + const reader = hostPluginRegistryReaders(home).get(tool); + if (reader === undefined) throw new Error(`no reader declared for ${tool}`); + return reader; +} + +describe("Claude Code's own installed_plugins.json", () => { + const PATH = ".claude/plugins/installed_plugins.json"; + + it("counts a ref installed for this project, and one installed for the machine", async () => { + await write( + PATH, + JSON.stringify({ + version: 1, + plugins: { + "aidd-telemetry@aidd-framework": [{ scope: "project", projectPath: PROJECT }], + "aidd-dev@aidd-framework": [{ scope: "user" }], + }, + }) + ); + + const reading = await readerFor("claude").read(PROJECT); + + expect(reading.refs?.get("aidd-telemetry@aidd-framework")).toEqual({ + enabled: true, + scope: "project", + }); + expect(reading.refs?.get("aidd-dev@aidd-framework")).toEqual({ enabled: true, scope: "user" }); + }); + + /** + * `aidd` registers every plugin at project scope (`native-plugin-cli-adapter.ts`'s + * `PROJECT_SCOPE_ARGS`), so an entry's `projectPath` decides whether this host loads it here. + */ + it("does not count a ref installed only for another project", async () => { + await write( + PATH, + JSON.stringify({ + version: 1, + plugins: { + "aidd-telemetry@aidd-framework": [{ scope: "project", projectPath: "/repo/theirs" }], + }, + }) + ); + + const reading = await readerFor("claude").read(PROJECT); + + expect(reading.refs?.has("aidd-telemetry@aidd-framework")).toBe(false); + // A ref absent from a map that exists is `not-registered`, never the `unanswerable` an + // unread registry produces. + expect(reading.unreadable).toBeUndefined(); + }); + + it("counts a ref carrying one entry for this project among entries for others", async () => { + await write( + PATH, + JSON.stringify({ + version: 1, + plugins: { + "aidd-telemetry@aidd-framework": [ + { scope: "project", projectPath: "/repo/theirs" }, + { scope: "project", projectPath: PROJECT }, + ], + }, + }) + ); + + expect((await readerFor("claude").read(PROJECT)).refs?.size).toBe(1); + }); + + it("ignores an entry that names neither a scope nor a project", async () => { + await write( + PATH, + JSON.stringify({ + version: 1, + plugins: { "aidd-telemetry@aidd-framework": [{ version: "1" }] }, + }) + ); + + expect((await readerFor("claude").read(PROJECT)).refs?.size).toBe(0); + }); + + it("reads an empty registry as an empty answer, not as unreadable", async () => { + await write(PATH, JSON.stringify({ version: 1, plugins: {} })); + + const reading = await readerFor("claude").read(PROJECT); + + expect(reading.refs?.size).toBe(0); + expect(reading.unreadable).toBeUndefined(); + }); + + it("says it could not read an absent registry, and carries no refs at all", async () => { + const reading = await readerFor("claude").read(PROJECT); + + expect(reading.refs).toBeUndefined(); + expect(reading.unreadable).toBe("ENOENT"); + }); + + /** + * Copilot's own `~/.copilot/config.json` opens with two `//` lines, so a registry that + * looks like JSON and turns out to be JSONC is a file a reader really does meet. + */ + it("reads a JSONC registry as unreadable, never as carrying no plugins", async () => { + await write(PATH, '// managed automatically\n{ "version": 1, "plugins": {} }\n'); + + const reading = await readerFor("claude").read(PROJECT); + + expect(reading.refs).toBeUndefined(); + expect(reading.unreadable).toBeDefined(); + }); + + it("reads a registry with no plugins object as unreadable rather than empty", async () => { + await write(PATH, JSON.stringify({ version: 1 })); + + const reading = await readerFor("claude").read(PROJECT); + + expect(reading.refs).toBeUndefined(); + }); +}); + +describe("Codex's own config.toml", () => { + const PATH = ".codex/config.toml"; + + it("finds its plugin tables among the arbitrary ones around them", async () => { + await write( + PATH, + [ + '[projects."/somewhere/else"]', + 'trust_level = "trusted"', + "", + '[plugins."aidd-telemetry@aidd-framework"]', + "enabled = true", + "", + '[plugins."aidd-dev@aidd-framework"]', + "enabled = false", + "", + '[hooks.state."aidd-telemetry@aidd-framework:hooks/hooks.json:session_start:0:0"]', + 'trusted_hash = "abc"', + "", + ].join("\n") + ); + + const reading = await readerFor("codex").read(PROJECT); + + expect(reading.refs?.get("aidd-telemetry@aidd-framework")).toEqual({ enabled: true }); + expect(reading.refs?.get("aidd-dev@aidd-framework")).toEqual({ enabled: false }); + expect(reading.refs?.size).toBe(2); + }); + + // Codex writes no plugin table without `enabled`. Asserted against the next table rather + // than end-of-file, so it cannot pass by conflating "no key" with "no more input". + it("treats a table with no enabled line as enabled", async () => { + await write( + PATH, + '[plugins."aidd-telemetry@aidd-framework"]\n[plugins."other@elsewhere"]\nenabled = true\n' + ); + + expect( + (await readerFor("codex").read(PROJECT)).refs?.get("aidd-telemetry@aidd-framework") + ).toEqual({ enabled: true }); + }); + + it.each([ + ["a blank line before it", "\nenabled = false\n"], + ["a comment line before it", "# why\nenabled = false\n"], + ["another key before it", 'version = "1"\nenabled = false\n'], + ["a trailing comment on it", "enabled = false # turned off\n"], + ])("finds enabled = false with %s", async (_shape, body) => { + await write(PATH, `[plugins."aidd-telemetry@aidd-framework"]\n${body}`); + + expect( + (await readerFor("codex").read(PROJECT)).refs?.get("aidd-telemetry@aidd-framework") + ).toEqual({ enabled: false }); + }); + + it("finds a plugin whose header carries a trailing comment", async () => { + await write( + PATH, + '[plugins."aidd-telemetry@aidd-framework"] # installed by hand\nenabled = true\n' + ); + + expect( + (await readerFor("codex").read(PROJECT)).refs?.get("aidd-telemetry@aidd-framework") + ).toEqual({ enabled: true }); + }); + + /** + * A header spelled inside a multi-line string is not a table, and TOML forbids the real one + * being defined twice — so exactly one occurrence is real, whichever comes first. + */ + it.each([ + [ + "before the real table", + '[projects."/p"]\nnotes = """\n[plugins."aidd-telemetry@aidd-framework"]\nenabled = true\n"""\n\n[plugins."aidd-telemetry@aidd-framework"]\nenabled = false\n', + ], + [ + "after the real table", + '[plugins."aidd-telemetry@aidd-framework"]\nenabled = false\n\n[projects."/p"]\nnotes = """\n[plugins."aidd-telemetry@aidd-framework"]\nenabled = true\n"""\n', + ], + ])("ignores a header inside a multi-line string, %s", async (_where, content) => { + await write(PATH, content); + + expect( + (await readerFor("codex").read(PROJECT)).refs?.get("aidd-telemetry@aidd-framework") + ).toEqual({ enabled: false }); + }); + + it("stays outside a multi-line string that opens and closes on one line", async () => { + await write( + PATH, + '[projects."/p"]\nnotes = """one line"""\n\n[plugins."aidd-telemetry@aidd-framework"]\nenabled = false\n' + ); + + expect( + (await readerFor("codex").read(PROJECT)).refs?.get("aidd-telemetry@aidd-framework") + ).toEqual({ enabled: false }); + }); + + it("does not read the next table's enabled as this table's", async () => { + await write(PATH, '[plugins."a@m"]\n[plugins."b@m"]\nenabled = false\n'); + + const refs = (await readerFor("codex").read(PROJECT)).refs; + + expect(refs?.get("a@m")).toEqual({ enabled: true }); + expect(refs?.get("b@m")).toEqual({ enabled: false }); + }); + + it("says it could not read an absent config, and carries no refs", async () => { + const reading = await readerFor("codex").read(PROJECT); + + expect(reading.refs).toBeUndefined(); + expect(reading.unreadable).toBe("ENOENT"); + }); +}); + +/** + * Every shape below was driven live against `GitHub Copilot CLI 1.0.82`; the fixtures carry + * that file's shape, never its contents. + */ +describe("Copilot's own settings.json", () => { + const PATH = ".copilot/settings.json"; + + it("reads the refs its enabledPlugins carries", async () => { + await write( + PATH, + JSON.stringify({ + extraKnownMarketplaces: { "aidd-framework": { source: { source: "directory" } } }, + enabledPlugins: { "aidd-telemetry@aidd-framework": true }, + }) + ); + + expect( + (await readerFor("copilot").read(PROJECT)).refs?.get("aidd-telemetry@aidd-framework") + ).toEqual({ enabled: true }); + }); + + // Measured: `copilot plugin uninstall` writes `false` and keeps the key, so + // registered-but-off is an ordinary state on this host. + it("reads an uninstalled plugin as registered and disabled, not as absent", async () => { + await write( + PATH, + JSON.stringify({ enabledPlugins: { "aidd-telemetry@aidd-framework": false } }) + ); + + expect( + (await readerFor("copilot").read(PROJECT)).refs?.get("aidd-telemetry@aidd-framework") + ).toEqual({ enabled: false }); + }); + + // A settings file exists from the first `copilot` run and gains `enabledPlugins` only on + // the first install, so its absence is "carries none" — a real answer, not a failed read. + it("reads a settings file with no enabledPlugins as carrying none", async () => { + await write(PATH, JSON.stringify({ extraKnownMarketplaces: {} })); + + const reading = await readerFor("copilot").read(PROJECT); + + expect(reading.refs?.size).toBe(0); + expect(reading.unreadable).toBeUndefined(); + }); + + it("says it could not read an absent settings file", async () => { + expect((await readerFor("copilot").read(PROJECT)).unreadable).toBe("ENOENT"); + }); +}); diff --git a/cli/tests/contexts/tools/infrastructure/native-plugin-cli-adapter.claude.integration.test.ts b/cli/tests/contexts/tools/infrastructure/native-plugin-cli-adapter.claude.integration.test.ts new file mode 100644 index 000000000..da663dfb8 --- /dev/null +++ b/cli/tests/contexts/tools/infrastructure/native-plugin-cli-adapter.claude.integration.test.ts @@ -0,0 +1,82 @@ +import { spawnSync } from "node:child_process"; +import { describe, expect, it, vi } from "vitest"; +import { NativePluginCliAdapter } from "../../../../src/contexts/tools/infrastructure/native-plugin-cli-adapter.js"; + +vi.mock("node:child_process", () => ({ + spawnSync: vi.fn(), +})); + +const mockSpawnSync = vi.mocked(spawnSync); + +function makeResult(overrides: Partial>) { + return { + pid: 1, + output: [], + stdout: "", + stderr: "", + status: 0, + signal: null, + error: undefined, + ...overrides, + } as ReturnType; +} + +// Claude is the one profile that declares `scopeArgs`; codex and copilot declare none and get +// nothing appended whatever is passed, which is why neither carries a test like this one. +function claudeAdapter(): NativePluginCliAdapter { + return new NativePluginCliAdapter("claude", { + scopeArgs: { project: ["--scope", "local"], user: ["--scope", "user"] }, + enableVerb: "install", + disableVerb: "uninstall", + }); +} + +describe("ClaudeCliAdapter — plugin enable/uninstall carry the requested scope", () => { + it("enables a plugin at project scope by default — --scope local, never claude's own implicit default", () => { + mockSpawnSync.mockReturnValue(makeResult({})); + + claudeAdapter().enablePlugin("aidd-context@aidd-framework"); + + expect(mockSpawnSync).toHaveBeenCalledWith( + "claude", + ["plugin", "install", "aidd-context@aidd-framework", "--scope", "local"], + expect.anything() + ); + }); + + it("enables a plugin at user scope when asked", () => { + mockSpawnSync.mockReturnValue(makeResult({})); + + claudeAdapter().enablePlugin("aidd-context@aidd-framework", "user"); + + expect(mockSpawnSync).toHaveBeenCalledWith( + "claude", + ["plugin", "install", "aidd-context@aidd-framework", "--scope", "user"], + expect.anything() + ); + }); + + it("uninstalls a plugin at project scope by default", () => { + mockSpawnSync.mockReturnValue(makeResult({})); + + claudeAdapter().uninstallPlugin("aidd-context@aidd-framework"); + + expect(mockSpawnSync).toHaveBeenCalledWith( + "claude", + ["plugin", "uninstall", "aidd-context@aidd-framework", "--scope", "local"], + expect.anything() + ); + }); + + it("uninstalls a plugin at user scope when asked", () => { + mockSpawnSync.mockReturnValue(makeResult({})); + + claudeAdapter().uninstallPlugin("aidd-context@aidd-framework", "user"); + + expect(mockSpawnSync).toHaveBeenCalledWith( + "claude", + ["plugin", "uninstall", "aidd-context@aidd-framework", "--scope", "user"], + expect.anything() + ); + }); +}); diff --git a/cli/tests/contexts/tools/infrastructure/native-plugin-cli-adapter.codex.integration.test.ts b/cli/tests/contexts/tools/infrastructure/native-plugin-cli-adapter.codex.integration.test.ts new file mode 100644 index 000000000..79f113cc2 --- /dev/null +++ b/cli/tests/contexts/tools/infrastructure/native-plugin-cli-adapter.codex.integration.test.ts @@ -0,0 +1,174 @@ +import { spawnSync } from "node:child_process"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { NativePluginCliAdapter } from "../../../../src/contexts/tools/infrastructure/native-plugin-cli-adapter.js"; +import { NativePluginCliError } from "../../../../src/kernel/errors.js"; + +function pathWithExecutable(name: string): { dir: string; restore: () => void } { + const dir = mkdtempSync(join(tmpdir(), "aidd-bin-")); + writeFileSync(join(dir, name), "#!/bin/sh\n", { mode: 0o755 }); + const prev = process.env.PATH; + process.env.PATH = dir; + return { + dir, + restore: () => { + process.env.PATH = prev; + rmSync(dir, { recursive: true, force: true }); + }, + }; +} + +vi.mock("node:child_process", () => ({ + spawnSync: vi.fn(), +})); + +const mockSpawnSync = vi.mocked(spawnSync); + +function makeResult(overrides: Partial>) { + return { + pid: 1, + output: [], + stdout: "", + stderr: "", + status: 0, + signal: null, + error: undefined, + ...overrides, + } as ReturnType; +} + +describe("CodexCliAdapter", () => { + let restorePath: (() => void) | undefined; + afterEach(() => { + restorePath?.(); + restorePath = undefined; + }); + + it("reports available when the codex binary is on PATH (no spawn)", () => { + const env = pathWithExecutable("codex"); + restorePath = env.restore; + + expect( + new NativePluginCliAdapter("codex", { + upgradeVerb: "upgrade", + enableVerb: "add", + }).isAvailable() + ).toBe(true); + expect(mockSpawnSync).not.toHaveBeenCalled(); + }); + + it("reports unavailable when the codex binary is not on PATH", () => { + const emptyDir = mkdtempSync(join(tmpdir(), "aidd-empty-")); + const prev = process.env.PATH; + process.env.PATH = emptyDir; + restorePath = () => { + process.env.PATH = prev; + rmSync(emptyDir, { recursive: true, force: true }); + }; + + expect( + new NativePluginCliAdapter("codex", { + upgradeVerb: "upgrade", + enableVerb: "add", + }).isAvailable() + ).toBe(false); + }); + + it("registers a marketplace via `codex plugin marketplace add `", () => { + mockSpawnSync.mockReturnValue(makeResult({})); + + new NativePluginCliAdapter("codex", { + upgradeVerb: "upgrade", + enableVerb: "add", + }).addMarketplace("/abs/mkt", "project"); + + expect(mockSpawnSync).toHaveBeenCalledWith( + "codex", + ["plugin", "marketplace", "add", "/abs/mkt"], + expect.anything() + ); + }); + + it("upgrades marketplaces via `codex plugin marketplace upgrade`", () => { + mockSpawnSync.mockReturnValue(makeResult({})); + + new NativePluginCliAdapter("codex", { + upgradeVerb: "upgrade", + enableVerb: "add", + }).upgradeMarketplaces(); + + expect(mockSpawnSync).toHaveBeenCalledWith( + "codex", + ["plugin", "marketplace", "upgrade"], + expect.anything() + ); + }); + + it("enables a plugin via `codex plugin add `", () => { + mockSpawnSync.mockReturnValue(makeResult({})); + + new NativePluginCliAdapter("codex", { upgradeVerb: "upgrade", enableVerb: "add" }).enablePlugin( + "aidd-context@aidd-framework" + ); + + expect(mockSpawnSync).toHaveBeenCalledWith( + "codex", + ["plugin", "add", "aidd-context@aidd-framework"], + expect.anything() + ); + }); + + it("throws NativePluginCliError with stderr detail on non-zero exit", () => { + mockSpawnSync.mockReturnValue( + makeResult({ status: 1, stderr: "plugin `ghost` was not found in marketplace `m1`" }) + ); + + expect(() => + new NativePluginCliAdapter("codex", { + upgradeVerb: "upgrade", + enableVerb: "add", + }).enablePlugin("ghost@m1") + ).toThrow(NativePluginCliError); + expect(() => + new NativePluginCliAdapter("codex", { + upgradeVerb: "upgrade", + enableVerb: "add", + }).enablePlugin("ghost@m1") + ).toThrow("plugin `ghost` was not found"); + }); + + it("uninstalls a plugin via `codex plugin remove `", () => { + mockSpawnSync.mockReturnValue(makeResult({})); + + new NativePluginCliAdapter("codex", { disableVerb: "remove" }).uninstallPlugin( + "aidd-telemetry@aidd-framework" + ); + + expect(mockSpawnSync).toHaveBeenCalledWith( + "codex", + ["plugin", "remove", "aidd-telemetry@aidd-framework"], + expect.anything() + ); + }); + + it("throws NativePluginCliError when uninstalling an already-absent plugin", () => { + mockSpawnSync.mockReturnValue(makeResult({ status: 1, stderr: "plugin `ghost` not found" })); + + expect(() => + new NativePluginCliAdapter("codex", { disableVerb: "remove" }).uninstallPlugin("ghost@m1") + ).toThrow(NativePluginCliError); + }); + + it("throws NativePluginCliError when the process fails to spawn", () => { + mockSpawnSync.mockReturnValue(makeResult({ error: new Error("spawn EACCES"), status: null })); + + expect(() => + new NativePluginCliAdapter("codex", { + upgradeVerb: "upgrade", + enableVerb: "add", + }).addMarketplace("/abs/mkt", "project") + ).toThrow(NativePluginCliError); + }); +}); diff --git a/cli/tests/contexts/tools/infrastructure/native-plugin-cli-adapter.copilot.integration.test.ts b/cli/tests/contexts/tools/infrastructure/native-plugin-cli-adapter.copilot.integration.test.ts new file mode 100644 index 000000000..14ef06673 --- /dev/null +++ b/cli/tests/contexts/tools/infrastructure/native-plugin-cli-adapter.copilot.integration.test.ts @@ -0,0 +1,167 @@ +import { spawnSync } from "node:child_process"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { NativePluginCliAdapter } from "../../../../src/contexts/tools/infrastructure/native-plugin-cli-adapter.js"; +import { NativePluginCliError } from "../../../../src/kernel/errors.js"; + +vi.mock("node:child_process", () => ({ + spawnSync: vi.fn(), +})); + +const mockSpawnSync = vi.mocked(spawnSync); + +function makeResult(overrides: Partial>) { + return { + pid: 1, + output: [], + stdout: "", + stderr: "", + status: 0, + signal: null, + error: undefined, + ...overrides, + } as ReturnType; +} + +describe("CopilotCliAdapter", () => { + let restorePath: (() => void) | undefined; + afterEach(() => { + restorePath?.(); + restorePath = undefined; + }); + + it("reports available when the copilot binary is on PATH (no spawn)", () => { + const dir = mkdtempSync(join(tmpdir(), "aidd-bin-")); + writeFileSync(join(dir, "copilot"), "#!/bin/sh\n", { mode: 0o755 }); + const prev = process.env.PATH; + process.env.PATH = dir; + restorePath = () => { + process.env.PATH = prev; + rmSync(dir, { recursive: true, force: true }); + }; + + expect( + new NativePluginCliAdapter("copilot", { + upgradeVerb: "update", + enableVerb: "install", + }).isAvailable() + ).toBe(true); + expect(mockSpawnSync).not.toHaveBeenCalled(); + }); + + it("reports unavailable when the copilot binary is not on PATH", () => { + const emptyDir = mkdtempSync(join(tmpdir(), "aidd-empty-")); + const prev = process.env.PATH; + process.env.PATH = emptyDir; + restorePath = () => { + process.env.PATH = prev; + rmSync(emptyDir, { recursive: true, force: true }); + }; + + expect( + new NativePluginCliAdapter("copilot", { + upgradeVerb: "update", + enableVerb: "install", + }).isAvailable() + ).toBe(false); + }); + + it("registers a marketplace via `copilot plugin marketplace add `", () => { + mockSpawnSync.mockReturnValue(makeResult({})); + + new NativePluginCliAdapter("copilot", { + upgradeVerb: "update", + enableVerb: "install", + }).addMarketplace("/abs/mkt", "project"); + + expect(mockSpawnSync).toHaveBeenCalledWith( + "copilot", + ["plugin", "marketplace", "add", "/abs/mkt"], + expect.anything() + ); + }); + + it("refreshes marketplaces via `copilot plugin marketplace update`", () => { + mockSpawnSync.mockReturnValue(makeResult({})); + + new NativePluginCliAdapter("copilot", { + upgradeVerb: "update", + enableVerb: "install", + }).upgradeMarketplaces(); + + expect(mockSpawnSync).toHaveBeenCalledWith( + "copilot", + ["plugin", "marketplace", "update"], + expect.anything() + ); + }); + + it("installs a plugin via `copilot plugin install `", () => { + mockSpawnSync.mockReturnValue(makeResult({})); + + new NativePluginCliAdapter("copilot", { + upgradeVerb: "update", + enableVerb: "install", + }).enablePlugin("aidd-context@aidd-framework"); + + expect(mockSpawnSync).toHaveBeenCalledWith( + "copilot", + ["plugin", "install", "aidd-context@aidd-framework"], + expect.anything() + ); + }); + + it("throws NativePluginCliError with stderr detail on non-zero exit", () => { + mockSpawnSync.mockReturnValue(makeResult({ status: 1, stderr: 'Marketplace "m1" not found' })); + + expect(() => + new NativePluginCliAdapter("copilot", { + upgradeVerb: "update", + enableVerb: "install", + }).enablePlugin("ghost@m1") + ).toThrow(NativePluginCliError); + expect(() => + new NativePluginCliAdapter("copilot", { + upgradeVerb: "update", + enableVerb: "install", + }).enablePlugin("ghost@m1") + ).toThrow("Marketplace"); + }); + + it("uninstalls a plugin via `copilot plugin uninstall `", () => { + mockSpawnSync.mockReturnValue(makeResult({})); + + new NativePluginCliAdapter("copilot", { disableVerb: "uninstall" }).uninstallPlugin( + "aidd-telemetry@aidd-framework" + ); + + expect(mockSpawnSync).toHaveBeenCalledWith( + "copilot", + ["plugin", "uninstall", "aidd-telemetry@aidd-framework"], + expect.anything() + ); + }); + + it("throws NativePluginCliError when uninstalling an already-absent plugin", () => { + mockSpawnSync.mockReturnValue(makeResult({ status: 1, stderr: "Plugin not found" })); + + expect(() => + new NativePluginCliAdapter("copilot", { disableVerb: "uninstall" }).uninstallPlugin( + "ghost@m1" + ) + ).toThrow(NativePluginCliError); + }); + + it("throws NativePluginCliError when the process fails to spawn", () => { + mockSpawnSync.mockReturnValue(makeResult({ error: new Error("spawn EACCES"), status: null })); + + expect(() => + new NativePluginCliAdapter("copilot", { + upgradeVerb: "update", + enableVerb: "install", + }).addMarketplace("/abs/mkt", "project") + ).toThrow(NativePluginCliError); + }); +}); diff --git a/cli/tests/application/use-cases/framework/framework-build-use-case.integration.test.ts b/cli/tests/contexts/translate/application/framework-build-use-case.integration.test.ts similarity index 79% rename from cli/tests/application/use-cases/framework/framework-build-use-case.integration.test.ts rename to cli/tests/contexts/translate/application/framework-build-use-case.integration.test.ts index 36737ffcf..abfcdea7d 100644 --- a/cli/tests/application/use-cases/framework/framework-build-use-case.integration.test.ts +++ b/cli/tests/contexts/translate/application/framework-build-use-case.integration.test.ts @@ -1,35 +1,33 @@ -import { resolve } from "node:path"; +import { join, resolve } from "node:path"; import { beforeEach, describe, expect, it } from "vitest"; -import { FrameworkBuildUseCase } from "../../../../src/application/use-cases/framework/framework-build-use-case.js"; -import { MarketplaceBuildStrategy } from "../../../../src/application/use-cases/framework/strategies/marketplace-build-strategy.js"; -import { buildCopilotMarketplaceContract } from "../../../../src/application/use-cases/framework/strategies/tool-contracts.js"; +import type { JsonSchemaValidator } from "../../../../src/contexts/tools/domain/ports/schema-validator.js"; +import { buildCopilotMarketplaceContract } from "../../../../src/contexts/tools/domain/profiles/copilot/build.js"; +import type { BuildOutputStrategy } from "../../../../src/contexts/translate/application/strategies/build-output-strategy.js"; +import { MarketplaceBuildStrategy } from "../../../../src/contexts/translate/application/strategies/marketplace-build-strategy.js"; +import { FrameworkBuildUseCase } from "../../../../src/contexts/translate/application/translate-source.js"; import { FrameworkPlaceholderInPluginError, InvalidBuildPathsError, InvalidSourceMarketplaceError, JsonSchemaValidationError, -} from "../../../../src/domain/errors.js"; -import type { AssetProvider } from "../../../../src/domain/ports/asset-provider.js"; -import type { JsonSchemaValidator } from "../../../../src/domain/ports/json-schema-validator.js"; +} from "../../../../src/kernel/errors.js"; +import type { AssetProvider } from "../../../../src/kernel/ports/asset-provider.js"; import { CapturingLogger } from "../../../helpers/ports/capturing-logger.js"; import { InMemoryFileAdapter } from "../../../helpers/ports/in-memory-file-adapter.js"; import { seedFromDirectory } from "../../../helpers/ports/seed-from-directory.js"; const FIXTURE_DIR = resolve(process.cwd(), "tests/fixtures/framework"); const SOURCE_DIR = FIXTURE_DIR; -// resolve(), not the bare literal: on Windows path.resolve treats a leading "/" as -// drive-relative and prepends the current drive, so production's own resolve(outDir) -// would otherwise write under a different key than this constant's raw string names. +// resolve(), not the bare literal: on Windows a leading "/" is drive-relative, so +// production's own resolve(outDir) would key the tree differently from this constant. const OUT_DIR = resolve("/tmp/aidd-build-test-out"); -// Minimal plugin manifest JSON schema: only "name" required. const MINIMAL_MANIFEST_SCHEMA = { type: "object", required: ["name"], properties: { name: { type: "string" } }, }; -// Minimal marketplace JSON schema: only "name", "metadata", "owner", "plugins" required. const MINIMAL_MARKETPLACE_SCHEMA = { type: "object", required: ["name", "metadata", "owner", "plugins"], @@ -57,9 +55,6 @@ function makeAssetProvider( loadConfigAsset: () => { throw new Error("not used"); }, - loadDefaultMarketplace: () => { - throw new Error("not used"); - }, loadSchema: (name) => { if (name === "plugin-manifest") return manifestSchema; if (name === "marketplace") return marketplaceSchema; @@ -143,7 +138,6 @@ describe("FrameworkBuildUseCase", () => { }); it("synthesized plugin.json omits skills when no SKILL.md files exist", async () => { - // Remove all SKILL.md files from the fixture for (const path of fs.listUnder(`${SOURCE_DIR}/plugins/aidd-test/skills`)) { if (path.endsWith("SKILL.md")) fs.deleteFile(path); } @@ -302,10 +296,11 @@ describe("FrameworkBuildUseCase", () => { target: "copilot", }); const [plugin] = result.plugins; - expect(plugin.skippedSections).toContain("commands"); - expect(plugin.skippedSections).toContain("rules"); - expect(logger.warnMessages.some((m) => m.includes("commands"))).toBe(true); - expect(logger.warnMessages.some((m) => m.includes("rules"))).toBe(true); + expect(plugin.skippedSections).toEqual(["commands", "rules"]); + expect(logger.warnMessages).toEqual([ + "Skipping commands/ in plugin 'aidd-test' (out of scope for MVP1).", + "Skipping rules/ in plugin 'aidd-test' (out of scope for MVP1).", + ]); }); }); @@ -339,7 +334,7 @@ describe("FrameworkBuildUseCase", () => { const uc = makeUseCase(fs); await expect( uc.execute({ sourceDir: SOURCE_DIR, outDir: OUT_DIR, target: "copilot" }) - ).rejects.toThrow(InvalidSourceMarketplaceError); + ).rejects.toThrow(/^Invalid source marketplace: malformed JSON: /); }); it("throws InvalidSourceMarketplaceError when 'plugins' array is missing", async () => { @@ -350,7 +345,7 @@ describe("FrameworkBuildUseCase", () => { const uc = makeUseCase(fs); await expect( uc.execute({ sourceDir: SOURCE_DIR, outDir: OUT_DIR, target: "copilot" }) - ).rejects.toThrow(InvalidSourceMarketplaceError); + ).rejects.toThrow("Invalid source marketplace: missing 'plugins' array."); }); it("throws InvalidSourceMarketplaceError when a plugin name does not match a directory", async () => { @@ -365,13 +360,56 @@ describe("FrameworkBuildUseCase", () => { const uc = makeUseCase(fs); await expect( uc.execute({ sourceDir: SOURCE_DIR, outDir: OUT_DIR, target: "copilot" }) - ).rejects.toThrow(InvalidSourceMarketplaceError); + ).rejects.toThrow( + `Invalid source marketplace: plugin 'nonexistent-plugin' not found at ${join( + SOURCE_DIR, + "plugins", + "nonexistent-plugin" + )}.` + ); }); + + it("names the catalog it could not read at all", async () => { + await fs.deleteFile(`${SOURCE_DIR}/.claude-plugin/marketplace.json`); + const uc = makeUseCase(fs); + await expect( + uc.execute({ sourceDir: SOURCE_DIR, outDir: OUT_DIR, target: "copilot" }) + ).rejects.toThrow( + `Invalid source marketplace: cannot read ${join( + SOURCE_DIR, + ".claude-plugin/marketplace.json" + )}.` + ); + }); + + for (const root of ["null", '"a catalog"', "[]"]) { + it(`refuses a catalog whose root reads ${root}`, async () => { + fs.setFile(`${SOURCE_DIR}/.claude-plugin/marketplace.json`, root); + const uc = makeUseCase(fs); + await expect( + uc.execute({ sourceDir: SOURCE_DIR, outDir: OUT_DIR, target: "copilot" }) + ).rejects.toThrow("Invalid source marketplace: root must be an object."); + }); + } + + for (const entry of ["null", "5", "{}"]) { + it(`refuses a plugin entry that reads ${entry}`, async () => { + fs.setFile( + `${SOURCE_DIR}/.claude-plugin/marketplace.json`, + `{ "name": "test", "owner": { "name": "X" }, "plugins": [${entry}] }` + ); + const uc = makeUseCase(fs); + await expect( + uc.execute({ sourceDir: SOURCE_DIR, outDir: OUT_DIR, target: "copilot" }) + ).rejects.toThrow( + "Invalid source marketplace: each plugin entry must have a 'name' string." + ); + }); + } }); describe("marketplace field sourcing", () => { it("uses version from source marketplace entry when present", async () => { - // Inject a version on the marketplace entry fs.setFile( `${SOURCE_DIR}/.claude-plugin/marketplace.json`, JSON.stringify({ @@ -506,3 +544,54 @@ describe("FrameworkBuildUseCase", () => { }); }); }); + +/** Each layout step reports what it wrote, and the use case is the only place those counts are + * added up; a stub layout states them so the arithmetic is readable. */ +const countingStrategy: BuildOutputStrategy = { + preBuild: () => Promise.resolve(), + writePluginManifest: () => Promise.resolve(1), + writeAgents: () => Promise.resolve(2), + writeSkills: () => Promise.resolve(4), + writeHooks: () => Promise.resolve(8), + writeMcp: () => Promise.resolve(16), + postBuild: () => Promise.resolve(32), +}; + +describe("what a build reports having written", () => { + it("adds each layout step's own count per plugin, then what the layout wrote after them", async () => { + const fs = await makeSeededFs(); + const v = makeValidator(); + const ap = makeAssetProvider(); + const uc = new FrameworkBuildUseCase(fs, v, ap, new CapturingLogger(), countingStrategy); + const result = await uc.execute({ + sourceDir: SOURCE_DIR, + outDir: OUT_DIR, + target: "copilot", + }); + expect(result).toEqual({ + outDir: OUT_DIR, + totalFiles: 63, + plugins: [{ name: "aidd-test", filesWritten: 31, skippedSections: ["commands", "rules"] }], + }); + }); + + it("reports no skipped section for a plugin shipping neither commands nor rules", async () => { + const fs = await makeSeededFs(); + for (const section of ["commands", "rules"]) { + for (const path of fs.listUnder(`${SOURCE_DIR}/plugins/aidd-test/${section}`)) { + fs.deleteFile(path); + } + } + const logger = new CapturingLogger(); + const v = makeValidator(); + const ap = makeAssetProvider(); + const uc = new FrameworkBuildUseCase(fs, v, ap, logger, countingStrategy); + const result = await uc.execute({ + sourceDir: SOURCE_DIR, + outDir: OUT_DIR, + target: "copilot", + }); + expect(result.plugins).toEqual([{ name: "aidd-test", filesWritten: 31, skippedSections: [] }]); + expect(logger.warnMessages).toEqual([]); + }); +}); diff --git a/cli/tests/application/use-cases/framework/flat-build-strategy.hooks.integration.test.ts b/cli/tests/contexts/translate/application/strategies/flat-build-strategy.hooks.integration.test.ts similarity index 76% rename from cli/tests/application/use-cases/framework/flat-build-strategy.hooks.integration.test.ts rename to cli/tests/contexts/translate/application/strategies/flat-build-strategy.hooks.integration.test.ts index 32efbe70e..63fe9b4c4 100644 --- a/cli/tests/application/use-cases/framework/flat-build-strategy.hooks.integration.test.ts +++ b/cli/tests/contexts/translate/application/strategies/flat-build-strategy.hooks.integration.test.ts @@ -1,30 +1,22 @@ -/** - * Integration tests for per-tool flat hook config registration. - * Covers claude, cursor, codex hook output shapes and codex no-install-hook leak. - * Spec §Per-tool contract, AC 1-5, 7. - */ import { resolve } from "node:path"; import { beforeEach, describe, expect, it } from "vitest"; -import { FrameworkBuildUseCase } from "../../../../src/application/use-cases/framework/framework-build-use-case.js"; -import { FlatBuildStrategy } from "../../../../src/application/use-cases/framework/strategies/flat-build-strategy.js"; -import { - buildClaudeFlatContract, - buildCodexFlatContract, - buildCopilotFlatContract, - buildCursorFlatContract, - buildOpencodeFlatContract, -} from "../../../../src/application/use-cases/framework/strategies/tool-contracts.js"; -import type { AssetProvider } from "../../../../src/domain/ports/asset-provider.js"; -import type { JsonSchemaValidator } from "../../../../src/domain/ports/json-schema-validator.js"; -import { AjvSchemaValidatorAdapter } from "../../../../src/infrastructure/adapters/ajv-schema-validator-adapter.js"; -import { CapturingLogger } from "../../../helpers/ports/capturing-logger.js"; -import { InMemoryFileAdapter } from "../../../helpers/ports/in-memory-file-adapter.js"; -import { seedFromDirectory } from "../../../helpers/ports/seed-from-directory.js"; +import type { JsonSchemaValidator } from "../../../../../src/contexts/tools/domain/ports/schema-validator.js"; +import { buildClaudeFlatContract } from "../../../../../src/contexts/tools/domain/profiles/claude/build.js"; +import { buildCodexFlatContract } from "../../../../../src/contexts/tools/domain/profiles/codex/build.js"; +import { buildCopilotFlatContract } from "../../../../../src/contexts/tools/domain/profiles/copilot/build.js"; +import { buildCursorFlatContract } from "../../../../../src/contexts/tools/domain/profiles/cursor/build.js"; +import { buildOpencodeFlatContract } from "../../../../../src/contexts/tools/domain/profiles/opencode/build.js"; +import { FlatBuildStrategy } from "../../../../../src/contexts/translate/application/strategies/flat-build-strategy.js"; +import { FrameworkBuildUseCase } from "../../../../../src/contexts/translate/application/translate-source.js"; +import { AjvSchemaValidatorAdapter } from "../../../../../src/contexts/translate/infrastructure/schema-validator.js"; +import type { AssetProvider } from "../../../../../src/kernel/ports/asset-provider.js"; +import { CapturingLogger } from "../../../../helpers/ports/capturing-logger.js"; +import { InMemoryFileAdapter } from "../../../../helpers/ports/in-memory-file-adapter.js"; +import { seedFromDirectory } from "../../../../helpers/ports/seed-from-directory.js"; const FIXTURE_DIR = resolve(process.cwd(), "tests/fixtures/framework"); -// resolve(), not the bare literal: on Windows path.resolve treats a leading "/" as -// drive-relative and prepends the current drive, so production's own resolve(outDir) -// would otherwise write under a different key than this constant's raw string names. +// resolve(), not the bare literal: on Windows a leading "/" is drive-relative, so +// production's own resolve(outDir) would key the tree differently from this constant. const ABS_OUT = resolve("/tmp/aidd-flat-hooks-int-test"); const PLUGIN = "aidd-test"; // Avoid biome noTemplateCurlyInString @@ -39,9 +31,6 @@ function makeAssetProvider(): AssetProvider { loadConfigAsset: () => { throw new Error("not used"); }, - loadDefaultMarketplace: () => { - throw new Error("not used"); - }, loadSchema: () => ({}), }; } @@ -53,9 +42,8 @@ function makeOpencodeAssetProvider(): AssetProvider { } function makeIsDirectory(fs: InMemoryFileAdapter): (path: string) => Promise { - // listUnder() normalizes path before comparing; a hand-rolled prefix scan here would - // compare a native-separator outDir against the adapter's "/"-only keys and never match - // on Windows, where production's resolve(outDir) is backslash-joined. + // listUnder() normalizes the path first; a hand-rolled prefix scan would compare a + // native-separator outDir against the adapter's "/"-only keys and never match on Windows. return async (path: string): Promise => { if (fs.has(path)) return false; return fs.listUnder(path).length > 0; @@ -87,8 +75,6 @@ function makeStrategy( return new FrameworkBuildUseCase(memFs, makeValidator(), makeAssetProvider(), logger, strategy); } -// ── claude flat hooks ────────────────────────────────────────────────────────── - describe("claude flat hooks", () => { let memFs: InMemoryFileAdapter; @@ -146,8 +132,6 @@ describe("claude flat hooks", () => { }); }); -// ── cursor flat hooks ────────────────────────────────────────────────────────── - describe("cursor flat hooks", () => { let memFs: InMemoryFileAdapter; @@ -225,7 +209,6 @@ describe("cursor flat hooks", () => { }); it("cursor hooks.json has no unresolved CLAUDE_PLUGIN_ROOT", async () => { - // Override fixture with a SessionStart event for a real end-to-end path check memFs.setFile( `${FIXTURE_DIR}/plugins/${PLUGIN}/hooks/hooks.json`, JSON.stringify({ @@ -247,8 +230,6 @@ describe("cursor flat hooks", () => { }); }); -// ── copilot flat hooks (shape) ───────────────────────────────────────────────── - describe("copilot flat hooks shape", () => { let memFs: InMemoryFileAdapter; @@ -267,7 +248,6 @@ describe("copilot flat hooks shape", () => { }; expect(parsed.version).toBe(1); const entries = parsed.hooks?.PreToolUse ?? []; - // Each entry must be {type, command} directly — no nested {hooks:[...]} wrapper for (const entry of entries) { expect(entry).not.toHaveProperty("hooks"); expect(entry).toHaveProperty("type"); @@ -276,8 +256,6 @@ describe("copilot flat hooks shape", () => { }); }); -// ── codex flat hooks ─────────────────────────────────────────────────────────── - describe("codex flat hooks (no install-hook leak)", () => { let memFs: InMemoryFileAdapter; @@ -321,13 +299,6 @@ describe("codex flat hooks (no install-hook leak)", () => { }); }); -// ── opencode flat hooks ───────────────────────────────────────────────────────── - -// Regression coverage for the route `aidd setup --ai opencode` and -// `aidd framework build --target opencode --flat` both drive (finding #1): before this -// fix `buildOpencodeFlatContract` declared `hooks: { supported: false }` regardless of -// opencode.ts's own `acceptsHooks: true`, so neither route delivered the plugin module -// OpenCode's loader scans `.opencode/plugin/` for, and both warned hooks were skipped. describe("opencode flat hooks", () => { let memFs: InMemoryFileAdapter; let logger: CapturingLogger; @@ -358,17 +329,21 @@ describe("opencode flat hooks", () => { await useCase.execute({ sourceDir: FIXTURE_DIR, outDir: ABS_OUT, target: "opencode" }); } - it("delivers the hook script into .opencode/plugin/, with no plugin-name segment", async () => { + it("delivers the hook script into .opencode/hooks//, namespaced", async () => { await runOpencodeBuild(); - expect(memFs.has(`${ABS_OUT}/.opencode/plugin/check.sh`)).toBe(true); + expect(memFs.has(`${ABS_OUT}/.opencode/hooks/${PLUGIN}/check.sh`)).toBe(true); + // Never OpenCode's own scanned plugin directory: a plain hook script there is + // imported in-process and kills the host. + expect(memFs.has(`${ABS_OUT}/.opencode/plugin/check.sh`)).toBe(false); }); it("never writes a hooks.json — opencode's loader reads a runtime module, not a manifest", async () => { await runOpencodeBuild(); - expect(memFs.has(`${ABS_OUT}/.opencode/plugin/hooks.json`)).toBe(false); - expect(memFs.has(`${ABS_OUT}/.opencode/plugin/${PLUGIN}.hooks.json`)).toBe(false); + expect(memFs.has(`${ABS_OUT}/.opencode/hooks/hooks.json`)).toBe(false); + expect(memFs.has(`${ABS_OUT}/.opencode/hooks/${PLUGIN}/hooks.json`)).toBe(false); + expect(memFs.has(`${ABS_OUT}/.opencode/hooks/${PLUGIN}.hooks.json`)).toBe(false); }); it("emits no logger.warn about hooks — they are delivered, not skipped", async () => { diff --git a/cli/tests/contexts/translate/application/strategies/flat-build-strategy.integration.test.ts b/cli/tests/contexts/translate/application/strategies/flat-build-strategy.integration.test.ts new file mode 100644 index 000000000..e55422a1e --- /dev/null +++ b/cli/tests/contexts/translate/application/strategies/flat-build-strategy.integration.test.ts @@ -0,0 +1,767 @@ +import { basename, resolve } from "node:path"; +import { beforeEach, describe, expect, it } from "vitest"; +import type { + ArtifactContract, + ToolBuildContract, +} from "../../../../../src/contexts/tools/domain/build-contract.js"; +import type { JsonSchemaValidator } from "../../../../../src/contexts/tools/domain/ports/schema-validator.js"; +import { buildCopilotFlatContract } from "../../../../../src/contexts/tools/domain/profiles/copilot/build.js"; +import { buildOpencodeFlatContract } from "../../../../../src/contexts/tools/domain/profiles/opencode/build.js"; +import { FlatBuildStrategy } from "../../../../../src/contexts/translate/application/strategies/flat-build-strategy.js"; +import { FrameworkBuildUseCase } from "../../../../../src/contexts/translate/application/translate-source.js"; +import { AjvSchemaValidatorAdapter } from "../../../../../src/contexts/translate/infrastructure/schema-validator.js"; +import { + FlatTargetExistsError, + FrameworkPlaceholderInPluginError, + JsonSchemaValidationError, + OutDirNotDirectoryError, +} from "../../../../../src/kernel/errors.js"; +import type { AssetProvider } from "../../../../../src/kernel/ports/asset-provider.js"; +import { CapturingLogger } from "../../../../helpers/ports/capturing-logger.js"; +import { InMemoryFileAdapter } from "../../../../helpers/ports/in-memory-file-adapter.js"; +import { seedFromDirectory } from "../../../../helpers/ports/seed-from-directory.js"; + +const FIXTURE_DIR = resolve(process.cwd(), "tests/fixtures/framework"); +// resolve(): on Windows a leading "/" gets the current drive, as production's resolve(outDir) does. +const ABS_OUT = resolve("/tmp/aidd-flat-test"); +// FlatBuildStrategy embeds this into written JSON content ("/"-joined, forward-slash - see +// resolveClaudeRootAbsolute), never ABS_OUT's own native separators. +const ABS_OUT_IN_CONTENT = ABS_OUT.replace(/\\/g, "/"); +const PLUGIN = "aidd-test"; +// Avoid biome noTemplateCurlyInString: split literal for the placeholder. +const CLAUDE_ROOT_VAR = "$" + "{CLAUDE_PLUGIN_ROOT}"; + +const MINIMAL_MANIFEST_SCHEMA = { + type: "object", + required: ["name"], + properties: { name: { type: "string" } }, +}; + +const MINIMAL_MARKETPLACE_SCHEMA = { + type: "object", + required: ["name", "metadata", "owner", "plugins"], + properties: { + name: { type: "string" }, + metadata: { type: "object" }, + owner: { type: "object" }, + plugins: { type: "array" }, + }, +}; + +function makeValidator(fail = false): JsonSchemaValidator { + return { + validate(_schema: object, _data: unknown): void { + if (fail) throw new JsonSchemaValidationError(["schema validation failed"]); + }, + }; +} + +function makeAssetProvider(): AssetProvider { + return { + loadConfigAsset: (_toolId, fileName) => { + if (fileName === "opencode.json") { + return { + $schema: "https://opencode.ai/config.json", + instructions: [".opencode/rules/**/*.md"], + }; + } + throw new Error("not used"); + }, + loadSchema: (name) => { + if (name === "plugin-manifest") return MINIMAL_MANIFEST_SCHEMA; + if (name === "marketplace") return MINIMAL_MARKETPLACE_SCHEMA; + return {}; + }, + }; +} + +function makeIsDirectory(fs: InMemoryFileAdapter): (path: string) => Promise { + // listUnder() normalizes before comparing; a hand-rolled prefix scan would compare a + // native-separator outDir against the adapter's "/"-only keys and never match on Windows. + return async (path: string): Promise => { + if (fs.has(path)) return false; + return fs.listUnder(path).length > 0; + }; +} + +async function makeSeededFs(): Promise { + const memFs = new InMemoryFileAdapter(); + await seedFromDirectory(memFs, FIXTURE_DIR, { useAbsolutePaths: true }); + memFs.setFile(`${ABS_OUT}/.keep`, ""); + return memFs; +} + +function makeUseCase( + memFs: InMemoryFileAdapter, + force = false, + validator?: JsonSchemaValidator +): FrameworkBuildUseCase { + const v = validator ?? makeValidator(); + const ap = makeAssetProvider(); + const av = new AjvSchemaValidatorAdapter(); + const strategy = new FlatBuildStrategy( + memFs, + av, + ap, + buildCopilotFlatContract(), + force, + ABS_OUT, + makeIsDirectory(memFs) + ); + return new FrameworkBuildUseCase(memFs, v, ap, new CapturingLogger(), strategy); +} + +describe("FlatOutputStrategy integration", () => { + let memFs: InMemoryFileAdapter; + + beforeEach(async () => { + memFs = await makeSeededFs(); + }); + + describe("happy path", () => { + it("writes agent under .github/agents/-.agent.md (plugin-prefixed)", async () => { + const useCase = makeUseCase(memFs); + await useCase.execute({ sourceDir: FIXTURE_DIR, outDir: ABS_OUT, target: "copilot" }); + const agentPath = `${ABS_OUT}/.github/agents/${PLUGIN}-code-reviewer.agent.md`; + expect(memFs.has(agentPath)).toBe(true); + }); + + it("strips frontmatter to Copilot allowlist in agent file and uses plugin-prefixed name", async () => { + const useCase = makeUseCase(memFs); + await useCase.execute({ sourceDir: FIXTURE_DIR, outDir: ABS_OUT, target: "copilot" }); + const content = memFs.getFile(`${ABS_OUT}/.github/agents/${PLUGIN}-code-reviewer.agent.md`); + expect(content).toContain(`${PLUGIN}-code-reviewer`); + expect(content).toContain("description"); + }); + + it("writes skill files under .github/skills/-/ (plugin-prefixed)", async () => { + const useCase = makeUseCase(memFs); + await useCase.execute({ sourceDir: FIXTURE_DIR, outDir: ABS_OUT, target: "copilot" }); + const skillPath = `${ABS_OUT}/.github/skills/${PLUGIN}-commit/SKILL.md`; + expect(memFs.has(skillPath)).toBe(true); + }); + + it("rewrites @./ references in skill files", async () => { + const useCase = makeUseCase(memFs); + await useCase.execute({ sourceDir: FIXTURE_DIR, outDir: ABS_OUT, target: "copilot" }); + const content = memFs.getFile(`${ABS_OUT}/.github/skills/${PLUGIN}-hello.md`); + expect(content).toContain("[SKILL.md](./SKILL.md)"); + }); + + it("rewrites @CLAUDE_ROOT/skills/ in skill files to relative flat path", async () => { + const useCase = makeUseCase(memFs); + await useCase.execute({ sourceDir: FIXTURE_DIR, outDir: ABS_OUT, target: "copilot" }); + const content = memFs.getFile(`${ABS_OUT}/.github/skills/${PLUGIN}-hello.md`); + expect(content).not.toContain(`@${CLAUDE_ROOT_VAR}`); + }); + + it("writes per-plugin hooks file under .github/hooks/.hooks.json", async () => { + const useCase = makeUseCase(memFs); + await useCase.execute({ sourceDir: FIXTURE_DIR, outDir: ABS_OUT, target: "copilot" }); + const hooksPath = `${ABS_OUT}/.github/hooks/${PLUGIN}.hooks.json`; + expect(memFs.has(hooksPath)).toBe(true); + }); + + it("rewrites CLAUDE_ROOT/hooks/ in hooks JSON to per-plugin workspace-relative path", async () => { + const useCase = makeUseCase(memFs); + await useCase.execute({ sourceDir: FIXTURE_DIR, outDir: ABS_OUT, target: "copilot" }); + const content = memFs.getFile(`${ABS_OUT}/.github/hooks/${PLUGIN}.hooks.json`); + expect(content).not.toContain("CLAUDE_PLUGIN_ROOT"); + expect(content).toContain(`./.github/hooks/${PLUGIN}/check.sh`); + }); + + it("copies sibling hook scripts to .github/hooks// alongside the JSON", async () => { + const useCase = makeUseCase(memFs); + await useCase.execute({ sourceDir: FIXTURE_DIR, outDir: ABS_OUT, target: "copilot" }); + const scriptPath = `${ABS_OUT}/.github/hooks/${PLUGIN}/check.sh`; + expect(memFs.has(scriptPath)).toBe(true); + }); + + it("merges MCP servers into .vscode/mcp.json under servers key with plugin prefix", async () => { + const useCase = makeUseCase(memFs); + await useCase.execute({ sourceDir: FIXTURE_DIR, outDir: ABS_OUT, target: "copilot" }); + const mcpPath = `${ABS_OUT}/.vscode/mcp.json`; + expect(memFs.has(mcpPath)).toBe(true); + const raw = memFs.getFile(mcpPath) ?? ""; + const parsed = JSON.parse(raw) as { servers: Record }; + expect(parsed.servers).toHaveProperty(`${PLUGIN}-aidd-test-server`); + }); + + it("rewrites CLAUDE_ROOT in MCP to absolute path under absOut", async () => { + const useCase = makeUseCase(memFs); + await useCase.execute({ sourceDir: FIXTURE_DIR, outDir: ABS_OUT, target: "copilot" }); + const content = memFs.getFile(`${ABS_OUT}/.vscode/mcp.json`) ?? ""; + expect(content).not.toContain("CLAUDE_PLUGIN_ROOT"); + expect(content).toContain(ABS_OUT_IN_CONTENT); + }); + + it("does NOT write a marketplace.json", async () => { + const useCase = makeUseCase(memFs); + await useCase.execute({ sourceDir: FIXTURE_DIR, outDir: ABS_OUT, target: "copilot" }); + expect(memFs.has(`${ABS_OUT}/.plugin/marketplace.json`)).toBe(false); + expect(memFs.has(`${ABS_OUT}/.github/plugin/marketplace.json`)).toBe(false); + }); + }); + + describe("idempotency with --force", () => { + it("re-run with force produces byte-identical files", async () => { + const useCase1 = makeUseCase(memFs, false); + await useCase1.execute({ sourceDir: FIXTURE_DIR, outDir: ABS_OUT, target: "copilot" }); + const agentPath = `${ABS_OUT}/.github/agents/${PLUGIN}-code-reviewer.agent.md`; + const snapshot = memFs.getFile(agentPath); + + const useCase2 = makeUseCase(memFs, true); + await useCase2.execute({ sourceDir: FIXTURE_DIR, outDir: ABS_OUT, target: "copilot" }); + expect(memFs.getFile(agentPath)).toBe(snapshot); + }); + }); + + describe("collision detection without --force", () => { + it("halts with FlatTargetExistsError when agent file already exists", async () => { + const useCase1 = makeUseCase(memFs, false); + await useCase1.execute({ sourceDir: FIXTURE_DIR, outDir: ABS_OUT, target: "copilot" }); + + const useCase2 = makeUseCase(memFs, false); + await expect( + useCase2.execute({ sourceDir: FIXTURE_DIR, outDir: ABS_OUT, target: "copilot" }) + ).rejects.toBeInstanceOf(FlatTargetExistsError); + }); + }); + + describe("safety guards", () => { + it("throws OutDirNotDirectoryError when outDir does not exist", async () => { + const emptyFs = new InMemoryFileAdapter(); + await seedFromDirectory(emptyFs, FIXTURE_DIR, { useAbsolutePaths: true }); + const v = makeValidator(); + const ap = makeAssetProvider(); + const strategy = new FlatBuildStrategy( + emptyFs, + new AjvSchemaValidatorAdapter(), + ap, + buildCopilotFlatContract(), + false, + "/nonexistent", + makeIsDirectory(emptyFs) + ); + const useCase = new FrameworkBuildUseCase(emptyFs, v, ap, new CapturingLogger(), strategy); + await expect( + useCase.execute({ sourceDir: FIXTURE_DIR, outDir: "/nonexistent", target: "copilot" }) + ).rejects.toBeInstanceOf(OutDirNotDirectoryError); + }); + + it("throws OutDirNotDirectoryError when outDir is a file, not a directory", async () => { + const fileFs = new InMemoryFileAdapter(); + await seedFromDirectory(fileFs, FIXTURE_DIR, { useAbsolutePaths: true }); + fileFs.setFile(ABS_OUT, "I am a file, not a directory"); + const v2 = makeValidator(); + const ap2 = makeAssetProvider(); + const strategy = new FlatBuildStrategy( + fileFs, + new AjvSchemaValidatorAdapter(), + ap2, + buildCopilotFlatContract(), + false, + ABS_OUT, + makeIsDirectory(fileFs) + ); + const useCase = new FrameworkBuildUseCase(fileFs, v2, ap2, new CapturingLogger(), strategy); + await expect( + useCase.execute({ sourceDir: FIXTURE_DIR, outDir: ABS_OUT, target: "copilot" }) + ).rejects.toBeInstanceOf(OutDirNotDirectoryError); + }); + }); + + describe("invalid manifest", () => { + it("throws JsonSchemaValidationError for invalid plugin.json (orchestrator-side)", async () => { + const useCase = makeUseCase(memFs, false, makeValidator(true)); + await expect( + useCase.execute({ sourceDir: FIXTURE_DIR, outDir: ABS_OUT, target: "copilot" }) + ).rejects.toBeInstanceOf(JsonSchemaValidationError); + }); + }); + + describe("hooks path resolution for CLAUDE_ROOT/skills/", () => { + it("rewrites skills ref to ./.github/skills/- in hooks JSON (plugin-prefixed)", async () => { + const useCase = makeUseCase(memFs); + const hooksKey = `${FIXTURE_DIR}/plugins/${PLUGIN}/hooks/hooks.json`; + const skillsRef = `${CLAUDE_ROOT_VAR}/skills/commit/SKILL.md`; + memFs.setFile( + hooksKey, + JSON.stringify({ + hooks: { + PreToolUse: [{ hooks: [{ type: "command", command: skillsRef }] }], + }, + }) + ); + await useCase.execute({ sourceDir: FIXTURE_DIR, outDir: ABS_OUT, target: "copilot" }); + const content = memFs.getFile(`${ABS_OUT}/.github/hooks/${PLUGIN}.hooks.json`) ?? ""; + expect(content).toContain(`./.github/skills/${PLUGIN}-commit/SKILL.md`); + }); + }); + + describe("MCP path resolution for CLAUDE_ROOT", () => { + it("rewrites CLAUDE_ROOT/bin/server.js to absolute path under absOut", async () => { + const useCase = makeUseCase(memFs); + await useCase.execute({ sourceDir: FIXTURE_DIR, outDir: ABS_OUT, target: "copilot" }); + const content = memFs.getFile(`${ABS_OUT}/.vscode/mcp.json`) ?? ""; + expect(content).toContain(ABS_OUT_IN_CONTENT); + }); + }); + + describe("MCP key collision detection", () => { + it("throws FlatTargetExistsError when two writeMcp calls produce the same prefixed key", async () => { + const pluginSrc = `${FIXTURE_DIR}/plugins/${PLUGIN}`; + const strategy = new FlatBuildStrategy( + memFs, + new AjvSchemaValidatorAdapter(), + makeAssetProvider(), + buildCopilotFlatContract(), + false, + ABS_OUT, + makeIsDirectory(memFs) + ); + await strategy.writeMcp(PLUGIN, pluginSrc); + await expect(strategy.writeMcp(PLUGIN, pluginSrc)).rejects.toBeInstanceOf( + FlatTargetExistsError + ); + }); + }); + + describe("opencode.json config emission", () => { + function makeOpencodeUseCase(fs: InMemoryFileAdapter, force = false): FrameworkBuildUseCase { + const ap = makeAssetProvider(); + const strategy = new FlatBuildStrategy( + fs, + new AjvSchemaValidatorAdapter(), + ap, + buildOpencodeFlatContract(), + force, + ABS_OUT, + makeIsDirectory(fs) + ); + return new FrameworkBuildUseCase(fs, makeValidator(), ap, new CapturingLogger(), strategy); + } + + it("emits opencode.json with $schema + instructions and no mcp when no plugin ships MCP", async () => { + await memFs.deleteFile(`${FIXTURE_DIR}/plugins/${PLUGIN}/.mcp.json`); + await makeOpencodeUseCase(memFs).execute({ + sourceDir: FIXTURE_DIR, + outDir: ABS_OUT, + target: "opencode", + }); + const raw = memFs.getFile(`${ABS_OUT}/opencode.json`); + expect(raw, "opencode.json must be emitted even with zero MCP servers").toBeDefined(); + const config = JSON.parse(raw ?? "{}") as Record; + expect(config.$schema).toBe("https://opencode.ai/config.json"); + expect(config.instructions).toEqual([".opencode/rules/**/*.md"]); + expect(config).not.toHaveProperty("mcp"); + }); + + it("emits opencode.json with $schema + instructions + mcp when a plugin ships MCP", async () => { + await makeOpencodeUseCase(memFs).execute({ + sourceDir: FIXTURE_DIR, + outDir: ABS_OUT, + target: "opencode", + }); + const config = JSON.parse(memFs.getFile(`${ABS_OUT}/opencode.json`) ?? "{}") as { + $schema: string; + instructions: string[]; + mcp: Record; + }; + expect(config.$schema).toBe("https://opencode.ai/config.json"); + expect(config.instructions).toEqual([".opencode/rules/**/*.md"]); + expect(Object.keys(config.mcp).length).toBeGreaterThan(0); + }); + }); + + describe("AC #11: unsupported hooks warn-and-skip", () => { + it("warns and skips hooks for a hooks-bearing plugin when hooks is unsupported", async () => { + const captLogger = new CapturingLogger(); + // No shipped flat contract declares hooks unsupported any more, so this exercises + // writeHooks's own unsupported branch on a contract built for that case. + const base = buildOpencodeFlatContract(); + const strategy = new FlatBuildStrategy( + memFs, + new AjvSchemaValidatorAdapter(), + makeAssetProvider(), + { ...base, artifacts: { ...base.artifacts, hooks: { supported: false } } }, + false, + ABS_OUT, + makeIsDirectory(memFs), + captLogger + ); + const pluginSrc = `${FIXTURE_DIR}/plugins/${PLUGIN}`; + memFs.setFile(`${FIXTURE_DIR}/plugins/${PLUGIN}/hooks/hooks.json`, '{"hooks":{}}'); + await strategy.writeHooks(PLUGIN, pluginSrc); + expect(captLogger.warnMessages.some((m) => m.includes("hooks"))).toBe(true); + const hooksFiles = memFs + .listAll() + .filter((p) => p.startsWith(ABS_OUT) && p.includes("hooks")); + expect(hooksFiles).toHaveLength(0); + }); + }); +}); + +/** A layout stated in the test itself: the shipped contracts cover no artifact that declares + * no transform, no extension and no merge, and those are the branches a flat build turns on. */ +// Posix on every platform: the adapter keys writes that way and expectations concatenate on it. +const STUB_OUT = resolve("/tmp/aidd-flat-stub-test").replaceAll("\\", "/"); +const STUB_PLUGIN_SRC = "/src/plugins/aidd-test"; + +function supportedArtifact( + path: (plugin: string, relPath: string) => string, + extra: Partial> = {} +): ArtifactContract { + return { + supported: true, + source: { kind: "fullTree", srcDir: "." }, + path, + ...extra, + }; +} + +function stubContract(over: Partial = {}): ToolBuildContract { + return { + manifestFileRelative: null, + synthesizeManifest: null, + manifestSchemaName: null, + artifacts: { + agents: supportedArtifact((plugin, rel) => `.stub/agents/${plugin}-${rel.slice(7)}`), + skills: supportedArtifact((plugin, rel) => `.stub/skills/${plugin}/${rel.slice(7)}`), + hooks: supportedArtifact((plugin, rel) => `.stub/hooks/${plugin}/${rel.slice(6)}`), + mcp: { supported: false }, + rules: { supported: false }, + commands: { supported: false }, + }, + buildMarketplaceCatalog: null, + buildMarketplaceEntry: null, + ...over, + }; +} + +function stubStrategy( + memFs: InMemoryFileAdapter, + contract: ToolBuildContract, + logger?: CapturingLogger +): FlatBuildStrategy { + return new FlatBuildStrategy( + memFs, + new AjvSchemaValidatorAdapter(), + makeAssetProvider(), + contract, + false, + STUB_OUT, + makeIsDirectory(memFs), + logger + ); +} + +function writtenUnder(memFs: InMemoryFileAdapter, dir: string): Record { + return Object.fromEntries(memFs.listUnder(dir).map((path) => [path, memFs.getFile(path)])); +} + +describe("the agents a flat layout writes", () => { + const reviewer = "---\nname: reviewer\n---\n\nReview the diff.\n"; + + it("writes each agent markdown at the path the layout names, and nothing else under agents/", async () => { + const memFs = new InMemoryFileAdapter({ + [`${STUB_PLUGIN_SRC}/agents/reviewer.md`]: reviewer, + [`${STUB_PLUGIN_SRC}/agents/notes.txt`]: "not an agent", + }); + const written = await stubStrategy(memFs, stubContract()).writeAgents( + "aidd-test", + STUB_PLUGIN_SRC + ); + expect(written).toBe(1); + expect(writtenUnder(memFs, STUB_OUT)).toEqual({ + [`${STUB_OUT}/.stub/agents/aidd-test-reviewer.md`]: + "---\nname: 'reviewer'\n---\n\nReview the diff.\n", + }); + }); + + it("hands the agent to the layout's own transform when it declares one", async () => { + const memFs = new InMemoryFileAdapter({ [`${STUB_PLUGIN_SRC}/agents/reviewer.md`]: reviewer }); + const contract = stubContract({ + artifacts: { + ...stubContract().artifacts, + agents: supportedArtifact((plugin, rel) => `.stub/agents/${plugin}-${rel.slice(7)}`, { + transform: (content, plugin, base) => `${plugin}:${base}\n${content}`, + }), + }, + }); + await stubStrategy(memFs, contract).writeAgents("aidd-test", STUB_PLUGIN_SRC); + expect(writtenUnder(memFs, STUB_OUT)).toEqual({ + [`${STUB_OUT}/.stub/agents/aidd-test-reviewer.md`]: `aidd-test:reviewer.md\n${reviewer}`, + }); + }); + + it("writes nothing for a layout hosting no agent", async () => { + const memFs = new InMemoryFileAdapter({ [`${STUB_PLUGIN_SRC}/agents/reviewer.md`]: reviewer }); + const contract = stubContract({ + artifacts: { ...stubContract().artifacts, agents: { supported: false } }, + }); + expect(await stubStrategy(memFs, contract).writeAgents("aidd-test", STUB_PLUGIN_SRC)).toBe(0); + expect(writtenUnder(memFs, STUB_OUT)).toEqual({}); + }); + + it("names the agent that references the framework's tools directory", async () => { + const memFs = new InMemoryFileAdapter({ + [`${STUB_PLUGIN_SRC}/agents/nested/reviewer.md`]: "See @{{TOOLS}}/x.md\n", + }); + await expect( + stubStrategy(memFs, stubContract()).writeAgents("aidd-test", STUB_PLUGIN_SRC) + ).rejects.toThrow( + new FrameworkPlaceholderInPluginError("aidd-test", "agents/nested/reviewer.md") + ); + }); +}); + +describe("the skills a flat layout writes", () => { + const entry = "---\nname: hello\n---\n\nHello, see @./reference.json\n"; + const asset = '{ "see": "@./SKILL.md" }\n'; + + function skillFs(): InMemoryFileAdapter { + return new InMemoryFileAdapter({ + [`${STUB_PLUGIN_SRC}/skills/hello/SKILL.md`]: entry, + [`${STUB_PLUGIN_SRC}/skills/hello/reference.json`]: asset, + }); + } + + it("rewrites a skill markdown's own links and carries every other file as it is", async () => { + const memFs = skillFs(); + expect( + await stubStrategy(memFs, stubContract()).writeSkills("aidd-test", STUB_PLUGIN_SRC) + ).toBe(2); + expect(writtenUnder(memFs, STUB_OUT)).toEqual({ + [`${STUB_OUT}/.stub/skills/aidd-test/hello/SKILL.md`]: + "---\nname: hello\n---\n\nHello, see [reference.json](./reference.json)\n", + [`${STUB_OUT}/.stub/skills/aidd-test/hello/reference.json`]: asset, + }); + }); + + it("names the entry file after its own folder where the layout asks for it", async () => { + const memFs = skillFs(); + const contract = stubContract({ + artifacts: { + ...stubContract().artifacts, + skills: supportedArtifact((plugin, rel) => `.stub/skills/${plugin}-${rel.slice(7)}`, { + rewriteSkillName: true, + }), + }, + }); + await stubStrategy(memFs, contract).writeSkills("aidd-test", STUB_PLUGIN_SRC); + expect(memFs.getFile(`${STUB_OUT}/.stub/skills/aidd-test-hello/SKILL.md`)).toBe( + "---\nname: 'aidd-test-hello'\n---\n\nHello, see [reference.json](./reference.json)\n" + ); + }); + + it("leaves the entry file alone when its flat destination has no folder to name it after", async () => { + const memFs = skillFs(); + const contract = stubContract({ + artifacts: { + ...stubContract().artifacts, + skills: supportedArtifact((_plugin, rel) => basename(rel), { rewriteSkillName: true }), + }, + }); + await stubStrategy(memFs, contract).writeSkills("aidd-test", STUB_PLUGIN_SRC); + expect(memFs.getFile(`${STUB_OUT}/SKILL.md`)).toBe( + "---\nname: hello\n---\n\nHello, see [reference.json](./reference.json)\n" + ); + }); + + it("writes nothing for a layout hosting no skill", async () => { + const memFs = skillFs(); + const contract = stubContract({ + artifacts: { ...stubContract().artifacts, skills: { supported: false } }, + }); + expect(await stubStrategy(memFs, contract).writeSkills("aidd-test", STUB_PLUGIN_SRC)).toBe(0); + expect(writtenUnder(memFs, STUB_OUT)).toEqual({}); + }); +}); + +describe("the hooks a flat layout writes", () => { + const hooksJson = '{ "hooks": { "PreToolUse": [] } }'; + + function hooksFs(): InMemoryFileAdapter { + return new InMemoryFileAdapter({ + [`${STUB_PLUGIN_SRC}/hooks/hooks.json`]: hooksJson, + [`${STUB_PLUGIN_SRC}/hooks/lib/check.sh`]: "#!/bin/sh\n", + }); + } + + it("writes the manifest per plugin and every script beside it", async () => { + const memFs = hooksFs(); + expect(await stubStrategy(memFs, stubContract()).writeHooks("aidd-test", STUB_PLUGIN_SRC)).toBe( + 2 + ); + expect(writtenUnder(memFs, STUB_OUT)).toEqual({ + [`${STUB_OUT}/.stub/hooks/aidd-test/aidd-test.hooks.json`]: + '{\n "hooks": {\n "PreToolUse": []\n }\n}\n', + [`${STUB_OUT}/.stub/hooks/aidd-test/lib/check.sh`]: "#!/bin/sh\n", + }); + }); + + it("hands the rewritten manifest to the layout's own hooks transform", async () => { + const memFs = hooksFs(); + const contract = stubContract({ + artifacts: { + ...stubContract().artifacts, + hooks: supportedArtifact((plugin, rel) => `.stub/hooks/${plugin}/${rel.slice(6)}`, { + hooksTransform: (json) => `transformed:${json}`, + }), + }, + }); + await stubStrategy(memFs, contract).writeHooks("aidd-test", STUB_PLUGIN_SRC); + expect(memFs.getFile(`${STUB_OUT}/.stub/hooks/aidd-test/aidd-test.hooks.json`)).toBe( + 'transformed:{\n "hooks": {\n "PreToolUse": []\n }\n}\n' + ); + }); + + it("merges the manifest into the shared file the layout names, reporting what the merge said", async () => { + const memFs = hooksFs(); + const logger = new CapturingLogger(); + const contract = stubContract({ + artifacts: { + ...stubContract().artifacts, + hooks: supportedArtifact((plugin, rel) => `.stub/hooks/${plugin}/${rel.slice(6)}`, { + hooksMerge: (existing, incoming) => ({ + content: `${existing ?? "none"}+${incoming}`, + warnings: ["one event has no equivalent"], + }), + hooksMergeDest: (outDir) => `${outDir}/.stub/settings.json`, + }), + }, + }); + await stubStrategy(memFs, contract, logger).writeHooks("aidd-test", STUB_PLUGIN_SRC); + expect(memFs.getFile(`${STUB_OUT}/.stub/settings.json`)).toBe( + 'none+{"hooks":{"PreToolUse":[]}}' + ); + expect(logger.warnMessages).toEqual(["one event has no equivalent"]); + }); + + it("generates the bridge a layout with no manifest of its own declares", async () => { + const memFs = hooksFs(); + const contract = stubContract({ + artifacts: { + ...stubContract().artifacts, + hooks: supportedArtifact((plugin, rel) => `.stub/hooks/${plugin}/${rel.slice(6)}`, { + skipHooksJson: true, + hooksBridge: { + generate: (raw, plugin) => `bridge(${plugin}):${raw}`, + path: (plugin) => `.stub/plugin/${plugin}.js`, + skipIfSourceHas: "own-plugin.js", + }, + }), + }, + }); + expect(await stubStrategy(memFs, contract).writeHooks("aidd-test", STUB_PLUGIN_SRC)).toBe(2); + expect(writtenUnder(memFs, STUB_OUT)).toEqual({ + [`${STUB_OUT}/.stub/plugin/aidd-test.js`]: `bridge(aidd-test):${hooksJson}`, + [`${STUB_OUT}/.stub/hooks/aidd-test/lib/check.sh`]: "#!/bin/sh\n", + }); + }); + + it("generates none where the bridge maps nothing the plugin declared", async () => { + const memFs = hooksFs(); + const contract = stubContract({ + artifacts: { + ...stubContract().artifacts, + hooks: supportedArtifact((plugin, rel) => `.stub/hooks/${plugin}/${rel.slice(6)}`, { + skipHooksJson: true, + hooksBridge: { + generate: () => null, + path: (plugin) => `.stub/plugin/${plugin}.js`, + skipIfSourceHas: "own-plugin.js", + }, + }), + }, + }); + expect(await stubStrategy(memFs, contract).writeHooks("aidd-test", STUB_PLUGIN_SRC)).toBe(1); + expect(writtenUnder(memFs, STUB_OUT)).toEqual({ + [`${STUB_OUT}/.stub/hooks/aidd-test/lib/check.sh`]: "#!/bin/sh\n", + }); + }); + + it("generates no bridge for a plugin shipping its own", async () => { + const memFs = hooksFs(); + memFs.setFile(`${STUB_PLUGIN_SRC}/hooks/own-plugin.js`, "export const plugin = () => {};\n"); + const contract = stubContract({ + artifacts: { + ...stubContract().artifacts, + hooks: supportedArtifact((plugin, rel) => `.stub/hooks/${plugin}/${rel.slice(6)}`, { + skipHooksJson: true, + hooksBridge: { + generate: (raw, plugin) => `bridge(${plugin}):${raw}`, + path: (plugin) => `.stub/plugin/${plugin}.js`, + skipIfSourceHas: "own-plugin.js", + }, + }), + }, + }); + expect(await stubStrategy(memFs, contract).writeHooks("aidd-test", STUB_PLUGIN_SRC)).toBe(2); + expect(memFs.has(`${STUB_OUT}/.stub/plugin/aidd-test.js`)).toBe(false); + }); + + it("writes nothing for a plugin shipping no hooks manifest", async () => { + const memFs = new InMemoryFileAdapter({ [`${STUB_PLUGIN_SRC}/skills/hello/SKILL.md`]: "# H" }); + expect(await stubStrategy(memFs, stubContract()).writeHooks("aidd-test", STUB_PLUGIN_SRC)).toBe( + 0 + ); + expect(writtenUnder(memFs, STUB_OUT)).toEqual({}); + }); + + it("says once that a layout hosting no hook skips the plugin's own", async () => { + const memFs = hooksFs(); + const logger = new CapturingLogger(); + const contract = stubContract({ + artifacts: { ...stubContract().artifacts, hooks: { supported: false } }, + }); + expect( + await stubStrategy(memFs, contract, logger).writeHooks("aidd-test", STUB_PLUGIN_SRC) + ).toBe(0); + expect(logger.warnMessages).toEqual([ + "Skipping hooks/ in plugin 'aidd-test' (hooks not supported for this target).", + ]); + }); + + it("skips them silently where the build was given nobody to tell", async () => { + const memFs = hooksFs(); + const contract = stubContract({ + artifacts: { ...stubContract().artifacts, hooks: { supported: false } }, + }); + expect(await stubStrategy(memFs, contract).writeHooks("aidd-test", STUB_PLUGIN_SRC)).toBe(0); + expect(writtenUnder(memFs, STUB_OUT)).toEqual({}); + }); +}); + +describe("what a flat layout writes once every plugin is built", () => { + it("writes no per-plugin manifest", async () => { + const memFs = new InMemoryFileAdapter({}); + expect(await stubStrategy(memFs, stubContract()).writePluginManifest()).toBe(0); + }); + + it("counts nothing where the layout emits no configuration of its own", async () => { + const memFs = new InMemoryFileAdapter({}); + expect( + await stubStrategy(memFs, stubContract()).postBuild({ name: "m", plugins: [] }, [], STUB_OUT) + ).toBe(0); + }); + + it("hands every built plugin's name, the output and the source to the layout's own step", async () => { + const memFs = new InMemoryFileAdapter({}); + const seen: { names: readonly string[]; outDir: string; sourceDir: string }[] = []; + const contract = stubContract({ + emitConfigArtifact: (names, outDir, sourceDir) => { + seen.push({ names, outDir, sourceDir }); + return Promise.resolve(1); + }, + }); + const strategy = stubStrategy(memFs, contract); + memFs.setFile(`${STUB_OUT}/.keep`, ""); + await strategy.preBuild(STUB_OUT, "/src"); + expect( + await strategy.postBuild({ name: "m", plugins: [] }, [{ name: "aidd-test" }], STUB_OUT) + ).toBe(1); + expect(seen).toEqual([{ names: ["aidd-test"], outDir: STUB_OUT, sourceDir: "/src" }]); + }); +}); diff --git a/cli/tests/application/use-cases/framework/marketplace-build-strategy.claude.integration.test.ts b/cli/tests/contexts/translate/application/strategies/marketplace-build-strategy.claude.integration.test.ts similarity index 91% rename from cli/tests/application/use-cases/framework/marketplace-build-strategy.claude.integration.test.ts rename to cli/tests/contexts/translate/application/strategies/marketplace-build-strategy.claude.integration.test.ts index 4f98d03bf..dd94ccf1e 100644 --- a/cli/tests/application/use-cases/framework/marketplace-build-strategy.claude.integration.test.ts +++ b/cli/tests/contexts/translate/application/strategies/marketplace-build-strategy.claude.integration.test.ts @@ -1,25 +1,24 @@ import { createHash } from "node:crypto"; import { resolve } from "node:path"; import { beforeEach, describe, expect, it } from "vitest"; -import { FrameworkBuildUseCase } from "../../../../src/application/use-cases/framework/framework-build-use-case.js"; -import { MarketplaceBuildStrategy } from "../../../../src/application/use-cases/framework/strategies/marketplace-build-strategy.js"; -import { buildClaudeContract } from "../../../../src/application/use-cases/framework/strategies/tool-contracts.js"; +import { buildClaudeContract } from "../../../../../src/contexts/tools/domain/profiles/claude/build.js"; +import { MarketplaceBuildStrategy } from "../../../../../src/contexts/translate/application/strategies/marketplace-build-strategy.js"; +import { FrameworkBuildUseCase } from "../../../../../src/contexts/translate/application/translate-source.js"; +import { AjvSchemaValidatorAdapter } from "../../../../../src/contexts/translate/infrastructure/schema-validator.js"; import { FrameworkPlaceholderInPluginError, InvalidBuildPathsError, JsonSchemaValidationError, -} from "../../../../src/domain/errors.js"; -import type { AssetProvider } from "../../../../src/domain/ports/asset-provider.js"; -import { AjvSchemaValidatorAdapter } from "../../../../src/infrastructure/adapters/ajv-schema-validator-adapter.js"; -import { BundledAssetProviderAdapter } from "../../../../src/infrastructure/assets/asset-loader.js"; -import { CapturingLogger } from "../../../helpers/ports/capturing-logger.js"; -import { InMemoryFileAdapter } from "../../../helpers/ports/in-memory-file-adapter.js"; -import { seedFromDirectory } from "../../../helpers/ports/seed-from-directory.js"; +} from "../../../../../src/kernel/errors.js"; +import type { AssetProvider } from "../../../../../src/kernel/ports/asset-provider.js"; +import { BundledAssetProviderAdapter } from "../../../../../src/runtime/assets/asset-loader.js"; +import { CapturingLogger } from "../../../../helpers/ports/capturing-logger.js"; +import { InMemoryFileAdapter } from "../../../../helpers/ports/in-memory-file-adapter.js"; +import { seedFromDirectory } from "../../../../helpers/ports/seed-from-directory.js"; const REAL_FIXTURE_DIR = resolve(process.cwd(), "tests/fixtures/framework-real"); -// resolve(), not the bare literal: on Windows path.resolve treats a leading "/" as -// drive-relative and prepends the current drive, so production's own resolve(outDir) -// would otherwise write under a different key than this constant's raw string names. +// resolve(), never the bare literal: on Windows path.resolve prepends the current drive to a +// leading "/", so production's own resolve(outDir) would write under a different key. const OUT_DIR = resolve("/tmp/aidd-claude-test-out"); // Avoid biome noTemplateCurlyInString: split literal diff --git a/cli/tests/application/use-cases/framework/marketplace-build-strategy.codex.integration.test.ts b/cli/tests/contexts/translate/application/strategies/marketplace-build-strategy.codex.integration.test.ts similarity index 91% rename from cli/tests/application/use-cases/framework/marketplace-build-strategy.codex.integration.test.ts rename to cli/tests/contexts/translate/application/strategies/marketplace-build-strategy.codex.integration.test.ts index 1ebb3ddb2..6d4e00522 100644 --- a/cli/tests/application/use-cases/framework/marketplace-build-strategy.codex.integration.test.ts +++ b/cli/tests/contexts/translate/application/strategies/marketplace-build-strategy.codex.integration.test.ts @@ -1,28 +1,27 @@ import { createHash } from "node:crypto"; import { resolve } from "node:path"; import { beforeEach, describe, expect, it } from "vitest"; -import { FrameworkBuildUseCase } from "../../../../src/application/use-cases/framework/framework-build-use-case.js"; -import { MarketplaceBuildStrategy } from "../../../../src/application/use-cases/framework/strategies/marketplace-build-strategy.js"; -import { buildCodexContract } from "../../../../src/application/use-cases/framework/strategies/tool-contracts.js"; +import { buildCodexContract } from "../../../../../src/contexts/tools/domain/profiles/codex/build.js"; +import { parseToml } from "../../../../../src/contexts/tools/domain/profiles/codex/toml.js"; +import { MarketplaceBuildStrategy } from "../../../../../src/contexts/translate/application/strategies/marketplace-build-strategy.js"; +import { FrameworkBuildUseCase } from "../../../../../src/contexts/translate/application/translate-source.js"; +import { AjvSchemaValidatorAdapter } from "../../../../../src/contexts/translate/infrastructure/schema-validator.js"; import { FrameworkPlaceholderInPluginError, InvalidBuildPathsError, JsonSchemaValidationError, -} from "../../../../src/domain/errors.js"; -import { parseFrontmatter } from "../../../../src/domain/formats/markdown.js"; -import { parseToml } from "../../../../src/domain/formats/toml.js"; -import type { AssetProvider } from "../../../../src/domain/ports/asset-provider.js"; -import { AjvSchemaValidatorAdapter } from "../../../../src/infrastructure/adapters/ajv-schema-validator-adapter.js"; -import { BundledAssetProviderAdapter } from "../../../../src/infrastructure/assets/asset-loader.js"; -import { CapturingLogger } from "../../../helpers/ports/capturing-logger.js"; -import { InMemoryFileAdapter } from "../../../helpers/ports/in-memory-file-adapter.js"; -import { seedFromDirectory } from "../../../helpers/ports/seed-from-directory.js"; +} from "../../../../../src/kernel/errors.js"; +import { parseFrontmatter } from "../../../../../src/kernel/markdown.js"; +import type { AssetProvider } from "../../../../../src/kernel/ports/asset-provider.js"; +import { BundledAssetProviderAdapter } from "../../../../../src/runtime/assets/asset-loader.js"; +import { CapturingLogger } from "../../../../helpers/ports/capturing-logger.js"; +import { InMemoryFileAdapter } from "../../../../helpers/ports/in-memory-file-adapter.js"; +import { seedFromDirectory } from "../../../../helpers/ports/seed-from-directory.js"; const REAL_FIXTURE_DIR = resolve(process.cwd(), "tests/fixtures/framework-real"); const CODEX_FIXTURE_DIR = resolve(process.cwd(), "tests/fixtures/framework-codex"); -// resolve(), not the bare literal: on Windows path.resolve treats a leading "/" as -// drive-relative and prepends the current drive, so production's own resolve(outDir) -// would otherwise write under a different key than this constant's raw string names. +// resolve(), not the bare literal: on Windows a leading "/" is drive-relative, so +// production's own resolve(outDir) would key the tree differently from this constant. const OUT_DIR = resolve("/tmp/aidd-codex-test-out"); // Avoid biome noTemplateCurlyInString: split literal @@ -289,9 +288,7 @@ describe("CodexOutputStrategy", () => { await uc.execute({ sourceDir: CODEX_FIXTURE_DIR, outDir: OUT_DIR, target: "codex" }); const skillOut = fs.getFile(`${OUT_DIR}/plugins/aidd-codex-fixture/skills/sample/SKILL.md`) ?? ""; - // Should not contain the raw variable reference expect(skillOut).not.toContain(CLAUDE_ROOT_VAR); - // Should contain a markdown link in place of the variable reference expect(skillOut).toContain("[planner.md]"); }); }); @@ -315,11 +312,8 @@ describe("CodexOutputStrategy", () => { }); }); - // #707: Codex is installed two ways - this built tree and a merged project config - and - // they have drifted three times. The rename lives in one place, renameCodexHookEvents, - // and this is the half that proves the *build* route spends it. Codex has no `Stop`, so a - // built tree that keeps it subscribes the turn-end hook to an event that never arrives - // and the turn is never closed, in silence. + // Codex has no `Stop`: a built tree that keeps it subscribes the turn-end hook to an + // event that never arrives, and the turn is never closed, in silence. describe("hook events Codex actually delivers (#707)", () => { it("renames Stop to SessionEnd in a built plugin's hooks.json", async () => { const fs = await makeSeededFsFromReal(); @@ -378,7 +372,6 @@ describe("CodexOutputStrategy", () => { it("warns and skips commands/ and rules/ directories", async () => { const fs = await makeSeededFsFromReal(); - // Inject commands/ and rules/ into the plugin source fs.setFile(`${REAL_FIXTURE_DIR}/plugins/aidd-dev/commands/foo.md`, "# Foo"); fs.setFile(`${REAL_FIXTURE_DIR}/plugins/aidd-dev/rules/bar.md`, "# Bar"); const logger = new CapturingLogger(); diff --git a/cli/tests/application/use-cases/framework/marketplace-build-strategy.cursor.integration.test.ts b/cli/tests/contexts/translate/application/strategies/marketplace-build-strategy.cursor.integration.test.ts similarity index 91% rename from cli/tests/application/use-cases/framework/marketplace-build-strategy.cursor.integration.test.ts rename to cli/tests/contexts/translate/application/strategies/marketplace-build-strategy.cursor.integration.test.ts index 4c2615ed5..c5b9d3a2b 100644 --- a/cli/tests/application/use-cases/framework/marketplace-build-strategy.cursor.integration.test.ts +++ b/cli/tests/contexts/translate/application/strategies/marketplace-build-strategy.cursor.integration.test.ts @@ -1,24 +1,23 @@ import { resolve } from "node:path"; import { beforeEach, describe, expect, it } from "vitest"; -import { FrameworkBuildUseCase } from "../../../../src/application/use-cases/framework/framework-build-use-case.js"; -import { MarketplaceBuildStrategy } from "../../../../src/application/use-cases/framework/strategies/marketplace-build-strategy.js"; -import { buildCursorContract } from "../../../../src/application/use-cases/framework/strategies/tool-contracts.js"; +import { buildCursorContract } from "../../../../../src/contexts/tools/domain/profiles/cursor/build.js"; +import { MarketplaceBuildStrategy } from "../../../../../src/contexts/translate/application/strategies/marketplace-build-strategy.js"; +import { FrameworkBuildUseCase } from "../../../../../src/contexts/translate/application/translate-source.js"; +import { AjvSchemaValidatorAdapter } from "../../../../../src/contexts/translate/infrastructure/schema-validator.js"; import { FrameworkPlaceholderInPluginError, InvalidBuildPathsError, JsonSchemaValidationError, -} from "../../../../src/domain/errors.js"; -import type { AssetProvider } from "../../../../src/domain/ports/asset-provider.js"; -import { AjvSchemaValidatorAdapter } from "../../../../src/infrastructure/adapters/ajv-schema-validator-adapter.js"; -import { BundledAssetProviderAdapter } from "../../../../src/infrastructure/assets/asset-loader.js"; -import { CapturingLogger } from "../../../helpers/ports/capturing-logger.js"; -import { InMemoryFileAdapter } from "../../../helpers/ports/in-memory-file-adapter.js"; -import { seedFromDirectory } from "../../../helpers/ports/seed-from-directory.js"; +} from "../../../../../src/kernel/errors.js"; +import type { AssetProvider } from "../../../../../src/kernel/ports/asset-provider.js"; +import { BundledAssetProviderAdapter } from "../../../../../src/runtime/assets/asset-loader.js"; +import { CapturingLogger } from "../../../../helpers/ports/capturing-logger.js"; +import { InMemoryFileAdapter } from "../../../../helpers/ports/in-memory-file-adapter.js"; +import { seedFromDirectory } from "../../../../helpers/ports/seed-from-directory.js"; const REAL_FIXTURE_DIR = resolve(process.cwd(), "tests/fixtures/framework-real"); -// resolve(), not the bare literal: on Windows path.resolve treats a leading "/" as -// drive-relative and prepends the current drive, so production's own resolve(outDir) -// would otherwise write under a different key than this constant's raw string names. +// resolve(), not the bare literal: on Windows path.resolve treats a leading "/" as drive- +// relative, so production's own resolve(outDir) would write under a different key. const OUT_DIR = resolve("/tmp/aidd-cursor-test-out"); // Avoid biome noTemplateCurlyInString: split literal diff --git a/cli/tests/contexts/translate/application/strategies/marketplace-build-strategy.integration.test.ts b/cli/tests/contexts/translate/application/strategies/marketplace-build-strategy.integration.test.ts new file mode 100644 index 000000000..8fee52bfa --- /dev/null +++ b/cli/tests/contexts/translate/application/strategies/marketplace-build-strategy.integration.test.ts @@ -0,0 +1,481 @@ +import { join, resolve } from "node:path"; +import { describe, expect, it } from "vitest"; +import type { + ArtifactContract, + PluginPresence, + SourcePluginEntryRef, + ToolBuildContract, +} from "../../../../../src/contexts/tools/domain/build-contract.js"; +import type { JsonSchemaValidator } from "../../../../../src/contexts/tools/domain/ports/schema-validator.js"; +import { MarketplaceBuildStrategy } from "../../../../../src/contexts/translate/application/strategies/marketplace-build-strategy.js"; +import { + JsonSchemaValidationError, + MarketplaceOutDirNotEmptyError, +} from "../../../../../src/kernel/errors.js"; +import type { AssetProvider } from "../../../../../src/kernel/ports/asset-provider.js"; +import { InMemoryFileAdapter } from "../../../../helpers/ports/in-memory-file-adapter.js"; + +const PLUGIN = "aidd-test"; +// resolve(): on Windows a leading "/" is drive-relative. Posix after: the adapter keys writes so. +const OUT_DIR = resolve("/tmp/aidd-marketplace-stub-out").replaceAll("\\", "/"); +const PLUGIN_SRC = "/src/plugins/aidd-test"; +const PLUGIN_OUT = `${OUT_DIR}/plugins/${PLUGIN}`; +// Avoid biome noTemplateCurlyInString: split literal for the placeholder. +const CLAUDE_ROOT_TOKEN = "$" + "{CLAUDE_PLUGIN_ROOT}"; +const STUB_ROOT_TOKEN = "$" + "{STUB_PLUGIN_ROOT}"; + +const passingValidator: JsonSchemaValidator = { + validate(_schema: object, _data: unknown): void {}, +}; + +function assetProviderNaming(loaded: string[]): AssetProvider { + return { + loadConfigAsset: () => { + throw new Error("not used"); + }, + loadSchema: (name) => { + loaded.push(name); + return { schemaFor: name }; + }, + }; +} + +function supportedArtifact( + path: (plugin: string, relPath: string) => string, + extra: Partial> = {} +): ArtifactContract { + return { supported: true, source: { kind: "fullTree", srcDir: "." }, path, ...extra }; +} + +function stubContract(over: Partial = {}): ToolBuildContract { + return { + manifestFileRelative: null, + synthesizeManifest: null, + manifestSchemaName: null, + artifacts: { + agents: supportedArtifact((_plugin, rel) => rel), + skills: supportedArtifact((_plugin, rel) => rel), + hooks: supportedArtifact((_plugin, rel) => rel), + mcp: supportedArtifact((_plugin, rel) => rel), + rules: { supported: false }, + commands: { supported: false }, + }, + buildMarketplaceCatalog: null, + buildMarketplaceEntry: null, + ...over, + }; +} + +function strategyFor( + fs: InMemoryFileAdapter, + contract: ToolBuildContract, + options: { force?: boolean; validator?: JsonSchemaValidator; assetProvider?: AssetProvider } = {} +): MarketplaceBuildStrategy { + return new MarketplaceBuildStrategy( + fs, + options.validator ?? passingValidator, + options.assetProvider ?? assetProviderNaming([]), + contract, + options.force + ); +} + +function writtenUnder(fs: InMemoryFileAdapter, dir: string): Record { + return Object.fromEntries(fs.listUnder(dir).map((path) => [path, fs.getFile(path)])); +} + +describe("preparing a marketplace output directory", () => { + it("accepts a directory holding no entry yet", async () => { + const fs = new InMemoryFileAdapter({ [OUT_DIR]: "" }); + await expect(strategyFor(fs, stubContract()).preBuild(OUT_DIR)).resolves.toBeUndefined(); + }); + + it("refuses a directory that already holds something, naming it", async () => { + const fs = new InMemoryFileAdapter({ [`${OUT_DIR}/old.json`]: "{}" }); + await expect(strategyFor(fs, stubContract()).preBuild(OUT_DIR)).rejects.toThrow( + new MarketplaceOutDirNotEmptyError(OUT_DIR) + ); + }); + + it("writes into a directory that already holds something when forced", async () => { + const fs = new InMemoryFileAdapter({ [`${OUT_DIR}/old.json`]: "{}" }); + await expect( + strategyFor(fs, stubContract(), { force: true }).preBuild(OUT_DIR) + ).resolves.toBeUndefined(); + }); +}); + +describe("the plugin manifest a marketplace layout synthesizes", () => { + const sourceManifest = '{ "name": "aidd-test", "version": "1.0.0" }'; + + function manifestContract(over: Partial = {}): ToolBuildContract { + return stubContract({ + manifestFileRelative: ".stub-plugin/plugin.json", + synthesizeManifest: (source, presence) => ({ ...source, skills: presence.skillsList }), + ...over, + }); + } + + it("writes it where the layout names it, indented and newline-terminated", async () => { + const fs = new InMemoryFileAdapter({ + [`${PLUGIN_SRC}/.claude-plugin/plugin.json`]: sourceManifest, + [`${PLUGIN_SRC}/skills/hello/SKILL.md`]: "# Hello", + }); + expect( + await strategyFor(fs, manifestContract()).writePluginManifest(PLUGIN, PLUGIN_SRC, OUT_DIR) + ).toBe(1); + expect(writtenUnder(fs, OUT_DIR)).toEqual({ + [`${PLUGIN_OUT}/.stub-plugin/plugin.json`]: + '{\n "name": "aidd-test",\n "version": "1.0.0",\n "skills": [\n "hello"\n ]\n}\n', + }); + }); + + it("writes none for a layout that synthesizes none", async () => { + const fs = new InMemoryFileAdapter({ + [`${PLUGIN_SRC}/.claude-plugin/plugin.json`]: sourceManifest, + }); + expect( + await strategyFor(fs, stubContract()).writePluginManifest(PLUGIN, PLUGIN_SRC, OUT_DIR) + ).toBe(0); + expect(writtenUnder(fs, OUT_DIR)).toEqual({}); + }); + + it("writes none for a layout that synthesizes one but names no file for it", async () => { + const fs = new InMemoryFileAdapter({ + [`${PLUGIN_SRC}/.claude-plugin/plugin.json`]: sourceManifest, + }); + const contract = manifestContract({ manifestFileRelative: null }); + expect(await strategyFor(fs, contract).writePluginManifest(PLUGIN, PLUGIN_SRC, OUT_DIR)).toBe( + 0 + ); + expect(writtenUnder(fs, OUT_DIR)).toEqual({}); + }); + + it("validates it against the schema the layout names, before writing anything", async () => { + const fs = new InMemoryFileAdapter({ + [`${PLUGIN_SRC}/.claude-plugin/plugin.json`]: sourceManifest, + }); + const loaded: string[] = []; + const refusing: JsonSchemaValidator = { + validate(_schema: object, _data: unknown): void { + throw new JsonSchemaValidationError(["refused"]); + }, + }; + await expect( + strategyFor(fs, manifestContract({ manifestSchemaName: "plugin-manifest" }), { + validator: refusing, + assetProvider: assetProviderNaming(loaded), + }).writePluginManifest(PLUGIN, PLUGIN_SRC, OUT_DIR) + ).rejects.toThrow(JsonSchemaValidationError); + expect(loaded).toEqual(["plugin-manifest"]); + expect(writtenUnder(fs, OUT_DIR)).toEqual({}); + }); +}); + +describe("the agents a marketplace layout writes", () => { + const reviewer = "---\nname: reviewer\n---\n\nReview.\n"; + + it("writes each agent markdown under the plugin's own tree, and nothing else", async () => { + const fs = new InMemoryFileAdapter({ + [`${PLUGIN_SRC}/agents/reviewer.md`]: reviewer, + [`${PLUGIN_SRC}/agents/notes.txt`]: "not an agent", + }); + const contract = stubContract({ + artifacts: { + ...stubContract().artifacts, + agents: supportedArtifact((plugin, rel) => `${plugin}/${rel}`), + }, + }); + expect(await strategyFor(fs, contract).writeAgents(PLUGIN, PLUGIN_SRC, OUT_DIR)).toBe(1); + expect(writtenUnder(fs, OUT_DIR)).toEqual({ + [`${PLUGIN_OUT}/${PLUGIN}/agents/reviewer.md`]: reviewer, + }); + }); + + it("hands each agent to the layout's own transform when it declares one", async () => { + const fs = new InMemoryFileAdapter({ [`${PLUGIN_SRC}/agents/reviewer.md`]: reviewer }); + const contract = stubContract({ + artifacts: { + ...stubContract().artifacts, + agents: supportedArtifact((_plugin, rel) => rel.replace(/\.md$/, ".toml"), { + transform: (content, plugin, base) => `${plugin}:${base}\n${content}`, + }), + }, + }); + await strategyFor(fs, contract).writeAgents(PLUGIN, PLUGIN_SRC, OUT_DIR); + expect(writtenUnder(fs, OUT_DIR)).toEqual({ + [`${PLUGIN_OUT}/agents/reviewer.toml`]: `aidd-test:reviewer.md\n${reviewer}`, + }); + }); + + it("writes none for a layout hosting no agent", async () => { + const fs = new InMemoryFileAdapter({ [`${PLUGIN_SRC}/agents/reviewer.md`]: reviewer }); + const contract = stubContract({ + artifacts: { ...stubContract().artifacts, agents: { supported: false } }, + }); + expect(await strategyFor(fs, contract).writeAgents(PLUGIN, PLUGIN_SRC, OUT_DIR)).toBe(0); + expect(writtenUnder(fs, OUT_DIR)).toEqual({}); + }); +}); + +describe("the skills a marketplace layout writes", () => { + it("carries the plugin's whole skill tree under its own directory", async () => { + const fs = new InMemoryFileAdapter({ + [`${PLUGIN_SRC}/skills/hello/SKILL.md`]: "# Hello\n", + [`${PLUGIN_SRC}/skills/hello/reference.json`]: "{}\n", + }); + expect(await strategyFor(fs, stubContract()).writeSkills(PLUGIN, PLUGIN_SRC, OUT_DIR)).toBe(2); + expect(writtenUnder(fs, OUT_DIR)).toEqual({ + [`${PLUGIN_OUT}/skills/hello/SKILL.md`]: "# Hello\n", + [`${PLUGIN_OUT}/skills/hello/reference.json`]: "{}\n", + }); + }); + + it("writes none for a layout hosting no skill", async () => { + const fs = new InMemoryFileAdapter({ [`${PLUGIN_SRC}/skills/hello/SKILL.md`]: "# Hello\n" }); + const contract = stubContract({ + artifacts: { ...stubContract().artifacts, skills: { supported: false } }, + }); + expect(await strategyFor(fs, contract).writeSkills(PLUGIN, PLUGIN_SRC, OUT_DIR)).toBe(0); + expect(writtenUnder(fs, OUT_DIR)).toEqual({}); + }); +}); + +describe("the hooks a marketplace layout writes", () => { + const hooksJson = `{ "command": "${CLAUDE_ROOT_TOKEN}/hooks/check.sh" }`; + const script = `#!/bin/sh\nexec "${CLAUDE_ROOT_TOKEN}/hooks/lib/run.sh"\n`; + + function hooksFs(): InMemoryFileAdapter { + return new InMemoryFileAdapter({ + [`${PLUGIN_SRC}/hooks/hooks.json`]: hooksJson, + [`${PLUGIN_SRC}/hooks/lib/check.sh`]: script, + }); + } + + it("keeps the whole hooks tree and rewrites the plugin root the layout expands", async () => { + const fs = hooksFs(); + const contract = stubContract({ pluginRootToken: STUB_ROOT_TOKEN }); + expect(await strategyFor(fs, contract).writeHooks(PLUGIN, PLUGIN_SRC, OUT_DIR)).toBe(2); + expect(writtenUnder(fs, OUT_DIR)).toEqual({ + [`${PLUGIN_OUT}/hooks/hooks.json`]: `{ "command": "${STUB_ROOT_TOKEN}/hooks/check.sh" }`, + [`${PLUGIN_OUT}/hooks/lib/check.sh`]: `#!/bin/sh\nexec "${STUB_ROOT_TOKEN}/hooks/lib/run.sh"\n`, + }); + }); + + it("leaves the plugin root alone for a layout that expands none", async () => { + const fs = hooksFs(); + expect(await strategyFor(fs, stubContract()).writeHooks(PLUGIN, PLUGIN_SRC, OUT_DIR)).toBe(2); + expect(fs.getFile(`${PLUGIN_OUT}/hooks/hooks.json`)).toBe(hooksJson); + }); + + it("hands the manifest, and no script, to the layout's own transform", async () => { + const fs = hooksFs(); + const contract = stubContract({ + artifacts: { + ...stubContract().artifacts, + hooks: supportedArtifact((_plugin, rel) => rel, { + transform: (content, plugin, base) => `${plugin}:${base}\n${content}`, + }), + }, + }); + await strategyFor(fs, contract).writeHooks(PLUGIN, PLUGIN_SRC, OUT_DIR); + expect(writtenUnder(fs, OUT_DIR)).toEqual({ + [`${PLUGIN_OUT}/hooks/hooks.json`]: `aidd-test:hooks.json\n${hooksJson}`, + [`${PLUGIN_OUT}/hooks/lib/check.sh`]: script, + }); + }); + + it("writes none for a plugin shipping no hooks directory", async () => { + const fs = new InMemoryFileAdapter({ [`${PLUGIN_SRC}/skills/hello/SKILL.md`]: "# Hello\n" }); + expect(await strategyFor(fs, stubContract()).writeHooks(PLUGIN, PLUGIN_SRC, OUT_DIR)).toBe(0); + expect(writtenUnder(fs, OUT_DIR)).toEqual({}); + }); +}); + +describe("the mcp declaration a marketplace layout writes", () => { + const mcpJson = `{ "command": "${CLAUDE_ROOT_TOKEN}/bin/server.js" }`; + + it("writes it at the plugin root with the plugin root the layout expands", async () => { + const fs = new InMemoryFileAdapter({ [`${PLUGIN_SRC}/.mcp.json`]: mcpJson }); + const contract = stubContract({ pluginRootToken: STUB_ROOT_TOKEN }); + expect(await strategyFor(fs, contract).writeMcp(PLUGIN, PLUGIN_SRC, OUT_DIR)).toBe(1); + expect(writtenUnder(fs, OUT_DIR)).toEqual({ + [`${PLUGIN_OUT}/.mcp.json`]: `{ "command": "${STUB_ROOT_TOKEN}/bin/server.js" }`, + }); + }); + + it("hands it to the layout's own transform when it declares one", async () => { + const fs = new InMemoryFileAdapter({ [`${PLUGIN_SRC}/.mcp.json`]: mcpJson }); + const contract = stubContract({ + artifacts: { + ...stubContract().artifacts, + mcp: supportedArtifact((_plugin, rel) => rel, { + transform: (content, plugin, base) => `${plugin}:${base}\n${content}`, + }), + }, + }); + await strategyFor(fs, contract).writeMcp(PLUGIN, PLUGIN_SRC, OUT_DIR); + expect(fs.getFile(`${PLUGIN_OUT}/.mcp.json`)).toBe(`aidd-test:.mcp.json\n${mcpJson}`); + }); + + it("writes none for a plugin declaring none", async () => { + const fs = new InMemoryFileAdapter({ [`${PLUGIN_SRC}/skills/hello/SKILL.md`]: "# Hello\n" }); + expect(await strategyFor(fs, stubContract()).writeMcp(PLUGIN, PLUGIN_SRC, OUT_DIR)).toBe(0); + expect(writtenUnder(fs, OUT_DIR)).toEqual({}); + }); + + it("writes none for a layout hosting no mcp declaration", async () => { + const fs = new InMemoryFileAdapter({ [`${PLUGIN_SRC}/.mcp.json`]: mcpJson }); + const contract = stubContract({ + artifacts: { ...stubContract().artifacts, mcp: { supported: false } }, + }); + expect(await strategyFor(fs, contract).writeMcp(PLUGIN, PLUGIN_SRC, OUT_DIR)).toBe(0); + expect(writtenUnder(fs, OUT_DIR)).toEqual({}); + }); +}); + +describe("the catalog a marketplace layout writes once every plugin is built", () => { + const sourceMarketplace = { + name: "aidd-framework", + plugins: [ + { name: PLUGIN, version: "9.9.9" }, + { name: "unbuilt-plugin", version: "1.0.0" }, + ] satisfies SourcePluginEntryRef[], + }; + + function catalogContract( + seen: { name: string; pluginSrc: string; srcEntry: SourcePluginEntryRef | undefined }[], + schemaName: "marketplace" | null + ): ToolBuildContract { + return stubContract({ + buildMarketplaceEntry: (name, pluginSrc, _outDir, srcEntry) => { + seen.push({ name, pluginSrc, srcEntry }); + return Promise.resolve({ name, version: srcEntry?.version ?? "0.0.0" }); + }, + buildMarketplaceCatalog: (source, entries) => + Promise.resolve({ + catalog: { name: source.name, plugins: entries }, + schemaName, + destRelPath: ".stub-plugin/marketplace.json", + }), + }); + } + + it("writes one entry per built plugin, from the source catalog's own entry", async () => { + const fs = new InMemoryFileAdapter({}); + const seen: { name: string; pluginSrc: string; srcEntry: SourcePluginEntryRef | undefined }[] = + []; + expect( + await strategyFor(fs, catalogContract(seen, null)).postBuild( + sourceMarketplace, + [{ name: PLUGIN }], + OUT_DIR + ) + ).toBe(1); + expect(seen).toEqual([ + { + name: PLUGIN, + pluginSrc: join(OUT_DIR, "plugins", PLUGIN), + srcEntry: { name: PLUGIN, version: "9.9.9" }, + }, + ]); + expect(writtenUnder(fs, OUT_DIR)).toEqual({ + [`${OUT_DIR}/.stub-plugin/marketplace.json`]: + '{\n "name": "aidd-framework",\n "plugins": [\n {\n "name": "aidd-test",\n "version": "9.9.9"\n }\n ]\n}\n', + }); + }); + + it("hands no source entry for a plugin the source catalog does not name", async () => { + const fs = new InMemoryFileAdapter({}); + const seen: { name: string; pluginSrc: string; srcEntry: SourcePluginEntryRef | undefined }[] = + []; + await strategyFor(fs, catalogContract(seen, null)).postBuild( + sourceMarketplace, + [{ name: "other-plugin" }], + OUT_DIR + ); + expect(seen).toEqual([ + { + name: "other-plugin", + pluginSrc: join(OUT_DIR, "plugins", "other-plugin"), + srcEntry: undefined, + }, + ]); + }); + + it("validates it against the schema the layout names", async () => { + const fs = new InMemoryFileAdapter({}); + const loaded: string[] = []; + const refusing: JsonSchemaValidator = { + validate(_schema: object, _data: unknown): void { + throw new JsonSchemaValidationError(["refused"]); + }, + }; + await expect( + strategyFor(fs, catalogContract([], "marketplace"), { + validator: refusing, + assetProvider: assetProviderNaming(loaded), + }).postBuild(sourceMarketplace, [{ name: PLUGIN }], OUT_DIR) + ).rejects.toThrow(JsonSchemaValidationError); + expect(loaded).toEqual(["marketplace"]); + expect(writtenUnder(fs, OUT_DIR)).toEqual({}); + }); + + it("writes none for a layout building a catalog but no entry for it", async () => { + const fs = new InMemoryFileAdapter({}); + const contract = stubContract({ + buildMarketplaceEntry: null, + buildMarketplaceCatalog: (source, entries) => + Promise.resolve({ + catalog: { name: source.name, plugins: entries }, + schemaName: null, + destRelPath: ".stub-plugin/marketplace.json", + }), + }); + expect( + await strategyFor(fs, contract).postBuild(sourceMarketplace, [{ name: PLUGIN }], OUT_DIR) + ).toBe(0); + expect(writtenUnder(fs, OUT_DIR)).toEqual({}); + }); + + it("writes none for a layout with no catalog of its own", async () => { + const fs = new InMemoryFileAdapter({}); + expect( + await strategyFor(fs, stubContract()).postBuild( + sourceMarketplace, + [{ name: PLUGIN }], + OUT_DIR + ) + ).toBe(0); + expect(writtenUnder(fs, OUT_DIR)).toEqual({}); + }); +}); + +describe("what a marketplace layout does with a plugin's presence flags", () => { + it("reads them off the source tree, not the output", async () => { + const fs = new InMemoryFileAdapter({ + [`${PLUGIN_SRC}/.claude-plugin/plugin.json`]: '{ "name": "aidd-test" }', + [`${PLUGIN_SRC}/agents/reviewer.md`]: "# Reviewer", + [`${PLUGIN_SRC}/skills/hello/SKILL.md`]: "# Hello", + [`${PLUGIN_SRC}/hooks/hooks.json`]: "{}", + }); + const seen: PluginPresence[] = []; + const contract = stubContract({ + manifestFileRelative: "plugin.json", + synthesizeManifest: (source, presence) => { + seen.push(presence); + return source; + }, + }); + await strategyFor(fs, contract).writePluginManifest(PLUGIN, PLUGIN_SRC, OUT_DIR); + expect(seen).toEqual([ + { + hasAgents: true, + agentsList: ["reviewer.md"], + skillsList: ["hello"], + hasHooksJson: true, + hasMcpJson: false, + }, + ]); + }); +}); diff --git a/cli/tests/contexts/translate/application/strategies/plugin-source-tree-reader.unit.test.ts b/cli/tests/contexts/translate/application/strategies/plugin-source-tree-reader.unit.test.ts new file mode 100644 index 000000000..58994eab3 --- /dev/null +++ b/cli/tests/contexts/translate/application/strategies/plugin-source-tree-reader.unit.test.ts @@ -0,0 +1,82 @@ +import { describe, expect, it } from "vitest"; +import { + detectPluginPresenceFlags, + listAgentFiles, + listSkillNames, +} from "../../../../../src/contexts/translate/application/strategies/plugin-source-tree-reader.js"; +import { InMemoryFileAdapter } from "../../../../helpers/ports/in-memory-file-adapter.js"; + +const PLUGIN_SRC = "/src/plugins/aidd-test"; + +function fsWith(files: Record): InMemoryFileAdapter { + return new InMemoryFileAdapter(files); +} + +describe("listAgentFiles", () => { + it("lists nothing when the plugin ships no agents directory", async () => { + const fs = fsWith({ [`${PLUGIN_SRC}/skills/hello/SKILL.md`]: "# Hello" }); + expect(await listAgentFiles(fs, `${PLUGIN_SRC}/agents`)).toEqual([]); + }); + + it("names each agent markdown file relative to the agents directory, in order, and nothing else", async () => { + const fs = fsWith({ + [`${PLUGIN_SRC}/agents/planner.md`]: "# Planner", + [`${PLUGIN_SRC}/agents/alpha.md`]: "# Alpha", + [`${PLUGIN_SRC}/agents/nested/deep.md`]: "# Deep", + [`${PLUGIN_SRC}/agents/notes.txt`]: "not an agent", + }); + expect(await listAgentFiles(fs, `${PLUGIN_SRC}/agents`)).toEqual([ + "alpha.md", + "nested/deep.md", + "planner.md", + ]); + }); +}); + +describe("listSkillNames", () => { + it("lists nothing when the plugin ships no skills directory", async () => { + const fs = fsWith({ [`${PLUGIN_SRC}/agents/planner.md`]: "# Planner" }); + expect(await listSkillNames(fs, PLUGIN_SRC)).toEqual([]); + }); + + it("names each skill folder once, in order, ignoring an entry file sitting at the skills root", async () => { + const fs = fsWith({ + [`${PLUGIN_SRC}/skills/commit/SKILL.md`]: "# Commit", + [`${PLUGIN_SRC}/skills/apply/SKILL.md`]: "# Apply", + [`${PLUGIN_SRC}/skills/apply/actions/run/SKILL.md`]: "# Run", + [`${PLUGIN_SRC}/skills/apply/reference.json`]: "{}", + [`${PLUGIN_SRC}/skills/SKILL.md`]: "# Loose", + [`${PLUGIN_SRC}/skills/notes/readme.md`]: "# Notes", + }); + expect(await listSkillNames(fs, PLUGIN_SRC)).toEqual(["apply", "commit"]); + }); +}); + +describe("detectPluginPresenceFlags", () => { + it("reports what a plugin shipping agents, skills, hooks and an mcp declaration holds", async () => { + const fs = fsWith({ + [`${PLUGIN_SRC}/agents/reviewer.md`]: "# Reviewer", + [`${PLUGIN_SRC}/skills/hello/SKILL.md`]: "# Hello", + [`${PLUGIN_SRC}/hooks/hooks.json`]: "{}", + [`${PLUGIN_SRC}/.mcp.json`]: "{}", + }); + expect(await detectPluginPresenceFlags(fs, PLUGIN_SRC)).toEqual({ + hasAgents: true, + agentsList: ["reviewer.md"], + skillsList: ["hello"], + hasHooksJson: true, + hasMcpJson: true, + }); + }); + + it("reports a plugin holding none of them", async () => { + const fs = fsWith({ [`${PLUGIN_SRC}/.claude-plugin/plugin.json`]: "{}" }); + expect(await detectPluginPresenceFlags(fs, PLUGIN_SRC)).toEqual({ + hasAgents: false, + agentsList: [], + skillsList: [], + hasHooksJson: false, + hasMcpJson: false, + }); + }); +}); diff --git a/cli/tests/contexts/translate/application/strategies/write-skill-tree.unit.test.ts b/cli/tests/contexts/translate/application/strategies/write-skill-tree.unit.test.ts new file mode 100644 index 000000000..179e883d9 --- /dev/null +++ b/cli/tests/contexts/translate/application/strategies/write-skill-tree.unit.test.ts @@ -0,0 +1,65 @@ +import { describe, expect, it } from "vitest"; +import { writeSkillTree } from "../../../../../src/contexts/translate/application/strategies/write-skill-tree.js"; +import { FrameworkPlaceholderInPluginError } from "../../../../../src/kernel/errors.js"; +import { InMemoryFileAdapter } from "../../../../helpers/ports/in-memory-file-adapter.js"; + +const PLUGIN = "aidd-test"; +const PLUGIN_SRC = "/src/plugins/aidd-test"; +const PLUGIN_OUT = "/out/plugins/aidd-test"; + +const entryContent = "---\nname: hello\n---\n\nSee @./reference.json and @../commit/SKILL.md\n"; +const assetContent = '{ "entry": "run.sh" }\n'; + +function fsWith(files: Record): InMemoryFileAdapter { + return new InMemoryFileAdapter(files); +} + +function writtenUnder(fs: InMemoryFileAdapter, dir: string): Record { + return Object.fromEntries(fs.listUnder(dir).map((path) => [path, fs.getFile(path)])); +} + +describe("writeSkillTree", () => { + it("writes nothing for a plugin shipping no skills directory", async () => { + const fs = fsWith({ [`${PLUGIN_SRC}/agents/reviewer.md`]: "# Reviewer" }); + expect(await writeSkillTree(fs, PLUGIN, PLUGIN_SRC, PLUGIN_OUT)).toBe(0); + expect(writtenUnder(fs, PLUGIN_OUT)).toEqual({}); + }); + + it("rewrites a skill's own markdown links and carries its asset byte for byte", async () => { + const fs = fsWith({ + [`${PLUGIN_SRC}/skills/hello/SKILL.md`]: entryContent, + [`${PLUGIN_SRC}/skills/hello/reference.json`]: assetContent, + }); + expect(await writeSkillTree(fs, PLUGIN, PLUGIN_SRC, PLUGIN_OUT)).toBe(2); + expect(writtenUnder(fs, PLUGIN_OUT)).toEqual({ + [`${PLUGIN_OUT}/skills/hello/SKILL.md`]: + "---\nname: hello\n---\n\nSee [reference.json](./reference.json) and [commit/SKILL.md](../commit/SKILL.md)\n", + [`${PLUGIN_OUT}/skills/hello/reference.json`]: assetContent, + }); + }); + + it("hands the skill's entry file to the transform, and no other file", async () => { + const fs = fsWith({ + [`${PLUGIN_SRC}/skills/hello/SKILL.md`]: "# Entry\n", + [`${PLUGIN_SRC}/skills/hello/actions/run.md`]: "# Action\n", + [`${PLUGIN_SRC}/skills/hello/reference.json`]: assetContent, + }); + const transform = (content: string, plugin: string, base: string): string => + `${plugin}:${base}\n${content}`; + expect(await writeSkillTree(fs, PLUGIN, PLUGIN_SRC, PLUGIN_OUT, transform)).toBe(3); + expect(writtenUnder(fs, PLUGIN_OUT)).toEqual({ + [`${PLUGIN_OUT}/skills/hello/SKILL.md`]: "aidd-test:SKILL.md\n# Entry\n", + [`${PLUGIN_OUT}/skills/hello/actions/run.md`]: "# Action\n", + [`${PLUGIN_OUT}/skills/hello/reference.json`]: assetContent, + }); + }); + + it("refuses a skill naming the framework's tools placeholder", async () => { + const fs = fsWith({ + [`${PLUGIN_SRC}/skills/hello/SKILL.md`]: "See @{{TOOLS}}/agents/planner.md.\n", + }); + await expect(writeSkillTree(fs, PLUGIN, PLUGIN_SRC, PLUGIN_OUT)).rejects.toThrow( + new FrameworkPlaceholderInPluginError(PLUGIN, "hello/SKILL.md") + ); + }); +}); diff --git a/cli/tests/domain/formats/cursor-hooks.unit.test.ts b/cli/tests/contexts/translate/domain/formats/cursor-hooks.unit.test.ts similarity index 95% rename from cli/tests/domain/formats/cursor-hooks.unit.test.ts rename to cli/tests/contexts/translate/domain/formats/cursor-hooks.unit.test.ts index 732e2dfa9..35c2eaa87 100644 --- a/cli/tests/domain/formats/cursor-hooks.unit.test.ts +++ b/cli/tests/contexts/translate/domain/formats/cursor-hooks.unit.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { convertClaudeHooksToCursorPlugin } from "../../../src/domain/formats/cursor-hooks.js"; +import { convertClaudeHooksToCursorPlugin } from "../../../../../src/contexts/translate/domain/formats/cursor-hooks.js"; describe("convertClaudeHooksToCursorPlugin", () => { it("converts PascalCase event to camelCase", () => { diff --git a/cli/tests/domain/formats/plugin-root-token-rewrite.unit.test.ts b/cli/tests/contexts/translate/domain/formats/plugin-root-token-rewrite.unit.test.ts similarity index 85% rename from cli/tests/domain/formats/plugin-root-token-rewrite.unit.test.ts rename to cli/tests/contexts/translate/domain/formats/plugin-root-token-rewrite.unit.test.ts index 07fe24851..7811f741f 100644 --- a/cli/tests/domain/formats/plugin-root-token-rewrite.unit.test.ts +++ b/cli/tests/contexts/translate/domain/formats/plugin-root-token-rewrite.unit.test.ts @@ -1,8 +1,6 @@ import { describe, expect, it } from "vitest"; -import { - CLAUDE_PLUGIN_ROOT_TOKEN, - rewritePluginRootToken, -} from "../../../src/domain/formats/plugin-root-token-rewrite.js"; +import { CLAUDE_PLUGIN_ROOT_TOKEN } from "../../../../../src/contexts/tools/domain/formats/plugin-root-token.js"; +import { rewritePluginRootToken } from "../../../../../src/contexts/translate/domain/formats/plugin-root-token-rewrite.js"; // Avoid biome noTemplateCurlyInString: split literals const CLAUDE_TOKEN = "$" + "{CLAUDE_PLUGIN_ROOT}"; @@ -129,39 +127,45 @@ describe("rewritePluginRootToken", () => { describe("per-tool pluginRootToken contract values", () => { it("claude contract uses the claude native token", async () => { const { buildClaudeContract } = await import( - "../../../src/application/use-cases/framework/strategies/tool-contracts.js" + "../../../../../src/contexts/tools/domain/profiles/claude/build.js" ); expect(buildClaudeContract().pluginRootToken).toBe(CLAUDE_TOKEN); }); it("cursor contract uses the cursor native token", async () => { const { buildCursorContract } = await import( - "../../../src/application/use-cases/framework/strategies/tool-contracts.js" + "../../../../../src/contexts/tools/domain/profiles/cursor/build.js" ); expect(buildCursorContract().pluginRootToken).toBe(CURSOR_TOKEN); }); it("codex contract uses the codex native token", async () => { const { buildCodexContract } = await import( - "../../../src/application/use-cases/framework/strategies/tool-contracts.js" + "../../../../../src/contexts/tools/domain/profiles/codex/build.js" ); expect(buildCodexContract().pluginRootToken).toBe(CODEX_TOKEN); }); it("copilot marketplace contract uses the OpenPlugin native token", async () => { const { buildCopilotMarketplaceContract } = await import( - "../../../src/application/use-cases/framework/strategies/tool-contracts.js" + "../../../../../src/contexts/tools/domain/profiles/copilot/build.js" ); expect(buildCopilotMarketplaceContract().pluginRootToken).toBe(CODEX_TOKEN); }); it("flat contracts do not set pluginRootToken", async () => { - const { - buildClaudeFlatContract, - buildCursorFlatContract, - buildCopilotFlatContract, - buildCodexFlatContract, - } = await import("../../../src/application/use-cases/framework/strategies/tool-contracts.js"); + const { buildClaudeFlatContract } = await import( + "../../../../../src/contexts/tools/domain/profiles/claude/build.js" + ); + const { buildCursorFlatContract } = await import( + "../../../../../src/contexts/tools/domain/profiles/cursor/build.js" + ); + const { buildCopilotFlatContract } = await import( + "../../../../../src/contexts/tools/domain/profiles/copilot/build.js" + ); + const { buildCodexFlatContract } = await import( + "../../../../../src/contexts/tools/domain/profiles/codex/build.js" + ); expect(buildClaudeFlatContract().pluginRootToken).toBeUndefined(); expect(buildCursorFlatContract().pluginRootToken).toBeUndefined(); expect(buildCopilotFlatContract().pluginRootToken).toBeUndefined(); diff --git a/cli/tests/domain/models/framework-descriptor.unit.test.ts b/cli/tests/contexts/translate/domain/framework-descriptor.unit.test.ts similarity index 96% rename from cli/tests/domain/models/framework-descriptor.unit.test.ts rename to cli/tests/contexts/translate/domain/framework-descriptor.unit.test.ts index 0d0b23c36..3575f7746 100644 --- a/cli/tests/domain/models/framework-descriptor.unit.test.ts +++ b/cli/tests/contexts/translate/domain/framework-descriptor.unit.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { FrameworkDescriptor } from "../../../src/domain/models/framework.js"; +import { FrameworkDescriptor } from "../../../../src/contexts/translate/domain/canon.js"; function makeDescriptor() { return new FrameworkDescriptor({ diff --git a/cli/tests/contexts/translate/domain/plugin-content-translator-skip.unit.test.ts b/cli/tests/contexts/translate/domain/plugin-content-translator-skip.unit.test.ts new file mode 100644 index 000000000..d59024c21 --- /dev/null +++ b/cli/tests/contexts/translate/domain/plugin-content-translator-skip.unit.test.ts @@ -0,0 +1,96 @@ +import { describe, expect, it } from "vitest"; +import { cursor } from "../../../../src/contexts/tools/domain/profiles/cursor/profile.js"; +import { opencode } from "../../../../src/contexts/tools/domain/profiles/opencode/profile.js"; +import { PluginContentTranslator } from "../../../../src/contexts/translate/domain/content-translator.js"; +import { PluginDistribution } from "../../../../src/contexts/translate/domain/plugin-distribution.js"; +import { FileHash } from "../../../../src/kernel/file.js"; + +const stubHasher = { hash: (_content: string) => new FileHash("a".repeat(32)) }; +const translator = new PluginContentTranslator(stubHasher); + +const HOOKS_CONTENT = JSON.stringify({ + hooks: { PreToolUse: [{ hooks: [{ type: "command", command: "node ./hooks/pre.js" }] }] }, +}); + +function buildDistWithNoHooksMcp(name = "test-plugin"): PluginDistribution { + return new PluginDistribution({ + manifest: { name, version: "1.0.0" }, + format: "claude", + files: [ + { relativePath: "commands/greet.md", content: "---\nname: aidd:01:greet\n---\n# Greet" }, + ], + components: { + commands: [ + { relativePath: "commands/greet.md", content: "---\nname: aidd:01:greet\n---\n# Greet" }, + ], + agents: [], + rules: [], + skills: [], + hooks: [], + mcp: [], + }, + }); +} + +function buildDistWithHooks(name = "test-plugin"): PluginDistribution { + return new PluginDistribution({ + manifest: { name, version: "1.0.0" }, + format: "claude", + files: [ + { relativePath: "hooks/hooks.json", content: HOOKS_CONTENT }, + { relativePath: "hooks/pre.js", content: "module.exports = () => {};" }, + ], + components: { + commands: [], + agents: [], + rules: [], + skills: [], + hooks: [ + { relativePath: "hooks/hooks.json", content: HOOKS_CONTENT }, + { relativePath: "hooks/pre.js", content: "module.exports = () => {};" }, + ], + mcp: [], + }, + }); +} + +describe("PluginContentTranslator skip list", () => { + describe("flat mode (opencode)", () => { + it("returns empty skipped list when plugin has no hooks or mcp", () => { + const dist = buildDistWithNoHooksMcp(); + const result = translator.translateWithComponentPaths(dist, opencode); + expect(result.skipped).toEqual([]); + }); + + it("returns no skip entry when plugin has hooks — OpenCode now accepts them", () => { + const dist = buildDistWithHooks("aidd-pm"); + const result = translator.translateWithComponentPaths(dist, opencode); + expect(result.skipped).toEqual([]); + }); + + it("delivers every hooks/ file but hooks.json namespaced under the plugin's own flatHooksDir subtree", () => { + const dist = buildDistWithHooks("aidd-pm"); + const result = translator.translateWithComponentPaths(dist, opencode); + const paths = result.files.map((f) => f.relativePath); + expect(paths).toContain(".opencode/hooks/aidd-pm/pre.js"); + expect(paths).not.toContain(".opencode/hooks/aidd-pm/hooks.json"); + // Never OpenCode's own scanned plugin directory: a plain hook script there is + // imported in-process and kills the host. + expect(paths).not.toContain(".opencode/plugin/pre.js"); + }); + }); + + describe("native mode (cursor)", () => { + it("returns empty skipped list when plugin has no hooks or mcp", () => { + const dist = buildDistWithNoHooksMcp(); + const result = translator.translateWithComponentPaths(dist, cursor); + expect(result.skipped).toEqual([]); + }); + + it("returns empty skipped list when plugin has hooks (cursor acceptsHooks: true)", () => { + const dist = buildDistWithHooks("test-plugin"); + const result = translator.translateWithComponentPaths(dist, cursor); + expect(result.skipped).toEqual([]); + }); + }); +}); diff --git a/cli/tests/contexts/translate/domain/plugin-content-translator.unit.test.ts b/cli/tests/contexts/translate/domain/plugin-content-translator.unit.test.ts new file mode 100644 index 000000000..d0913b772 --- /dev/null +++ b/cli/tests/contexts/translate/domain/plugin-content-translator.unit.test.ts @@ -0,0 +1,738 @@ +import { describe, expect, it } from "vitest"; +import { PluginsCapability } from "../../../../src/contexts/tools/domain/capabilities/plugins-capability.js"; +import { claude } from "../../../../src/contexts/tools/domain/profiles/claude/profile.js"; +import { codex } from "../../../../src/contexts/tools/domain/profiles/codex/profile.js"; +import { copilot } from "../../../../src/contexts/tools/domain/profiles/copilot/profile.js"; +import { cursor } from "../../../../src/contexts/tools/domain/profiles/cursor/profile.js"; +import { opencode } from "../../../../src/contexts/tools/domain/profiles/opencode/profile.js"; +import { vscodeToolConfig } from "../../../../src/contexts/tools/domain/profiles/vscode/profile.js"; +import type { ToolConfig } from "../../../../src/contexts/tools/domain/registry.js"; +import { PluginContentTranslator } from "../../../../src/contexts/translate/domain/content-translator.js"; +import { + type PluginComponentFile, + type PluginComponents, + PluginDistribution, +} from "../../../../src/contexts/translate/domain/plugin-distribution.js"; +import { FileHash } from "../../../../src/kernel/file.js"; +import { parseFrontmatter } from "../../../../src/kernel/markdown.js"; + +const stubHasher = { hash: (_content: string) => new FileHash("a".repeat(32)) }; +const translator = new PluginContentTranslator(stubHasher); + +const greetContent = `--- +name: aidd:04:greet +description: Greet command +--- + +Greet from sample-plugin. +`; + +const skillContent = `--- +name: hello +description: Hello skill +--- + +Hello from sample-plugin skill. +`; + +const agentContent = `--- +name: reviewer +description: Reviewer agent +--- + +Reviewer agent from sample-plugin. +`; + +const ruleContent = `--- +description: Coding standards rule +paths: + - "**/*.ts" +--- + +Use strict types. +`; + +const hooksJsonContent = `{ "hooks": [] }`; +const mcpJsonContent = `{ "mcpServers": {} }`; +const claudeManifestContent = `{ "name": "sample-plugin", "version": "1.0.0" }`; + +function makeFile(relativePath: string, content: string): PluginComponentFile { + return { relativePath, content }; +} + +function makeDist( + overrides: Partial[0]> = {} +): PluginDistribution { + const commands = [makeFile("commands/greet.md", greetContent)]; + const skills = [makeFile("skills/hello/SKILL.md", skillContent)]; + const agents = [makeFile("agents/reviewer.md", agentContent)]; + const rules = [makeFile("rules/standards.md", ruleContent)]; + const hooks = [makeFile("hooks/hooks.json", hooksJsonContent)]; + const mcp = [makeFile(".mcp.json", mcpJsonContent)]; + const manifest = makeFile(".claude-plugin/plugin.json", claudeManifestContent); + return new PluginDistribution({ + manifest: { name: "sample-plugin", version: "1.0.0" }, + format: "claude", + files: [...skills, ...commands, ...agents, ...rules, ...hooks, ...mcp, manifest], + components: { skills, commands, agents, rules, hooks, mcp }, + ...overrides, + }); +} + +function pathsFor(tool: ToolConfig, dist = makeDist()): string[] { + return translator.translate(dist, tool).map((f) => f.relativePath); +} + +describe("PluginContentTranslator.translate()", () => { + describe("claude target", () => { + it("emits all components claude supports under .claude/plugins/sample-plugin/", () => { + const paths = pathsFor(claude); + expect(paths).toContain(".claude/plugins/sample-plugin/commands/greet.md"); + expect(paths).toContain(".claude/plugins/sample-plugin/agents/reviewer.md"); + expect(paths).toContain(".claude/plugins/sample-plugin/skills/hello/SKILL.md"); + expect(paths).toContain(".claude/plugins/sample-plugin/rules/standards.md"); + expect(paths).toContain(".claude/plugins/sample-plugin/hooks/hooks.json"); + expect(paths).toContain(".claude/plugins/sample-plugin/.mcp.json"); + }); + + it("emits native plugin manifest at plugin.json", () => { + const files = translator.translate(makeDist(), claude); + const manifest = files.find( + (f) => f.relativePath === ".claude/plugins/sample-plugin/plugin.json" + ); + expect(manifest).toBeDefined(); + expect(manifest?.content).toContain("sample-plugin"); + }); + + it("emits hooks companion scripts alongside hooks.json", () => { + const scriptFile = makeFile("hooks/update_memory.js", "console.log('updated');"); + const hooksFiles = [makeFile("hooks/hooks.json", hooksJsonContent), scriptFile]; + const dist = makeDist({ + files: [ + makeFile("skills/hello/SKILL.md", skillContent), + makeFile("commands/greet.md", greetContent), + makeFile("agents/reviewer.md", agentContent), + makeFile("rules/standards.md", ruleContent), + ...hooksFiles, + makeFile(".mcp.json", mcpJsonContent), + makeFile(".claude-plugin/plugin.json", claudeManifestContent), + ], + components: { + skills: [makeFile("skills/hello/SKILL.md", skillContent)], + commands: [makeFile("commands/greet.md", greetContent)], + agents: [makeFile("agents/reviewer.md", agentContent)], + rules: [makeFile("rules/standards.md", ruleContent)], + hooks: hooksFiles, + mcp: [makeFile(".mcp.json", mcpJsonContent)], + }, + }); + const paths = pathsFor(claude, dist); + expect(paths).toContain(".claude/plugins/sample-plugin/hooks/hooks.json"); + expect(paths).toContain(".claude/plugins/sample-plugin/hooks/update_memory.js"); + }); + + it("keeps a hook script's own directories, which its requires resolve against", () => { + const hooksFiles = [ + makeFile("hooks/hooks.json", hooksJsonContent), + makeFile("hooks/journal.cjs", 'require("./lib/repo.js");'), + makeFile("hooks/lib/repo.cjs", "module.exports = {};"), + ]; + const dist = makeDist({ + files: [...hooksFiles, makeFile(".claude-plugin/plugin.json", claudeManifestContent)], + components: { + skills: [], + commands: [], + agents: [], + rules: [], + hooks: hooksFiles, + mcp: [], + }, + }); + const paths = pathsFor(claude, dist); + expect(paths).toContain(".claude/plugins/sample-plugin/hooks/journal.cjs"); + expect(paths).toContain(".claude/plugins/sample-plugin/hooks/lib/repo.cjs"); + expect(paths).not.toContain(".claude/plugins/sample-plugin/hooks/repo.cjs"); + }); + }); + + describe("cursor target (Mode B — user-scope flat materialization)", () => { + it("emits rules with .mdc extension under plugin-name-prefixed path", () => { + expect(pathsFor(cursor)).toContain("sample-plugin/rules/standards.mdc"); + }); + + it("emits cursor-format frontmatter on rules (globs key)", () => { + const files = translator.translate(makeDist(), cursor); + const rule = files.find((f) => f.relativePath.endsWith("standards.mdc")); + expect(rule?.content).toContain("globs:"); + }); + + it("does not emit plugin.json (pluginManifestRelativePath is null)", () => { + const files = translator.translate(makeDist(), cursor); + const manifest = files.find((f) => f.relativePath.endsWith("plugin.json")); + expect(manifest).toBeUndefined(); + }); + + it("does not emit hooks (acceptsHooks is false)", () => { + expect(pathsFor(cursor)).not.toContain(expect.stringContaining("hooks/hooks.json")); + }); + + it("does not emit mcp (acceptsMcp is false)", () => { + expect(pathsFor(cursor)).not.toContain(expect.stringContaining("mcp.json")); + }); + + it("emits commands under plugin-name-prefixed path", () => { + expect(pathsFor(cursor)).toContain("sample-plugin/commands/greet.md"); + }); + + it("file paths are base-relative (no .cursor/ prefix — base resolved at install time)", () => { + const paths = pathsFor(cursor); + expect(paths.every((p) => !p.startsWith(".cursor/"))).toBe(true); + }); + }); + + describe("codex target", () => { + it("emits agents as TOML", () => { + expect(pathsFor(codex)).toContain(".codex/plugins/sample-plugin/agents/reviewer.toml"); + }); + + it("agent content is TOML format", () => { + const files = translator.translate(makeDist(), codex); + const agent = files.find((f) => f.relativePath.endsWith("reviewer.toml")); + expect(agent?.content).toContain("name ="); + expect(agent?.content).toContain("description ="); + expect(agent?.content).toContain("developer_instructions ="); + }); + + it("emits native plugin manifest at plugin.json", () => { + const files = translator.translate(makeDist(), codex); + const manifest = files.find( + (f) => f.relativePath === ".codex/plugins/sample-plugin/plugin.json" + ); + expect(manifest).toBeDefined(); + }); + }); + + describe("copilot target", () => { + it("emits commands as prompts with .prompt.md extension", () => { + expect(pathsFor(copilot)).toContain(".github/plugins/sample-plugin/prompts/greet.prompt.md"); + }); + + it("emits agents with .agent.md extension", () => { + expect(pathsFor(copilot)).toContain(".github/plugins/sample-plugin/agents/reviewer.agent.md"); + }); + + it("emits rules as instructions with .instructions.md extension", () => { + expect(pathsFor(copilot)).toContain( + ".github/plugins/sample-plugin/instructions/standards.instructions.md" + ); + }); + }); + + describe("opencode target (flat mode)", () => { + it("emits commands under .opencode/commands/sample-plugin/ with name prefix", () => { + const files = translator.translate(makeDist(), opencode); + const greet = files.find( + (f) => f.relativePath === ".opencode/commands/sample-plugin/greet.md" + ); + expect(greet).toBeDefined(); + expect(greet?.content).toContain("name: 'aidd-sample-plugin:greet'"); + }); + + it("emits agents under .opencode/agents/sample-plugin/", () => { + expect(pathsFor(opencode)).toContain(".opencode/agents/sample-plugin/reviewer.md"); + }); + + it("emits skills under .opencode/skills/sample-plugin/", () => { + expect(pathsFor(opencode)).toContain(".opencode/skills/sample-plugin/hello/SKILL.md"); + }); + + it("emits rules under .opencode/rules/sample-plugin/", () => { + expect(pathsFor(opencode)).toContain(".opencode/rules/sample-plugin/standards.md"); + }); + }); + + describe("vscode (IDE tool)", () => { + it("returns empty array", () => { + expect(translator.translate(makeDist(), vscodeToolConfig)).toEqual([]); + }); + }); +}); + +describe("cross-format matrix (source × target)", () => { + const sourceFormats = [ + { format: "claude" as const, manifestPath: ".claude-plugin/plugin.json" }, + { format: "cursor" as const, manifestPath: ".cursor-plugin/plugin.json" }, + { format: "codex" as const, manifestPath: ".codex-plugin/plugin.json" }, + { format: "copilot" as const, manifestPath: "plugin.json" }, + ]; + + const targets = [ + { name: "claude", tool: claude, manifestExpected: "plugin.json" }, + { name: "cursor", tool: cursor, manifestExpected: "plugin.json" }, + { name: "codex", tool: codex, manifestExpected: "plugin.json" }, + { name: "copilot", tool: copilot, manifestExpected: "plugin.json" }, + ]; + + function makeSourceDist(format: (typeof sourceFormats)[number]): PluginDistribution { + const commands = [makeFile("commands/greet.md", greetContent)]; + const agents = [makeFile("agents/reviewer.md", agentContent)]; + const skills = [makeFile("skills/hello/SKILL.md", skillContent)]; + const manifest = makeFile(format.manifestPath, claudeManifestContent); + return new PluginDistribution({ + manifest: { name: "sample-plugin", version: "1.0.0" }, + format: format.format, + files: [...commands, ...agents, ...skills, manifest], + components: { commands, agents, skills, rules: [], hooks: [], mcp: [] }, + }); + } + + for (const source of sourceFormats) { + for (const target of targets) { + if (target.name === "cursor") { + // Cursor Mode B: pluginManifestRelativePath is null — no manifest file written into plugin dir. + it(`${source.format} source → ${target.name} target: does not emit manifest (Mode B, null pluginManifestRelativePath)`, () => { + const dist = makeSourceDist(source); + const files = translator.translate(dist, target.tool); + expect(files.map((f) => f.relativePath)).not.toContain( + expect.stringMatching(/plugin\.json$/) + ); + }); + } else { + it(`${source.format} source → ${target.name} target: emits manifest at ${target.manifestExpected}`, () => { + const dist = makeSourceDist(source); + const files = translator.translate(dist, target.tool); + const expected = `${target.tool.capabilities.plugins.pluginsDir}sample-plugin/${target.manifestExpected}`; + expect(files.map((f) => f.relativePath)).toContain(expected); + }); + } + } + } +}); + +describe("PluginContentTranslator.detectFlatCollisions()", () => { + it("reports no collision when plugins use different plugin names", () => { + const dist1 = makeDist({ manifest: { name: "plugin-a", version: "1.0.0" } }); + const dist2 = makeDist({ manifest: { name: "plugin-b", version: "1.0.0" } }); + const collisions = translator.detectFlatCollisions([dist1, dist2], opencode); + expect(collisions).toEqual([]); + }); + + it("reports collisions when same plugin name is used twice", () => { + const dist1 = makeDist({ manifest: { name: "same-plugin", version: "1.0.0" } }); + const dist2 = makeDist({ manifest: { name: "same-plugin", version: "2.0.0" } }); + const collisions = translator.detectFlatCollisions([dist1, dist2], opencode); + expect(collisions.length).toBeGreaterThan(0); + expect(collisions[0].plugin).toBe("same-plugin"); + }); + + it("returns empty array for native-mode tools", () => { + expect(translator.detectFlatCollisions([makeDist()], claude)).toEqual([]); + }); +}); + +/** The path a plugin's own content takes on its way to disk: the chain `aidd plugin install` + * follows, and the one the golden suite cannot see, since `aidd translate` never calls + * `rewriteContent` at all. */ +describe("a plugin whose content references the framework", () => { + const withPlaceholder = () => { + const skills = [ + makeFile( + "skills/hello/SKILL.md", + `---\nname: hello\ndescription: Hello skill\n---\n\nSee \`{{TOOLS}}/plugins/aidd-pm/x.yml\` and @{{DOCS}}/memory/testing.md\n` + ), + ]; + // `files` and `components` both, or the override keeps the default skill content and + // the assertion passes on a file that never carried a placeholder. + return makeDist({ + files: skills, + components: { skills, commands: [], agents: [], rules: [], hooks: [], mcp: [] }, + }); + }; + + it("resolves the reference for the tool being installed into", () => { + const file = translator + .translate(withPlaceholder(), copilot) + .find((f) => f.relativePath.endsWith("SKILL.md")); + + expect(file?.content).toContain(".github/plugins/aidd-pm/x.yml"); + expect(file?.content).toContain( + "[aidd_docs/memory/testing.md](../../aidd_docs/memory/testing.md)" + ); + expect(file?.content).not.toContain("{{TOOLS}}"); + expect(file?.content).not.toContain("{{DOCS}}"); + }); +}); + +const CLAUDE_ROOT_TOKEN = "$" + "{CLAUDE_PLUGIN_ROOT}"; +const CODEX_ROOT_TOKEN = "$" + "{PLUGIN_ROOT}"; + +const skillEntryContent = `--- +name: hello +description: Hello skill +--- + +Hello body. +`; + +const skillActionContent = `--- +name: run +description: Run action +--- + +Run body. +`; + +const skillAssetContent = `{ "entry": "${CLAUDE_ROOT_TOKEN}/skills/hello/run.sh" }\n`; +const hookScriptContent = `#!/bin/sh\nexec "${CLAUDE_ROOT_TOKEN}/hooks/lib/check.sh"\n`; + +function hooksManifestFor(token: string): string { + return JSON.stringify({ + hooks: { + PreToolUse: [ + { matcher: "Bash", hooks: [{ type: "command", command: `${token}/hooks/check.sh` }] }, + ], + }, + }); +} + +function distOf(files: PluginComponentFile[], components: Partial) { + return new PluginDistribution({ + manifest: { name: "sample-plugin", version: "1.0.0" }, + format: "claude", + files: [...files, makeFile(".claude-plugin/plugin.json", claudeManifestContent)], + components: { + skills: [], + commands: [], + agents: [], + rules: [], + hooks: [], + mcp: [], + ...components, + }, + }); +} + +function skillDist(): PluginDistribution { + const skills = [ + makeFile("skills/hello/SKILL.md", skillEntryContent), + makeFile("skills/hello/actions/run.md", skillActionContent), + makeFile("skills/hello/reference.json", skillAssetContent), + ]; + return distOf([...skills], { skills }); +} + +function hooksDist(extra: PluginComponentFile[] = []): PluginDistribution { + const hooks = [ + makeFile("hooks/hooks.json", hooksManifestFor(CLAUDE_ROOT_TOKEN)), + makeFile("hooks/lib/check.sh", hookScriptContent), + ...extra, + ]; + return distOf([...hooks], { hooks }); +} + +function treeOf(tool: ToolConfig, dist: PluginDistribution): Record { + return Object.fromEntries( + translator.translate(dist, tool).map((f) => [f.relativePath, f.content]) + ); +} + +function installedPathsOf(tool: ToolConfig, dist: PluginDistribution): string[] { + return Object.keys(treeOf(tool, dist)).sort(); +} + +describe("a plugin of one skill, its action and its asset", () => { + it("lays the whole skill tree under the plugin directory claude expands, beside the manifest", () => { + expect(installedPathsOf(claude, skillDist())).toEqual([ + ".claude/plugins/sample-plugin/plugin.json", + ".claude/plugins/sample-plugin/skills/hello/SKILL.md", + ".claude/plugins/sample-plugin/skills/hello/actions/run.md", + ".claude/plugins/sample-plugin/skills/hello/reference.json", + ]); + }); + + it("carries the skill's asset byte for byte, its plugin-root variable untranslated", () => { + const tree = treeOf(codex, skillDist()); + expect(tree[".codex/plugins/sample-plugin/skills/hello/reference.json"]).toBe( + skillAssetContent + ); + }); + + it("lays the tree at the project root, and no manifest, for a target whose plugins have no directory of their own", () => { + expect(installedPathsOf(cursor, skillDist())).toEqual([ + "sample-plugin/skills/hello/SKILL.md", + "sample-plugin/skills/hello/actions/run.md", + "sample-plugin/skills/hello/reference.json", + ]); + }); + + it("namespaces every skill file per plugin, prose and asset alike, for a flat target", () => { + expect(treeOf(opencode, skillDist())).toEqual({ + ".opencode/skills/sample-plugin/hello/SKILL.md": skillEntryContent, + ".opencode/skills/sample-plugin/hello/actions/run.md": skillActionContent, + ".opencode/skills/sample-plugin/hello/reference.json": skillAssetContent, + }); + }); +}); + +describe("a plugin's hooks", () => { + it("puts the manifest where codex reads it, rewrites its plugin root, and keeps each script's own bytes", () => { + expect(treeOf(codex, hooksDist())).toEqual({ + ".codex/plugins/sample-plugin/hooks/hooks.json": hooksManifestFor(CODEX_ROOT_TOKEN), + ".codex/plugins/sample-plugin/hooks/lib/check.sh": hookScriptContent, + ".codex/plugins/sample-plugin/plugin.json": claudeManifestContent, + }); + }); + + it("flattens the manifest into the shape cursor reads and keeps the scripts under hooks/", () => { + expect(treeOf(cursor, hooksDist())).toEqual({ + "sample-plugin/hooks.json": JSON.stringify( + { hooks: { preToolUse: [{ type: "command", command: "./hooks/check.sh" }] } }, + null, + 2 + ), + "sample-plugin/hooks/lib/check.sh": hookScriptContent, + }); + }); + + it("delivers a flat target's scripts per plugin and no manifest", () => { + expect(treeOf(opencode, hooksDist())).toEqual({ + ".opencode/hooks/sample-plugin/lib/check.sh": hookScriptContent, + }); + }); + + it("renames the loader's own module to the plugin, carrying its bytes", () => { + const loaderModule = "export const plugin = () => {};\n"; + const tree = treeOf(opencode, hooksDist([makeFile("hooks/opencode-plugin.js", loaderModule)])); + expect(tree).toEqual({ + ".opencode/hooks/sample-plugin/lib/check.sh": hookScriptContent, + ".opencode/plugin/sample-plugin.js": loaderModule, + }); + }); +}); + +describe("a flat target that rewrites what it hosts", () => { + const rewriting = { + ...opencode, + rewriteContent: (content: string) => content.replace("SOURCE", "REWRITTEN"), + }; + + it("rewrites a skill's prose and carries its asset as it is", () => { + const skills = [ + makeFile("skills/hello/SKILL.md", "Read SOURCE.\n"), + makeFile("skills/hello/reference.json", '{ "from": "SOURCE" }\n'), + ]; + expect(treeOf(rewriting, distOf([...skills], { skills }))).toEqual({ + ".opencode/skills/sample-plugin/hello/SKILL.md": "Read REWRITTEN.\n", + ".opencode/skills/sample-plugin/hello/reference.json": '{ "from": "SOURCE" }\n', + }); + }); +}); + +describe("a flat target whose loader is triggered by a generated bridge", () => { + const bridged = { + ...opencode, + capabilities: { + ...opencode.capabilities, + plugins: new PluginsCapability({ + mode: "flat", + flatNamespacePrefix: "aidd-", + acceptsHooks: true, + flatHooksDir: ".stub/hooks/", + flatHooksBridge: { + generate: (raw: string, plugin: string) => + raw.includes("PreToolUse") ? `bridge(${plugin}):${raw}` : null, + path: (plugin: string) => `.stub/plugin/${plugin}.js`, + skipIfSourceHas: "own-plugin.js", + }, + }), + }, + }; + + it("generates the bridge from the plugin's own hooks manifest", () => { + expect(treeOf(bridged, hooksDist())).toEqual({ + ".stub/hooks/sample-plugin/lib/check.sh": hookScriptContent, + ".stub/plugin/sample-plugin.js": `bridge(sample-plugin):${hooksManifestFor(CLAUDE_ROOT_TOKEN)}`, + }); + }); + + it("generates none for a plugin shipping its own", () => { + const withOwn = hooksDist([ + makeFile("hooks/own-plugin.js", "export const plugin = () => {};\n"), + ]); + expect(Object.keys(treeOf(bridged, withOwn)).sort()).toEqual([ + ".stub/hooks/sample-plugin/lib/check.sh", + ".stub/hooks/sample-plugin/own-plugin.js", + ]); + }); + + it("generates none where the manifest names nothing the bridge maps", () => { + const hooks = [ + makeFile("hooks/hooks.json", '{ "hooks": {} }'), + makeFile("hooks/lib/check.sh", hookScriptContent), + ]; + expect(treeOf(bridged, distOf([...hooks], { hooks }))).toEqual({ + ".stub/hooks/sample-plugin/lib/check.sh": hookScriptContent, + }); + }); + + it("generates none for a plugin shipping no hooks manifest at all", () => { + const hooks = [makeFile("hooks/lib/check.sh", hookScriptContent)]; + expect(treeOf(bridged, distOf([...hooks], { hooks }))).toEqual({ + ".stub/hooks/sample-plugin/lib/check.sh": hookScriptContent, + }); + }); +}); + +describe("a plugin's mcp declaration", () => { + it("lands at the name cursor reads it under", () => { + const mcp = [makeFile(".mcp.json", mcpJsonContent)]; + expect(treeOf(cursor, distOf([...mcp], { mcp }))).toEqual({ + "sample-plugin/mcp.json": mcpJsonContent, + }); + }); + + it("is dropped by a target that hosts none", () => { + const refusingMcp = { + ...claude, + capabilities: { + ...claude.capabilities, + plugins: new PluginsCapability({ + mode: "native", + pluginsDir: ".claude/plugins/", + pluginManifestRelativePath: null, + acceptsHooks: true, + acceptsMcp: false, + }), + }, + }; + const mcp = [makeFile(".mcp.json", mcpJsonContent)]; + expect(treeOf(refusingMcp, distOf([...mcp], { mcp }))).toEqual({}); + }); +}); + +describe("what an installed file came from", () => { + it("maps back only the components, never the manifest, the hooks or the mcp declaration", () => { + const { componentPaths } = translator.translateWithComponentPaths(makeDist(), claude); + expect(Object.fromEntries(componentPaths)).toEqual({ + ".claude/plugins/sample-plugin/skills/hello/SKILL.md": "skills/hello/SKILL.md", + ".claude/plugins/sample-plugin/commands/greet.md": "commands/greet.md", + ".claude/plugins/sample-plugin/agents/reviewer.md": "agents/reviewer.md", + ".claude/plugins/sample-plugin/rules/standards.md": "rules/standards.md", + }); + }); +}); + +describe("what a person still has to do before a hook runs", () => { + it("names codex's trust step once for a plugin that ships hooks", () => { + const { notices } = translator.translateWithComponentPaths(hooksDist(), codex); + expect(notices).toEqual([ + { + pluginName: "sample-plugin", + component: "hooks", + toolId: "codex", + message: codex.capabilities.plugins.hooksTrustNotice, + }, + ]); + }); + + it("says nothing for a plugin that ships no hook", () => { + expect(translator.translateWithComponentPaths(skillDist(), codex).notices).toEqual([]); + }); + + it("says nothing for a target that asks for no trust", () => { + expect(translator.translateWithComponentPaths(hooksDist(), claude).notices).toEqual([]); + }); +}); + +describe("a target that runs no hook a plugin ships", () => { + const refusingHooks = { + ...opencode, + capabilities: { + ...opencode.capabilities, + plugins: new PluginsCapability({ + mode: "flat", + flatNamespacePrefix: "aidd-", + acceptsHooks: false, + hooksUnsupportedReason: "OpenCode runs no hook a plugin ships.", + }), + }, + }; + + it("records the refusal against the plugin and the target", () => { + expect(translator.translateWithComponentPaths(hooksDist(), refusingHooks).skipped).toEqual([ + { + pluginName: "sample-plugin", + component: "hooks", + toolId: "opencode", + reason: "OpenCode runs no hook a plugin ships.", + }, + ]); + }); + + it("records nothing for a plugin that ships no hook", () => { + expect(translator.translateWithComponentPaths(skillDist(), refusingHooks).skipped).toEqual([]); + }); +}); + +describe("a command a flat target installs", () => { + const cases = [ + { authored: "aidd:04:sub:greet", installed: "aidd-sample-plugin:sub:greet" }, + { authored: "team:greet", installed: "aidd-sample-plugin:greet" }, + { authored: "greet", installed: "aidd-sample-plugin:greet" }, + ]; + + for (const { authored, installed } of cases) { + it(`names '${authored}' as '${installed}'`, () => { + const commands = [ + makeFile( + "commands/greet.md", + `---\nname: ${authored}\ndescription: Greet command\n---\n\nGreet.\n` + ), + ]; + const content = treeOf(opencode, distOf([...commands], { commands }))[ + ".opencode/commands/sample-plugin/greet.md" + ]; + expect(parseFrontmatter(content).frontmatter).toEqual({ + name: installed, + description: "Greet command", + }); + }); + } + + it("falls back to the file's own name when the command declares none", () => { + const commands = [ + makeFile("commands/greet.md", "---\ndescription: Greet command\n---\n\nGreet.\n"), + ]; + const content = treeOf(opencode, distOf([...commands], { commands }))[ + ".opencode/commands/sample-plugin/greet.md" + ]; + expect(parseFrontmatter(content).frontmatter).toEqual({ + description: "Greet command", + name: "aidd-sample-plugin:greet.md", + }); + }); +}); + +describe("a target that hosts no plugin", () => { + const emptyResult = { files: [], componentPaths: new Map(), skipped: [], notices: [] }; + + it("produces nothing for an IDE", () => { + expect(translator.translateWithComponentPaths(makeDist(), vscodeToolConfig)).toEqual( + emptyResult + ); + }); + + it("produces nothing for a tool declaring plugins unsupported", () => { + const unsupported = { + ...claude, + capabilities: { + ...claude.capabilities, + plugins: new PluginsCapability({ + mode: "unsupported", + hooksUnsupportedReason: "Claude hosts no plugin here.", + }), + }, + }; + expect(translator.translateWithComponentPaths(makeDist(), unsupported)).toEqual(emptyResult); + }); +}); diff --git a/cli/tests/domain/formats/claude-marketplace-manifest.unit.test.ts b/cli/tests/contexts/translate/infrastructure/claude-marketplace-manifest.unit.test.ts similarity index 89% rename from cli/tests/domain/formats/claude-marketplace-manifest.unit.test.ts rename to cli/tests/contexts/translate/infrastructure/claude-marketplace-manifest.unit.test.ts index 3e1182f1c..bab60810a 100644 --- a/cli/tests/domain/formats/claude-marketplace-manifest.unit.test.ts +++ b/cli/tests/contexts/translate/infrastructure/claude-marketplace-manifest.unit.test.ts @@ -1,11 +1,11 @@ import { readFileSync } from "node:fs"; import { fileURLToPath } from "node:url"; import { describe, expect, it } from "vitest"; -import { JsonSchemaValidationError } from "../../../src/domain/errors.js"; -import { AjvSchemaValidatorAdapter } from "../../../src/infrastructure/adapters/ajv-schema-validator-adapter.js"; +import { AjvSchemaValidatorAdapter } from "../../../../src/contexts/translate/infrastructure/schema-validator.js"; +import { JsonSchemaValidationError } from "../../../../src/kernel/errors.js"; const schemaPath = new URL( - "../../../assets/schemas/claude-marketplace-manifest.json", + "../../../../assets/schemas/claude-marketplace-manifest.json", import.meta.url ); const schema = JSON.parse(readFileSync(fileURLToPath(schemaPath), "utf8")) as object; @@ -19,7 +19,7 @@ describe("claude-marketplace-manifest.json schema", () => { describe("valid documents", () => { it("validates the framework-real fixture marketplace.json successfully", () => { const fixturePath = new URL( - "../../../tests/fixtures/framework-real/.claude-plugin/marketplace.json", + "../../../fixtures/framework-real/.claude-plugin/marketplace.json", import.meta.url ); const fixture = JSON.parse(readFileSync(fileURLToPath(fixturePath), "utf8")) as unknown; diff --git a/cli/tests/domain/formats/codex-plugin-manifest.unit.test.ts b/cli/tests/contexts/translate/infrastructure/codex-plugin-manifest.unit.test.ts similarity index 89% rename from cli/tests/domain/formats/codex-plugin-manifest.unit.test.ts rename to cli/tests/contexts/translate/infrastructure/codex-plugin-manifest.unit.test.ts index 4392e53b6..1b2377f4b 100644 --- a/cli/tests/domain/formats/codex-plugin-manifest.unit.test.ts +++ b/cli/tests/contexts/translate/infrastructure/codex-plugin-manifest.unit.test.ts @@ -1,10 +1,13 @@ import { readFileSync } from "node:fs"; import { fileURLToPath } from "node:url"; import { describe, expect, it } from "vitest"; -import { JsonSchemaValidationError } from "../../../src/domain/errors.js"; -import { AjvSchemaValidatorAdapter } from "../../../src/infrastructure/adapters/ajv-schema-validator-adapter.js"; +import { AjvSchemaValidatorAdapter } from "../../../../src/contexts/translate/infrastructure/schema-validator.js"; +import { JsonSchemaValidationError } from "../../../../src/kernel/errors.js"; -const schemaPath = new URL("../../../assets/schemas/codex-plugin-manifest.json", import.meta.url); +const schemaPath = new URL( + "../../../../assets/schemas/codex-plugin-manifest.json", + import.meta.url +); const schema = JSON.parse(readFileSync(fileURLToPath(schemaPath), "utf8")) as object; const validator = new AjvSchemaValidatorAdapter(); diff --git a/cli/tests/contexts/translate/infrastructure/schema-validator.unit.test.ts b/cli/tests/contexts/translate/infrastructure/schema-validator.unit.test.ts new file mode 100644 index 000000000..7eea943a0 --- /dev/null +++ b/cli/tests/contexts/translate/infrastructure/schema-validator.unit.test.ts @@ -0,0 +1,70 @@ +import { describe, expect, it } from "vitest"; +import { AjvSchemaValidatorAdapter } from "../../../../src/contexts/translate/infrastructure/schema-validator.js"; +import { JsonSchemaValidationError } from "../../../../src/kernel/errors.js"; + +const STRING_SCHEMA = { type: "string" }; +const OBJECT_SCHEMA = { + type: "object", + properties: { + name: { type: "string" }, + age: { type: "number" }, + }, + required: ["name"], +}; + +describe("AjvSchemaValidatorAdapter", () => { + describe("validate", () => { + it("does not throw for valid data against string schema", () => { + const validator = new AjvSchemaValidatorAdapter(); + expect(() => validator.validate(STRING_SCHEMA, "hello")).not.toThrow(); + }); + + it("throws JsonSchemaValidationError for invalid type", () => { + const validator = new AjvSchemaValidatorAdapter(); + expect(() => validator.validate(STRING_SCHEMA, 42)).toThrow(JsonSchemaValidationError); + }); + + it("does not throw for valid object", () => { + const validator = new AjvSchemaValidatorAdapter(); + expect(() => validator.validate(OBJECT_SCHEMA, { name: "Alice", age: 30 })).not.toThrow(); + }); + + it("throws when required property is missing", () => { + const validator = new AjvSchemaValidatorAdapter(); + expect(() => validator.validate(OBJECT_SCHEMA, { age: 30 })).toThrow( + JsonSchemaValidationError + ); + }); + + it("error message includes field path", () => { + const validator = new AjvSchemaValidatorAdapter(); + try { + validator.validate(OBJECT_SCHEMA, { name: 123 }); + expect.fail("should have thrown"); + } catch (e) { + expect(e).toBeInstanceOf(JsonSchemaValidationError); + expect((e as Error).message).toContain("/name"); + } + }); + + it("collects all errors when allErrors is true", () => { + const schema = { + type: "object", + properties: { + a: { type: "string" }, + b: { type: "number" }, + }, + required: ["a", "b"], + }; + const validator = new AjvSchemaValidatorAdapter(); + try { + validator.validate(schema, {}); + expect.fail("should have thrown"); + } catch (e) { + expect(e).toBeInstanceOf(JsonSchemaValidationError); + expect((e as Error).message).toContain("a"); + expect((e as Error).message).toContain("b"); + } + }); + }); +}); diff --git a/cli/tests/domain/formats/codex-marketplace.unit.test.ts b/cli/tests/domain/formats/codex-marketplace.unit.test.ts deleted file mode 100644 index c4ff0585b..000000000 --- a/cli/tests/domain/formats/codex-marketplace.unit.test.ts +++ /dev/null @@ -1,176 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { ForeignSchemaValidationError } from "../../../src/domain/errors.js"; -import { parseCodexMarketplace } from "../../../src/domain/formats/codex-marketplace.js"; - -const VALID_JSON = JSON.stringify({ - name: "local-example-plugins", - plugins: [ - { - name: "codex-dev-tools", - version: "1.2.0", - description: "Developer tools for Codex", - author: { name: "Codex Community" }, - skills: "./skills/", - mcpServers: "./.mcp.json", - }, - ], -}); - -describe("parseCodexMarketplace", () => { - describe("happy path", () => { - it("returns a NormalizedCatalog with source codex", () => { - const catalog = parseCodexMarketplace(VALID_JSON); - expect(catalog.source).toBe("codex"); - }); - - it("parses name from first plugin entry", () => { - const catalog = parseCodexMarketplace(VALID_JSON); - expect(catalog.plugins[0].name).toBe("codex-dev-tools"); - }); - - it("parses optional version when present", () => { - const catalog = parseCodexMarketplace(VALID_JSON); - expect(catalog.plugins[0].version).toBe("1.2.0"); - }); - - it("parses optional description when present", () => { - const catalog = parseCodexMarketplace(VALID_JSON); - expect(catalog.plugins[0].description).toBe("Developer tools for Codex"); - }); - - it("returns multiple plugin entries from plugins array", () => { - const raw = JSON.stringify({ - plugins: [ - { name: "codex-a", version: "1.0.0", description: "First" }, - { name: "codex-b", description: "Second" }, - { name: "codex-c" }, - ], - }); - const catalog = parseCodexMarketplace(raw); - expect(catalog.plugins).toHaveLength(3); - }); - - it("omits version when absent", () => { - const raw = JSON.stringify({ plugins: [{ name: "codex-testing", description: "Testing" }] }); - const catalog = parseCodexMarketplace(raw); - expect(catalog.plugins[0].version).toBeUndefined(); - }); - - it("omits description when absent", () => { - const raw = JSON.stringify({ plugins: [{ name: "codex-minimal" }] }); - const catalog = parseCodexMarketplace(raw); - expect(catalog.plugins[0].description).toBeUndefined(); - }); - - it("returns empty array for empty plugins list", () => { - const raw = JSON.stringify({ plugins: [] }); - const catalog = parseCodexMarketplace(raw); - expect(catalog.plugins).toHaveLength(0); - }); - - it("ignores unknown fields like author, skills, mcpServers, interface", () => { - expect(() => parseCodexMarketplace(VALID_JSON)).not.toThrow(); - const catalog = parseCodexMarketplace(VALID_JSON); - expect(catalog.plugins[0].name).toBe("codex-dev-tools"); - }); - - it("sets source to codex on each plugin entry", () => { - const raw = JSON.stringify({ plugins: [{ name: "codex-a" }, { name: "codex-b" }] }); - const catalog = parseCodexMarketplace(raw); - expect(catalog.plugins[0].source).toBe("codex"); - expect(catalog.plugins[1].source).toBe("codex"); - }); - - it("ignores top-level name field (catalog metadata only)", () => { - const raw = JSON.stringify({ name: "my-marketplace", plugins: [{ name: "codex-plugin" }] }); - const catalog = parseCodexMarketplace(raw); - expect(catalog.plugins).toHaveLength(1); - expect(catalog.plugins[0].name).toBe("codex-plugin"); - }); - }); - - describe("malformed JSON", () => { - it("throws ForeignSchemaValidationError for invalid JSON string", () => { - expect(() => parseCodexMarketplace("{ not valid json")).toThrow(ForeignSchemaValidationError); - }); - - it("error message includes source codex", () => { - try { - parseCodexMarketplace("bad"); - } catch (err) { - expect(err instanceof ForeignSchemaValidationError).toBe(true); - expect((err as Error).message).toContain("codex"); - } - }); - }); - - describe("invalid root shape", () => { - it("throws when root is an array not object", () => { - expect(() => parseCodexMarketplace(JSON.stringify([]))).toThrow(ForeignSchemaValidationError); - }); - - it("throws when root is null", () => { - expect(() => parseCodexMarketplace("null")).toThrow(ForeignSchemaValidationError); - }); - - it("throws when root is a string", () => { - expect(() => parseCodexMarketplace(JSON.stringify("hello"))).toThrow( - ForeignSchemaValidationError - ); - }); - - it("throws when plugins field is missing", () => { - expect(() => parseCodexMarketplace(JSON.stringify({ name: "catalog" }))).toThrow( - ForeignSchemaValidationError - ); - }); - - it("throws when plugins field is not an array", () => { - expect(() => parseCodexMarketplace(JSON.stringify({ plugins: "not-array" }))).toThrow( - ForeignSchemaValidationError - ); - }); - }); - - describe("invalid plugin entry", () => { - it("throws when a plugin entry is not an object", () => { - expect(() => parseCodexMarketplace(JSON.stringify({ plugins: ["string-entry"] }))).toThrow( - ForeignSchemaValidationError - ); - }); - - it("throws when plugin name is missing", () => { - expect(() => - parseCodexMarketplace(JSON.stringify({ plugins: [{ version: "1.0.0" }] })) - ).toThrow(ForeignSchemaValidationError); - }); - - it("throws when plugin name is empty string", () => { - expect(() => parseCodexMarketplace(JSON.stringify({ plugins: [{ name: "" }] }))).toThrow( - ForeignSchemaValidationError - ); - }); - - it("throws when plugin name is not a string", () => { - expect(() => parseCodexMarketplace(JSON.stringify({ plugins: [{ name: 42 }] }))).toThrow( - ForeignSchemaValidationError - ); - }); - - it("error message includes index of bad entry", () => { - try { - parseCodexMarketplace(JSON.stringify({ plugins: [{ name: "ok" }, { version: "1.0.0" }] })); - } catch (err) { - expect((err as Error).message).toContain("plugins[1]"); - } - }); - - it("error message mentions name field", () => { - try { - parseCodexMarketplace(JSON.stringify({ plugins: [{}] })); - } catch (err) { - expect((err as Error).message).toContain("name"); - } - }); - }); -}); diff --git a/cli/tests/domain/formats/commit-session-trailer.unit.test.ts b/cli/tests/domain/formats/commit-session-trailer.unit.test.ts deleted file mode 100644 index 7b94afbf4..000000000 --- a/cli/tests/domain/formats/commit-session-trailer.unit.test.ts +++ /dev/null @@ -1,91 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { - SESSION_TRAILER_DELEGATE_FILE, - SESSION_TRAILER_TOKEN, - sessionTrailerDelegateScript, - sessionTrailerHookLine, -} from "../../../src/domain/formats/commit-session-trailer.js"; - -describe("the line added to a repository's own prepare-commit-msg", () => { - it("forwards git's own arguments, so the delegate can tell a merge from an authored commit", () => { - expect(sessionTrailerHookLine("/repo/.git/hooks/aidd-session-trailer.sh")).toBe( - 'sh "/repo/.git/hooks/aidd-session-trailer.sh" "$@"' - ); - }); - - it("quotes the path, so a checkout living under a directory with a space still runs", () => { - const line = sessionTrailerHookLine("/Users/a b/repo/.git/hooks/x.sh"); - - expect(line).toContain('"/Users/a b/repo/.git/hooks/x.sh"'); - }); - - // A hook is shell, run by the `sh` Git for Windows ships, and that shell does not resolve - // `C:\Users\…` — inside double quotes a backslash is an ordinary character, so the path - // would arrive literally and name nothing. Node's `resolve` hands back backslashes there, - // so this is where a filesystem path stops being one. - it("writes a Windows path with forward slashes, which is the only form sh resolves", () => { - const line = sessionTrailerHookLine("C:\\Users\\a\\repo\\.git\\hooks\\x.sh"); - - expect(line).toBe('sh "C:/Users/a/repo/.git/hooks/x.sh" "$@"'); - expect(line).not.toContain("\\"); - }); - - it("leaves a POSIX path exactly as it was", () => { - expect(sessionTrailerHookLine("/repo/.git/hooks/x.sh")).toBe('sh "/repo/.git/hooks/x.sh" "$@"'); - }); -}); - -describe("the delegate a commit's message actually passes through", () => { - const script = sessionTrailerDelegateScript(); - - it("reads Codex's own variable before Claude Code's, the precedence session-anchor.ts measured", () => { - // Shell parameter expansion, not a JS placeholder: this literal is what the delegate has - // to contain, character for character, so it is asserted as written. - // biome-ignore lint/suspicious/noTemplateCurlyInString: the string is shell, not JS - expect(script).toContain('session_id="${CODEX_THREAD_ID:-${CLAUDE_CODE_SESSION_ID:-}}"'); - }); - - it("writes nothing when no session made the commit - an unknown is never a guess", () => { - expect(script).toContain('[ -n "$session_id" ] || exit 0'); - }); - - it("skips a merge and a squash, so one commit never claims the work it brings in", () => { - expect(script).toContain("merge | squash) exit 0 ;;"); - }); - - it("writes the trailer once however often it runs, amend included", () => { - expect(script).toContain("--if-exists doNothing"); - expect(script).toContain(`--trailer "${SESSION_TRAILER_TOKEN}=$session_id"`); - }); - - it("never fails a commit: every path out of it exits zero", () => { - const exits = script.match(/exit \d+/gu) ?? []; - - expect(exits.length).toBeGreaterThan(0); - expect(exits.every((line) => line === "exit 0")).toBe(true); - }); - - // Runs on every commit in the repository, long after whatever installed it. Depending on - // node, or on this CLI still being on PATH, would make an uninstall break commits. - it("needs nothing but a shell and git - it runs neither node nor this CLI", () => { - const instructions = script - .split("\n") - .filter((line) => line.trim() !== "" && !line.trimStart().startsWith("#")); - - expect(script.startsWith("#!/bin/sh\n")).toBe(true); - expect(instructions.some((line) => /\bnode\b/u.test(line))).toBe(false); - expect(instructions.some((line) => /\baidd\b/u.test(line))).toBe(false); - expect(instructions.some((line) => line.includes("git interpret-trailers"))).toBe(true); - }); - - it("names the commands that install and remove it, where a person will look", () => { - expect(script).toContain("aidd telemetry on"); - expect(script).toContain("aidd telemetry off"); - }); -}); - -describe("what the delegate is called on disk", () => { - it("is named for what it does, and is a shell script", () => { - expect(SESSION_TRAILER_DELEGATE_FILE).toBe("aidd-session-trailer.sh"); - }); -}); diff --git a/cli/tests/domain/formats/copilot-events.unit.test.ts b/cli/tests/domain/formats/copilot-events.unit.test.ts deleted file mode 100644 index fcd13c4fb..000000000 --- a/cli/tests/domain/formats/copilot-events.unit.test.ts +++ /dev/null @@ -1,133 +0,0 @@ -import { readFileSync } from "node:fs"; -import { fileURLToPath } from "node:url"; -import { describe, expect, it } from "vitest"; -import { mapCopilotEventsToSinkRecords } from "../../../src/domain/formats/copilot-events.js"; - -const SESSION = "33333333-3333-4333-8333-333333333333"; -const EMPTY_SESSION = "44444444-4444-4444-8444-444444444444"; -const CACHED_SESSION = "55555555-5555-4555-8555-555555555555"; - -// Both fixtures are real, redacted excerpts of a captured `@github/copilot@1.0.80` file — -// system.message, user.message, assistant.message and every reasoning field stripped, per -// #697's acceptance criterion. See copilot-events.ts's own header comment for the -// arithmetic this rests on. -function loadFixture(relativePath: string): string { - const url = new URL(`../../fixtures/local-cost/${relativePath}`, import.meta.url); - return readFileSync(fileURLToPath(url), "utf8"); -} - -const FULL_PATH = `.copilot/session-state/${SESSION}/events.jsonl`; -const EMPTY_PATH = `.copilot/session-state/${EMPTY_SESSION}/events.jsonl`; -const CACHED_PATH = `.copilot/session-state/${CACHED_SESSION}/events.jsonl`; - -describe("mapCopilotEventsToSinkRecords", () => { - it("yields one kind: session record, from session.shutdown's own tokenDetails", () => { - const records = mapCopilotEventsToSinkRecords(loadFixture(FULL_PATH), SESSION); - - expect(records).toEqual([ - { - kind: "session", - vendor_id: SESSION, - vendor_field: "sessionId", - turn_id: "99ccf9e7-b3ac-4145-a622-31852ec698cb", - turn_field: "id", - event_timestamp: "2026-08-21T14:07:49.286Z", - input_tokens: 10, - output_tokens: 42, - cache_read_tokens: 0, - cache_creation_tokens: 21070, - }, - ]); - }); - - it("stamps the vendor id it was given, never one read off the file's own content", () => { - // The file's own session.start names SESSION; asking for a different id still gets - // that different id back — the file only ever confirms it holds *a* session, never - // which one, and the caller's own answer is the one that must win. See the header - // comment on copilot-events.ts for why the two could disagree at all. - const records = mapCopilotEventsToSinkRecords(loadFixture(FULL_PATH), "some-other-id"); - - expect(records[0]?.vendor_id).toBe("some-other-id"); - }); - - it("still yields a record from a truncated file with no session.start line at all", () => { - // A capture missing its first line - a partial copy, a rotated file - carries no - // session.start to (wrongly) fall back to. Reading identity from the caller's own - // argument rather than from file content is what keeps this case from silently - // dropping the record - see the header comment on copilot-events.ts. - const noSessionStart = loadFixture(FULL_PATH) - .split("\n") - .filter((line) => !line.includes('"session.start"')) - .join("\n"); - - const records = mapCopilotEventsToSinkRecords(noSessionStart, SESSION); - - expect(records).toHaveLength(1); - expect(records[0]?.vendor_id).toBe(SESSION); - }); - - it("never reads modelMetrics.usage.inputTokens, which is inclusive of the cache figure", () => { - // Measured: 10 (tokenDetails.input) + 21070 (cache_write) = 21080 (usage.inputTokens). - const [record] = mapCopilotEventsToSinkRecords(loadFixture(FULL_PATH), SESSION); - - expect(record?.input_tokens).not.toBe(21080); - }); - - it("reads the four counters disjoint when the cached prompt is large, the case left open", () => { - // Measured live, @github/copilot@1.0.82, 2026-09-06: 9 (tokenDetails.input) + 42038 - // (cache_read) + 21404 (cache_write) = 63451 = modelMetrics..usage.inputTokens, - // and Copilot's own terminal line for that run read `↑ 63.5k (42.0k cached, 21.4k - // written)`. This is the capture copilot.ts asked for by name: at cache_read 0, "input - // excludes the cached prompt" and "input already includes it" produce the same number - // and cannot be told apart. At 42038 they cannot be confused - an input that included - // the cached prompt would read 63451, not 9. - const [record] = mapCopilotEventsToSinkRecords(loadFixture(CACHED_PATH), CACHED_SESSION); - - expect(record?.input_tokens).toBe(9); - expect(record?.cache_read_tokens).toBe(42038); - expect(record?.cache_creation_tokens).toBe(21404); - expect( - (record?.input_tokens ?? 0) + - (record?.cache_read_tokens ?? 0) + - (record?.cache_creation_tokens ?? 0) - ).toBe(63451); - }); - - it("never carries cost_usd — totalPremiumRequests is a multiplier, not a currency", () => { - const [record] = mapCopilotEventsToSinkRecords(loadFixture(FULL_PATH), SESSION); - - expect(record && "cost_usd" in record).toBe(false); - }); - - it("never names a model — currentModel is only ever the session's last model", () => { - const [record] = mapCopilotEventsToSinkRecords(loadFixture(FULL_PATH), SESSION); - - expect(record && "model" in record).toBe(false); - }); - - it("yields nothing, not a record of zeros, when shutdown carried no tokenDetails", () => { - const records = mapCopilotEventsToSinkRecords(loadFixture(EMPTY_PATH), EMPTY_SESSION); - - expect(records).toEqual([]); - }); - - it("yields nothing for a session that never shut down", () => { - const noShutdown = loadFixture(FULL_PATH) - .split("\n") - .filter((line) => !line.includes('"session.shutdown"')) - .join("\n"); - - expect(mapCopilotEventsToSinkRecords(noShutdown, SESSION)).toEqual([]); - }); - - it("turns red rather than storing a zero when tokenDetails' own field is renamed", () => { - const moved = loadFixture(FULL_PATH).replaceAll("tokenCount", "token_count"); - - expect(mapCopilotEventsToSinkRecords(moved, SESSION)).toEqual([]); - }); - - it("touches no filesystem — two strings in, an array out", () => { - expect(typeof mapCopilotEventsToSinkRecords).toBe("function"); - expect(mapCopilotEventsToSinkRecords.length).toBe(2); - }); -}); diff --git a/cli/tests/domain/formats/copilot-marketplace.unit.test.ts b/cli/tests/domain/formats/copilot-marketplace.unit.test.ts deleted file mode 100644 index 3d763ccab..000000000 --- a/cli/tests/domain/formats/copilot-marketplace.unit.test.ts +++ /dev/null @@ -1,130 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { ForeignSchemaValidationError } from "../../../src/domain/errors.js"; -import { parseCopilotMarketplace } from "../../../src/domain/formats/copilot-marketplace.js"; - -const VALID_JSON = JSON.stringify({ - name: "copilot-dev-tools", - description: "Developer tools for Copilot", - version: "2.0.0", - author: { name: "Copilot Community" }, - repository: "https://github.com/example/copilot-dev-tools", - license: "MIT", - keywords: ["copilot", "dev-tools"], - agents: ["./agents"], - skills: ["./skills/debug"], -}); - -describe("parseCopilotMarketplace", () => { - describe("happy path", () => { - it("returns a NormalizedCatalog with source copilot", () => { - const catalog = parseCopilotMarketplace(VALID_JSON); - expect(catalog.source).toBe("copilot"); - }); - - it("returns exactly one plugin entry (single-manifest convention)", () => { - const catalog = parseCopilotMarketplace(VALID_JSON); - expect(catalog.plugins).toHaveLength(1); - }); - - it("parses name from manifest", () => { - const catalog = parseCopilotMarketplace(VALID_JSON); - expect(catalog.plugins[0].name).toBe("copilot-dev-tools"); - }); - - it("parses optional version when present", () => { - const catalog = parseCopilotMarketplace(VALID_JSON); - expect(catalog.plugins[0].version).toBe("2.0.0"); - }); - - it("parses optional description when present", () => { - const catalog = parseCopilotMarketplace(VALID_JSON); - expect(catalog.plugins[0].description).toBe("Developer tools for Copilot"); - }); - - it("omits version when absent", () => { - const raw = JSON.stringify({ name: "copilot-testing", description: "Testing utilities" }); - const catalog = parseCopilotMarketplace(raw); - expect(catalog.plugins[0].version).toBeUndefined(); - }); - - it("omits description when absent", () => { - const raw = JSON.stringify({ name: "copilot-minimal" }); - const catalog = parseCopilotMarketplace(raw); - expect(catalog.plugins[0].description).toBeUndefined(); - }); - - it("ignores unknown fields like author, repository, license, keywords, agents, skills", () => { - expect(() => parseCopilotMarketplace(VALID_JSON)).not.toThrow(); - const catalog = parseCopilotMarketplace(VALID_JSON); - expect(catalog.plugins[0].name).toBe("copilot-dev-tools"); - }); - - it("sets source to copilot on the plugin entry", () => { - const catalog = parseCopilotMarketplace(VALID_JSON); - expect(catalog.plugins[0].source).toBe("copilot"); - }); - }); - - describe("malformed JSON", () => { - it("throws ForeignSchemaValidationError for invalid JSON string", () => { - expect(() => parseCopilotMarketplace("{ not valid json")).toThrow( - ForeignSchemaValidationError - ); - }); - - it("error message includes source copilot", () => { - try { - parseCopilotMarketplace("bad"); - } catch (err) { - expect(err instanceof ForeignSchemaValidationError).toBe(true); - expect((err as Error).message).toContain("copilot"); - } - }); - }); - - describe("invalid root shape", () => { - it("throws when root is an array not object", () => { - expect(() => parseCopilotMarketplace(JSON.stringify([]))).toThrow( - ForeignSchemaValidationError - ); - }); - - it("throws when root is null", () => { - expect(() => parseCopilotMarketplace("null")).toThrow(ForeignSchemaValidationError); - }); - - it("throws when root is a string", () => { - expect(() => parseCopilotMarketplace(JSON.stringify("hello"))).toThrow( - ForeignSchemaValidationError - ); - }); - }); - - describe("invalid name field", () => { - it("throws when name is missing", () => { - expect(() => parseCopilotMarketplace(JSON.stringify({ version: "1.0.0" }))).toThrow( - ForeignSchemaValidationError - ); - }); - - it("throws when name is empty string", () => { - expect(() => parseCopilotMarketplace(JSON.stringify({ name: "" }))).toThrow( - ForeignSchemaValidationError - ); - }); - - it("throws when name is not a string", () => { - expect(() => parseCopilotMarketplace(JSON.stringify({ name: 42 }))).toThrow( - ForeignSchemaValidationError - ); - }); - - it("error message mentions name field", () => { - try { - parseCopilotMarketplace(JSON.stringify({})); - } catch (err) { - expect((err as Error).message).toContain("name"); - } - }); - }); -}); diff --git a/cli/tests/domain/formats/cursor-marketplace.unit.test.ts b/cli/tests/domain/formats/cursor-marketplace.unit.test.ts deleted file mode 100644 index 3d0a39f4c..000000000 --- a/cli/tests/domain/formats/cursor-marketplace.unit.test.ts +++ /dev/null @@ -1,142 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { ForeignSchemaValidationError } from "../../../src/domain/errors.js"; -import { parseCursorMarketplace } from "../../../src/domain/formats/cursor-marketplace.js"; - -const VALID_JSON = JSON.stringify({ - plugins: [ - { name: "cursor-dev-tools", version: "1.2.0", description: "Developer tools for Cursor" }, - { name: "cursor-testing", description: "Testing utilities" }, - { name: "cursor-minimal" }, - ], -}); - -describe("parseCursorMarketplace", () => { - describe("happy path", () => { - it("returns a NormalizedCatalog with source cursor", () => { - const catalog = parseCursorMarketplace(VALID_JSON); - expect(catalog.source).toBe("cursor"); - }); - - it("parses all plugin entries", () => { - const catalog = parseCursorMarketplace(VALID_JSON); - expect(catalog.plugins).toHaveLength(3); - }); - - it("parses name on every entry", () => { - const catalog = parseCursorMarketplace(VALID_JSON); - expect(catalog.plugins[0].name).toBe("cursor-dev-tools"); - expect(catalog.plugins[1].name).toBe("cursor-testing"); - expect(catalog.plugins[2].name).toBe("cursor-minimal"); - }); - - it("parses optional version when present", () => { - const catalog = parseCursorMarketplace(VALID_JSON); - expect(catalog.plugins[0].version).toBe("1.2.0"); - }); - - it("omits version when absent", () => { - const catalog = parseCursorMarketplace(VALID_JSON); - expect(catalog.plugins[1].version).toBeUndefined(); - expect(catalog.plugins[2].version).toBeUndefined(); - }); - - it("parses optional description when present", () => { - const catalog = parseCursorMarketplace(VALID_JSON); - expect(catalog.plugins[0].description).toBe("Developer tools for Cursor"); - expect(catalog.plugins[1].description).toBe("Testing utilities"); - }); - - it("omits description when absent", () => { - const catalog = parseCursorMarketplace(VALID_JSON); - expect(catalog.plugins[2].description).toBeUndefined(); - }); - - it("returns empty plugins array for empty catalog", () => { - const catalog = parseCursorMarketplace(JSON.stringify({ plugins: [] })); - expect(catalog.plugins).toHaveLength(0); - }); - - it("ignores unknown fields on plugin entries", () => { - const raw = JSON.stringify({ - plugins: [{ name: "x", unknownField: "ignored", anotherUnknown: 42 }], - }); - expect(() => parseCursorMarketplace(raw)).not.toThrow(); - const catalog = parseCursorMarketplace(raw); - expect(catalog.plugins[0].name).toBe("x"); - }); - - it("ignores unknown top-level fields", () => { - const raw = JSON.stringify({ plugins: [], unknownTopLevel: true }); - expect(() => parseCursorMarketplace(raw)).not.toThrow(); - }); - }); - - describe("malformed JSON", () => { - it("throws ForeignSchemaValidationError for invalid JSON string", () => { - expect(() => parseCursorMarketplace("{ not valid json")).toThrow( - ForeignSchemaValidationError - ); - }); - - it("error message includes source cursor", () => { - try { - parseCursorMarketplace("bad"); - } catch (err) { - expect(err instanceof ForeignSchemaValidationError).toBe(true); - expect((err as Error).message).toContain("cursor"); - } - }); - }); - - describe("missing or invalid plugins field", () => { - it("throws when plugins is not an array", () => { - expect(() => parseCursorMarketplace(JSON.stringify({ plugins: "oops" }))).toThrow( - ForeignSchemaValidationError - ); - }); - - it("throws when plugins is missing", () => { - expect(() => parseCursorMarketplace(JSON.stringify({}))).toThrow( - ForeignSchemaValidationError - ); - }); - - it("throws when root is an array not object", () => { - expect(() => parseCursorMarketplace(JSON.stringify([]))).toThrow( - ForeignSchemaValidationError - ); - }); - - it("throws when root is null", () => { - expect(() => parseCursorMarketplace("null")).toThrow(ForeignSchemaValidationError); - }); - }); - - describe("invalid plugin entries", () => { - it("throws when a plugin entry is not an object", () => { - expect(() => parseCursorMarketplace(JSON.stringify({ plugins: ["not-an-object"] }))).toThrow( - ForeignSchemaValidationError - ); - }); - - it("throws when plugin name is missing", () => { - expect(() => - parseCursorMarketplace(JSON.stringify({ plugins: [{ version: "1.0.0" }] })) - ).toThrow(ForeignSchemaValidationError); - }); - - it("throws when plugin name is empty string", () => { - expect(() => parseCursorMarketplace(JSON.stringify({ plugins: [{ name: "" }] }))).toThrow( - ForeignSchemaValidationError - ); - }); - - it("error message includes entry index", () => { - try { - parseCursorMarketplace(JSON.stringify({ plugins: [{ name: "ok" }, { version: "1.0.0" }] })); - } catch (err) { - expect((err as Error).message).toContain("plugins[1]"); - } - }); - }); -}); diff --git a/cli/tests/domain/formats/flat-paths.unit.test.ts b/cli/tests/domain/formats/flat-paths.unit.test.ts deleted file mode 100644 index 5c40bc278..000000000 --- a/cli/tests/domain/formats/flat-paths.unit.test.ts +++ /dev/null @@ -1,122 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { - flatMcpKeyPrefix, - genericFlatAgentPath, - genericFlatHooksFile, - genericFlatHooksScriptPath, - genericFlatSkillPath, - genericFlatSkillTreePath, -} from "../../../src/domain/formats/flat-paths.js"; - -describe("genericFlatAgentPath", () => { - it("strips .md suffix, adds outputExt, and prepends plugin prefix", () => { - expect(genericFlatAgentPath(".github/agents/", "aidd-dev", "implementer.md", ".agent.md")).toBe( - ".github/agents/aidd-dev-implementer.agent.md" - ); - }); - - it("does not double-strip when name has no .md", () => { - expect(genericFlatAgentPath(".github/agents/", "aidd-dev", "reviewer", ".agent.md")).toBe( - ".github/agents/aidd-dev-reviewer.agent.md" - ); - }); - - it("preserves .md output ext for tools that keep .md", () => { - expect(genericFlatAgentPath(".claude/agents/", "my-plugin", "agent.md", ".md")).toBe( - ".claude/agents/my-plugin-agent.md" - ); - }); - - it("plugin param is used as name prefix", () => { - expect(genericFlatAgentPath(".cursor/agents/", "aidd-context", "agent.md", ".md")).toBe( - ".cursor/agents/aidd-context-agent.md" - ); - }); -}); - -describe("genericFlatSkillPath", () => { - it("sits directly under skills root with plugin prefix on folder name", () => { - expect(genericFlatSkillPath(".github/skills/", "aidd-dev", "commit/SKILL.md")).toBe( - ".github/skills/aidd-dev-commit/SKILL.md" - ); - }); - - it("prepends plugin prefix to single-level rel path", () => { - expect(genericFlatSkillPath(".github/skills/", "aidd-dev", "hello.md")).toBe( - ".github/skills/aidd-dev-hello.md" - ); - }); - - it("works with different prefixes", () => { - expect(genericFlatSkillPath(".claude/skills/", "aidd-context", "00-onboard/SKILL.md")).toBe( - ".claude/skills/aidd-context-00-onboard/SKILL.md" - ); - }); -}); - -describe("genericFlatSkillTreePath", () => { - it("nests the whole plugin skills subtree under one plugin/ segment", () => { - expect(genericFlatSkillTreePath(".opencode/skills/", "aidd-dev", "commit/SKILL.md")).toBe( - ".opencode/skills/aidd-dev/commit/SKILL.md" - ); - }); - - it("keeps a non-skill top-level child's own name intact", () => { - expect( - genericFlatSkillTreePath(".opencode/skills/", "aidd-telemetry", "shared/attribution.cjs") - ).toBe(".opencode/skills/aidd-telemetry/shared/attribution.cjs"); - expect(genericFlatSkillTreePath(".opencode/skills/", "aidd-telemetry", "package.json")).toBe( - ".opencode/skills/aidd-telemetry/package.json" - ); - }); - - it("works with different prefixes", () => { - expect(genericFlatSkillTreePath(".claude/skills/", "aidd-context", "00-onboard/SKILL.md")).toBe( - ".claude/skills/aidd-context/00-onboard/SKILL.md" - ); - }); -}); - -describe("genericFlatHooksFile", () => { - it("returns per-plugin hooks file path", () => { - expect(genericFlatHooksFile(".github/hooks/", "aidd-dev")).toBe( - ".github/hooks/aidd-dev.hooks.json" - ); - }); - - it("uses the full plugin name", () => { - expect(genericFlatHooksFile(".github/hooks/", "my-awesome-plugin")).toBe( - ".github/hooks/my-awesome-plugin.hooks.json" - ); - }); - - it("works with different prefixes", () => { - expect(genericFlatHooksFile(".claude/hooks/", "aidd-dev")).toBe( - ".claude/hooks/aidd-dev.hooks.json" - ); - }); -}); - -describe("genericFlatHooksScriptPath", () => { - it("returns per-plugin script path under hooks/plugin/", () => { - expect(genericFlatHooksScriptPath(".github/hooks/", "aidd-dev", "check.sh")).toBe( - ".github/hooks/aidd-dev/check.sh" - ); - }); - - it("works with different prefixes", () => { - expect(genericFlatHooksScriptPath(".cursor/hooks/", "aidd-dev", "check.sh")).toBe( - ".cursor/hooks/aidd-dev/check.sh" - ); - }); -}); - -describe("flatMcpKeyPrefix", () => { - it("returns plugin name with trailing dash", () => { - expect(flatMcpKeyPrefix("aidd-dev")).toBe("aidd-dev-"); - }); - - it("uses the full plugin name", () => { - expect(flatMcpKeyPrefix("my-awesome-plugin")).toBe("my-awesome-plugin-"); - }); -}); diff --git a/cli/tests/domain/formats/markdown.unit.test.ts b/cli/tests/domain/formats/markdown.unit.test.ts deleted file mode 100644 index 2dc7ab850..000000000 --- a/cli/tests/domain/formats/markdown.unit.test.ts +++ /dev/null @@ -1,132 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { parseFrontmatter, serializeFrontmatter } from "../../../src/domain/formats/markdown.js"; - -describe("parseFrontmatter()", () => { - it("parses frontmatter and body from a well-formed file", () => { - const content = "---\nname: my-agent\ndescription: A test agent\n---\nBody text here."; - const { frontmatter, body } = parseFrontmatter(content); - expect(frontmatter).toEqual({ name: "my-agent", description: "A test agent" }); - expect(body).toBe("Body text here."); - }); - - it("returns empty frontmatter and full content when no delimiter", () => { - const content = "Just a plain body with no frontmatter."; - const { frontmatter, body } = parseFrontmatter(content); - expect(frontmatter).toEqual({}); - expect(body).toBe(content); - }); - - it("returns empty frontmatter when closing delimiter is missing", () => { - const content = "---\nname: broken\nno closing delimiter"; - const { frontmatter, body } = parseFrontmatter(content); - expect(frontmatter).toEqual({}); - expect(body).toBe(content); - }); - - it("parses boolean values correctly", () => { - const content = "---\nalwaysApply: false\nenabled: true\n---\nbody"; - const { frontmatter } = parseFrontmatter(content); - expect(frontmatter.alwaysApply).toBe(false); - expect(frontmatter.enabled).toBe(true); - }); - - it("parses array values correctly", () => { - const content = "---\npaths:\n - src/**/*.ts\n - tests/**/*.ts\n---\nbody"; - const { frontmatter } = parseFrontmatter(content); - expect(frontmatter.paths).toEqual(["src/**/*.ts", "tests/**/*.ts"]); - }); - - it("parses quoted string values", () => { - const content = "---\nname: 'my agent'\n---\nbody"; - const { frontmatter } = parseFrontmatter(content); - expect(frontmatter.name).toBe("my agent"); - }); -}); - -describe("serializeFrontmatter()", () => { - it("serializes frontmatter and body into delimited format", () => { - const result = serializeFrontmatter({ name: "my-agent", description: "A test" }, "Body text."); - expect(result).toContain("---"); - expect(result).toContain("name: 'my-agent'"); - expect(result).toContain("description: 'A test'"); - expect(result).toContain("Body text."); - }); - - it("returns body only (without leading newline) when frontmatter is empty", () => { - const result = serializeFrontmatter({}, "\nBody only."); - expect(result).toBe("Body only."); - }); - - it("serializes array values as YAML lists", () => { - const result = serializeFrontmatter({ paths: ["src/**/*.ts"] }, "body"); - expect(result).toContain("paths:"); - expect(result).toContain(' - "src/**/*.ts"'); - }); - - it("serializes boolean values without quotes", () => { - const result = serializeFrontmatter({ alwaysApply: false }, "body"); - expect(result).toContain("alwaysApply: false"); - }); - - it("round-trips: parse then serialize preserves content", () => { - const original = "---\nname: 'my-agent'\ndescription: 'A test'\n---\nBody text."; - const { frontmatter, body } = parseFrontmatter(original); - const result = serializeFrontmatter(frontmatter, body); - const reparsed = parseFrontmatter(result); - expect(reparsed.frontmatter).toEqual(frontmatter); - expect(reparsed.body).toBe(body); - }); -}); - -describe("parseFrontmatter() — block scalars", () => { - it("parses literal block scalar (|) preserving newlines", () => { - const content = "---\ndescription: |\n line one\n line two\n---\nbody"; - const { frontmatter } = parseFrontmatter(content); - expect(typeof frontmatter.description).toBe("string"); - expect(frontmatter.description as string).toContain("line one"); - expect(frontmatter.description as string).toContain("line two"); - }); - - it("parses folded block scalar (>) joining lines with space", () => { - const content = "---\ndescription: >\n folded line one\n folded line two\n---\nbody"; - const { frontmatter } = parseFrontmatter(content); - expect(typeof frontmatter.description).toBe("string"); - expect((frontmatter.description as string).trim()).toContain("folded line one"); - }); - - it("parses null scalar value", () => { - const content = "---\nvalue: null\n---\nbody"; - const { frontmatter } = parseFrontmatter(content); - expect(frontmatter.value).toBeNull(); - }); - - it("parses inline JSON array string as array", () => { - const content = '---\ntools: ["read","write"]\n---\nbody'; - const { frontmatter } = parseFrontmatter(content); - expect(frontmatter.tools).toEqual(["read", "write"]); - }); - - it("falls back to string for malformed inline JSON array", () => { - const content = "---\ntools: [invalid json}\n---\nbody"; - const { frontmatter } = parseFrontmatter(content); - expect(frontmatter.tools).toBe("[invalid json}"); - }); - // A Windows checkout hands the parser the same document with CRLF (#707). Pinned here - // rather than only on the Windows runner: the defect is a pure string transform, so a - // test that fails when the fix is reverted runs on any platform. - it("parses the same document whichever way its lines end", () => { - const lf = "---\nname: hi\nallowed_tools:\n - Read\n - Bash\n---\nbody\n"; - const crlf = lf.replace(/\n/g, "\r\n"); - expect(parseFrontmatter(crlf).frontmatter).toEqual(parseFrontmatter(lf).frontmatter); - expect(parseFrontmatter(crlf).frontmatter).toEqual({ - name: "hi", - allowed_tools: ["Read", "Bash"], - }); - }); - - it("keeps a carriage return that is content rather than a line ending", () => { - const content = "---\nname: a\rb\n---\nbody"; - const { frontmatter } = parseFrontmatter(content); - expect(frontmatter.name).toBe("a\rb"); - }); -}); diff --git a/cli/tests/domain/formats/marketplace-json.unit.test.ts b/cli/tests/domain/formats/marketplace-json.unit.test.ts deleted file mode 100644 index 3b93a1859..000000000 --- a/cli/tests/domain/formats/marketplace-json.unit.test.ts +++ /dev/null @@ -1,66 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { MarketplaceEntryAlreadyExistsError } from "../../../src/domain/errors.js"; -import { appendPluginToMarketplace } from "../../../src/domain/formats/marketplace-json.js"; - -const ENTRY = { - name: "my-plugin", - version: "0.1.0", - source: "./my-plugin", - description: "A plugin", - recommended: false, - strict: true, -}; - -describe("appendPluginToMarketplace", () => { - it("appends entry to empty plugins array", () => { - const result = appendPluginToMarketplace(JSON.stringify({ plugins: [] }), ENTRY); - const parsed = JSON.parse(result) as { plugins: unknown[] }; - expect(parsed.plugins).toHaveLength(1); - expect(parsed.plugins[0]).toMatchObject({ name: "my-plugin" }); - }); - - it("appends entry when plugins key is absent", () => { - const result = appendPluginToMarketplace(JSON.stringify({}), ENTRY); - const parsed = JSON.parse(result) as { plugins: unknown[] }; - expect(parsed.plugins).toHaveLength(1); - }); - - it("appends to existing plugins", () => { - const existing = { - plugins: [ - { - name: "other", - version: "1.0.0", - source: ".", - description: "", - recommended: false, - strict: false, - }, - ], - }; - const result = appendPluginToMarketplace(JSON.stringify(existing), ENTRY); - const parsed = JSON.parse(result) as { plugins: unknown[] }; - expect(parsed.plugins).toHaveLength(2); - }); - - it("throws MarketplaceEntryAlreadyExistsError on name collision", () => { - const existing = { plugins: [ENTRY] }; - expect(() => appendPluginToMarketplace(JSON.stringify(existing), ENTRY)).toThrow( - MarketplaceEntryAlreadyExistsError - ); - }); - - it("preserves other keys in the JSON object", () => { - const json = JSON.stringify({ name: "my-market", url: "https://example.com", plugins: [] }); - const result = appendPluginToMarketplace(json, ENTRY); - const parsed = JSON.parse(result) as Record; - expect(parsed.name).toBe("my-market"); - expect(parsed.url).toBe("https://example.com"); - }); - - it("output is pretty-printed with trailing newline", () => { - const result = appendPluginToMarketplace(JSON.stringify({ plugins: [] }), ENTRY); - expect(result.endsWith("\n")).toBe(true); - expect(result).toContain(" "); - }); -}); diff --git a/cli/tests/domain/formats/opencode-marketplace.unit.test.ts b/cli/tests/domain/formats/opencode-marketplace.unit.test.ts deleted file mode 100644 index d5b7d1224..000000000 --- a/cli/tests/domain/formats/opencode-marketplace.unit.test.ts +++ /dev/null @@ -1,147 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { ForeignSchemaValidationError } from "../../../src/domain/errors.js"; -import { parseOpencodeMarketplace } from "../../../src/domain/formats/opencode-marketplace.js"; - -const VALID_JSON = JSON.stringify({ - $schema: "https://opencode.ai/config.json", - provider: {}, - plugin: ["opencode-dev-tools", "@my-org/opencode-testing", ["opencode-minimal", { debug: true }]], -}); - -describe("parseOpencodeMarketplace", () => { - describe("happy path", () => { - it("returns a NormalizedCatalog with source opencode", () => { - const catalog = parseOpencodeMarketplace(VALID_JSON); - expect(catalog.source).toBe("opencode"); - }); - - it("parses bare string specifier as plugin name", () => { - const catalog = parseOpencodeMarketplace(VALID_JSON); - expect(catalog.plugins[0].name).toBe("opencode-dev-tools"); - }); - - it("parses scoped npm package specifier as plugin name", () => { - const catalog = parseOpencodeMarketplace(VALID_JSON); - expect(catalog.plugins[1].name).toBe("@my-org/opencode-testing"); - }); - - it("parses [specifier, options] tuple taking first element as name", () => { - const catalog = parseOpencodeMarketplace(VALID_JSON); - expect(catalog.plugins[2].name).toBe("opencode-minimal"); - }); - - it("returns three plugins from sample fixture array", () => { - const catalog = parseOpencodeMarketplace(VALID_JSON); - expect(catalog.plugins).toHaveLength(3); - }); - - it("sets source to opencode on each plugin entry", () => { - const catalog = parseOpencodeMarketplace(VALID_JSON); - for (const plugin of catalog.plugins) { - expect(plugin.source).toBe("opencode"); - } - }); - - it("omits version (not available in opencode.json plugin array)", () => { - const catalog = parseOpencodeMarketplace(VALID_JSON); - expect(catalog.plugins[0].version).toBeUndefined(); - }); - - it("omits description (not available in opencode.json plugin array)", () => { - const catalog = parseOpencodeMarketplace(VALID_JSON); - expect(catalog.plugins[0].description).toBeUndefined(); - }); - - it("returns empty array when plugin field is an empty array", () => { - const raw = JSON.stringify({ plugin: [] }); - const catalog = parseOpencodeMarketplace(raw); - expect(catalog.plugins).toHaveLength(0); - }); - - it("returns empty array when plugin field is absent", () => { - const raw = JSON.stringify({ provider: {} }); - const catalog = parseOpencodeMarketplace(raw); - expect(catalog.plugins).toHaveLength(0); - }); - - it("ignores other config fields like provider, mcp, tools", () => { - expect(() => parseOpencodeMarketplace(VALID_JSON)).not.toThrow(); - expect(parseOpencodeMarketplace(VALID_JSON).plugins).toHaveLength(3); - }); - }); - - describe("malformed JSON", () => { - it("throws ForeignSchemaValidationError for invalid JSON string", () => { - expect(() => parseOpencodeMarketplace("{ not valid json")).toThrow( - ForeignSchemaValidationError - ); - }); - - it("error message includes source opencode", () => { - try { - parseOpencodeMarketplace("bad"); - } catch (err) { - expect(err instanceof ForeignSchemaValidationError).toBe(true); - expect((err as Error).message).toContain("opencode"); - } - }); - }); - - describe("invalid root shape", () => { - it("throws when root is an array not object", () => { - expect(() => parseOpencodeMarketplace(JSON.stringify([]))).toThrow( - ForeignSchemaValidationError - ); - }); - - it("throws when root is null", () => { - expect(() => parseOpencodeMarketplace("null")).toThrow(ForeignSchemaValidationError); - }); - - it("throws when root is a string", () => { - expect(() => parseOpencodeMarketplace(JSON.stringify("hello"))).toThrow( - ForeignSchemaValidationError - ); - }); - - it("throws when plugin field is not an array", () => { - expect(() => parseOpencodeMarketplace(JSON.stringify({ plugin: "not-array" }))).toThrow( - ForeignSchemaValidationError - ); - }); - }); - - describe("invalid plugin entry", () => { - it("throws when a plugin entry is a plain object (not string or tuple)", () => { - expect(() => parseOpencodeMarketplace(JSON.stringify({ plugin: [{ name: "bad" }] }))).toThrow( - ForeignSchemaValidationError - ); - }); - - it("throws when plugin entry is an empty tuple", () => { - expect(() => parseOpencodeMarketplace(JSON.stringify({ plugin: [[]] }))).toThrow( - ForeignSchemaValidationError - ); - }); - - it("throws when tuple first element is empty string", () => { - expect(() => parseOpencodeMarketplace(JSON.stringify({ plugin: [["", {}]] }))).toThrow( - ForeignSchemaValidationError - ); - }); - - it("throws when plugin entry is a number", () => { - expect(() => parseOpencodeMarketplace(JSON.stringify({ plugin: [42] }))).toThrow( - ForeignSchemaValidationError - ); - }); - - it("error message includes index of bad entry", () => { - try { - parseOpencodeMarketplace(JSON.stringify({ plugin: ["ok", 99] })); - } catch (err) { - expect((err as Error).message).toContain("plugin[1]"); - } - }); - }); -}); diff --git a/cli/tests/domain/models/conflict-decision.unit.test.ts b/cli/tests/domain/models/conflict-decision.unit.test.ts deleted file mode 100644 index 33b67128c..000000000 --- a/cli/tests/domain/models/conflict-decision.unit.test.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { describe, expect, it } from "vitest"; -import type { ConflictDecision } from "../../../src/domain/models/merge.js"; - -describe("ConflictDecision", () => { - it("exists as a type and accepts overwrite", () => { - const decision: ConflictDecision = "overwrite"; - expect(decision).toBe("overwrite"); - }); - - it("accepts skip", () => { - const decision: ConflictDecision = "skip"; - expect(decision).toBe("skip"); - }); - - it("accepts backup", () => { - const decision: ConflictDecision = "backup"; - expect(decision).toBe("backup"); - }); - - it("narrowing: switch on ConflictDecision", () => { - const decisions: ConflictDecision[] = ["overwrite", "skip", "backup"]; - const results: string[] = []; - - for (const decision of decisions) { - switch (decision) { - case "overwrite": - results.push("overwrote"); - break; - case "skip": - results.push("skipped"); - break; - case "backup": - results.push("backed up"); - break; - } - } - - expect(results).toEqual(["overwrote", "skipped", "backed up"]); - }); -}); diff --git a/cli/tests/domain/models/cost-report-contract.unit.test.ts b/cli/tests/domain/models/cost-report-contract.unit.test.ts deleted file mode 100644 index a6a781c30..000000000 --- a/cli/tests/domain/models/cost-report-contract.unit.test.ts +++ /dev/null @@ -1,66 +0,0 @@ -import { readFileSync } from "node:fs"; -import { fileURLToPath } from "node:url"; -import { describe, expect, it } from "vitest"; -import { COST_REPORT_ENVELOPE_VERSION } from "../../../src/domain/models/cost-report-envelope.js"; -import { TASK_UNATTRIBUTED_REASONS } from "../../../src/domain/models/task-attribution.js"; - -// The same honesty check `metrics-contract.unit.test.ts` runs over the record: never a -// hand-maintained list on either side, only the code's own exported values and the -// document's own prose, both read fresh off disk. A reason a consumer can receive and the -// contract never names is a shape nobody can parse against. - -const CONTRACT_DOC_URL = new URL( - "../../../../aidd_docs/product/cost-report-contract.md", - import.meta.url -); - -function contractText(): string { - return readFileSync(fileURLToPath(CONTRACT_DOC_URL), "utf8"); -} - -describe("the cost report contract document", () => { - it("names every reason a row with no task can carry", () => { - const document = contractText(); - - // Required as a table cell, not merely somewhere in the prose: a reason can be named in - // a version note and still be missing from the table a reader parses against, which is - // exactly what happened while this document was being edited - the `"no-journal"` row - // was clipped out of the table while three prose mentions kept a looser check green. - const undocumented = TASK_UNATTRIBUTED_REASONS.filter( - (reason) => !document.includes(`| \`"${reason}"\` |`) - ); - - expect(undocumented).toEqual([]); - }); - - // A journal file really can be read and still yield no session: `report-cost-use-case.ts` - // drops one whose `session_start` header is torn (`if (!journal.session) return null`), - // and the adapter's own "keeps a session's boundaries when its header line is torn" test - // proves that shape reaches it. Those records land on this reason, so a document claiming - // no journal was read "at all", or that nothing looked at what the session declared, - // states something false about a case the code produces. The word this hinges on is - // "usable". - it("never claims the unattributed reason means no journal existed", () => { - const document = contractText(); - - expect(document).not.toMatch(/no run journal was read for this record's session at all/i); - expect(document).toContain("no usable run journal"); - }); - - it("states the envelope version the code actually emits", () => { - const stated = /Every object carries `cost_report_version`, currently `(\d+)`/.exec( - contractText() - ); - - expect(stated?.[1]).toBe(String(COST_REPORT_ENVELOPE_VERSION)); - }); - - // The prose sentence above and the worked example below it drifted apart - the sentence - // said 10 while the example still showed 8, two versions behind. A reader parses against - // the example, so pinning only the sentence guards the half nobody copies. - it("shows that same version in its own worked example", () => { - const shown = /"cost_report_version": (\d+)/.exec(contractText()); - - expect(shown?.[1]).toBe(String(COST_REPORT_ENVELOPE_VERSION)); - }); -}); diff --git a/cli/tests/domain/models/cost-report-order.property.unit.test.ts b/cli/tests/domain/models/cost-report-order.property.unit.test.ts deleted file mode 100644 index a199d9472..000000000 --- a/cli/tests/domain/models/cost-report-order.property.unit.test.ts +++ /dev/null @@ -1,179 +0,0 @@ -import "../../../src/domain/tools/ai/claude.js"; -import "../../../src/domain/tools/ai/codex.js"; -import * as fc from "fast-check"; -import { describe, expect, it } from "vitest"; -import { - buildCostReport, - type CostReportInput, - type CostReportSessionJournal, -} from "../../../src/domain/models/cost-report.js"; -import type { TelemetrySinkRecord } from "../../../src/domain/models/telemetry-sink-record.js"; - -/** - * A re-read appends, so one session's lines sit in different orders on two machines, and - * nothing a consumer does controls it. `cost-report.unit.test.ts` already reverses four - * records; this covers what that fixture cannot. - * - * Every row kind added since is keyed differently from the others, and mixed key kinds in - * one `Map` are exactly where insertion order leaks into output: `by_flow` keys an - * interval-derived row on the `FlowInterval` object and a tool-stated one on the skill's - * name, and `by_agent` keys two of its three rows on symbols. A report that ranks by size - * then breaks ties on a row key cannot be allowed to answer differently because the records - * arrived shuffled. - * - * Verified against the real thing before it was written: 30,222 records of a live sink, - * shuffled within every day file, produced a byte-identical report. This is the guard for - * it, over permutations rather than one reversal. - */ -const AT = "2026-08-18T10:00:00Z"; -const LATER = "2026-08-18T11:30:00Z"; - -const NAMES_AGENTS = { - localRead: { tokenCounters: true, amount: false, toolStatedStep: true, agentName: true }, - export: null, - journalAttributable: true, - taskAttributable: true, -} as const; - -const NAMES_NO_AGENT = { - localRead: { tokenCounters: true, amount: false, toolStatedStep: false, agentName: false }, - export: null, - journalAttributable: false, - taskAttributable: false, -} as const; - -const DECLARED = [ - { tool: "claude", coverage: "covered", capability: NAMES_AGENTS }, - { tool: "codex", coverage: "covered", capability: NAMES_NO_AGENT }, -] as const; - -/** One session the journal witnessed, so an interval-derived flow row exists beside a - * tool-stated one — the two key kinds this property is about. */ -const JOURNALS: readonly CostReportSessionJournal[] = [ - { - vendorId: "s-witnessed", - tool: "claude-code", - writtenPaths: [], - taskIntervals: [], - flowIntervals: [ - { - skill: "aidd-orchestrator:01-sdlc", - startMs: Date.parse("2026-08-18T09:00:00Z"), - endMs: Date.parse("2026-08-18T10:30:00Z"), - closedBy: "boundary", - }, - ], - }, -]; - -function record(overrides: Partial): TelemetrySinkRecord { - return { - sink_schema_version: 2, - kind: "request", - provenance: "local-read", - tool: "claude", - vendor_id: "s-witnessed", - vendor_field: "sessionId", - step_attribution: "unattributed", - event_timestamp: AT, - cost_usd: 1, - ...overrides, - }; -} - -/** Every row kind the report can produce, once each: an interval-derived flow and a - * tool-stated one, all three agent attributions, a named prompt and one that named none, - * two models, two tools, and a pair with identical figures whose order only a tie-break can - * decide. */ -const RECORDS: readonly TelemetrySinkRecord[] = [ - // Inside the witnessed flow, agent named by the tool. - record({ turn_id: "a", agent_name: "aidd-dev:executor", model: "opus", prompt_id: "p-1" }), - // Inside the witnessed flow, no agent — the main thread, since claude names agents. - record({ turn_id: "b", model: "haiku", prompt_id: "p-1" }), - // Outside every interval, but the tool named an orchestrating skill: a tool-stated flow. - record({ - turn_id: "c", - vendor_id: "s-unwitnessed", - event_timestamp: LATER, - step_attribution: "tool-stated", - step: "aidd-orchestrator:01-sdlc", - model: "opus", - }), - record({ - turn_id: "d", - vendor_id: "s-unwitnessed", - event_timestamp: LATER, - step_attribution: "tool-stated", - step: "aidd-orchestrator:02-backlog", - model: "haiku", - }), - // A tool that never names an agent: the third agent row, and it must not read as a main - // thread however the records arrive. - record({ turn_id: "e", tool: "codex", vendor_id: "s-codex", event_timestamp: LATER }), - // Two rows with identical figures, so only the tie-break on the row's own key can order - // them — the case repetition alone never catches. - record({ turn_id: "f", model: "zulu", cost_usd: 3, prompt_id: "p-2" }), - record({ turn_id: "g", model: "alpha", cost_usd: 3, prompt_id: "p-3" }), - // One billed call two routes saw, with equal counters and different content: only - // `pickDeterministically` can choose a survivor, and picking `group[0]` would make the - // choice depend on which line the day file happened to list first. - record({ - turn_id: "h", - billed_request_id: "req-1", - model: "opus", - cost_usd: 4, - input_tokens: 10, - agent_name: "Explore", - }), - record({ - turn_id: "i", - billed_request_id: "req-1", - model: "opus", - cost_usd: 4, - input_tokens: 10, - prompt_id: "p-4", - }), -]; - -function reportOf(records: readonly TelemetrySinkRecord[]): string { - const input: CostReportInput = { - fromDay: "2026-08-17", - toDay: "2026-08-21", - records, - journals: JOURNALS, - declaredTools: DECLARED, - undatedRecords: 0, - unreadableLines: 0, - measurementEnabled: true, - }; - return JSON.stringify(buildCostReport(input)); -} - -describe("buildCostReport — every row kind, arriving in any order", () => { - it("answers the same report for every permutation of the same records", () => { - const expected = reportOf(RECORDS); - - fc.assert( - fc.property(fc.shuffledSubarray([...RECORDS], { minLength: RECORDS.length }), (shuffled) => { - expect(reportOf(shuffled)).toBe(expected); - }), - { numRuns: 300 } - ); - }); - - // The fixture has to actually exercise what the property is about: a permutation of records - // that produce only one kind of row proves nothing about mixed keys. - it("covers both flow row kinds and all three agent attributions", () => { - const report = JSON.parse(reportOf(RECORDS)) as { - byFlows: { attribution: string }[]; - byAgents: { attribution: string }[]; - }; - - expect(new Set(report.byFlows.map((row) => row.attribution))).toEqual( - new Set(["journal-interval", "tool-stated", "unattributed"]) - ); - expect(new Set(report.byAgents.map((row) => row.attribution))).toEqual( - new Set(["tool-stated", "main-thread", "not-stated"]) - ); - }); -}); diff --git a/cli/tests/domain/models/file-diff.unit.test.ts b/cli/tests/domain/models/file-diff.unit.test.ts deleted file mode 100644 index ec8f6dd99..000000000 --- a/cli/tests/domain/models/file-diff.unit.test.ts +++ /dev/null @@ -1,51 +0,0 @@ -import { describe, expect, it } from "vitest"; -import type { FileDiff, FileDiffKind } from "../../../src/domain/models/file.js"; - -describe("FileDiffKind", () => { - it("accepts all valid kinds", () => { - const kinds: FileDiffKind[] = ["added", "removed", "changed", "unchanged"]; - expect(kinds).toHaveLength(4); - }); - - it("narrowing: added kind", () => { - const diff: FileDiff = { relativePath: "foo.md", kind: "added" }; - expect(diff.kind).toBe("added"); - }); - - it("narrowing: removed kind", () => { - const diff: FileDiff = { relativePath: "foo.md", kind: "removed" }; - expect(diff.kind).toBe("removed"); - }); - - it("narrowing: changed kind", () => { - const diff: FileDiff = { relativePath: "foo.md", kind: "changed" }; - expect(diff.kind).toBe("changed"); - }); - - it("narrowing: unchanged kind", () => { - const diff: FileDiff = { relativePath: "foo.md", kind: "unchanged" }; - expect(diff.kind).toBe("unchanged"); - }); -}); - -describe("FileDiff", () => { - it("conflict flag is optional", () => { - const diff: FileDiff = { relativePath: "foo.md", kind: "changed" }; - expect(diff.conflict).toBeUndefined(); - }); - - it("conflict flag can be true", () => { - const diff: FileDiff = { relativePath: "foo.md", kind: "changed", conflict: true }; - expect(diff.conflict).toBe(true); - }); - - it("conflict flag can be false", () => { - const diff: FileDiff = { relativePath: "foo.md", kind: "changed", conflict: false }; - expect(diff.conflict).toBe(false); - }); - - it("carries relativePath", () => { - const diff: FileDiff = { relativePath: ".claude/CLAUDE.md", kind: "added" }; - expect(diff.relativePath).toBe(".claude/CLAUDE.md"); - }); -}); diff --git a/cli/tests/domain/models/manifest-v2-prod-migration.unit.test.ts b/cli/tests/domain/models/manifest-v2-prod-migration.unit.test.ts deleted file mode 100644 index 10f39aaee..000000000 --- a/cli/tests/domain/models/manifest-v2-prod-migration.unit.test.ts +++ /dev/null @@ -1,104 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { Manifest } from "../../../src/domain/models/manifest.js"; -import type { ToolId } from "../../../src/domain/models/tool-ids.js"; - -const CLAUDE = "claude" as ToolId; -const CURSOR = "cursor" as ToolId; - -/** - * Realistic v2 manifest as shipped by npm 4.0.0. - * Schema: version, docsDir, repo, tools (with files/mergeFiles/excludedMcp), docs, scripts. - */ -const makeV2ProdManifest = () => ({ - version: 2, - docsDir: "aidd_docs", - repo: "ai-driven-dev/framework", - tools: { - claude: { - toolId: "claude", - version: "4.0.0", - files: [ - { relativePath: ".claude/CLAUDE.md", hash: "a".repeat(32) }, - { relativePath: ".claude/settings.json", hash: "b".repeat(32) }, - ], - mergeFiles: [ - { - relativePath: ".claude/settings.json", - sectionKey: "mcpServers", - entries: { "aidd-server": "c".repeat(32) }, - }, - ], - excludedMcp: [{ configPath: ".claude/settings.json", entryKey: "old-server" }], - }, - cursor: { - toolId: "cursor", - version: "4.0.0", - files: [{ relativePath: ".cursor/rules/naming.mdc", hash: "d".repeat(32) }], - mergeFiles: [], - excludedMcp: [], - }, - }, - docs: { version: "4.0.0", files: [] }, - scripts: null, -}); - -describe("Manifest v2 prod → v6 migration (npm 4.0.0 baseline)", () => { - it("loads a realistic v2 manifest without throwing", () => { - expect(() => Manifest.fromJSON(makeV2ProdManifest())).not.toThrow(); - }); - - it("serializes to version 6 after load", () => { - const manifest = Manifest.fromJSON(makeV2ProdManifest()); - expect(manifest.toJSON().version).toBe(6); - }); - - it("does not contain legacy top-level fields after migration", () => { - const manifest = Manifest.fromJSON(makeV2ProdManifest()); - const json = manifest.toJSON(); - for (const field of ["docs", "docsDir", "repo", "mode", "scripts", "plugins", "marketplaces"]) { - expect(field in json).toBe(false); - } - }); - - it("preserves claude tool files after migration", () => { - const manifest = Manifest.fromJSON(makeV2ProdManifest()); - const files = manifest.getToolFiles(CLAUDE); - expect(files.some((f) => f.relativePath === ".claude/CLAUDE.md")).toBe(true); - expect(files.some((f) => f.relativePath === ".claude/settings.json")).toBe(true); - }); - - it("preserves cursor tool files after migration", () => { - const manifest = Manifest.fromJSON(makeV2ProdManifest()); - const files = manifest.getToolFiles(CURSOR); - expect(files.some((f) => f.relativePath === ".cursor/rules/naming.mdc")).toBe(true); - }); - - it("preserves mergeFiles on claude tool", () => { - const manifest = Manifest.fromJSON(makeV2ProdManifest()); - const mergeFiles = manifest.getMergeFiles(CLAUDE); - expect(mergeFiles).toHaveLength(1); - expect(mergeFiles[0]?.relativePath).toBe(".claude/settings.json"); - expect(mergeFiles[0]?.sectionKey).toBe("mcpServers"); - }); - - it("preserves excludedMcp on claude tool", () => { - const manifest = Manifest.fromJSON(makeV2ProdManifest()); - const excluded = manifest.getExcludedMcp(CLAUDE); - expect(excluded).toHaveLength(1); - expect(excluded[0]?.entryKey).toBe("old-server"); - }); - - it("round-trips: re-loading the v6 output produces a stable result", () => { - const once = Manifest.fromJSON(makeV2ProdManifest()).toJSON(); - const twice = Manifest.fromJSON(once).toJSON(); - expect(twice).toEqual(once); - expect(twice.version).toBe(6); - }); - - it("isFileTracked returns true for files present in the migrated manifest", () => { - const manifest = Manifest.fromJSON(makeV2ProdManifest()); - expect(manifest.isFileTracked(".claude/CLAUDE.md")).toBe(true); - expect(manifest.isFileTracked(".cursor/rules/naming.mdc")).toBe(true); - expect(manifest.isFileTracked(".unknown/file.md")).toBe(false); - }); -}); diff --git a/cli/tests/domain/models/manifest-v3-migration.unit.test.ts b/cli/tests/domain/models/manifest-v3-migration.unit.test.ts deleted file mode 100644 index 3bf340190..000000000 --- a/cli/tests/domain/models/manifest-v3-migration.unit.test.ts +++ /dev/null @@ -1,163 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { DuplicatePluginError, PluginNotFoundError } from "../../../src/domain/errors.js"; -import { Manifest } from "../../../src/domain/models/manifest.js"; -import { Plugin } from "../../../src/domain/models/plugin.js"; -import type { ToolId } from "../../../src/domain/models/tool-ids.js"; - -const CLAUDE = "claude" as ToolId; -const CURSOR = "cursor" as ToolId; - -const makeV2Manifest = () => ({ - version: 2, - docsDir: "aidd_docs", - repo: "owner/repo", - tools: { - claude: { - toolId: "claude", - version: "3.0.0", - files: [{ relativePath: ".claude/CLAUDE.md", hash: "a".repeat(32) }], - mergeFiles: [], - }, - cursor: { - toolId: "cursor", - version: "1.0.0", - files: [{ relativePath: ".cursor/rules/naming.md", hash: "b".repeat(32) }], - }, - }, - docs: null, - scripts: null, -}); - -const makePlugin = (name = "my-plugin") => - Plugin.fromJSON({ - name, - source: { kind: "github", repo: "owner/my-plugin" }, - version: "1.0.0", - strict: false, - files: { [`.claude/plugins/${name}/README.md`]: "c".repeat(32) }, - }); - -describe("Manifest v2 → v3 migration", () => { - it("migrates v2 manifest with multiple tools: each tool has plugins: []", () => { - const manifest = Manifest.fromJSON(makeV2Manifest()); - expect(manifest.getPlugins(CLAUDE)).toHaveLength(0); - expect(manifest.getPlugins(CURSOR)).toHaveLength(0); - }); - - it("migrated manifest serializes with version 6", () => { - const manifest = Manifest.fromJSON(makeV2Manifest()); - expect(manifest.toJSON().version).toBe(6); - }); -}); - -describe("Manifest v1 → v3 chain", () => { - it("migrates v1 manifest preserving non-vscode copilot files and adding plugins: []", () => { - const v1 = { - version: 1, - docsDir: "aidd_docs", - tools: { - copilot: { - toolId: "copilot", - version: "1.0.0", - files: [ - { relativePath: ".github/agents/alexia.agent.md", hash: "d".repeat(32) }, - { relativePath: ".vscode/settings.json", hash: "e".repeat(32) }, - ], - }, - }, - docs: null, - scripts: null, - }; - const manifest = Manifest.fromJSON(v1); - const copilotFiles = manifest.getToolFiles("copilot" as ToolId); - expect(copilotFiles.some((f) => f.relativePath === ".github/agents/alexia.agent.md")).toBe( - true - ); - expect(copilotFiles.some((f) => f.relativePath === ".vscode/settings.json")).toBe(false); - expect(manifest.getPlugins("copilot" as ToolId)).toHaveLength(0); - expect(manifest.toJSON().version).toBe(6); - }); -}); - -describe("Manifest v3 round-trip", () => { - it("serializes and re-parses a v3 manifest with plugins", () => { - const manifest = Manifest.fromJSON(makeV2Manifest()); - manifest.addPlugin(CLAUDE, makePlugin("cool-plugin")); - const serialized = manifest.toJSON(); - const reparsed = Manifest.fromJSON(serialized); - const plugins = reparsed.getPlugins(CLAUDE); - expect(plugins).toHaveLength(1); - expect(plugins[0].name).toBe("cool-plugin"); - expect(plugins[0].version).toBe("1.0.0"); - }); - - it("round-trips a manifest with no plugins identically to one without plugin field", () => { - const manifest = Manifest.fromJSON(makeV2Manifest()); - const json = manifest.toJSON(); - expect(json.tools.claude.plugins).toBeUndefined(); - expect(json.tools.cursor.plugins).toBeUndefined(); - }); -}); - -describe("addPlugin()", () => { - it("adds a plugin to the specified tool", () => { - const manifest = Manifest.fromJSON(makeV2Manifest()); - manifest.addPlugin(CLAUDE, makePlugin()); - expect(manifest.getPlugins(CLAUDE)).toHaveLength(1); - }); - - it("throws DuplicatePluginError when adding a plugin with the same name", () => { - const manifest = Manifest.fromJSON(makeV2Manifest()); - manifest.addPlugin(CLAUDE, makePlugin("dup")); - expect(() => manifest.addPlugin(CLAUDE, makePlugin("dup"))).toThrow(DuplicatePluginError); - }); - - it("does not affect other tools", () => { - const manifest = Manifest.fromJSON(makeV2Manifest()); - manifest.addPlugin(CLAUDE, makePlugin()); - expect(manifest.getPlugins(CURSOR)).toHaveLength(0); - }); -}); - -describe("removePlugin()", () => { - it("removes a plugin by name", () => { - const manifest = Manifest.fromJSON(makeV2Manifest()); - manifest.addPlugin(CLAUDE, makePlugin("to-remove")); - manifest.removePlugin(CLAUDE, "to-remove"); - expect(manifest.getPlugins(CLAUDE)).toHaveLength(0); - }); - - it("throws PluginNotFoundError when plugin does not exist", () => { - const manifest = Manifest.fromJSON(makeV2Manifest()); - expect(() => manifest.removePlugin(CLAUDE, "ghost")).toThrow(PluginNotFoundError); - }); - - it("does not remove a plugin from the wrong tool", () => { - const manifest = Manifest.fromJSON(makeV2Manifest()); - manifest.addPlugin(CLAUDE, makePlugin("shared-name")); - expect(() => manifest.removePlugin(CURSOR, "shared-name")).toThrow(PluginNotFoundError); - }); -}); - -describe("isFileTracked() with plugins", () => { - it("returns true for a file tracked inside a plugin", () => { - const manifest = Manifest.fromJSON(makeV2Manifest()); - manifest.addPlugin(CLAUDE, makePlugin("my-plugin")); - expect(manifest.isFileTracked(".claude/plugins/my-plugin/README.md")).toBe(true); - }); - - it("returns false for an untracked file not in any plugin", () => { - const manifest = Manifest.fromJSON(makeV2Manifest()); - expect(manifest.isFileTracked(".claude/plugins/unknown/README.md")).toBe(false); - }); -}); - -describe("addTool() preserves existing plugins on re-add", () => { - it("keeps plugins when addTool is called again", () => { - const manifest = Manifest.fromJSON(makeV2Manifest()); - manifest.addPlugin(CLAUDE, makePlugin("keep-me")); - manifest.addTool(CLAUDE, "4.0.0", []); - expect(manifest.getPlugins(CLAUDE)).toHaveLength(1); - expect(manifest.getPlugins(CLAUDE)[0].name).toBe("keep-me"); - }); -}); diff --git a/cli/tests/domain/models/manifest-v5-migration.unit.test.ts b/cli/tests/domain/models/manifest-v5-migration.unit.test.ts deleted file mode 100644 index b1a3acb5b..000000000 --- a/cli/tests/domain/models/manifest-v5-migration.unit.test.ts +++ /dev/null @@ -1,68 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { Manifest } from "../../../src/domain/models/manifest.js"; - -describe("Manifest v5 → v6 migration", () => { - it("strips marketplaces field on round-trip", () => { - const v5 = { - version: 5, - tools: {}, - marketplaces: { - "test-marketplace": { - name: "test-marketplace", - source: { kind: "github", repo: "owner/test-marketplace" }, - scope: "project", - addedAt: "2024-01-01T00:00:00.000Z", - }, - }, - }; - const manifest = Manifest.fromJSON(v5); - const json = manifest.toJSON(); - expect(json.version).toBe(6); - expect("marketplaces" in json).toBe(false); - }); - - it("loads a v5 manifest with marketplaces without throwing", () => { - const v5 = { - version: 5, - tools: { - claude: { - toolId: "claude", - version: "4.0.0", - files: [{ relativePath: ".claude/CLAUDE.md", hash: "a".repeat(32) }], - mergeFiles: [], - }, - }, - marketplaces: { - "aidd-framework": { - name: "aidd-framework", - source: { kind: "github", repo: "ai-driven-dev/framework" }, - scope: "project", - addedAt: "2024-01-01T00:00:00.000Z", - }, - }, - }; - expect(() => Manifest.fromJSON(v5)).not.toThrow(); - const manifest = Manifest.fromJSON(v5); - expect(manifest.hasTool("claude" as Parameters[0])).toBe(true); - expect("marketplaces" in manifest.toJSON()).toBe(false); - }); - - it("v6 manifest round-trips identically (no marketplaces field)", () => { - const v6 = { - version: 6, - tools: { - claude: { - toolId: "claude", - version: "4.0.0", - files: [{ relativePath: ".claude/CLAUDE.md", hash: "a".repeat(32) }], - mergeFiles: [], - }, - }, - }; - const once = Manifest.fromJSON(v6).toJSON(); - const twice = Manifest.fromJSON(once).toJSON(); - expect(twice).toEqual(once); - expect(twice.version).toBe(6); - expect("marketplaces" in twice).toBe(false); - }); -}); diff --git a/cli/tests/domain/models/manifest.property.unit.test.ts b/cli/tests/domain/models/manifest.property.unit.test.ts deleted file mode 100644 index 7e415d2b3..000000000 --- a/cli/tests/domain/models/manifest.property.unit.test.ts +++ /dev/null @@ -1,148 +0,0 @@ -import * as fc from "fast-check"; -import { describe, expect, it } from "vitest"; -import { FileHash, InstallationFile } from "../../../src/domain/models/file.js"; -import { Manifest } from "../../../src/domain/models/manifest.js"; -import type { ToolId } from "../../../src/domain/models/tool-ids.js"; -import { VALID_TOOL_IDS } from "../../../src/domain/models/tool-ids.js"; - -// ── Arbitraries ────────────────────────────────────────────────────────────── - -/** 32-char lowercase hex → valid MD5. fast-check v4 removed hexaString; use stringMatching. */ -const md5Arb = fc.stringMatching(/^[0-9a-f]{32}$/); - -/** File path: no null bytes, no leading slash, non-empty. */ -const relativePathArb = fc - .string({ minLength: 1, maxLength: 60 }) - .filter((s) => !s.includes("\0") && !s.startsWith("/") && s.trim().length > 0); - -const installationFileArb = fc - .record({ relativePath: relativePathArb, hash: md5Arb }) - .map( - ({ relativePath, hash }) => - new InstallationFile({ relativePath, content: "x", hash: new FileHash(hash) }) - ); - -/** Valid tool id drawn from the real constant list. */ -const toolIdArb = fc.constantFrom(...(VALID_TOOL_IDS as ToolId[])); - -const toolEntryArb = fc.record({ - toolId: toolIdArb, - version: fc - .string({ minLength: 1, maxLength: 20 }) - .filter((s) => !s.includes("\n") && s.trim().length > 0), - files: fc.array(installationFileArb, { maxLength: 6 }), -}); - -// ── Helpers ─────────────────────────────────────────────────────────────────── - -/** Build a v6 Manifest from tool entries. Dedup by toolId (last-wins via addTool). */ -function buildManifest( - tools: Array<{ toolId: ToolId; version: string; files: InstallationFile[] }> -): Manifest { - const m = Manifest.create(); - for (const t of tools) { - m.addTool(t.toolId, t.version, t.files); - } - return m; -} - -// ── Property 1: round-trip identity ────────────────────────────────────────── - -describe("Manifest property tests", () => { - it("toJSON → fromJSON → toJSON is identity", () => { - fc.assert( - fc.property(fc.array(toolEntryArb, { maxLength: 4 }), (tools) => { - const m = buildManifest(tools); - const firstSerialized = m.toJSON(); - const reparsed = Manifest.fromJSON(firstSerialized); - const secondSerialized = reparsed.toJSON(); - expect(secondSerialized).toEqual(firstSerialized); - }), - { numRuns: 100 } - ); - }); - - // ── Property 2: migration chain idempotent on v6 input ────────────────────── - - it("migration chain on v6 input is idempotent (fromJSON round-trips cleanly)", () => { - fc.assert( - fc.property(fc.array(toolEntryArb, { maxLength: 4 }), (tools) => { - const m = buildManifest(tools); - const v6 = m.toJSON(); - // Apply fromJSON twice — the migration branch must be a no-op on version 6. - const once = Manifest.fromJSON(v6).toJSON(); - const twice = Manifest.fromJSON(once).toJSON(); - expect(twice).toEqual(once); - }), - { numRuns: 100 } - ); - }); - - // ── Property 3: v3/v4 raw shapes deserialize to v5 ────────────────────────── - - it("v3 raw shapes migrate to version 6 without throwing", () => { - const v3ToolEntryArb = fc.record({ - toolId: toolIdArb, - version: fc - .string({ minLength: 1, maxLength: 20 }) - .filter((s) => !s.includes("\n") && s.trim().length > 0), - files: fc.array(fc.record({ relativePath: relativePathArb, hash: md5Arb }), { maxLength: 4 }), - mergeFiles: fc.constant([]), - }); - - fc.assert( - fc.property(fc.array(v3ToolEntryArb, { maxLength: 4 }), (rawTools) => { - const toolsRecord: Record = {}; - const seenIds = new Set(); - for (const t of rawTools) { - if (!seenIds.has(t.toolId)) { - seenIds.add(t.toolId); - toolsRecord[t.toolId] = { ...t, plugins: [] }; - } - } - const rawV3 = { - version: 3, - mode: "local", - docsDir: "aidd_docs", - tools: toolsRecord, - }; - const migrated = Manifest.fromJSON(rawV3); - expect(migrated.toJSON().version).toBe(6); - }), - { numRuns: 100 } - ); - }); - - it("v4 raw shapes migrate to version 6 without throwing", () => { - const v4ToolEntryArb = fc.record({ - toolId: toolIdArb, - version: fc - .string({ minLength: 1, maxLength: 20 }) - .filter((s) => !s.includes("\n") && s.trim().length > 0), - files: fc.array(fc.record({ relativePath: relativePathArb, hash: md5Arb }), { maxLength: 4 }), - mergeFiles: fc.constant([]), - }); - - fc.assert( - fc.property(fc.array(v4ToolEntryArb, { maxLength: 4 }), (rawTools) => { - const toolsRecord: Record = {}; - const seenIds = new Set(); - for (const t of rawTools) { - if (!seenIds.has(t.toolId)) { - seenIds.add(t.toolId); - toolsRecord[t.toolId] = { ...t, plugins: [] }; - } - } - const rawV4 = { - version: 4, - mode: "local", - docsDir: "aidd_docs", - tools: toolsRecord, - }; - const migrated = Manifest.fromJSON(rawV4); - expect(migrated.toJSON().version).toBe(6); - }), - { numRuns: 100 } - ); - }); -}); diff --git a/cli/tests/domain/models/manifest.unit.test.ts b/cli/tests/domain/models/manifest.unit.test.ts deleted file mode 100644 index da7505a79..000000000 --- a/cli/tests/domain/models/manifest.unit.test.ts +++ /dev/null @@ -1,543 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { FileHash, InstallationFile } from "../../../src/domain/models/file.js"; -import { Manifest } from "../../../src/domain/models/manifest.js"; -import type { McpExclusion } from "../../../src/domain/models/mcp-exclusion.js"; -import type { MergeFileEntry } from "../../../src/domain/models/merge.js"; -import type { ToolId } from "../../../src/domain/tools/registry.js"; - -const makeHash = (hex: string): FileHash => new FileHash(hex.padEnd(32, "0")); - -const makeFile = (path: string, hashHex: string): InstallationFile => - new InstallationFile({ - relativePath: path, - content: "content", - hash: makeHash(hashHex), - }); - -const claudeFiles = [ - makeFile(".claude/agents/code-reviewer.md", "aabbcc"), - makeFile(".claude/rules/naming.md", "ddeeff"), -]; - -describe("Manifest", () => { - describe("addTool()", () => { - it("adds a new tool entry", () => { - const manifest = Manifest.create(); - manifest.addTool("claude" as ToolId, "3.0.0", claudeFiles); - expect(manifest.hasTool("claude" as ToolId)).toBe(true); - }); - - it("replaces an existing tool entry", () => { - const manifest = Manifest.create(); - manifest.addTool("claude" as ToolId, "3.0.0", claudeFiles); - const newFiles = [makeFile(".claude/agents/new-agent.md", "112233")]; - manifest.addTool("claude" as ToolId, "3.1.0", newFiles); - expect(manifest.getToolVersion("claude" as ToolId)).toBe("3.1.0"); - }); - }); - - describe("removeTool()", () => { - it("removes only the specified tool", () => { - const manifest = Manifest.create(); - manifest.addTool("claude" as ToolId, "3.0.0", claudeFiles); - manifest.addTool("cursor" as ToolId, "3.0.0", [ - makeFile(".cursor/rules/naming.md", "445566"), - ]); - manifest.removeTool("claude" as ToolId); - expect(manifest.hasTool("claude" as ToolId)).toBe(false); - expect(manifest.hasTool("cursor" as ToolId)).toBe(true); - }); - - it("aborts when removing a tool that is not installed", () => { - const manifest = Manifest.create(); - expect(() => manifest.removeTool("claude" as ToolId)).toThrow(); - }); - }); - - describe("hasTool()", () => { - it("returns true when tool is installed", () => { - const manifest = Manifest.create(); - manifest.addTool("claude" as ToolId, "3.0.0", claudeFiles); - expect(manifest.hasTool("claude" as ToolId)).toBe(true); - }); - - it("returns false when tool is not installed", () => { - const manifest = Manifest.create(); - expect(manifest.hasTool("claude" as ToolId)).toBe(false); - }); - }); - - describe("getToolVersion()", () => { - it("returns version for installed tool", () => { - const manifest = Manifest.create(); - manifest.addTool("claude" as ToolId, "3.0.0", claudeFiles); - expect(manifest.getToolVersion("claude" as ToolId)).toBe("3.0.0"); - }); - - it("returns undefined for missing tool", () => { - const manifest = Manifest.create(); - expect(manifest.getToolVersion("claude" as ToolId)).toBeUndefined(); - }); - }); - - describe("serialization round-trip", () => { - it("fromJSON() rejects unsupported manifest version", () => { - const manifest = Manifest.create(); - manifest.addTool("claude" as ToolId, "3.0.0", claudeFiles); - const json = manifest.toJSON(); - const badVersion = { ...json, version: 99 }; - expect(() => Manifest.fromJSON(badVersion)).toThrow(/version/); - }); - - it("toJSON() / fromJSON() preserves tool entries", () => { - const manifest = Manifest.create(); - manifest.addTool("claude" as ToolId, "3.0.0", claudeFiles); - manifest.addTool("cursor" as ToolId, "3.0.0", [ - makeFile(".cursor/rules/naming.md", "445566"), - ]); - - const json = manifest.toJSON(); - const restored = Manifest.fromJSON(json); - - expect(restored.hasTool("claude" as ToolId)).toBe(true); - expect(restored.hasTool("cursor" as ToolId)).toBe(true); - expect(restored.getToolVersion("claude" as ToolId)).toBe("3.0.0"); - expect(restored.getToolVersion("cursor" as ToolId)).toBe("3.0.0"); - }); - - it("marketplaces field is absent in a fresh manifest JSON", () => { - const manifest = Manifest.create(); - const json = manifest.toJSON(); - expect("marketplaces" in json).toBe(false); - }); - - it("file hashes are preserved after round-trip", () => { - const manifest = Manifest.create(); - manifest.addTool("claude" as ToolId, "3.0.0", claudeFiles); - - const restored = Manifest.fromJSON(manifest.toJSON()); - const restoredJson = restored.toJSON(); - - expect(restoredJson.tools.claude).toBeDefined(); - expect(restoredJson.tools.claude.files).toHaveLength(2); - expect(restoredJson.tools.claude.files[0].hash).toBe(`aabbcc${"0".repeat(26)}`); - }); - - it("fromJSON() reports an error on invalid data", () => { - expect(() => Manifest.fromJSON(null)).toThrow(); - }); - }); - - describe("isFileTracked()", () => { - it("returns true for a file tracked by a tool", () => { - const manifest = Manifest.create(); - manifest.addTool("claude" as ToolId, "3.0.0", claudeFiles); - expect(manifest.isFileTracked(".claude/agents/code-reviewer.md")).toBe(true); - }); - - it("returns false for a file not in the manifest", () => { - const manifest = Manifest.create(); - manifest.addTool("claude" as ToolId, "3.0.0", claudeFiles); - expect(manifest.isFileTracked("some/unknown/file.md")).toBe(false); - }); - }); - - describe("mergeFiles", () => { - const mergeFiles: MergeFileEntry[] = [ - { - relativePath: ".mcp.json", - sectionKey: "mcpServers", - entries: { - playwright: makeHash("aabb11"), - github: makeHash("ccdd22"), - }, - }, - ]; - - it("addTool stores mergeFiles entries", () => { - const manifest = Manifest.create(); - manifest.addTool("claude" as ToolId, "3.0.0", claudeFiles, mergeFiles); - expect(manifest.getMergeFiles("claude" as ToolId)).toHaveLength(1); - expect(manifest.getMergeFiles("claude" as ToolId)[0].relativePath).toBe(".mcp.json"); - }); - - it("getMergeFiles returns empty array for tool without merge files", () => { - const manifest = Manifest.create(); - manifest.addTool("claude" as ToolId, "3.0.0", claudeFiles); - expect(manifest.getMergeFiles("claude" as ToolId)).toEqual([]); - }); - - it("getMergeFiles returns empty array for missing tool", () => { - const manifest = Manifest.create(); - expect(manifest.getMergeFiles("claude" as ToolId)).toEqual([]); - }); - - it("isFileTracked returns true for merge file paths", () => { - const manifest = Manifest.create(); - manifest.addTool("claude" as ToolId, "3.0.0", claudeFiles, mergeFiles); - expect(manifest.isFileTracked(".mcp.json")).toBe(true); - }); - - it("serialization round-trip preserves mergeFiles", () => { - const manifest = Manifest.create(); - manifest.addTool("claude" as ToolId, "3.0.0", claudeFiles, mergeFiles); - const restored = Manifest.fromJSON(manifest.toJSON()); - const restoredMerge = restored.getMergeFiles("claude" as ToolId); - expect(restoredMerge).toHaveLength(1); - expect(restoredMerge[0].relativePath).toBe(".mcp.json"); - expect(restoredMerge[0].sectionKey).toBe("mcpServers"); - expect(Object.keys(restoredMerge[0].entries)).toEqual(["playwright", "github"]); - expect(restoredMerge[0].entries.playwright.value).toBe(`aabb11${"0".repeat(26)}`); - }); - - it("toJSON produces version 6", () => { - const manifest = Manifest.create(); - manifest.addTool("claude" as ToolId, "3.0.0", claudeFiles); - expect(manifest.toJSON().version).toBe(6); - }); - }); - - describe("version validation", () => { - it("rejects unsupported manifest version", () => { - const badData = { version: 99, docsDir: "aidd_docs", tools: {}, docs: null, scripts: null }; - expect(() => Manifest.fromJSON(badData)).toThrow(/version/); - }); - }); - - describe("MCP exclusion tracking", () => { - const exclusionA: McpExclusion = { configPath: ".mcp.json", entryKey: "playwright" }; - const exclusionB: McpExclusion = { configPath: ".mcp.json", entryKey: "github" }; - - it("addTool with excludedMcp stores exclusions", () => { - const manifest = Manifest.create(); - manifest.addTool("claude" as ToolId, "3.0.0", claudeFiles, [], [exclusionA]); - expect(manifest.getExcludedMcp("claude" as ToolId)).toEqual([exclusionA]); - }); - - it("getExcludedMcp returns empty array for tool without exclusions", () => { - const manifest = Manifest.create(); - manifest.addTool("claude" as ToolId, "3.0.0", claudeFiles); - expect(manifest.getExcludedMcp("claude" as ToolId)).toEqual([]); - }); - - it("addExcludedMcp appends and deduplicates", () => { - const manifest = Manifest.create(); - manifest.addTool("claude" as ToolId, "3.0.0", claudeFiles); - manifest.addExcludedMcp("claude" as ToolId, [exclusionA]); - manifest.addExcludedMcp("claude" as ToolId, [exclusionA, exclusionB]); - const result = manifest.getExcludedMcp("claude" as ToolId); - expect(result).toHaveLength(2); - expect(result).toEqual([exclusionA, exclusionB]); - }); - - it("addExcludedMcp throws for uninstalled tool", () => { - const manifest = Manifest.create(); - expect(() => manifest.addExcludedMcp("claude" as ToolId, [exclusionA])).toThrow( - /not installed/ - ); - }); - - it("removeExcludedMcp removes matching entries", () => { - const manifest = Manifest.create(); - manifest.addTool("claude" as ToolId, "3.0.0", claudeFiles, [], [exclusionA, exclusionB]); - manifest.removeExcludedMcp("claude" as ToolId, [exclusionA]); - expect(manifest.getExcludedMcp("claude" as ToolId)).toEqual([exclusionB]); - }); - - it("removeExcludedMcp throws for uninstalled tool", () => { - const manifest = Manifest.create(); - expect(() => manifest.removeExcludedMcp("claude" as ToolId, [exclusionA])).toThrow( - /not installed/ - ); - }); - - it("clearExcludedMcp empties the list", () => { - const manifest = Manifest.create(); - manifest.addTool("claude" as ToolId, "3.0.0", claudeFiles, [], [exclusionA, exclusionB]); - manifest.clearExcludedMcp("claude" as ToolId); - expect(manifest.getExcludedMcp("claude" as ToolId)).toEqual([]); - }); - - it("clearExcludedMcp throws for uninstalled tool", () => { - const manifest = Manifest.create(); - expect(() => manifest.clearExcludedMcp("claude" as ToolId)).toThrow(/not installed/); - }); - - it("toJSON/fromJSON round-trip preserves excludedMcp", () => { - const manifest = Manifest.create(); - manifest.addTool("claude" as ToolId, "3.0.0", claudeFiles, [], [exclusionA, exclusionB]); - const restored = Manifest.fromJSON(manifest.toJSON()); - expect(restored.getExcludedMcp("claude" as ToolId)).toEqual([exclusionA, exclusionB]); - }); - - it("fromJSON handles missing excludedMcp (backward compat)", () => { - const manifest = Manifest.create(); - manifest.addTool("claude" as ToolId, "3.0.0", claudeFiles); - const json = manifest.toJSON(); - const restored = Manifest.fromJSON(json); - expect(restored.getExcludedMcp("claude" as ToolId)).toEqual([]); - }); - - it("toJSON omits excludedMcp when empty", () => { - const manifest = Manifest.create(); - manifest.addTool("claude" as ToolId, "3.0.0", claudeFiles); - const json = manifest.toJSON(); - expect(json.tools.claude).not.toHaveProperty("excludedMcp"); - }); - - it("updateToolMergeFiles replaces merge files without touching regular files", () => { - const mergeEntry: MergeFileEntry = { - relativePath: ".mcp.json", - sectionKey: "mcpServers", - entries: { playwright: makeHash("aabb") }, - }; - const manifest = Manifest.create(); - manifest.addTool("claude" as ToolId, "3.0.0", claudeFiles, [mergeEntry], [exclusionA]); - const updatedMerge: MergeFileEntry = { - relativePath: ".mcp.json", - sectionKey: "mcpServers", - entries: {}, - }; - manifest.updateToolMergeFiles("claude" as ToolId, [updatedMerge]); - expect(manifest.getMergeFiles("claude" as ToolId)).toEqual([updatedMerge]); - expect(manifest.getToolFiles("claude" as ToolId)).toHaveLength(2); - expect(manifest.getExcludedMcp("claude" as ToolId)).toEqual([exclusionA]); - }); - - it("updateToolMergeFiles throws for uninstalled tool", () => { - const manifest = Manifest.create(); - expect(() => manifest.updateToolMergeFiles("claude" as ToolId, [])).toThrow(/not installed/); - }); - }); - - describe("migration v1 → v2", () => { - const HASH_EXT = "abc123".padEnd(32, "0"); - const HASH_KEY = "def456".padEnd(32, "0"); - const HASH_SET = "fed789".padEnd(32, "0"); - const HASH_CPL = "aabbcc".padEnd(32, "0"); - - const v1WithVscode = { - version: 1, - docsDir: "aidd_docs", - tools: { - copilot: { - toolId: "copilot", - version: "1.0.0", - files: [ - { relativePath: ".vscode/extensions.json", hash: HASH_EXT }, - { relativePath: ".vscode/keybindings.json", hash: HASH_KEY }, - { relativePath: ".vscode/settings.json", hash: HASH_SET }, - { relativePath: ".github/copilot-instructions.md", hash: HASH_CPL }, - ], - mergeFiles: [], - }, - }, - docs: null, - scripts: null, - }; - - const v1CopilotOnly = { - version: 1, - docsDir: "aidd_docs", - tools: { - copilot: { - toolId: "copilot", - version: "1.0.0", - files: [{ relativePath: ".github/copilot-instructions.md", hash: HASH_CPL }], - mergeFiles: [], - }, - }, - docs: null, - scripts: null, - }; - - const v1NoCopilot = { - version: 1, - docsDir: "aidd_docs", - tools: {}, - docs: null, - scripts: null, - }; - - const v0 = { version: 0, docsDir: "aidd_docs", tools: {}, docs: null, scripts: null }; - - it("moves .vscode/ files from copilot to vscode after migration", () => { - const manifest = Manifest.fromJSON(JSON.parse(JSON.stringify(v1WithVscode))); - expect(manifest.hasTool("vscode" as ToolId)).toBe(true); - const vscodeFiles = manifest.getToolFiles("vscode" as ToolId); - expect(vscodeFiles).toHaveLength(3); - const paths = vscodeFiles.map((f) => f.relativePath); - expect(paths).toContain(".vscode/extensions.json"); - expect(paths).toContain(".vscode/keybindings.json"); - expect(paths).toContain(".vscode/settings.json"); - }); - - it("removes .vscode/ files from copilot after migration", () => { - const manifest = Manifest.fromJSON(JSON.parse(JSON.stringify(v1WithVscode))); - const copilotFiles = manifest.getToolFiles("copilot" as ToolId); - expect(copilotFiles).toHaveLength(1); - expect(copilotFiles[0].relativePath).toBe(".github/copilot-instructions.md"); - }); - - it("migration is no-op when copilot has no .vscode/ files", () => { - const manifest = Manifest.fromJSON(JSON.parse(JSON.stringify(v1CopilotOnly))); - expect(manifest.hasTool("vscode" as ToolId)).toBe(false); - expect(manifest.getToolFiles("copilot" as ToolId)).toHaveLength(1); - }); - - it("migration is no-op when no copilot entry exists", () => { - const manifest = Manifest.fromJSON(JSON.parse(JSON.stringify(v1NoCopilot))); - expect(manifest.hasTool("vscode" as ToolId)).toBe(false); - expect(manifest.hasTool("copilot" as ToolId)).toBe(false); - }); - - it("v3 manifest migrates to v4 without error", () => { - const v3Json = { - version: 3, - docsDir: "aidd_docs", - tools: { copilot: { toolId: "copilot", version: "1.0.0", files: [], plugins: [] } }, - docs: null, - scripts: null, - }; - expect(() => Manifest.fromJSON(v3Json)).not.toThrow(); - }); - - it("v6 manifest loads without migration", () => { - const manifest = Manifest.create(); - manifest.addTool("copilot" as ToolId, "1.0.0", []); - const json = manifest.toJSON(); - expect(json.version).toBe(6); - expect(() => Manifest.fromJSON(json)).not.toThrow(); - }); - - it("v0 manifest throws ManifestValidationError", () => { - expect(() => Manifest.fromJSON(v0)).toThrow(/version/); - }); - - it("isFileTracked returns true for migrated .vscode/ file", () => { - const manifest = Manifest.fromJSON(JSON.parse(JSON.stringify(v1WithVscode))); - expect(manifest.isFileTracked(".vscode/extensions.json")).toBe(true); - }); - - it("getToolVersion returns copilot version for migrated vscode entry", () => { - const manifest = Manifest.fromJSON(JSON.parse(JSON.stringify(v1WithVscode))); - expect(manifest.getToolVersion("vscode" as ToolId)).toBe("1.0.0"); - }); - - it("does not duplicate files when vscode entry already exists before migration", () => { - const v1PartiallyMigrated = { - version: 1, - docsDir: "aidd_docs", - tools: { - copilot: { - toolId: "copilot", - version: "1.0.0", - files: [ - { relativePath: ".vscode/extensions.json", hash: HASH_EXT }, - { relativePath: ".vscode/keybindings.json", hash: HASH_KEY }, - { relativePath: ".github/copilot-instructions.md", hash: HASH_CPL }, - ], - mergeFiles: [], - }, - vscode: { - toolId: "vscode", - version: "1.0.0", - files: [{ relativePath: ".vscode/extensions.json", hash: HASH_EXT }], - mergeFiles: [], - }, - }, - docs: null, - scripts: null, - }; - - const manifest = Manifest.fromJSON(JSON.parse(JSON.stringify(v1PartiallyMigrated))); - const vscodeFiles = manifest.getToolFiles("vscode" as ToolId); - const paths = vscodeFiles.map((f) => f.relativePath); - - expect(paths.filter((p) => p === ".vscode/extensions.json")).toHaveLength(1); - expect(paths).toContain(".vscode/keybindings.json"); - }); - }); - - describe("v4→v6 migration (strips docs and marketplaces)", () => { - it("strips a docs field from a v4 manifest on fromJSON", () => { - const v4WithDocs = { - version: 4, - docsDir: "aidd_docs", - tools: {}, - docs: { - version: "3.0.0", - files: [{ relativePath: "aidd_docs/architecture.md", hash: "abc".padEnd(32, "0") }], - }, - scripts: null, - plugins: null, - mode: "local", - }; - const restored = Manifest.fromJSON(JSON.parse(JSON.stringify(v4WithDocs))); - const json = restored.toJSON(); - expect(json.version).toBe(6); - expect("docs" in json).toBe(false); - expect(restored.isFileTracked("aidd_docs/architecture.md")).toBe(false); - }); - - it("cascades v3 → v4 → v6 and ends without docs", () => { - const v3 = { - version: 3, - docsDir: "aidd_docs", - tools: {}, - docs: { version: "2.0.0", files: [] }, - scripts: null, - }; - const restored = Manifest.fromJSON(JSON.parse(JSON.stringify(v3))); - const json = restored.toJSON(); - expect(json.version).toBe(6); - expect("docs" in json).toBe(false); - }); - - it("v6 round-trip is stable", () => { - const manifest = Manifest.create(); - manifest.addTool("claude" as ToolId, "3.0.0", claudeFiles); - const restored = Manifest.fromJSON(manifest.toJSON()); - expect(restored.toJSON().version).toBe(6); - }); - }); - - describe("updateTrackedFileHash()", () => { - it("updates the hash when the file is already tracked", () => { - const manifest = Manifest.create(); - manifest.addTool("claude" as ToolId, "3.0.0", claudeFiles); - manifest.updateTrackedFileHash( - "claude" as ToolId, - ".claude/agents/code-reviewer.md", - makeHash("999999") - ); - const tracked = manifest - .getToolFiles("claude" as ToolId) - .find((f) => f.relativePath === ".claude/agents/code-reviewer.md"); - expect(tracked?.hash.value).toBe(makeHash("999999").value); - }); - - it("appends a new tracked file entry when the path is not yet tracked", () => { - const manifest = Manifest.create(); - manifest.addTool("codex" as ToolId, "3.0.0", []); - manifest.updateTrackedFileHash("codex" as ToolId, ".codex/config.json", makeHash("abcdef")); - expect(manifest.isFileTracked(".codex/config.json")).toBe(true); - const tracked = manifest - .getToolFiles("codex" as ToolId) - .find((f) => f.relativePath === ".codex/config.json"); - expect(tracked?.hash.value).toBe(makeHash("abcdef").value); - }); - - it("is a no-op when the tool is not installed", () => { - const manifest = Manifest.create(); - expect(() => - manifest.updateTrackedFileHash( - "claude" as ToolId, - ".claude/settings.json", - makeHash("111111") - ) - ).not.toThrow(); - expect(manifest.hasTool("claude" as ToolId)).toBe(false); - }); - }); -}); diff --git a/cli/tests/domain/models/marketplace-entry.unit.test.ts b/cli/tests/domain/models/marketplace-entry.unit.test.ts deleted file mode 100644 index 141524186..000000000 --- a/cli/tests/domain/models/marketplace-entry.unit.test.ts +++ /dev/null @@ -1,157 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { - InvalidMarketplaceNameError, - InvalidMarketplaceScopeError, - InvalidPluginSourceError, -} from "../../../src/domain/errors.js"; -import { - MarketplaceEntry, - type MarketplaceEntryData, -} from "../../../src/domain/models/marketplace-entry.js"; - -const makeData = (overrides: Partial = {}): MarketplaceEntryData => ({ - name: "awesome-plugins", - source: { kind: "github", repo: "owner/awesome-plugins" }, - scope: "project", - ...overrides, -}); - -describe("MarketplaceEntry", () => { - describe("create()", () => { - it("creates entry with valid params", () => { - const entry = MarketplaceEntry.create({ - name: "my-marketplace", - source: { kind: "github", repo: "owner/repo" }, - scope: "project", - }); - expect(entry.name).toBe("my-marketplace"); - expect(entry.scope).toBe("project"); - }); - - it("accepts user scope", () => { - const entry = MarketplaceEntry.create({ - name: "my-mkt", - source: { kind: "github", repo: "a/b" }, - scope: "user", - }); - expect(entry.scope).toBe("user"); - }); - - it("throws on invalid name", () => { - expect(() => - MarketplaceEntry.create({ - name: "Invalid_Name", - source: { kind: "github", repo: "a/b" }, - scope: "project", - }) - ).toThrow(InvalidMarketplaceNameError); - }); - - it("throws on invalid scope", () => { - expect(() => - MarketplaceEntry.create({ - name: "valid-name", - source: { kind: "github", repo: "a/b" }, - scope: "global" as "project", - }) - ).toThrow(InvalidMarketplaceScopeError); - }); - }); - - describe("deserialize()", () => { - it("round-trips through serialize()", () => { - const data = makeData(); - const entry = MarketplaceEntry.deserialize(data); - expect(entry.serialize()).toEqual(data); - }); - - it("round-trips with version field", () => { - const data = makeData({ version: "1.2.3" }); - const entry = MarketplaceEntry.deserialize(data); - expect(entry.version).toBe("1.2.3"); - expect(entry.serialize().version).toBe("1.2.3"); - }); - - it("omits version from serialize when absent", () => { - const entry = MarketplaceEntry.deserialize(makeData()); - expect(entry.serialize().version).toBeUndefined(); - }); - - it("preserves lastRefreshAt when present", () => { - const data = makeData({ lastRefreshAt: "2026-05-01T10:00:00.000Z" }); - const entry = MarketplaceEntry.deserialize(data); - expect(entry.lastRefreshAt).toBe("2026-05-01T10:00:00.000Z"); - }); - - it("omits lastRefreshAt from serialize when absent", () => { - const entry = MarketplaceEntry.deserialize(makeData()); - expect(entry.serialize().lastRefreshAt).toBeUndefined(); - }); - - it("throws on invalid name", () => { - expect(() => MarketplaceEntry.deserialize(makeData({ name: "INVALID" }))).toThrow( - InvalidMarketplaceNameError - ); - }); - - it("throws on invalid scope", () => { - expect(() => MarketplaceEntry.deserialize(makeData({ scope: "admin" as "project" }))).toThrow( - InvalidMarketplaceScopeError - ); - }); - - it("throws on invalid plugin source", () => { - expect(() => MarketplaceEntry.deserialize(makeData({ source: { kind: "unknown" } }))).toThrow( - InvalidPluginSourceError - ); - }); - }); - - describe("equals()", () => { - it("returns true for identical entries", () => { - const a = MarketplaceEntry.deserialize(makeData()); - const b = MarketplaceEntry.deserialize(makeData()); - expect(a.equals(b)).toBe(true); - }); - - it("returns false when name differs", () => { - const a = MarketplaceEntry.deserialize(makeData({ name: "one" })); - const b = MarketplaceEntry.deserialize(makeData({ name: "two" })); - expect(a.equals(b)).toBe(false); - }); - - it("returns false when scope differs", () => { - const a = MarketplaceEntry.deserialize(makeData({ scope: "project" })); - const b = MarketplaceEntry.deserialize(makeData({ scope: "user" })); - expect(a.equals(b)).toBe(false); - }); - - it("returns false when lastRefreshAt differs", () => { - const a = MarketplaceEntry.deserialize(makeData({ lastRefreshAt: "2026-01-01T00:00:00Z" })); - const b = MarketplaceEntry.deserialize(makeData()); - expect(a.equals(b)).toBe(false); - }); - - it("returns false when version differs", () => { - const a = MarketplaceEntry.deserialize(makeData({ version: "1.0.0" })); - const b = MarketplaceEntry.deserialize(makeData()); - expect(a.equals(b)).toBe(false); - }); - }); - - describe("withVersion()", () => { - it("returns a new instance with the given version", () => { - const entry = MarketplaceEntry.deserialize(makeData()); - const updated = entry.withVersion("2.0.0"); - expect(updated.version).toBe("2.0.0"); - expect(updated.name).toBe(entry.name); - expect(updated.scope).toBe(entry.scope); - }); - - it("does not mutate the original entry", () => { - const entry = MarketplaceEntry.deserialize(makeData()); - entry.withVersion("2.0.0"); - expect(entry.version).toBeUndefined(); - }); - }); -}); diff --git a/cli/tests/domain/models/mcp.unit.test.ts b/cli/tests/domain/models/mcp.unit.test.ts deleted file mode 100644 index df7b4c49f..000000000 --- a/cli/tests/domain/models/mcp.unit.test.ts +++ /dev/null @@ -1,260 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { InstallationFile } from "../../../src/domain/models/file.js"; -import { - computeMcpExclusions, - detectNewMcpEntries, - extractMcpKeys, - filterMcpExclusions, - transformFor, -} from "../../../src/domain/models/mcp-exclusion.js"; -import type { MergeFileEntry } from "../../../src/domain/models/merge.js"; -import type { Hasher } from "../../../src/domain/ports/hasher.js"; -import { DeterministicHasher } from "../../helpers/ports/deterministic-hasher.js"; - -function makeConfig(servers: Record): string { - return JSON.stringify({ mcpServers: servers }, null, 2); -} - -describe("transformFor()", () => { - it("returns undefined for linux", () => { - expect(transformFor("linux")).toBeUndefined(); - }); - - it("returns undefined for darwin", () => { - expect(transformFor("darwin")).toBeUndefined(); - }); - - it("returns a transform for win32", () => { - expect(transformFor("win32")).toBeDefined(); - }); - - describe("win32 transform", () => { - // biome-ignore lint/style/noNonNullAssertion: win32 is asserted defined in the test above - const transform = transformFor("win32")!; - - it("transforms npx without existing args", () => { - const result = JSON.parse(transform(makeConfig({ server: { command: "npx", args: [] } }))); - expect(result.mcpServers.server.command).toBe("cmd"); - expect(result.mcpServers.server.args).toEqual(["/c", "npx"]); - }); - - it("transforms npx with existing args", () => { - const result = JSON.parse( - transform(makeConfig({ server: { command: "npx", args: ["-y", "some-pkg"] } })) - ); - expect(result.mcpServers.server.command).toBe("cmd"); - expect(result.mcpServers.server.args).toEqual(["/c", "npx", "-y", "some-pkg"]); - }); - - it("transforms uvx command", () => { - const result = JSON.parse(transform(makeConfig({ server: { command: "uvx" } }))); - expect(result.mcpServers.server.command).toBe("uvx.exe"); - }); - - it("transforms uv command", () => { - const result = JSON.parse( - transform(makeConfig({ server: { command: "uv", args: ["run", "mcp"] } })) - ); - expect(result.mcpServers.server.command).toBe("uv.exe"); - expect(result.mcpServers.server.args).toEqual(["run", "mcp"]); - }); - - it("leaves node command unchanged", () => { - const result = JSON.parse( - transform(makeConfig({ server: { command: "node", args: ["server.js"] } })) - ); - expect(result.mcpServers.server.command).toBe("node"); - }); - - it("leaves docker command unchanged", () => { - const result = JSON.parse( - transform(makeConfig({ server: { command: "docker", args: ["run", "img"] } })) - ); - expect(result.mcpServers.server.command).toBe("docker"); - }); - - it("leaves http server entries unchanged", () => { - const result = JSON.parse( - transform(makeConfig({ server: { url: "http://localhost:3000" } })) - ); - expect(result.mcpServers.server).toEqual({ url: "http://localhost:3000" }); - }); - - it("handles empty mcpServers", () => { - const result = JSON.parse(transform(JSON.stringify({ mcpServers: {} }))); - expect(result.mcpServers).toEqual({}); - }); - - it("throws on invalid JSON", () => { - expect(() => transform("not-json")).toThrow(); - }); - }); -}); - -// ── Helpers for domain function tests ──────────────────────────────────────── - -const hasher: Hasher = new DeterministicHasher(); - -function makeGetEntrySection( - sectionKey: string | null, - lookup: Map -): (frameworkPath: string) => string | null { - return (frameworkPath) => { - const configName = lookup.get(frameworkPath); - if (!configName) return null; - return sectionKey; - }; -} - -function makeMcpFile( - relativePath: string, - servers: Record, - frameworkPath = "config/mcp.json" -): InstallationFile { - const content = JSON.stringify({ mcpServers: servers }, null, 2); - return new InstallationFile({ - relativePath, - content, - hash: hasher.hash(content), - mergeStrategy: "framework-prime", - frameworkPath, - }); -} - -function makeRegularFile(relativePath: string): InstallationFile { - return new InstallationFile({ - relativePath, - content: "# doc", - hash: hasher.hash("# doc"), - mergeStrategy: "none", - }); -} - -const lookup = new Map([["config/mcp.json", "mcp"]]); -const mcpGetEntrySection = makeGetEntrySection("mcpServers", lookup); - -// ── extractMcpKeys ─────────────────────────────────────────────────────────── - -describe("extractMcpKeys()", () => { - it("returns server keys for MCP-capable merge files", () => { - const file = makeMcpFile(".mcp.json", { github: {}, playwright: {} }); - const result = extractMcpKeys([file], mcpGetEntrySection); - expect(result.get(".mcp.json")).toEqual(["github", "playwright"]); - }); - - it("skips regular (non-merge) files", () => { - const file = makeRegularFile("README.md"); - const result = extractMcpKeys([file], mcpGetEntrySection); - expect(result.size).toBe(0); - }); - - it("skips files whose frameworkPath is not in the lookup", () => { - const file = makeMcpFile(".mcp.json", { github: {} }, "unknown/path.json"); - const result = extractMcpKeys([file], mcpGetEntrySection); - expect(result.size).toBe(0); - }); - - it("skips files where getEntrySection returns null sectionKey", () => { - const file = makeMcpFile(".mcp.json", { github: {} }); - const result = extractMcpKeys([file], makeGetEntrySection(null, lookup)); - expect(result.size).toBe(0); - }); - - it("returns empty map when no MCP content exists", () => { - const file = makeMcpFile(".mcp.json", {}); - const result = extractMcpKeys([file], mcpGetEntrySection); - expect(result.size).toBe(0); - }); -}); - -// ── filterMcpExclusions ────────────────────────────────────────────────────── - -describe("filterMcpExclusions()", () => { - it("removes excluded server keys from file content", () => { - const file = makeMcpFile(".mcp.json", { github: {}, playwright: {} }); - const exclusions = [{ configPath: ".mcp.json", entryKey: "github" }]; - const result = filterMcpExclusions([file], mcpGetEntrySection, exclusions, hasher); - const parsed = JSON.parse(result[0].content) as { mcpServers: Record }; - expect(Object.keys(parsed.mcpServers)).toEqual(["playwright"]); - }); - - it("returns the original array reference when exclusions is empty", () => { - const file = makeMcpFile(".mcp.json", { github: {} }); - const input = [file]; - const result = filterMcpExclusions(input, mcpGetEntrySection, [], hasher); - expect(result).toBe(input); - }); - - it("passes through regular files untouched", () => { - const regular = makeRegularFile("README.md"); - const exclusions = [{ configPath: "README.md", entryKey: "anything" }]; - const result = filterMcpExclusions([regular], mcpGetEntrySection, exclusions, hasher); - expect(result[0]).toBe(regular); - }); - - it("passes through MCP files with no matching exclusions", () => { - const file = makeMcpFile(".mcp.json", { github: {}, playwright: {} }); - const exclusions = [{ configPath: ".cursor/mcp.json", entryKey: "github" }]; - const result = filterMcpExclusions([file], mcpGetEntrySection, exclusions, hasher); - expect(result[0].content).toBe(file.content); - }); -}); - -// ── computeMcpExclusions ───────────────────────────────────────────────────── - -describe("computeMcpExclusions()", () => { - it("returns entries not present in selectedKeys", () => { - const file = makeMcpFile(".mcp.json", { github: {}, playwright: {} }); - const selected = new Set(["playwright"]); - const result = computeMcpExclusions([file], mcpGetEntrySection, selected); - expect(result).toEqual([{ configPath: ".mcp.json", entryKey: "github" }]); - }); - - it("returns empty when all keys are selected", () => { - const file = makeMcpFile(".mcp.json", { github: {}, playwright: {} }); - const selected = new Set(["github", "playwright"]); - const result = computeMcpExclusions([file], mcpGetEntrySection, selected); - expect(result).toHaveLength(0); - }); - - it("returns all entries when selectedKeys is empty", () => { - const file = makeMcpFile(".mcp.json", { github: {}, playwright: {} }); - const result = computeMcpExclusions([file], mcpGetEntrySection, new Set()); - expect(result).toHaveLength(2); - }); -}); - -// ── detectNewMcpEntries ────────────────────────────────────────────────────── - -describe("detectNewMcpEntries()", () => { - const knownEntry: MergeFileEntry = { - relativePath: ".mcp.json", - sectionKey: "mcpServers", - entries: { github: hasher.hash("github") }, - }; - - it("detects entries in distribution not tracked in manifest", () => { - const file = makeMcpFile(".mcp.json", { github: {}, playwright: {} }); - const result = detectNewMcpEntries([file], mcpGetEntrySection, [knownEntry], []); - expect(result).toEqual([{ configPath: ".mcp.json", entryKey: "playwright" }]); - }); - - it("returns empty when all distribution entries are already known", () => { - const file = makeMcpFile(".mcp.json", { github: {} }); - const result = detectNewMcpEntries([file], mcpGetEntrySection, [knownEntry], []); - expect(result).toHaveLength(0); - }); - - it("skips entries that are already in excluded list", () => { - const file = makeMcpFile(".mcp.json", { github: {}, playwright: {} }); - const excluded = [{ configPath: ".mcp.json", entryKey: "playwright" }]; - const result = detectNewMcpEntries([file], mcpGetEntrySection, [knownEntry], excluded); - expect(result).toHaveLength(0); - }); - - it("treats all entries as new when manifest has no entry for this file", () => { - const file = makeMcpFile(".mcp.json", { github: {}, playwright: {} }); - const result = detectNewMcpEntries([file], mcpGetEntrySection, [], []); - expect(result).toHaveLength(2); - }); -}); diff --git a/cli/tests/domain/models/merge-entry.unit.test.ts b/cli/tests/domain/models/merge-entry.unit.test.ts deleted file mode 100644 index 2f1206db3..000000000 --- a/cli/tests/domain/models/merge-entry.unit.test.ts +++ /dev/null @@ -1,292 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { InstallationFile } from "../../../src/domain/models/file.js"; -import { - buildMergeFileEntries, - extractMergeEntries, - hashJsonEntries, - parseEntryKeys, - removeEntriesFromJson, -} from "../../../src/domain/models/merge.js"; -import type { Hasher } from "../../../src/domain/ports/hasher.js"; -import { HasherAdapter } from "../../../src/infrastructure/adapters/hasher-adapter.js"; - -const hasher: Hasher = new HasherAdapter(); - -describe("extractMergeEntries", () => { - describe("with section key", () => { - it("extracts per-entry hashes from a nested section", () => { - const json = JSON.stringify({ - mcpServers: { - playwright: { command: "npx", args: ["-y", "playwright-mcp"] }, - github: { command: "gh", args: ["mcp"] }, - }, - }); - const entries = extractMergeEntries(json, "mcpServers", hasher); - expect(Object.keys(entries)).toEqual(["playwright", "github"]); - expect(entries.playwright.value).toBe( - hasher.hash(JSON.stringify({ command: "npx", args: ["-y", "playwright-mcp"] })).value - ); - expect(entries.github.value).toBe( - hasher.hash(JSON.stringify({ command: "gh", args: ["mcp"] })).value - ); - }); - - it("returns empty map when section key is missing", () => { - const json = JSON.stringify({ other: {} }); - const entries = extractMergeEntries(json, "mcpServers", hasher); - expect(entries).toEqual({}); - }); - - it("returns empty map when section is not an object", () => { - const json = JSON.stringify({ mcpServers: "not an object" }); - const entries = extractMergeEntries(json, "mcpServers", hasher); - expect(entries).toEqual({}); - }); - }); - - describe("without section key (top-level)", () => { - it("extracts per-entry hashes from top-level keys", () => { - const json = JSON.stringify({ - "editor.formatOnSave": true, - "editor.tabSize": 2, - }); - const entries = extractMergeEntries(json, null, hasher); - expect(Object.keys(entries)).toEqual(["editor.formatOnSave", "editor.tabSize"]); - expect(entries["editor.formatOnSave"].value).toBe(hasher.hash(JSON.stringify(true)).value); - }); - }); - - describe("edge cases", () => { - it("returns empty map for empty JSON object", () => { - const entries = extractMergeEntries("{}", "mcpServers", hasher); - expect(entries).toEqual({}); - }); - - it("returns empty map for empty section", () => { - const json = JSON.stringify({ mcpServers: {} }); - const entries = extractMergeEntries(json, "mcpServers", hasher); - expect(entries).toEqual({}); - }); - - it("returns empty map for empty top-level object without section key", () => { - const entries = extractMergeEntries("{}", null, hasher); - expect(entries).toEqual({}); - }); - - it("returns empty map when section value is an array", () => { - const json = JSON.stringify({ mcpServers: [1, 2, 3] }); - const entries = extractMergeEntries(json, "mcpServers", hasher); - expect(entries).toEqual({}); - }); - - it("returns empty map for malformed JSON", () => { - const entries = extractMergeEntries("not valid json {{{", "mcpServers", hasher); - expect(entries).toEqual({}); - }); - - it("handles JSONC content with comments and trailing commas", () => { - const jsonc = `{ - // line comment - "mcpServers": { - /** block comment **/ - "playwright": { "command": "npx", "args": ["-y", "pkg"] }, - } - }`; - const entries = extractMergeEntries(jsonc, "mcpServers", hasher); - expect(Object.keys(entries)).toEqual(["playwright"]); - }); - - it("produces deterministic hashes for identical values", () => { - const json = JSON.stringify({ - mcpServers: { - a: { command: "npx", args: ["-y", "pkg"] }, - b: { command: "npx", args: ["-y", "pkg"] }, - }, - }); - const entries = extractMergeEntries(json, "mcpServers", hasher); - expect(entries.a.value).toBe(entries.b.value); - }); - }); -}); - -describe("parseEntryKeys", () => { - it("extracts keys from a JSON section", () => { - const json = JSON.stringify({ mcpServers: { playwright: {}, github: {} } }); - expect(parseEntryKeys(json, "mcpServers")).toEqual(["playwright", "github"]); - }); - - it("returns empty array for missing section", () => { - expect(parseEntryKeys(JSON.stringify({}), "mcpServers")).toEqual([]); - }); - - it("returns empty array for invalid JSON", () => { - expect(parseEntryKeys("not json", "mcpServers")).toEqual([]); - }); -}); - -describe("buildMergeFileEntries", () => { - function getEntrySection(frameworkPath: string): string | null { - if (frameworkPath === "config/mcp.json" || frameworkPath === "config/.opencode/opencode.json") - return "mcp"; - if (frameworkPath === "config/claude/settings.json") return "mcpServers"; - return null; - } - - it("dedups two InstallationFiles sharing relativePath and sectionKey", () => { - const mcpContent = JSON.stringify({ - mcp: { - playwright: { command: "npx", args: ["-y", "pkg"] }, - figma: { url: "https://mcp.figma.com/mcp" }, - }, - }); - const opencodeTemplateContent = JSON.stringify({ - instructions: [".opencode/rules/**/*.md"], - mcp: {}, - }); - const files = [ - new InstallationFile({ - relativePath: "opencode.json", - content: mcpContent, - hash: hasher.hash(mcpContent), - mergeStrategy: "framework-prime", - frameworkPath: "config/mcp.json", - }), - new InstallationFile({ - relativePath: "opencode.json", - content: opencodeTemplateContent, - hash: hasher.hash(opencodeTemplateContent), - mergeStrategy: "framework-prime", - frameworkPath: "config/.opencode/opencode.json", - }), - ]; - - const result = buildMergeFileEntries(files, getEntrySection, hasher); - - expect(result).toHaveLength(1); - expect(result[0].relativePath).toBe("opencode.json"); - expect(result[0].sectionKey).toBe("mcp"); - expect(Object.keys(result[0].entries)).toEqual(["playwright", "figma"]); - }); - - it("later input wins on colliding entry key", () => { - const firstContent = JSON.stringify({ mcp: { playwright: { command: "old" } } }); - const secondContent = JSON.stringify({ mcp: { playwright: { command: "new" } } }); - const files = [ - new InstallationFile({ - relativePath: "opencode.json", - content: firstContent, - hash: hasher.hash(firstContent), - mergeStrategy: "framework-prime", - frameworkPath: "config/mcp.json", - }), - new InstallationFile({ - relativePath: "opencode.json", - content: secondContent, - hash: hasher.hash(secondContent), - mergeStrategy: "framework-prime", - frameworkPath: "config/.opencode/opencode.json", - }), - ]; - - const result = buildMergeFileEntries(files, getEntrySection, hasher); - - expect(result).toHaveLength(1); - expect(result[0].entries.playwright.value).toBe( - hasher.hash(JSON.stringify({ command: "new" })).value - ); - }); - - it("keeps separate entries when relativePath differs", () => { - const mcpContent = JSON.stringify({ mcp: { playwright: { command: "npx" } } }); - const claudeContent = JSON.stringify({ mcpServers: { github: { command: "gh" } } }); - const files = [ - new InstallationFile({ - relativePath: "opencode.json", - content: mcpContent, - hash: hasher.hash(mcpContent), - mergeStrategy: "framework-prime", - frameworkPath: "config/mcp.json", - }), - new InstallationFile({ - relativePath: ".mcp.json", - content: claudeContent, - hash: hasher.hash(claudeContent), - mergeStrategy: "framework-prime", - frameworkPath: "config/claude/settings.json", - }), - ]; - - const result = buildMergeFileEntries(files, getEntrySection, hasher); - - expect(result).toHaveLength(2); - expect(result.map((e) => e.relativePath).sort()).toEqual([".mcp.json", "opencode.json"]); - }); - - it("skips files with mergeStrategy none", () => { - const files = [ - new InstallationFile({ - relativePath: ".opencode/agents/foo.md", - content: "body", - hash: hasher.hash("body"), - mergeStrategy: "none", - }), - ]; - - const result = buildMergeFileEntries(files, getEntrySection, hasher); - - expect(result).toEqual([]); - }); -}); - -describe("removeEntriesFromJson", () => { - it("removes keys from a nested section", () => { - const json = JSON.stringify({ - mcpServers: { playwright: { cmd: "npx" }, github: { cmd: "gh" } }, - }); - const result = JSON.parse(removeEntriesFromJson(json, "mcpServers", ["playwright"])); - expect(result.mcpServers).toEqual({ github: { cmd: "gh" } }); - }); - - it("removes keys from root when sectionKey is null", () => { - const json = JSON.stringify({ playwright: { cmd: "npx" }, github: { cmd: "gh" } }); - const result = JSON.parse(removeEntriesFromJson(json, null, ["playwright"])); - expect(result).toEqual({ github: { cmd: "gh" } }); - }); - - it("drops a section entirely once emptied, even alongside unrelated top-level keys", () => { - const json = JSON.stringify({ - permissions: { allow: ["Bash(ls:*)"] }, - env: { CLAUDE_CODE_ENABLE_TELEMETRY: "1" }, - }); - const result = JSON.parse(removeEntriesFromJson(json, "env", ["CLAUDE_CODE_ENABLE_TELEMETRY"])); - expect(result).toEqual({ permissions: { allow: ["Bash(ls:*)"] } }); - expect("env" in result).toBe(false); - }); - - it("keeps a section that still has entries after removal, alongside unrelated keys", () => { - const json = JSON.stringify({ - permissions: { allow: ["Bash(ls:*)"] }, - env: { CLAUDE_CODE_ENABLE_TELEMETRY: "1", MY_OWN_VAR: "keep-me" }, - }); - const result = JSON.parse(removeEntriesFromJson(json, "env", ["CLAUDE_CODE_ENABLE_TELEMETRY"])); - expect(result).toEqual({ - permissions: { allow: ["Bash(ls:*)"] }, - env: { MY_OWN_VAR: "keep-me" }, - }); - }); -}); - -describe("hashJsonEntries", () => { - it("hashes each top-level value, one entry per key", () => { - const entries = hashJsonEntries({ a: 1, b: "two" }, hasher); - expect(entries.a.value).toBe(hasher.hash(JSON.stringify(1)).value); - expect(entries.b.value).toBe(hasher.hash(JSON.stringify("two")).value); - }); - - it("backs extractMergeEntries with the same hashing logic", () => { - const json = JSON.stringify({ env: { FOO: "bar" } }); - const viaExtract = extractMergeEntries(json, "env", hasher); - const viaHash = hashJsonEntries({ FOO: "bar" }, hasher); - expect(viaExtract.FOO.value).toBe(viaHash.FOO.value); - }); -}); diff --git a/cli/tests/domain/models/paths.unit.test.ts b/cli/tests/domain/models/paths.unit.test.ts deleted file mode 100644 index 5b644c301..000000000 --- a/cli/tests/domain/models/paths.unit.test.ts +++ /dev/null @@ -1,47 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { pathContainsOrEquals, pathsOverlap } from "../../../src/domain/models/paths.js"; - -// A build refuses to write into the tree it reads from, and the cache-rebuild path takes a -// temp-dir detour when the two overlap. Both questions are asked here, so both are pinned -// here - with backslash-spelled paths, which is what a Windows run actually passes and -// what the two hardcoded "/" comparisons this replaced never recognised (#707). -describe("pathContainsOrEquals()", () => { - it("sees the same directory spelled either way", () => { - expect(pathContainsOrEquals("/a/b", "/a/b")).toBe(true); - expect(pathContainsOrEquals("C:\\a\\b", "C:\\a\\b")).toBe(true); - }); - - it("sees a directory inside another, with either separator", () => { - expect(pathContainsOrEquals("/a", "/a/b/c")).toBe(true); - expect(pathContainsOrEquals("C:\\a", "C:\\a\\b\\c")).toBe(true); - }); - - it("does not mistake a shared name prefix for containment", () => { - expect(pathContainsOrEquals("/a/build", "/a/build-cache")).toBe(false); - expect(pathContainsOrEquals("C:\\a\\build", "C:\\a\\build-cache")).toBe(false); - }); - - it("answers in one direction only", () => { - expect(pathContainsOrEquals("/a/b/c", "/a")).toBe(false); - expect(pathContainsOrEquals("C:\\a\\b\\c", "C:\\a")).toBe(false); - }); - - it("separates unrelated directories", () => { - expect(pathContainsOrEquals("/a", "/b")).toBe(false); - expect(pathContainsOrEquals("C:\\a", "D:\\a")).toBe(false); - }); -}); - -describe("pathsOverlap()", () => { - it("answers in both directions, with either separator", () => { - expect(pathsOverlap("/a", "/a/b")).toBe(true); - expect(pathsOverlap("/a/b", "/a")).toBe(true); - expect(pathsOverlap("C:\\a", "C:\\a\\b")).toBe(true); - expect(pathsOverlap("C:\\a\\b", "C:\\a")).toBe(true); - }); - - it("leaves genuinely separate trees alone", () => { - expect(pathsOverlap("/a", "/b")).toBe(false); - expect(pathsOverlap("C:\\src", "C:\\out")).toBe(false); - }); -}); diff --git a/cli/tests/domain/models/plugin-asset-translation.unit.test.ts b/cli/tests/domain/models/plugin-asset-translation.unit.test.ts deleted file mode 100644 index 11c1b417c..000000000 --- a/cli/tests/domain/models/plugin-asset-translation.unit.test.ts +++ /dev/null @@ -1,177 +0,0 @@ -import { readFileSync } from "node:fs"; -import { fileURLToPath } from "node:url"; -import { describe, expect, it } from "vitest"; -import "../../../src/domain/tools/ai/claude.js"; -import "../../../src/domain/tools/ai/codex.js"; -import "../../../src/domain/tools/ai/copilot.js"; -import "../../../src/domain/tools/ai/cursor.js"; -import "../../../src/domain/tools/ai/opencode.js"; -import { FileHash } from "../../../src/domain/models/file.js"; -import { PluginContentTranslator } from "../../../src/domain/models/plugin-content-translator.js"; -import { PluginDistribution } from "../../../src/domain/models/plugin-distribution.js"; -import { AI_TOOL_IDS } from "../../../src/domain/models/tool-ids.js"; -import { claude } from "../../../src/domain/tools/ai/claude.js"; -import { codex } from "../../../src/domain/tools/ai/codex.js"; -import { copilot } from "../../../src/domain/tools/ai/copilot.js"; -import { cursor } from "../../../src/domain/tools/ai/cursor.js"; -import { opencode } from "../../../src/domain/tools/ai/opencode.js"; -import { getAiToolConfig } from "../../../src/domain/tools/registry.js"; - -/** - * A plugin ships two kinds of file, and installing it must not confuse them. - * - * Prose — a skill, an agent, a rule — is translated: its frontmatter is converted to the - * host tool's spelling and its paths are rewritten to the host tool's directories. An - * artefact — a script a skill runs, a hook the host executes — is carried byte for byte, - * because a path rewritten inside a program is a program that no longer parses. - * - * This is not hypothetical. Measured against the shipped measurement bundle, Codex's own - * rewrite grew it by six bytes and Copilot's shrank it by one. Both would have shipped a - * broken script, silently, on install. - */ -function pluginFile(relativePath: string): string { - return readFileSync( - fileURLToPath(new URL(`../../../../plugins/aidd-telemetry/${relativePath}`, import.meta.url)), - "utf8" - ); -} - -const ARTEFACTS = [ - "hooks/journal.cjs", - "hooks/journal.cjs", - "hooks/lib/record.cjs", - "hooks/lib/repo.cjs", - "hooks/lib/file-writes.cjs", - "hooks/lib/step-starts.cjs", - "hooks/lib/host.cjs", -] as const; - -describe("a plugin's executable files survive being installed", () => { - for (const relativePath of ARTEFACTS) { - it(`${relativePath} is not what any tool's own rewrite would make of it`, () => { - const content = pluginFile(relativePath); - const rewritten = AI_TOOL_IDS.map((tool) => - getAiToolConfig(tool).rewriteContent(content, "aidd_docs") - ); - - // The rewrite is the thing the translator must not apply to this file. Where a tool's - // rewrite happens to leave it alone, that is luck; where it does not, this names it. - const damagedBy = AI_TOOL_IDS.filter((_, index) => rewritten[index] !== content); - expect( - damagedBy.length === 0 || relativePath.endsWith(".js"), - `${relativePath} is rewritten by ${damagedBy.join(", ")} and is not carried verbatim` - ).toBe(true); - }); - } -}); - -/** The decisive check: not "would a rewrite damage it", but "does installing the plugin - * actually put it there, unchanged". Everything above is a guard; this is the proof. - * - * No skill in this plugin ships a script of its own any more (00-init, 01-cost and - * 02-check all moved to `aidd`), so this borrows real bytes from a file the plugin does - * still ship — `hooks/journal.cjs` — and places them at a skill-nested path. The path is - * the fixture; the content is not, which is what tells apart "carried verbatim" from - * "happened to compare a synthetic string to itself". */ -describe("installing the plugin carries a skill's own script, on every tool", () => { - const SCRIPT = "skills/02-check/scripts/example.cjs"; - const SCRIPT_CONTENT = pluginFile("hooks/journal.cjs"); - const translator = new PluginContentTranslator({ hash: () => new FileHash("a".repeat(32)) }); - - function distributionOf(): PluginDistribution { - const skills = [ - { relativePath: "skills/02-check/SKILL.md", content: pluginFile("skills/02-check/SKILL.md") }, - { relativePath: SCRIPT, content: SCRIPT_CONTENT }, - ]; - const hooks = [ - { relativePath: "hooks/hooks.json", content: pluginFile("hooks/hooks.json") }, - { relativePath: "hooks/journal.cjs", content: pluginFile("hooks/journal.cjs") }, - ]; - return new PluginDistribution({ - manifest: { name: "aidd-telemetry", version: "0.1.0" }, - format: "claude", - files: [...skills, ...hooks], - components: { skills, commands: [], agents: [], rules: [], hooks, mcp: [] }, - }); - } - - for (const tool of [claude, codex, copilot, cursor, opencode]) { - it(`${tool.toolId} installs it byte for byte`, () => { - const installed = translator - .translate(distributionOf(), tool, "aidd_docs") - .find((file) => file.relativePath.endsWith("02-check/scripts/example.cjs")); - - expect(installed, `${tool.toolId} drops the script entirely`).toBeDefined(); - expect(installed?.content).toBe(SCRIPT_CONTENT); - }); - } - - it("still translates the prose beside it", () => { - const installed = translator - .translate(distributionOf(), claude, "aidd_docs") - .find((file) => file.relativePath.endsWith("02-check/SKILL.md")); - - // Carrying artefacts verbatim must not turn every skill into an artefact: this one - // still goes through the frontmatter conversion, so it is not byte-identical. - expect(installed?.content).not.toBe(pluginFile("skills/02-check/SKILL.md")); - expect(installed?.content).toContain("States what is in place"); - }); - - /** A script whose text that tool's own rewrite really does change. Each tool rewrites - * its own directory's paths, so the sample is built from `tool.directory` — a single - * shared sample would trip two tools of five and let the other three pass by luck, which - * is exactly what asserting on the shipped bundle alone already does. */ - function rewritableScript(directory: string): string { - return `const p = "${directory}commands/01_plan/x";\nconst q = "@${directory}commands/02_do/y";\n`; - } - - function distributionWithScript(content: string): PluginDistribution { - const skills = [ - { relativePath: "skills/02-check/SKILL.md", content: pluginFile("skills/02-check/SKILL.md") }, - { relativePath: SCRIPT, content }, - ]; - return new PluginDistribution({ - manifest: { name: "aidd-telemetry", version: "0.1.0" }, - format: "claude", - files: skills, - components: { skills, commands: [], agents: [], rules: [], hooks: [], mcp: [] }, - }); - } - - for (const tool of [claude, codex, copilot, cursor, opencode]) { - it(`${tool.toolId} leaves a script's own paths alone`, () => { - // Paths this tool's own rewrite is built to touch, in a file that is not prose. - // Whether this particular tool's rewrite would change them varies; that the guard is - // not vacuous is asserted once, below, over every tool at once. - const script = rewritableScript(tool.directory); - - const installed = translator - .translate(distributionWithScript(script), tool, "aidd_docs") - .find((file) => file.relativePath.endsWith("02-check/scripts/example.cjs")); - - expect(installed?.content).toBe(script); - }); - } - - it("carries it verbatim on a flat install too, not just a native one", () => { - // OpenCode installs flat: skills keep their sub-path but every file used to be - // rewritten on the way. The script survived there only because that tool's own rewrite - // happens to leave it alone — luck, which this pins down. - const installed = translator - .translate(distributionOf(), opencode, "aidd_docs") - .find((file) => file.relativePath.endsWith("02-check/scripts/example.cjs")); - - expect(installed, "opencode drops the script entirely").toBeDefined(); - expect(installed?.content).toBe(SCRIPT_CONTENT); - }); - it("guards against a rewrite that some tool really would apply", () => { - // Without this, every assertion above could pass over content no rewrite touches, and - // the guard would be protecting nothing while looking thorough. - const rewritten = [claude, codex, copilot, cursor, opencode].filter((tool) => { - const script = rewritableScript(tool.directory); - return tool.rewriteContent(script, "aidd_docs") !== script; - }); - - expect(rewritten.length).toBeGreaterThan(0); - }); -}); diff --git a/cli/tests/domain/models/plugin-catalog.unit.test.ts b/cli/tests/domain/models/plugin-catalog.unit.test.ts deleted file mode 100644 index 83bf56cbd..000000000 --- a/cli/tests/domain/models/plugin-catalog.unit.test.ts +++ /dev/null @@ -1,154 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { - InvalidPluginManifestError, - InvalidPluginSourceError, -} from "../../../src/domain/errors.js"; -import { - hasRelativePluginSources, - parsePluginCatalog, -} from "../../../src/domain/models/plugin-catalog.js"; - -const VALID_RAW = { - plugins: [ - { - name: "dev", - source: { kind: "local", path: "./plugins/dev" }, - description: "Dev plugin", - recommended: true, - strict: true, - }, - { - name: "pm", - source: { kind: "github", repo: "ai-driven-dev/aidd-pm" }, - description: "PM plugin", - recommended: false, - strict: false, - }, - ], -}; - -describe("hasRelativePluginSources", () => { - it("returns true for catalog with a local relative path entry", () => { - const catalog = parsePluginCatalog({ - plugins: [{ name: "x", source: { kind: "local", path: "./plugins/x" } }], - }); - expect(hasRelativePluginSources(catalog)).toBe(true); - }); - - it("returns true for mixed entries when at least one is relative local", () => { - const catalog = parsePluginCatalog({ - plugins: [ - { name: "rel", source: { kind: "local", path: "./plugins/rel" } }, - { name: "abs", source: { kind: "github", repo: "owner/repo" } }, - ], - }); - expect(hasRelativePluginSources(catalog)).toBe(true); - }); - - it("returns false for catalog with only github entries", () => { - const catalog = parsePluginCatalog({ - plugins: [{ name: "g", source: { kind: "github", repo: "owner/repo" } }], - }); - expect(hasRelativePluginSources(catalog)).toBe(false); - }); - - it("returns false for catalog with local absolute paths only", () => { - const catalog = parsePluginCatalog({ - plugins: [{ name: "abs", source: { kind: "local", path: "/absolute/path" } }], - }); - expect(hasRelativePluginSources(catalog)).toBe(false); - }); - - it("returns false for empty plugins array", () => { - const catalog = parsePluginCatalog({ plugins: [] }); - expect(hasRelativePluginSources(catalog)).toBe(false); - }); -}); - -describe("parsePluginCatalog", () => { - describe("valid input", () => { - it("parses two entries from valid fixture", () => { - const catalog = parsePluginCatalog(VALID_RAW); - expect(catalog.plugins).toHaveLength(2); - }); - - it("parses name and source for each entry", () => { - const catalog = parsePluginCatalog(VALID_RAW); - expect(catalog.plugins[0].name).toBe("dev"); - expect(catalog.plugins[0].source).toEqual({ kind: "local", path: "./plugins/dev" }); - expect(catalog.plugins[1].name).toBe("pm"); - expect(catalog.plugins[1].source).toEqual({ kind: "github", repo: "ai-driven-dev/aidd-pm" }); - }); - - it("preserves recommended and strict values", () => { - const catalog = parsePluginCatalog(VALID_RAW); - expect(catalog.plugins[0].recommended).toBe(true); - expect(catalog.plugins[0].strict).toBe(true); - expect(catalog.plugins[1].recommended).toBe(false); - expect(catalog.plugins[1].strict).toBe(false); - }); - - it("defaults recommended to false when absent", () => { - const raw = { plugins: [{ name: "x", source: { kind: "local", path: "./x" } }] }; - const catalog = parsePluginCatalog(raw); - expect(catalog.plugins[0].recommended).toBe(false); - }); - - it("defaults strict to false when absent", () => { - const raw = { plugins: [{ name: "x", source: { kind: "local", path: "./x" } }] }; - const catalog = parsePluginCatalog(raw); - expect(catalog.plugins[0].strict).toBe(false); - }); - - it("includes optional description when present", () => { - const catalog = parsePluginCatalog(VALID_RAW); - expect(catalog.plugins[0].description).toBe("Dev plugin"); - }); - - it("omits description when absent", () => { - const raw = { plugins: [{ name: "x", source: { kind: "local", path: "./x" } }] }; - const catalog = parsePluginCatalog(raw); - expect(catalog.plugins[0].description).toBeUndefined(); - }); - }); - - describe("missing source field", () => { - it("throws InvalidPluginManifestError", () => { - const raw = { plugins: [{ name: "x" }] }; - expect(() => parsePluginCatalog(raw)).toThrow(InvalidPluginManifestError); - }); - }); - - describe("malformed source", () => { - it("throws InvalidPluginSourceError for unknown kind", () => { - const raw = { plugins: [{ name: "x", source: { kind: "svn" } }] }; - expect(() => parsePluginCatalog(raw)).toThrow(InvalidPluginSourceError); - }); - }); - - describe("invalid top-level structure", () => { - it("throws when plugins is not an array", () => { - expect(() => parsePluginCatalog({ plugins: "not-array" })).toThrow( - InvalidPluginManifestError - ); - }); - - it("throws when input is null", () => { - expect(() => parsePluginCatalog(null)).toThrow(InvalidPluginManifestError); - }); - - it("throws when input is an array", () => { - expect(() => parsePluginCatalog([])).toThrow(InvalidPluginManifestError); - }); - - it("throws when name is missing", () => { - const raw = { plugins: [{ source: { kind: "local", path: "./x" } }] }; - expect(() => parsePluginCatalog(raw)).toThrow(InvalidPluginManifestError); - }); - - it("throws when name is empty string", () => { - const raw = { plugins: [{ name: "", source: { kind: "local", path: "./x" } }] }; - expect(() => parsePluginCatalog(raw)).toThrow(InvalidPluginManifestError); - }); - }); -}); diff --git a/cli/tests/domain/models/plugin-component-kind.unit.test.ts b/cli/tests/domain/models/plugin-component-kind.unit.test.ts deleted file mode 100644 index 13df8b6f8..000000000 --- a/cli/tests/domain/models/plugin-component-kind.unit.test.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { InvalidPluginComponentKindError } from "../../../src/domain/errors.js"; -import { parsePluginComponentKind } from "../../../src/domain/models/plugin-component-kind.js"; - -describe("parsePluginComponentKind", () => { - it("accepts all valid kinds", () => { - const kinds = ["skills", "agents", "hooks", "mcp", "full"] as const; - for (const kind of kinds) { - expect(parsePluginComponentKind(kind)).toBe(kind); - } - }); - - it("throws InvalidPluginComponentKindError for unknown string", () => { - expect(() => parsePluginComponentKind("unknown")).toThrow(InvalidPluginComponentKindError); - }); - - it("throws InvalidPluginComponentKindError for empty string", () => { - expect(() => parsePluginComponentKind("")).toThrow(InvalidPluginComponentKindError); - }); - - it("throws for uppercase variant", () => { - expect(() => parsePluginComponentKind("Full")).toThrow(InvalidPluginComponentKindError); - }); -}); diff --git a/cli/tests/domain/models/plugin-content-translator-notice.unit.test.ts b/cli/tests/domain/models/plugin-content-translator-notice.unit.test.ts deleted file mode 100644 index af76f65b1..000000000 --- a/cli/tests/domain/models/plugin-content-translator-notice.unit.test.ts +++ /dev/null @@ -1,63 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { FileHash } from "../../../src/domain/models/file.js"; -import { PluginContentTranslator } from "../../../src/domain/models/plugin-content-translator.js"; -import { PluginDistribution } from "../../../src/domain/models/plugin-distribution.js"; -import { codex } from "../../../src/domain/tools/ai/codex.js"; -import { cursor } from "../../../src/domain/tools/ai/cursor.js"; -import { opencode } from "../../../src/domain/tools/ai/opencode.js"; - -const stubHasher = { hash: (_content: string) => new FileHash("a".repeat(32)) }; -const translator = new PluginContentTranslator(stubHasher); - -const HOOKS_CONTENT = JSON.stringify({ - hooks: { SessionStart: [{ hooks: [{ type: "command", command: "node ./hooks/start.js" }] }] }, -}); - -function buildDist(hasHooks: boolean, name = "test-plugin"): PluginDistribution { - const hooksFile = { relativePath: "hooks/hooks.json", content: HOOKS_CONTENT }; - return new PluginDistribution({ - manifest: { name, version: "1.0.0" }, - format: "claude", - files: hasHooks ? [hooksFile] : [], - components: { - commands: [], - agents: [], - rules: [], - skills: [], - hooks: hasHooks ? [hooksFile] : [], - mcp: [], - }, - }); -} - -describe("PluginContentTranslator hook trust notice", () => { - it("names what Codex still requires when the plugin actually delivers a hook", () => { - const result = translator.translateWithComponentPaths(buildDist(true), codex, "docs"); - - expect(result.notices).toHaveLength(1); - expect(result.notices[0]).toMatchObject({ - pluginName: "test-plugin", - component: "hooks", - toolId: "codex", - message: codex.capabilities.plugins.hooksTrustNotice, - }); - }); - - it("says nothing when the plugin delivers no hook, even for a gated tool", () => { - const result = translator.translateWithComponentPaths(buildDist(false), codex, "docs"); - - expect(result.notices).toEqual([]); - }); - - it("says nothing for a tool that runs a delivered hook with no trust gate", () => { - const result = translator.translateWithComponentPaths(buildDist(true), cursor, "docs"); - - expect(result.notices).toEqual([]); - }); - - it("says nothing in flat mode, where a delivered hook is never native-materialized", () => { - const result = translator.translateWithComponentPaths(buildDist(true), opencode, "docs"); - - expect(result.notices).toEqual([]); - }); -}); diff --git a/cli/tests/domain/models/plugin-content-translator-skip.unit.test.ts b/cli/tests/domain/models/plugin-content-translator-skip.unit.test.ts deleted file mode 100644 index 92a83435c..000000000 --- a/cli/tests/domain/models/plugin-content-translator-skip.unit.test.ts +++ /dev/null @@ -1,93 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { FileHash } from "../../../src/domain/models/file.js"; -import { PluginContentTranslator } from "../../../src/domain/models/plugin-content-translator.js"; -import { PluginDistribution } from "../../../src/domain/models/plugin-distribution.js"; -import { cursor } from "../../../src/domain/tools/ai/cursor.js"; -import { opencode } from "../../../src/domain/tools/ai/opencode.js"; - -const stubHasher = { hash: (_content: string) => new FileHash("a".repeat(32)) }; -const translator = new PluginContentTranslator(stubHasher); - -const HOOKS_CONTENT = JSON.stringify({ - hooks: { PreToolUse: [{ hooks: [{ type: "command", command: "node ./hooks/pre.js" }] }] }, -}); - -function buildDistWithNoHooksMcp(name = "test-plugin"): PluginDistribution { - return new PluginDistribution({ - manifest: { name, version: "1.0.0" }, - format: "claude", - files: [ - { relativePath: "commands/greet.md", content: "---\nname: aidd:01:greet\n---\n# Greet" }, - ], - components: { - commands: [ - { relativePath: "commands/greet.md", content: "---\nname: aidd:01:greet\n---\n# Greet" }, - ], - agents: [], - rules: [], - skills: [], - hooks: [], - mcp: [], - }, - }); -} - -function buildDistWithHooks(name = "test-plugin"): PluginDistribution { - return new PluginDistribution({ - manifest: { name, version: "1.0.0" }, - format: "claude", - files: [ - { relativePath: "hooks/hooks.json", content: HOOKS_CONTENT }, - { relativePath: "hooks/pre.js", content: "module.exports = () => {};" }, - ], - components: { - commands: [], - agents: [], - rules: [], - skills: [], - hooks: [ - { relativePath: "hooks/hooks.json", content: HOOKS_CONTENT }, - { relativePath: "hooks/pre.js", content: "module.exports = () => {};" }, - ], - mcp: [], - }, - }); -} - -describe("PluginContentTranslator skip list", () => { - describe("flat mode (opencode)", () => { - it("returns empty skipped list when plugin has no hooks or mcp", () => { - const dist = buildDistWithNoHooksMcp(); - const result = translator.translateWithComponentPaths(dist, opencode, "docs"); - expect(result.skipped).toEqual([]); - }); - - it("returns no skip entry when plugin has hooks — OpenCode now accepts them", () => { - const dist = buildDistWithHooks("aidd-pm"); - const result = translator.translateWithComponentPaths(dist, opencode, "docs"); - expect(result.skipped).toEqual([]); - }); - - it("delivers every hooks/ file but hooks.json under the tool's flatHooksDir", () => { - const dist = buildDistWithHooks("aidd-pm"); - const result = translator.translateWithComponentPaths(dist, opencode, "docs"); - const paths = result.files.map((f) => f.relativePath); - expect(paths).toContain(".opencode/plugin/pre.js"); - expect(paths).not.toContain(".opencode/plugin/hooks.json"); - }); - }); - - describe("native mode (cursor)", () => { - it("returns empty skipped list when plugin has no hooks or mcp", () => { - const dist = buildDistWithNoHooksMcp(); - const result = translator.translateWithComponentPaths(dist, cursor, "docs"); - expect(result.skipped).toEqual([]); - }); - - it("returns empty skipped list when plugin has hooks (cursor acceptsHooks: true)", () => { - const dist = buildDistWithHooks("test-plugin"); - const result = translator.translateWithComponentPaths(dist, cursor, "docs"); - expect(result.skipped).toEqual([]); - }); - }); -}); diff --git a/cli/tests/domain/models/plugin-content-translator.unit.test.ts b/cli/tests/domain/models/plugin-content-translator.unit.test.ts deleted file mode 100644 index d621271f7..000000000 --- a/cli/tests/domain/models/plugin-content-translator.unit.test.ts +++ /dev/null @@ -1,331 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { FileHash } from "../../../src/domain/models/file.js"; -import { PluginContentTranslator } from "../../../src/domain/models/plugin-content-translator.js"; -import { - type PluginComponentFile, - PluginDistribution, -} from "../../../src/domain/models/plugin-distribution.js"; -import { claude } from "../../../src/domain/tools/ai/claude.js"; -import { codex } from "../../../src/domain/tools/ai/codex.js"; -import { copilot } from "../../../src/domain/tools/ai/copilot.js"; -import { cursor } from "../../../src/domain/tools/ai/cursor.js"; -import { opencode } from "../../../src/domain/tools/ai/opencode.js"; -import { vscodeToolConfig } from "../../../src/domain/tools/ide/vscode.js"; -import type { ToolConfig } from "../../../src/domain/tools/registry.js"; - -const stubHasher = { hash: (_content: string) => new FileHash("a".repeat(32)) }; -const translator = new PluginContentTranslator(stubHasher); - -const greetContent = `--- -name: aidd:04:greet -description: Greet command ---- - -Greet from sample-plugin. -`; - -const skillContent = `--- -name: hello -description: Hello skill ---- - -Hello from sample-plugin skill. -`; - -const agentContent = `--- -name: reviewer -description: Reviewer agent ---- - -Reviewer agent from sample-plugin. -`; - -const ruleContent = `--- -description: Coding standards rule -paths: - - "**/*.ts" ---- - -Use strict types. -`; - -const hooksJsonContent = `{ "hooks": [] }`; -const mcpJsonContent = `{ "mcpServers": {} }`; -const claudeManifestContent = `{ "name": "sample-plugin", "version": "1.0.0" }`; - -function makeFile(relativePath: string, content: string): PluginComponentFile { - return { relativePath, content }; -} - -function makeDist( - overrides: Partial[0]> = {} -): PluginDistribution { - const commands = [makeFile("commands/greet.md", greetContent)]; - const skills = [makeFile("skills/hello/SKILL.md", skillContent)]; - const agents = [makeFile("agents/reviewer.md", agentContent)]; - const rules = [makeFile("rules/standards.md", ruleContent)]; - const hooks = [makeFile("hooks/hooks.json", hooksJsonContent)]; - const mcp = [makeFile(".mcp.json", mcpJsonContent)]; - const manifest = makeFile(".claude-plugin/plugin.json", claudeManifestContent); - return new PluginDistribution({ - manifest: { name: "sample-plugin", version: "1.0.0" }, - format: "claude", - files: [...skills, ...commands, ...agents, ...rules, ...hooks, ...mcp, manifest], - components: { skills, commands, agents, rules, hooks, mcp }, - ...overrides, - }); -} - -function pathsFor(tool: ToolConfig, dist = makeDist()): string[] { - return translator.translate(dist, tool, "").map((f) => f.relativePath); -} - -describe("PluginContentTranslator.translate()", () => { - describe("claude target", () => { - it("emits all components claude supports under .claude/plugins/sample-plugin/", () => { - const paths = pathsFor(claude); - expect(paths).toContain(".claude/plugins/sample-plugin/commands/greet.md"); - expect(paths).toContain(".claude/plugins/sample-plugin/agents/reviewer.md"); - expect(paths).toContain(".claude/plugins/sample-plugin/skills/hello/SKILL.md"); - expect(paths).toContain(".claude/plugins/sample-plugin/rules/standards.md"); - expect(paths).toContain(".claude/plugins/sample-plugin/hooks/hooks.json"); - expect(paths).toContain(".claude/plugins/sample-plugin/.mcp.json"); - }); - - it("emits native plugin manifest at plugin.json", () => { - const files = translator.translate(makeDist(), claude, ""); - const manifest = files.find( - (f) => f.relativePath === ".claude/plugins/sample-plugin/plugin.json" - ); - expect(manifest).toBeDefined(); - expect(manifest?.content).toContain("sample-plugin"); - }); - - it("emits hooks companion scripts alongside hooks.json", () => { - const scriptFile = makeFile("hooks/update_memory.js", "console.log('updated');"); - const hooksFiles = [makeFile("hooks/hooks.json", hooksJsonContent), scriptFile]; - const dist = makeDist({ - files: [ - makeFile("skills/hello/SKILL.md", skillContent), - makeFile("commands/greet.md", greetContent), - makeFile("agents/reviewer.md", agentContent), - makeFile("rules/standards.md", ruleContent), - ...hooksFiles, - makeFile(".mcp.json", mcpJsonContent), - makeFile(".claude-plugin/plugin.json", claudeManifestContent), - ], - components: { - skills: [makeFile("skills/hello/SKILL.md", skillContent)], - commands: [makeFile("commands/greet.md", greetContent)], - agents: [makeFile("agents/reviewer.md", agentContent)], - rules: [makeFile("rules/standards.md", ruleContent)], - hooks: hooksFiles, - mcp: [makeFile(".mcp.json", mcpJsonContent)], - }, - }); - const paths = pathsFor(claude, dist); - expect(paths).toContain(".claude/plugins/sample-plugin/hooks/hooks.json"); - expect(paths).toContain(".claude/plugins/sample-plugin/hooks/update_memory.js"); - }); - - it("keeps a hook script's own directories, which its requires resolve against", () => { - const hooksFiles = [ - makeFile("hooks/hooks.json", hooksJsonContent), - makeFile("hooks/journal.cjs", 'require("./lib/repo.js");'), - makeFile("hooks/lib/repo.cjs", "module.exports = {};"), - ]; - const dist = makeDist({ - files: [...hooksFiles, makeFile(".claude-plugin/plugin.json", claudeManifestContent)], - components: { - skills: [], - commands: [], - agents: [], - rules: [], - hooks: hooksFiles, - mcp: [], - }, - }); - const paths = pathsFor(claude, dist); - expect(paths).toContain(".claude/plugins/sample-plugin/hooks/journal.cjs"); - expect(paths).toContain(".claude/plugins/sample-plugin/hooks/lib/repo.cjs"); - expect(paths).not.toContain(".claude/plugins/sample-plugin/hooks/repo.cjs"); - }); - }); - - describe("cursor target (Mode B — user-scope flat materialization)", () => { - it("emits rules with .mdc extension under plugin-name-prefixed path", () => { - expect(pathsFor(cursor)).toContain("sample-plugin/rules/standards.mdc"); - }); - - it("emits cursor-format frontmatter on rules (globs key)", () => { - const files = translator.translate(makeDist(), cursor, ""); - const rule = files.find((f) => f.relativePath.endsWith("standards.mdc")); - expect(rule?.content).toContain("globs:"); - }); - - it("does not emit plugin.json (pluginManifestRelativePath is null)", () => { - const files = translator.translate(makeDist(), cursor, ""); - const manifest = files.find((f) => f.relativePath.endsWith("plugin.json")); - expect(manifest).toBeUndefined(); - }); - - it("does not emit hooks (acceptsHooks is false)", () => { - expect(pathsFor(cursor)).not.toContain(expect.stringContaining("hooks/hooks.json")); - }); - - it("does not emit mcp (acceptsMcp is false)", () => { - expect(pathsFor(cursor)).not.toContain(expect.stringContaining("mcp.json")); - }); - - it("emits commands under plugin-name-prefixed path", () => { - // greet.md → buildInstallPath yields ".cursor/commands/aidd/greet.md" - // toPluginRelativePath strips ".cursor/" then removes /aidd/ → "commands/greet.md" - // pluginRoot prepend → "sample-plugin/commands/greet.md" - expect(pathsFor(cursor)).toContain("sample-plugin/commands/greet.md"); - }); - - it("file paths are base-relative (no .cursor/ prefix — base resolved at install time)", () => { - const paths = pathsFor(cursor); - expect(paths.every((p) => !p.startsWith(".cursor/"))).toBe(true); - }); - }); - - describe("codex target", () => { - it("emits agents as TOML", () => { - expect(pathsFor(codex)).toContain(".codex/plugins/sample-plugin/agents/reviewer.toml"); - }); - - it("agent content is TOML format", () => { - const files = translator.translate(makeDist(), codex, ""); - const agent = files.find((f) => f.relativePath.endsWith("reviewer.toml")); - expect(agent?.content).toContain("name ="); - expect(agent?.content).toContain("description ="); - expect(agent?.content).toContain("developer_instructions ="); - }); - - it("emits native plugin manifest at plugin.json", () => { - const files = translator.translate(makeDist(), codex, ""); - const manifest = files.find( - (f) => f.relativePath === ".codex/plugins/sample-plugin/plugin.json" - ); - expect(manifest).toBeDefined(); - }); - }); - - describe("copilot target", () => { - it("emits commands as prompts with .prompt.md extension", () => { - expect(pathsFor(copilot)).toContain(".github/plugins/sample-plugin/prompts/greet.prompt.md"); - }); - - it("emits agents with .agent.md extension", () => { - expect(pathsFor(copilot)).toContain(".github/plugins/sample-plugin/agents/reviewer.agent.md"); - }); - - it("emits rules as instructions with .instructions.md extension", () => { - expect(pathsFor(copilot)).toContain( - ".github/plugins/sample-plugin/instructions/standards.instructions.md" - ); - }); - }); - - describe("opencode target (flat mode)", () => { - it("emits commands under .opencode/commands/sample-plugin/ with name prefix", () => { - const files = translator.translate(makeDist(), opencode, ""); - const greet = files.find( - (f) => f.relativePath === ".opencode/commands/sample-plugin/greet.md" - ); - expect(greet).toBeDefined(); - expect(greet?.content).toContain("name: 'aidd-sample-plugin:greet'"); - }); - - it("emits agents under .opencode/agents/sample-plugin/", () => { - expect(pathsFor(opencode)).toContain(".opencode/agents/sample-plugin/reviewer.md"); - }); - - it("emits skills under .opencode/skills/sample-plugin/", () => { - expect(pathsFor(opencode)).toContain(".opencode/skills/sample-plugin/hello/SKILL.md"); - }); - - it("emits rules under .opencode/rules/sample-plugin/", () => { - expect(pathsFor(opencode)).toContain(".opencode/rules/sample-plugin/standards.md"); - }); - }); - - describe("vscode (IDE tool)", () => { - it("returns empty array", () => { - expect(translator.translate(makeDist(), vscodeToolConfig, "")).toEqual([]); - }); - }); -}); - -describe("cross-format matrix (source × target)", () => { - const sourceFormats = [ - { format: "claude" as const, manifestPath: ".claude-plugin/plugin.json" }, - { format: "cursor" as const, manifestPath: ".cursor-plugin/plugin.json" }, - { format: "codex" as const, manifestPath: ".codex-plugin/plugin.json" }, - { format: "copilot" as const, manifestPath: "plugin.json" }, - ]; - - const targets = [ - { name: "claude", tool: claude, manifestExpected: "plugin.json" }, - { name: "cursor", tool: cursor, manifestExpected: "plugin.json" }, - { name: "codex", tool: codex, manifestExpected: "plugin.json" }, - { name: "copilot", tool: copilot, manifestExpected: "plugin.json" }, - ]; - - function makeSourceDist(format: (typeof sourceFormats)[number]): PluginDistribution { - const commands = [makeFile("commands/greet.md", greetContent)]; - const agents = [makeFile("agents/reviewer.md", agentContent)]; - const skills = [makeFile("skills/hello/SKILL.md", skillContent)]; - const manifest = makeFile(format.manifestPath, claudeManifestContent); - return new PluginDistribution({ - manifest: { name: "sample-plugin", version: "1.0.0" }, - format: format.format, - files: [...commands, ...agents, ...skills, manifest], - components: { commands, agents, skills, rules: [], hooks: [], mcp: [] }, - }); - } - - for (const source of sourceFormats) { - for (const target of targets) { - if (target.name === "cursor") { - // Cursor Mode B: pluginManifestRelativePath is null — no manifest file written into plugin dir. - it(`${source.format} source → ${target.name} target: does not emit manifest (Mode B, null pluginManifestRelativePath)`, () => { - const dist = makeSourceDist(source); - const files = translator.translate(dist, target.tool, ""); - expect(files.map((f) => f.relativePath)).not.toContain( - expect.stringMatching(/plugin\.json$/) - ); - }); - } else { - it(`${source.format} source → ${target.name} target: emits manifest at ${target.manifestExpected}`, () => { - const dist = makeSourceDist(source); - const files = translator.translate(dist, target.tool, ""); - const expected = `${target.tool.capabilities.plugins.pluginsDir}sample-plugin/${target.manifestExpected}`; - expect(files.map((f) => f.relativePath)).toContain(expected); - }); - } - } - } -}); - -describe("PluginContentTranslator.detectFlatCollisions()", () => { - it("reports no collision when plugins use different plugin names", () => { - const dist1 = makeDist({ manifest: { name: "plugin-a", version: "1.0.0" } }); - const dist2 = makeDist({ manifest: { name: "plugin-b", version: "1.0.0" } }); - const collisions = translator.detectFlatCollisions([dist1, dist2], opencode); - expect(collisions).toEqual([]); - }); - - it("reports collisions when same plugin name is used twice", () => { - const dist1 = makeDist({ manifest: { name: "same-plugin", version: "1.0.0" } }); - const dist2 = makeDist({ manifest: { name: "same-plugin", version: "2.0.0" } }); - const collisions = translator.detectFlatCollisions([dist1, dist2], opencode); - expect(collisions.length).toBeGreaterThan(0); - expect(collisions[0].plugin).toBe("same-plugin"); - }); - - it("returns empty array for native-mode tools", () => { - expect(translator.detectFlatCollisions([makeDist()], claude)).toEqual([]); - }); -}); diff --git a/cli/tests/domain/models/plugin-hooks-install.unit.test.ts b/cli/tests/domain/models/plugin-hooks-install.unit.test.ts deleted file mode 100644 index e00d5c51b..000000000 --- a/cli/tests/domain/models/plugin-hooks-install.unit.test.ts +++ /dev/null @@ -1,134 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { FileHash } from "../../../src/domain/models/file.js"; -import { PluginContentTranslator } from "../../../src/domain/models/plugin-content-translator.js"; -import { PluginDistribution } from "../../../src/domain/models/plugin-distribution.js"; -import { claude } from "../../../src/domain/tools/ai/claude.js"; -import { codex } from "../../../src/domain/tools/ai/codex.js"; -import { copilot } from "../../../src/domain/tools/ai/copilot.js"; -import { cursor } from "../../../src/domain/tools/ai/cursor.js"; -import { opencode } from "../../../src/domain/tools/ai/opencode.js"; -import type { AiTool, HasPlugins } from "../../../src/domain/tools/contracts.js"; - -/** - * A hook that arrives is not a hook that runs. Each of these installs a plugin whose hook - * names the plugin root and whose script sits beside it, then reads what landed — because - * every failure this covers installed cleanly and did nothing. - */ - -const stubHasher = { hash: (_content: string) => new FileHash("a".repeat(32)) }; -const translator = new PluginContentTranslator(stubHasher); - -const SOURCE_TOKEN = claude.capabilities.plugins.pluginRootToken ?? ""; -const HOOK_COMMAND = `node ${SOURCE_TOKEN}/hooks/journal.cjs session-start`; -const SCRIPT = `#!/usr/bin/env node\n// carries ${SOURCE_TOKEN} in a comment\n`; -const HOOKS_JSON = JSON.stringify({ - hooks: { SessionStart: [{ hooks: [{ type: "command", command: HOOK_COMMAND }] }] }, -}); - -const HOOK_HOSTS: ReadonlyArray> = [claude, cursor, copilot, codex]; - -const MCP_JSON = JSON.stringify({ - mcpServers: { local: { command: `${SOURCE_TOKEN}/bin/server.js`, args: [] } }, -}); - -function pluginWithHookAndScript(): PluginDistribution { - const hooks = [ - { relativePath: "hooks/hooks.json", content: HOOKS_JSON }, - { relativePath: "hooks/journal.cjs", content: SCRIPT }, - ]; - const mcp = [{ relativePath: ".mcp.json", content: MCP_JSON }]; - return new PluginDistribution({ - manifest: { name: "aidd-telemetry", version: "1.0.0" }, - format: "claude", - files: [...hooks, ...mcp], - components: { commands: [], agents: [], rules: [], skills: [], hooks, mcp }, - }); -} - -function installedFor(tool: AiTool) { - return translator.translateWithComponentPaths(pluginWithHookAndScript(), tool, "docs"); -} - -function contentEndingWith( - result: ReturnType, - suffix: string -): string | undefined { - return result.files.find((file) => file.relativePath.endsWith(suffix))?.content; -} - -describe("installing a plugin that ships hooks", () => { - it("delivers them to every tool that runs hooks", () => { - for (const tool of HOOK_HOSTS) { - expect(installedFor(tool).files, tool.toolId).not.toHaveLength(0); - } - }); - - it("writes a command naming the variable that tool expands, never another tool's", () => { - for (const tool of HOOK_HOSTS) { - if (tool.capabilities.plugins.hooksContentFormat !== "claude") continue; - const manifest = contentEndingWith(installedFor(tool), "hooks.json") ?? ""; - - expect(manifest, tool.toolId).toContain(tool.capabilities.plugins.pluginRootToken); - if (tool.capabilities.plugins.pluginRootToken === SOURCE_TOKEN) continue; - expect(manifest, tool.toolId).not.toContain(SOURCE_TOKEN); - } - }); - - it("resolves the root itself for a tool whose whole hook format is rewritten", () => { - // Cursor's converter turns the root into a path relative to the plugin, which is a - // third answer to the same question — the build route writes ${CURSOR_PLUGIN_ROOT} - // for the same plugin. Pinned as the divergence it is: Cursor is the one tool whose - // hooks could not be observed running, so neither answer has been checked against it. - const manifest = contentEndingWith(installedFor(cursor), "hooks.json") ?? ""; - - expect(manifest).toContain('"command": "node ./hooks/journal.cjs session-start"'); - expect(manifest).not.toContain(SOURCE_TOKEN); - expect(cursor.capabilities.plugins.pluginRootToken).not.toBe("./"); - }); - - it("leaves a script beside the hook byte for byte, its plugin root untouched", () => { - // Measured: rewriting a script's content changed it by six bytes on one tool and lost - // one on another. A script is carried, never translated. - for (const tool of HOOK_HOSTS) { - expect(contentEndingWith(installedFor(tool), "journal.cjs"), tool.toolId).toBe(SCRIPT); - } - }); - - it("points an mcp server at the plugin root the target tool expands", () => { - // The one place the substitution changes an installed byte today: a hook manifest for - // Cursor is rewritten wholesale by its own converter, and every other tool expands the - // spelling the source already uses. - for (const tool of HOOK_HOSTS) { - const served = contentEndingWith( - installedFor(tool), - tool.capabilities.plugins.mcpRelativePath - ); - - // A tool that delivered no mcp file would pass the assertion below by never - // reaching it, which is the failure shape this whole file exists to catch. - expect(served, `${tool.toolId} installed no mcp file to check`).toBeDefined(); - expect(served, tool.toolId).toContain(tool.capabilities.plugins.pluginRootToken); - } - }); - - it("delivers OpenCode's script under flatHooksDir instead of skipping it (Phase 7)", () => { - const result = installedFor(opencode); - - expect(result.skipped).toEqual([]); - const flatHooksDir = opencode.capabilities.plugins.flatHooksDir ?? ""; - expect(contentEndingWith(result, "journal.cjs")).toBe(SCRIPT); - expect(result.files.some((file) => file.relativePath === `${flatHooksDir}hooks.json`)).toBe( - false - ); - }); -}); - -describe("what a tool says about the hooks it runs", () => { - it("never leaves its answer to a default", () => { - for (const tool of [...HOOK_HOSTS, opencode]) { - const { acceptsHooks, hooksUnsupportedReason } = tool.capabilities.plugins; - - expect(acceptsHooks === (hooksUnsupportedReason === null), tool.toolId).toBe(true); - } - }); -}); diff --git a/cli/tests/domain/models/plugin-scaffold.unit.test.ts b/cli/tests/domain/models/plugin-scaffold.unit.test.ts deleted file mode 100644 index 574d3d06d..000000000 --- a/cli/tests/domain/models/plugin-scaffold.unit.test.ts +++ /dev/null @@ -1,77 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { buildScaffold } from "../../../src/domain/models/plugin-scaffold.js"; - -const BASE_INPUT = { name: "my-plugin", version: "0.1.0", description: "A test plugin" }; - -describe("buildScaffold", () => { - describe("common files", () => { - it("always includes plugin manifest, README, and CHANGELOG", () => { - const scaffold = buildScaffold({ ...BASE_INPUT, kind: "full" }); - expect(scaffold.has(".claude-plugin/plugin.json")).toBe(true); - expect(scaffold.has("README.md")).toBe(true); - expect(scaffold.has("CHANGELOG.md")).toBe(true); - }); - - it("manifest JSON contains the plugin name", () => { - const scaffold = buildScaffold({ ...BASE_INPUT, kind: "full" }); - const manifest = scaffold.get(".claude-plugin/plugin.json") ?? ""; - expect(JSON.parse(manifest)).toMatchObject({ name: "my-plugin" }); - }); - }); - - describe("kind: full", () => { - it("includes skills, agents, hooks, and mcp files", () => { - const scaffold = buildScaffold({ ...BASE_INPUT, kind: "full" }); - expect(scaffold.has("skills/00-example/SKILL.md")).toBe(true); - expect(scaffold.has("agents/example.md")).toBe(true); - expect(scaffold.has("hooks/hooks.json")).toBe(true); - expect(scaffold.has(".mcp.json")).toBe(true); - }); - }); - - describe("kind: skills", () => { - it("includes only skills files (no agents, hooks, mcp)", () => { - const scaffold = buildScaffold({ ...BASE_INPUT, kind: "skills" }); - expect(scaffold.has("skills/00-example/SKILL.md")).toBe(true); - expect(scaffold.has("agents/example.md")).toBe(false); - expect(scaffold.has("hooks/hooks.json")).toBe(false); - expect(scaffold.has(".mcp.json")).toBe(false); - }); - }); - - describe("kind: agents", () => { - it("includes only agents files (no skills, hooks, mcp)", () => { - const scaffold = buildScaffold({ ...BASE_INPUT, kind: "agents" }); - expect(scaffold.has("agents/example.md")).toBe(true); - expect(scaffold.has("skills/00-example/SKILL.md")).toBe(false); - expect(scaffold.has("hooks/hooks.json")).toBe(false); - }); - }); - - describe("kind: hooks", () => { - it("includes only hooks files", () => { - const scaffold = buildScaffold({ ...BASE_INPUT, kind: "hooks" }); - expect(scaffold.has("hooks/hooks.json")).toBe(true); - expect(scaffold.has("hooks/routing/.gitkeep")).toBe(true); - expect(scaffold.has("skills/00-example/SKILL.md")).toBe(false); - }); - }); - - describe("kind: mcp", () => { - it("includes only mcp files", () => { - const scaffold = buildScaffold({ ...BASE_INPUT, kind: "mcp" }); - expect(scaffold.has(".mcp.json")).toBe(true); - expect(scaffold.has("hooks/hooks.json")).toBe(false); - }); - }); - - it("skills include evals/scenarios.json", () => { - const scaffold = buildScaffold({ ...BASE_INPUT, kind: "skills" }); - expect(scaffold.has("skills/00-example/evals/scenarios.json")).toBe(true); - }); - - it("returns a ReadonlyMap", () => { - const scaffold = buildScaffold({ ...BASE_INPUT, kind: "full" }); - expect(scaffold).toBeInstanceOf(Map); - }); -}); diff --git a/cli/tests/domain/models/plugin-source.unit.test.ts b/cli/tests/domain/models/plugin-source.unit.test.ts deleted file mode 100644 index e50362c03..000000000 --- a/cli/tests/domain/models/plugin-source.unit.test.ts +++ /dev/null @@ -1,192 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { InvalidPluginSourceError } from "../../../src/domain/errors.js"; -import { - parsePluginSource, - parsePluginSourceShorthand, - serializePluginSource, -} from "../../../src/domain/models/plugin-source.js"; - -describe("parsePluginSource", () => { - describe("github kind", () => { - it("round-trips a minimal github source", () => { - const raw = { kind: "github", repo: "owner/repo" }; - const src = parsePluginSource(raw); - expect(serializePluginSource(src)).toEqual(raw); - }); - - it("round-trips a github source with ref and sha", () => { - const raw = { kind: "github", repo: "owner/repo", ref: "main", sha: "a".repeat(40) }; - const src = parsePluginSource(raw); - expect(serializePluginSource(src)).toEqual(raw); - }); - - it("throws when repo is missing", () => { - expect(() => parsePluginSource({ kind: "github" })).toThrow(InvalidPluginSourceError); - }); - - it("throws when repo format is invalid", () => { - expect(() => parsePluginSource({ kind: "github", repo: "not-valid" })).toThrow( - InvalidPluginSourceError - ); - }); - }); - - describe("url kind", () => { - it("round-trips a url source", () => { - const raw = { kind: "url", url: "https://example.com/plugin.zip" }; - const src = parsePluginSource(raw); - expect(serializePluginSource(src)).toEqual(raw); - }); - - it("round-trips a url source with optional fields", () => { - const raw = { - kind: "url", - url: "https://example.com/plugin.zip", - ref: "v1", - sha: "b".repeat(40), - }; - const src = parsePluginSource(raw); - expect(serializePluginSource(src)).toEqual(raw); - }); - - it("throws when url is missing", () => { - expect(() => parsePluginSource({ kind: "url" })).toThrow(InvalidPluginSourceError); - }); - }); - - describe("git-subdir kind", () => { - it("round-trips a git-subdir source", () => { - const raw = { - kind: "git-subdir", - url: "https://github.com/org/repo.git", - path: "plugins/my-plugin", - }; - const src = parsePluginSource(raw); - expect(serializePluginSource(src)).toEqual(raw); - }); - - it("throws when url is missing", () => { - expect(() => parsePluginSource({ kind: "git-subdir", path: "sub" })).toThrow( - InvalidPluginSourceError - ); - }); - - it("throws when path is missing", () => { - expect(() => parsePluginSource({ kind: "git-subdir", url: "https://example.com" })).toThrow( - InvalidPluginSourceError - ); - }); - }); - - describe("npm kind", () => { - it("round-trips a minimal npm source", () => { - const raw = { kind: "npm", package: "@my-org/my-plugin" }; - const src = parsePluginSource(raw); - expect(serializePluginSource(src)).toEqual(raw); - }); - - it("round-trips an npm source with version and registry", () => { - const raw = { - kind: "npm", - package: "@my-org/my-plugin", - version: "1.2.3", - registry: "https://registry.npmjs.org", - }; - const src = parsePluginSource(raw); - expect(serializePluginSource(src)).toEqual(raw); - }); - - it("throws when package is missing", () => { - expect(() => parsePluginSource({ kind: "npm" })).toThrow(InvalidPluginSourceError); - }); - - describe("npm name security validation", () => { - it("accepts a valid unscoped package name", () => { - expect(() => parsePluginSource({ kind: "npm", package: "my-plugin" })).not.toThrow(); - }); - - it("accepts a valid scoped package name", () => { - expect(() => - parsePluginSource({ kind: "npm", package: "@my-org/my-plugin" }) - ).not.toThrow(); - }); - - it("rejects a package name starting with a dash (injection vector)", () => { - expect(() => parsePluginSource({ kind: "npm", package: "-x" })).toThrow( - InvalidPluginSourceError - ); - }); - - it("rejects a package name starting with double-dash (option injection)", () => { - expect(() => - parsePluginSource({ kind: "npm", package: "--registry=https://evil.com" }) - ).toThrow(InvalidPluginSourceError); - }); - - it("rejects a package name starting with a dot", () => { - expect(() => parsePluginSource({ kind: "npm", package: ".my-plugin" })).toThrow( - InvalidPluginSourceError - ); - }); - - it("rejects a package name with uppercase letters", () => { - expect(() => parsePluginSource({ kind: "npm", package: "My-Plugin" })).toThrow( - InvalidPluginSourceError - ); - }); - }); - }); - - describe("URL scheme validation (shorthand)", () => { - it("accepts an https URL", () => { - const src = parsePluginSourceShorthand("https://github.com/org/repo.git"); - expect(src.kind).toBe("url"); - }); - - it("accepts an http URL", () => { - const src = parsePluginSourceShorthand("http://example.com/repo.git"); - expect(src.kind).toBe("url"); - }); - - it("accepts a git@ SSH URL", () => { - const src = parsePluginSourceShorthand("git@github.com:org/repo.git"); - expect(src.kind).toBe("url"); - }); - }); - - describe("local kind", () => { - it("round-trips a local source", () => { - const raw = { kind: "local", path: "./plugins/my-plugin" }; - const src = parsePluginSource(raw); - expect(serializePluginSource(src)).toEqual(raw); - }); - - it("throws when path is missing", () => { - expect(() => parsePluginSource({ kind: "local" })).toThrow(InvalidPluginSourceError); - }); - }); - - describe("invalid inputs", () => { - it("throws for unknown kind", () => { - expect(() => parsePluginSource({ kind: "svn", url: "svn://example.com" })).toThrow( - InvalidPluginSourceError - ); - }); - - it("throws for null", () => { - expect(() => parsePluginSource(null)).toThrow(InvalidPluginSourceError); - }); - - it("throws for array", () => { - expect(() => parsePluginSource([])).toThrow(InvalidPluginSourceError); - }); - - it("throws for primitive string", () => { - expect(() => parsePluginSource("github:owner/repo")).toThrow(InvalidPluginSourceError); - }); - - it("throws when kind is missing", () => { - expect(() => parsePluginSource({ repo: "owner/repo" })).toThrow(InvalidPluginSourceError); - }); - }); -}); diff --git a/cli/tests/domain/models/plugin.unit.test.ts b/cli/tests/domain/models/plugin.unit.test.ts deleted file mode 100644 index 12ce43161..000000000 --- a/cli/tests/domain/models/plugin.unit.test.ts +++ /dev/null @@ -1,116 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { InvalidPluginNameError, InvalidPluginVersionError } from "../../../src/domain/errors.js"; -import { Plugin, type PluginEntryData } from "../../../src/domain/models/plugin.js"; - -const makePluginData = (overrides: Partial = {}): PluginEntryData => ({ - name: "my-plugin", - source: { kind: "github", repo: "owner/my-plugin" }, - version: "1.0.0", - strict: false, - files: { ".claude/plugins/my-plugin/CLAUDE.md": "abc123" }, - ...overrides, -}); - -describe("Plugin", () => { - describe("fromJSON()", () => { - it("creates a plugin from valid data", () => { - const plugin = Plugin.fromJSON(makePluginData()); - expect(plugin.name).toBe("my-plugin"); - expect(plugin.version).toBe("1.0.0"); - expect(plugin.strict).toBe(false); - }); - - it("throws InvalidPluginNameError when name is invalid", () => { - expect(() => Plugin.fromJSON(makePluginData({ name: "My Plugin!" }))).toThrow( - InvalidPluginNameError - ); - }); - - it("throws InvalidPluginNameError for names with uppercase letters", () => { - expect(() => Plugin.fromJSON(makePluginData({ name: "MyPlugin" }))).toThrow( - InvalidPluginNameError - ); - }); - - it("throws InvalidPluginNameError for names with leading hyphens", () => { - expect(() => Plugin.fromJSON(makePluginData({ name: "-plugin" }))).toThrow( - InvalidPluginNameError - ); - }); - - it("throws InvalidPluginVersionError when version is not semver", () => { - expect(() => Plugin.fromJSON(makePluginData({ version: "not-a-version" }))).toThrow( - InvalidPluginVersionError - ); - }); - - it("accepts single-segment names", () => { - const plugin = Plugin.fromJSON(makePluginData({ name: "plugin" })); - expect(plugin.name).toBe("plugin"); - }); - - it("accepts multi-segment names", () => { - const plugin = Plugin.fromJSON(makePluginData({ name: "my-cool-plugin" })); - expect(plugin.name).toBe("my-cool-plugin"); - }); - - it("parses files into a ReadonlyMap", () => { - const plugin = Plugin.fromJSON(makePluginData()); - expect(plugin.files.get(".claude/plugins/my-plugin/CLAUDE.md")).toBe("abc123"); - }); - }); - - describe("toJSON()", () => { - it("round-trips via fromJSON/toJSON", () => { - const data = makePluginData(); - const plugin = Plugin.fromJSON(data); - expect(plugin.toJSON()).toEqual(data); - }); - }); - - describe("isFileTracked()", () => { - it("returns true for a tracked file path", () => { - const plugin = Plugin.fromJSON(makePluginData()); - expect(plugin.isFileTracked(".claude/plugins/my-plugin/CLAUDE.md")).toBe(true); - }); - - it("returns false for an untracked file path", () => { - const plugin = Plugin.fromJSON(makePluginData()); - expect(plugin.isFileTracked(".claude/agents/alexia.md")).toBe(false); - }); - }); - - describe("withVersion()", () => { - it("returns a new plugin with the updated version", () => { - const plugin = Plugin.fromJSON(makePluginData()); - const updated = plugin.withVersion("2.0.0"); - expect(updated.version).toBe("2.0.0"); - expect(plugin.version).toBe("1.0.0"); - }); - - it("preserves all other fields", () => { - const plugin = Plugin.fromJSON(makePluginData()); - const updated = plugin.withVersion("2.0.0"); - expect(updated.name).toBe(plugin.name); - expect(updated.strict).toBe(plugin.strict); - expect(updated.files).toBe(plugin.files); - }); - }); - - describe("withFiles()", () => { - it("returns a new plugin with updated files", () => { - const plugin = Plugin.fromJSON(makePluginData()); - const newFiles = new Map([["new/path.md", "hash-value"]]); - const updated = plugin.withFiles(newFiles); - expect(updated.files.get("new/path.md")).toBe("hash-value"); - expect(plugin.files.has("new/path.md")).toBe(false); - }); - - it("preserves all other fields", () => { - const plugin = Plugin.fromJSON(makePluginData()); - const updated = plugin.withFiles(new Map()); - expect(updated.name).toBe(plugin.name); - expect(updated.version).toBe(plugin.version); - }); - }); -}); diff --git a/cli/tests/domain/models/setup-flow.unit.test.ts b/cli/tests/domain/models/setup-flow.unit.test.ts deleted file mode 100644 index 074a869b0..000000000 --- a/cli/tests/domain/models/setup-flow.unit.test.ts +++ /dev/null @@ -1,101 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { - InvalidPluginModeConfigError, - InvalidSetupToolIdError, -} from "../../../src/domain/errors.js"; -import { SetupFlow } from "../../../src/domain/models/setup-flow.js"; - -const ROOT = "/project"; - -function makeFlow(overrides: Partial[0]> = {}): SetupFlow { - return new SetupFlow({ projectRoot: ROOT, ...overrides }); -} - -describe("SetupFlow", () => { - describe("constructor validation", () => { - it("throws InvalidSetupToolIdError for unknown AI tool IDs", () => { - expect(() => makeFlow({ aiTools: ["unknown-tool" as "claude"] })).toThrow( - InvalidSetupToolIdError - ); - }); - - it("throws InvalidPluginModeConfigError when mode is 'named' with no names", () => { - expect(() => makeFlow({ pluginMode: "named", pluginNames: [] })).toThrow( - InvalidPluginModeConfigError - ); - }); - - it("throws InvalidPluginModeConfigError when names provided but mode is not 'named'", () => { - expect(() => makeFlow({ pluginMode: "all", pluginNames: ["my-plugin"] })).toThrow( - InvalidPluginModeConfigError - ); - }); - - it("constructs successfully with valid params", () => { - const flow = makeFlow({ aiTools: ["claude"], pluginMode: "none" }); - expect(flow.projectRoot).toBe(ROOT); - expect(flow.aiTools).toEqual(["claude"]); - }); - }); - - describe("isScriptable()", () => { - it("returns true when not interactive", () => { - const flow = makeFlow({ interactive: false }); - expect(flow.isScriptable()).toBe(true); - }); - - it("returns false when interactive", () => { - const flow = makeFlow({ interactive: true }); - expect(flow.isScriptable()).toBe(false); - }); - }); - - describe("hasAnyTool()", () => { - it("returns true when aiTools is non-empty", () => { - const flow = makeFlow({ aiTools: ["claude"] }); - expect(flow.hasAnyTool()).toBe(true); - }); - - it("returns true when ideTools is non-empty", () => { - const flow = makeFlow({ ideTools: ["vscode"] }); - expect(flow.hasAnyTool()).toBe(true); - }); - - it("returns false when both aiTools and ideTools are empty", () => { - const flow = makeFlow({ aiTools: [], ideTools: [] }); - expect(flow.hasAnyTool()).toBe(false); - }); - }); - - describe("equals()", () => { - it("returns true for two flows with the same parameters", () => { - const a = makeFlow({ aiTools: ["claude"], interactive: false }); - const b = makeFlow({ aiTools: ["claude"], interactive: false }); - expect(a.equals(b)).toBe(true); - }); - - it("returns false when aiTools differ", () => { - const a = makeFlow({ aiTools: ["claude"] }); - const b = makeFlow({ aiTools: ["cursor"] }); - expect(a.equals(b)).toBe(false); - }); - - it("returns false when interactive differs", () => { - const a = makeFlow({ interactive: true }); - const b = makeFlow({ interactive: false }); - expect(a.equals(b)).toBe(false); - }); - - it("returns false when pluginMode differs", () => { - const a = makeFlow({ pluginMode: "all" }); - const b = makeFlow({ pluginMode: "none" }); - expect(a.equals(b)).toBe(false); - }); - - it("returns false when pluginNames differ", () => { - const a = makeFlow({ pluginMode: "named", pluginNames: ["plugin-a"] }); - const b = makeFlow({ pluginMode: "named", pluginNames: ["plugin-b"] }); - expect(a.equals(b)).toBe(false); - }); - }); -}); diff --git a/cli/tests/domain/models/step-attribution.unit.test.ts b/cli/tests/domain/models/step-attribution.unit.test.ts deleted file mode 100644 index 4daa4881b..000000000 --- a/cli/tests/domain/models/step-attribution.unit.test.ts +++ /dev/null @@ -1,456 +0,0 @@ -import { readFileSync } from "node:fs"; -import { fileURLToPath } from "node:url"; -import { describe, expect, it } from "vitest"; -import { - attributeMoment, - buildStepIntervals, -} from "../../../src/domain/models/step-attribution.js"; -import type { RunJournal } from "../../../src/domain/ports/run-journal-reader.js"; - -function journalOf(...boundaries: RunJournal["boundaries"]): RunJournal { - return { boundaries, filesWritten: [], taskDeclarations: [] }; -} - -function journalWith( - boundaries: RunJournal["boundaries"], - filesWritten: RunJournal["filesWritten"] -): RunJournal { - return { boundaries, filesWritten, taskDeclarations: [] }; -} - -const A_START = { - type: "step_start", - at: "2026-08-20T10:00:00Z", - skill: "aidd-dev:02-implement", -} as const; -const B_START = { - type: "step_start", - at: "2026-08-20T10:05:00Z", - skill: "aidd-dev:06-test", -} as const; -const A_AGAIN = { - type: "step_start", - at: "2026-08-20T10:10:00Z", - skill: "aidd-dev:02-implement", -} as const; -const TURN_END = { type: "turn_end", at: "2026-08-20T10:15:00Z" } as const; - -describe("step-attribution — pure: journal lines + records -> intervals", () => { - it("maps a moment inside a step interval to that step, marked as derived", () => { - const intervals = buildStepIntervals(journalOf(A_START, TURN_END)); - - const attribution = attributeMoment(intervals, "2026-08-20T10:02:00Z"); - - expect(attribution).toEqual({ source: "journal-interval", step: "aidd-dev:02-implement" }); - }); - - // A `turn_end` is a pause, not the end of a step - the rule `buildTaskIntervals` and - // `buildFlowIntervals` already read from this very journal. Measured on the one - // orchestrated session captured, 2026-09-04: four steps opened over four hours, every one - // closed by the next pause, and 69 of the session's 1,073 records fell inside a step - // interval. With a pause no longer closing one, 1,065 of them do. - it("runs a step past a pause, to the journal's own last witnessed moment", () => { - const intervals = buildStepIntervals( - journalWith( - [A_START, TURN_END], - [{ type: "file_written", at: "2026-08-20T11:00:00Z", path: "aidd_docs/note.md" }] - ) - ); - - expect(attributeMoment(intervals, "2026-08-20T10:30:00Z")).toEqual({ - source: "journal-interval", - step: "aidd-dev:02-implement", - }); - expect(intervals[0]?.endMs).toBe(Date.parse("2026-08-20T11:00:00Z")); - }); - - // A `turn_end` is a pause: a skill that spans three prompts is credited with its first - // turn and nothing after. Nothing any host emits says when a skill's work finished - - // measured, a `Skill` call's own `tool_result` returns in about a tenth of a second, which - // is the dispatch - so the skill declares its own end and the hook writes it. Stated, the - // end wins over every pause between it and the start. - it("runs a step past every pause, to the end its own skill declared", () => { - const intervals = buildStepIntervals( - journalOf( - A_START, - TURN_END, - { type: "turn_end", at: "2026-08-20T10:20:00Z" }, - { type: "step_end", at: "2026-08-20T10:30:00Z", skill: "aidd-dev:02-implement" } - ) - ); - - const attribution = attributeMoment(intervals, "2026-08-20T10:25:00Z"); - - expect(attribution).toEqual({ source: "journal-interval", step: "aidd-dev:02-implement" }); - }); - - // An end names its skill so that closing one never closes another. A skill invoking a - // second one leaves two open intervals; an end for the inner skill must leave the outer - // one running. - it("closes only the step its own skill names", () => { - const intervals = buildStepIntervals( - journalOf(A_START, B_START, { - type: "step_end", - at: "2026-08-20T10:07:00Z", - skill: "aidd-dev:06-test", - }) - ); - - const outer = intervals.find((interval) => interval.skill === "aidd-dev:02-implement"); - const inner = intervals.find((interval) => interval.skill === "aidd-dev:06-test"); - expect(inner?.endMs).toBe(Date.parse("2026-08-20T10:07:00Z")); - expect(outer?.endMs).toBe(Date.parse("2026-08-20T10:05:00Z")); - }); - - // Cursor and Codex name a skill by its folder alone - the plugin never reaches the journal - // - while the end a skill echoes always carries the plugin, because that is what the skill - // knows itself as. Compared exactly, a declared end closed nothing at all on those hosts. - it("closes a step opened by its bare name with the end its skill declares in full", () => { - const bareStart = { - type: "step_start", - at: "2026-08-20T10:00:00Z", - skill: "02-implement", - } as const; - const intervals = buildStepIntervals( - journalOf( - bareStart, - { type: "turn_end", at: "2026-08-20T10:10:00Z" }, - { type: "step_end", at: "2026-08-20T10:30:00Z", skill: "aidd-dev:02-implement" } - ) - ); - - expect(intervals[0]?.endMs).toBe(Date.parse("2026-08-20T10:30:00Z")); - }); - - // The journal carries a later moment than the disagreeing end, deliberately: with the end - // as its last line the interval would stop there anyway - at the journal's own last - // witnessed moment - and the assertion could not tell a refused closer from a cap. - it("still refuses an end whose plugin disagrees with the one that opened the step", () => { - const intervals = buildStepIntervals( - journalWith( - [A_START, { type: "step_end", at: "2026-08-20T10:02:00Z", skill: "aidd-pm:02-implement" }], - [{ type: "file_written", at: "2026-08-20T10:20:00Z", path: "aidd_docs/note.md" }] - ) - ); - - expect(intervals[0]?.endMs).toBe(Date.parse("2026-08-20T10:20:00Z")); - }); - - // An end for a skill that never started names nothing to close. Read as a boundary all the - // same it would truncate whatever interval was running, which is a step it has no claim on. - it("ignores an end for a skill this session never started", () => { - const intervals = buildStepIntervals( - journalWith( - [A_START, { type: "step_end", at: "2026-08-20T10:02:00Z", skill: "some-other:skill" }], - [{ type: "file_written", at: "2026-08-20T10:20:00Z", path: "aidd_docs/note.md" }] - ) - ); - - expect(attributeMoment(intervals, "2026-08-20T10:03:00Z")).toEqual({ - source: "journal-interval", - step: "aidd-dev:02-implement", - }); - }); - - it("closes an interval at the next step_start, not at the turn's end past it", () => { - const intervals = buildStepIntervals(journalOf(A_START, B_START, TURN_END)); - - expect(attributeMoment(intervals, "2026-08-20T10:04:59Z")).toEqual({ - source: "journal-interval", - step: "aidd-dev:02-implement", - }); - expect(attributeMoment(intervals, "2026-08-20T10:05:00Z")).toEqual({ - source: "journal-interval", - step: "aidd-dev:06-test", - }); - }); - - // A pause is not a closer, so what bounds the last step here is the journal's own last - // witnessed moment - which this journal's `turn_end` happens to be. Same moment as the - // old rule gave, reached for a different reason, so the boundary between covered and not - // stays pinned either way. - it("leaves nothing beyond the journal's last witnessed moment covered", () => { - const intervals = buildStepIntervals(journalOf(B_START, TURN_END)); - - expect(attributeMoment(intervals, "2026-08-20T10:14:59Z")).toMatchObject({ - source: "journal-interval", - }); - expect(attributeMoment(intervals, "2026-08-20T10:15:00Z")).toEqual({ - source: "unattributed", - }); - }); - - it("yields three intervals and two names from A, then B, then A", () => { - const intervals = buildStepIntervals(journalOf(A_START, B_START, A_AGAIN, TURN_END)); - - expect(intervals).toHaveLength(3); - expect(new Set(intervals.map((i) => i.skill))).toEqual( - new Set(["aidd-dev:02-implement", "aidd-dev:06-test"]) - ); - // A record in neither the first nor the third interval's own span. - expect(attributeMoment(intervals, "2026-08-20T10:05:30Z")).toEqual({ - source: "journal-interval", - step: "aidd-dev:06-test", - }); - // A record after the third interval reopens, back in the first skill's name again. - expect(attributeMoment(intervals, "2026-08-20T10:12:00Z")).toEqual({ - source: "journal-interval", - step: "aidd-dev:02-implement", - }); - }); - - it("reads a moment before the first boundary as unattributed, never folded into it", () => { - const intervals = buildStepIntervals(journalOf(A_START, TURN_END)); - - const attribution = attributeMoment(intervals, "2026-08-20T09:59:59Z"); - - expect(attribution).toEqual({ source: "unattributed" }); - }); - - it("reads a record with no moment at all as unattributed, never the first interval", () => { - const intervals = buildStepIntervals(journalOf(A_START, TURN_END)); - - expect(attributeMoment(intervals, undefined)).toEqual({ source: "unattributed" }); - }); - - // A regression for a real bug caught in review: a boundary with an unparseable `at` - // must not silently extend the *previous* step's interval past it, swallowing every - // later step's own records under the wrong skill name. - it("does not let an unparseable boundary extend the step before it into the step after", () => { - const intervals = buildStepIntervals( - journalOf(A_START, { type: "turn_end", at: "not-a-date" }, B_START, TURN_END) - ); - - const attribution = attributeMoment(intervals, "2026-08-20T10:07:00Z"); - - expect(attribution).toEqual({ source: "journal-interval", step: "aidd-dev:06-test" }); - }); - - it("reads every moment as unattributed when the journal opened no step", () => { - const intervals = buildStepIntervals(journalOf(TURN_END)); - - expect(attributeMoment(intervals, "2026-08-20T10:00:00Z")).toEqual({ - source: "unattributed", - }); - }); - - // The journal stamps every line with `nowIso()`, whose resolution is the second, so two - // lines sharing a moment is the common case rather than a corner. The shared walk sorts - // by moment, and a sort that is stable - as V8's is - leaves lines that share one in the - // order they were read, which for two `boundaries` entries is file order. Pinned here - // because that ordering is now inherited from the sort rather than written out, and a - // step whose own end shares its start's moment must cover nothing rather than everything. - it("closes a step at an end sharing its own start's moment, covering nothing", () => { - const intervals = buildStepIntervals( - journalWith( - [ - A_START, - { type: "step_end", at: A_START.at, skill: A_START.skill }, - { type: "turn_end", at: "2026-08-20T11:00:00Z" }, - ], - [{ type: "file_written", at: "2026-08-20T12:00:00Z", path: "aidd_docs/note.md" }] - ) - ); - - expect(intervals[0]?.endMs).toBe(Date.parse(A_START.at)); - expect(attributeMoment(intervals, A_START.at)).toEqual({ source: "unattributed" }); - }); - - // An orchestrating skill invokes others; that is what `ORCHESTRATING_SKILLS` declares it - // does. Reading the invoked skill's own `step_start` as the end of the orchestration - // credits an orchestration that ran for hours with the seconds before its first child. - // Measured on the one orchestrated session captured, 2026-09-04: `aidd-orchestrator:01-sdlc` - // opened at 05:56:27 and `aidd-pm:04-spec` opened at 05:59:53, so the orchestration was - // read as 206 seconds long against a session that ran until 09:27:21. The flow axis, which - // already refuses to let a non-orchestrating start close one, named 1,052 records for that - // same skill while this axis named 1. - it("does not let an invoked step close the orchestration that invoked it", () => { - const intervals = buildStepIntervals( - journalOf( - { type: "step_start", at: "2026-08-20T10:00:00Z", skill: "aidd-orchestrator:01-sdlc" }, - { type: "step_start", at: "2026-08-20T10:05:00Z", skill: "aidd-pm:04-spec" }, - { type: "turn_end", at: "2026-08-20T11:00:00Z" } - ) - ); - - const sdlc = intervals.find((interval) => interval.skill === "aidd-orchestrator:01-sdlc"); - expect(sdlc?.endMs).toBe(Date.parse("2026-08-20T11:00:00Z")); - }); - - // The invoked step is inside the orchestration, not beside it, so both intervals contain - // the same moment. The innermost is the one that answers: it is the more specific claim, - // and the outer one is still true of it. - it("attributes a moment inside both to the step, and one outside it to the orchestration", () => { - const intervals = buildStepIntervals( - journalOf( - { type: "step_start", at: "2026-08-20T10:00:00Z", skill: "aidd-orchestrator:01-sdlc" }, - { type: "step_start", at: "2026-08-20T10:05:00Z", skill: "aidd-pm:04-spec" }, - { type: "step_end", at: "2026-08-20T10:10:00Z", skill: "aidd-pm:04-spec" }, - { type: "turn_end", at: "2026-08-20T11:00:00Z" } - ) - ); - - expect(attributeMoment(intervals, "2026-08-20T10:02:00Z")).toEqual({ - source: "journal-interval", - step: "aidd-orchestrator:01-sdlc", - }); - expect(attributeMoment(intervals, "2026-08-20T10:07:00Z")).toEqual({ - source: "journal-interval", - step: "aidd-pm:04-spec", - }); - // Past the invoked step's own declared end, back inside the orchestration alone. - expect(attributeMoment(intervals, "2026-08-20T10:30:00Z")).toEqual({ - source: "journal-interval", - step: "aidd-orchestrator:01-sdlc", - }); - }); - - // An interval nothing ever closed ends at the journal's own last witnessed moment, which - // is a bound and not a measurement. Where one such interval sits inside another, the - // enclosing one answers: the inner one's end says only that the journal stopped, while - // the outer one is still known to have been open. Measured on the one orchestrated - // session captured, 2026-09-04: `aidd-dev:01-plan` opened at 06:00:50 inside an - // orchestration opened at 05:56:27, neither was ever closed, and reading the innermost - // start alone credited the invoked step with every one of the 972 records written over - // the three and a half hours that followed. - it("hands a moment to the orchestration when nothing ever closed the step inside it", () => { - const intervals = buildStepIntervals( - journalOf( - { type: "step_start", at: "2026-08-20T10:00:00Z", skill: "aidd-orchestrator:01-sdlc" }, - { type: "step_start", at: "2026-08-20T10:05:00Z", skill: "aidd-pm:04-spec" }, - { type: "turn_end", at: "2026-08-20T11:00:00Z" } - ) - ); - - expect(attributeMoment(intervals, "2026-08-20T10:30:00Z")).toEqual({ - source: "journal-interval", - step: "aidd-orchestrator:01-sdlc", - }); - }); - - // Two invoked steps in a row inside one orchestration. The first is closed by the - // second's own start, so its end is a witnessed boundary and it answers for the moments - // it covers; only the second is left unclosed, and it is the one that yields. There is - // never a tie between two unclosed invoked steps to break, because a `step_start` closes - // whichever plain step was open - which is what this case is here to demonstrate rather - // than assert in a comment. - it("keeps the earlier invoked step, and yields only the one nothing closed", () => { - const intervals = buildStepIntervals( - journalOf( - { type: "step_start", at: "2026-08-20T10:00:00Z", skill: "aidd-orchestrator:01-sdlc" }, - { type: "step_start", at: "2026-08-20T10:05:00Z", skill: "aidd-pm:04-spec" }, - { type: "step_start", at: "2026-08-20T10:20:00Z", skill: "aidd-dev:01-plan" }, - { type: "turn_end", at: "2026-08-20T11:00:00Z" } - ) - ); - - expect(attributeMoment(intervals, "2026-08-20T10:10:00Z")).toEqual({ - source: "journal-interval", - step: "aidd-pm:04-spec", - }); - expect(attributeMoment(intervals, "2026-08-20T10:30:00Z")).toEqual({ - source: "journal-interval", - step: "aidd-orchestrator:01-sdlc", - }); - }); - - // The yielding is between two intervals nothing closed, and no wider than that. Here the - // orchestration states its own end while the step inside it does not, so the step runs - // past it and no interval encloses it - the innermost claim stands, exactly as it does - // when both ends are witnessed. - it("keeps the innermost step when the orchestration around it states its own end", () => { - const intervals = buildStepIntervals( - journalOf( - { type: "step_start", at: "2026-08-20T10:00:00Z", skill: "aidd-orchestrator:01-sdlc" }, - { type: "step_start", at: "2026-08-20T10:10:00Z", skill: "aidd-pm:04-spec" }, - { type: "step_end", at: "2026-08-20T10:20:00Z", skill: "aidd-orchestrator:01-sdlc" }, - { type: "turn_end", at: "2026-08-20T11:00:00Z" } - ) - ); - - expect(attributeMoment(intervals, "2026-08-20T10:15:00Z")).toEqual({ - source: "journal-interval", - step: "aidd-pm:04-spec", - }); - }); - - // Nesting is declared, never inferred: only a skill `ORCHESTRATING_SKILLS` names invokes - // others. Two ordinary skills in a row are a sequence, and the second still ends the first. - it("still lets one ordinary step close another, which is a sequence and not a nesting", () => { - const intervals = buildStepIntervals(journalOf(A_START, B_START, TURN_END)); - - const first = intervals.find((interval) => interval.skill === A_START.skill); - expect(first?.endMs).toBe(Date.parse(B_START.at)); - }); - - // One orchestration does not nest inside another by default - the same rule - // `buildFlowIntervals` already applies to the wider concept, read from the same lines. - it("lets one orchestration close another", () => { - const intervals = buildStepIntervals( - journalOf( - { type: "step_start", at: "2026-08-20T10:00:00Z", skill: "aidd-orchestrator:01-sdlc" }, - { type: "step_start", at: "2026-08-20T10:05:00Z", skill: "aidd-orchestrator:02-backlog" }, - { type: "turn_end", at: "2026-08-20T11:00:00Z" } - ) - ); - - const first = intervals.find((interval) => interval.skill === "aidd-orchestrator:01-sdlc"); - expect(first?.endMs).toBe(Date.parse("2026-08-20T10:05:00Z")); - }); - - it("touches no filesystem — the module imports none of Node's fs APIs", () => { - const url = new URL("../../../src/domain/models/step-attribution.ts", import.meta.url); - const source = readFileSync(fileURLToPath(url), "utf8"); - - expect(source).not.toMatch(/from ["']node:fs/); - expect(source).not.toMatch(/require\(["']node:fs/); - }); -}); - -describe("buildStepIntervals — a step the session never closed", () => { - // Capped, not left open, and the objection this used to carry is gone rather than - // overruled: it said the cap "cannot be applied here" because this walk saw boundaries - // alone while a task or flow interval also saw `filesWritten` and `taskDeclarations`. - // This walk now reads the same three arrays they do, so the later moments it was said to - // lack are the ones it caps at. What is left is the degenerate journal below - one whose - // very last line is the opener - and there an open interval is not the safer error: one - // captured session carries a single `vendor_id` spanning 22 days, so "everything - // afterward" is three weeks of unrelated work, not a few minutes of it. - it("caps a step nothing closed at the journal's own last witnessed moment", () => { - const intervals = buildStepIntervals( - journalWith( - [{ type: "step_start", at: "2026-08-17T10:00:00Z", skill: "aidd-dev:01-plan" }], - [{ type: "file_written", at: "2026-08-17T12:00:00Z", path: "aidd_docs/note.md" }] - ) - ); - - expect(intervals).toEqual([ - { - skill: "aidd-dev:01-plan", - startMs: Date.parse("2026-08-17T10:00:00Z"), - endMs: Date.parse("2026-08-17T12:00:00Z"), - // The cap, and named as one: nothing in this journal ever closed the step. - closedBy: "journal-end", - }, - ]); - expect(attributeMoment(intervals, "2026-09-30T23:59:00Z")).toEqual({ source: "unattributed" }); - }); - - // The price of the cap, stated rather than discovered later: a journal whose only line is - // the opener has no later moment to cap at, so the interval covers nothing at all. A - // session reaches this only by opening a skill and then writing no file, declaring no - // task and firing no stop event - Copilot fires none, per `journal.cjs`'s own - // `HOOK_EVENT_NAME_TO_CANONICAL`. `records-join` survives it: that claim fails only when - // *every* record is unattributed, and a record whose own tool named its step is joined - // without any interval at all. - it("covers nothing when the opener is the only moment the journal ever witnessed", () => { - const intervals = buildStepIntervals( - journalOf({ type: "step_start", at: "2026-08-17T10:00:00Z", skill: "aidd-dev:01-plan" }) - ); - - expect(intervals[0]?.endMs).toBe(Date.parse("2026-08-17T10:00:00Z")); - expect(attributeMoment(intervals, "2026-08-17T10:00:01Z")).toEqual({ - source: "unattributed", - }); - }); -}); diff --git a/cli/tests/domain/models/task-attribution.unit.test.ts b/cli/tests/domain/models/task-attribution.unit.test.ts deleted file mode 100644 index b4720224f..000000000 --- a/cli/tests/domain/models/task-attribution.unit.test.ts +++ /dev/null @@ -1,318 +0,0 @@ -import { readFileSync } from "node:fs"; -import { fileURLToPath } from "node:url"; -import { describe, expect, it } from "vitest"; -import { - buildTaskIntervals, - momentFallsWithin, - taskUnattributedReason, -} from "../../../src/domain/models/task-attribution.js"; -import type { RunJournal } from "../../../src/domain/ports/run-journal-reader.js"; - -function journalOf( - taskDeclarations: RunJournal["taskDeclarations"], - boundaries: RunJournal["boundaries"] = [], - filesWritten: RunJournal["filesWritten"] = [] -): RunJournal { - return { boundaries, filesWritten, taskDeclarations }; -} - -const WANTED = { - type: "task_declared", - at: "2026-08-17T10:00:00Z", - path: "aidd_docs/tasks/2026_08/wanted/spec.md", -} as const; -const OTHER = { - type: "task_declared", - at: "2026-08-17T10:10:00Z", - path: "aidd_docs/tasks/2026_08/other/spec.md", -} as const; -const TURN_END = { type: "turn_end", at: "2026-08-17T10:15:00Z" } as const; - -describe("task-attribution — pure: journal lines -> bounded intervals", () => { - it("closes a declared interval at the turn_end that follows it", () => { - const intervals = buildTaskIntervals(journalOf([WANTED], [TURN_END])); - - expect(intervals).toEqual([ - { path: WANTED.path, startMs: Date.parse(WANTED.at), endMs: Date.parse(TURN_END.at) }, - ]); - }); - - // The live case, and the reason `turn_end` stopped closing a declaration on 2026-09-04. - // A `turn_end` is a pause, not a change of subject: the session declared - // `telemetry-screen` at 05:59, paused at 06:02, and worked on that same task for three - // more hours. Closed at the pause, 78% of the session read "before the next task this - // session declares" while only 1.8% of its tokens truly preceded any declaration. - // - // `turn_end` stays a *witness*, so an interval with nothing after it still ends there — - // the same moment, for the honest reason. What changes is an interval with work after it. - it("keeps a declaration open across a turn_end, ending at the work that followed", () => { - const wrote = { - type: "file_written", - at: "2026-08-17T10:20:00Z", - path: "aidd_docs/tasks/2026_08/wanted/phase-1.md", - } as const; - const intervals = buildTaskIntervals(journalOf([WANTED], [TURN_END], [wrote])); - - expect(intervals).toEqual([ - { path: WANTED.path, startMs: Date.parse(WANTED.at), endMs: Date.parse(wrote.at) }, - ]); - }); - - it("closes a declaration at a later declaration, never at the turn's own end past it", () => { - const intervals = buildTaskIntervals(journalOf([WANTED, OTHER], [TURN_END])); - - expect(intervals).toEqual([ - { path: WANTED.path, startMs: Date.parse(WANTED.at), endMs: Date.parse(OTHER.at) }, - { path: OTHER.path, startMs: Date.parse(OTHER.at), endMs: Date.parse(TURN_END.at) }, - ]); - }); - - it("caps an unclosed declaration at its own moment, never at Infinity", () => { - // No turn_end at all - the session crashed right after declaring. - const intervals = buildTaskIntervals(journalOf([WANTED])); - - expect(intervals).toEqual([ - { path: WANTED.path, startMs: Date.parse(WANTED.at), endMs: Date.parse(WANTED.at) }, - ]); - expect(momentFallsWithin(intervals, "2026-08-17T10:30:00Z")).toBe(false); - }); - - it("caps an unclosed declaration at the last boundary the journal actually recorded", () => { - const laterStep = { - type: "step_start", - at: "2026-08-17T10:20:00Z", - skill: "aidd-dev:02-implement", - } as const; - const intervals = buildTaskIntervals(journalOf([WANTED], [laterStep])); - - // step_start is not one of the two kinds an interval closes on, but it is still the - // journal's own last recorded moment - the honest bound for a crash right after it. - expect(intervals[0].endMs).toBe(Date.parse(laterStep.at)); - }); - - it("never lets a step_start close a declared interval early - only task_declared and turn_end do", () => { - const stepBetween = { - type: "step_start", - at: "2026-08-17T10:05:00Z", - skill: "aidd-dev:02-implement", - } as const; - const intervals = buildTaskIntervals(journalOf([WANTED], [stepBetween, TURN_END])); - - expect(intervals).toEqual([ - { path: WANTED.path, startMs: Date.parse(WANTED.at), endMs: Date.parse(TURN_END.at) }, - ]); - }); - - it("declares no interval at all for a journal that never named a task", () => { - expect(buildTaskIntervals(journalOf([], [TURN_END]))).toEqual([]); - }); - - it("drops a task_declared line whose own `at` this reader cannot parse, the same as one that was never written", () => { - // A real line on disk, an `at` `timed()` cannot place in time - this session yields no - // usable interval, and `taskUnattributedReason` folds it into "no-declaration" beside a - // session that truly never declared, since neither can tell the two apart. The label - // that reason prints says "no *usable* declaration", never "none was ever declared", - // exactly because this case exists. - const unparseable = { - type: "task_declared", - at: "not-a-real-timestamp", - path: WANTED.path, - } as const; - - expect(buildTaskIntervals(journalOf([unparseable], [TURN_END]))).toEqual([]); - }); - - it("emits no interval for a declared path this reader cannot turn into an identity, but still lets it close the interval before it", () => { - // `task-declared.cjs`'s own gate is a scan over free-form tool-call text, looser than - // `taskIdentityFromWrittenPath` - a literal `..` path segment passes the hook and still - // names no task. Dropping the line from `closers` entirely (rather than only from the - // intervals it would otherwise produce) would let WANTED's own interval run past the - // moment the climbing line was actually declared - silently widening it. - const climbing = { - type: "task_declared", - at: "2026-08-17T10:10:00Z", - path: "aidd_docs/tasks/2026_08/../../etc/passwd", - } as const; - - const intervals = buildTaskIntervals(journalOf([WANTED, climbing], [TURN_END])); - - expect(intervals).toEqual([ - { path: WANTED.path, startMs: Date.parse(WANTED.at), endMs: Date.parse(climbing.at) }, - ]); - }); - - it("reads a moment inside the interval as covered, and one outside as not", () => { - const intervals = buildTaskIntervals(journalOf([WANTED], [TURN_END])); - - expect(momentFallsWithin(intervals, "2026-08-17T10:05:00Z")).toBe(true); - expect(momentFallsWithin(intervals, "2026-08-17T09:59:59Z")).toBe(false); - expect(momentFallsWithin(intervals, "2026-08-17T10:15:00Z")).toBe(false); - }); - - it("reads a record with no moment, or an unparseable one, as not covered", () => { - const intervals = buildTaskIntervals(journalOf([WANTED], [TURN_END])); - - expect(momentFallsWithin(intervals, undefined)).toBe(false); - expect(momentFallsWithin(intervals, "not-a-date")).toBe(false); - }); - - it("touches no filesystem — the module imports none of Node's fs APIs", () => { - const url = new URL("../../../src/domain/models/task-attribution.ts", import.meta.url); - const source = readFileSync(fileURLToPath(url), "utf8"); - - expect(source).not.toMatch(/from ["']node:fs/); - expect(source).not.toMatch(/require\(["']node:fs/); - }); - - // The bug this deliverable exists to fix: a session still running when a report is asked - // for has declared a task, written a file after it, and produced no turn_end yet. - // `lastMs` used to come only from step starts, turn ends and declarations - none of which - // exist here - so the interval collapsed to `[t, t)` and lost every record after it. - it("widens an unclosed declaration's end to a written file the journal witnessed after it", () => { - const writtenAfter = { - type: "file_written", - at: "2026-08-17T10:40:00Z", - path: "x.md", - } as const; - const intervals = buildTaskIntervals(journalOf([WANTED], [], [writtenAfter])); - - expect(intervals).toEqual([ - { path: WANTED.path, startMs: Date.parse(WANTED.at), endMs: Date.parse(writtenAfter.at) }, - ]); - // The record this bug used to lose: after the declaration, before the write, no - // turn_end anywhere in sight - the ordinary state of a session still running. - expect(momentFallsWithin(intervals, "2026-08-17T10:20:00Z")).toBe(true); - }); - - it("never lets a written file reach further back than the interval's own last closer", () => { - const writtenBefore = { - type: "file_written", - at: "2026-08-17T09:00:00Z", - path: "x.md", - } as const; - const intervals = buildTaskIntervals(journalOf([WANTED], [TURN_END], [writtenBefore])); - - expect(intervals).toEqual([ - { path: WANTED.path, startMs: Date.parse(WANTED.at), endMs: Date.parse(TURN_END.at) }, - ]); - }); - - it("still never runs away: a written file does not turn the interval open-ended", () => { - const writtenAfter = { - type: "file_written", - at: "2026-08-17T10:40:00Z", - path: "x.md", - } as const; - const intervals = buildTaskIntervals(journalOf([WANTED], [], [writtenAfter])); - - // Long after the last thing the journal witnessed - never attributed, whatever silence - // followed the write. - expect(momentFallsWithin(intervals, "2026-08-20T00:00:00Z")).toBe(false); - }); - - it("clamps an unclosed interval's end to the report's own period end, never past it", () => { - // A clock-skewed or damaged `file_written` line dated far in the future still parses - - // `timed()` only refuses a moment it cannot parse at all - and used to widen an - // unclosed interval's end to it unbounded, attributing anything the period could ever - // report. No record this reader is ever asked to place can fall past the period's own - // end, so capping there costs nothing real and closes the hole entirely. - const farFuture = { - type: "file_written", - at: "9999-12-31T00:00:00Z", - path: "x.md", - } as const; - const periodEndMs = Date.parse("2026-08-18T00:00:00Z"); - - const intervals = buildTaskIntervals(journalOf([WANTED], [], [farFuture]), periodEndMs); - - expect(intervals).toEqual([ - { path: WANTED.path, startMs: Date.parse(WANTED.at), endMs: periodEndMs }, - ]); - expect(momentFallsWithin(intervals, "2040-01-01T00:00:00Z")).toBe(false); - }); - - it("leaves an unclosed interval's end exactly where a real closer put it, when that is well inside the period", () => { - // The clamp must never pull a legitimate end earlier - only a witnessed moment beyond - // the period end is capped. - const periodEndMs = Date.parse("2026-08-20T00:00:00Z"); - - const intervals = buildTaskIntervals(journalOf([WANTED], [TURN_END]), periodEndMs); - - expect(intervals).toEqual([ - { path: WANTED.path, startMs: Date.parse(WANTED.at), endMs: Date.parse(TURN_END.at) }, - ]); - }); -}); - -describe("taskUnattributedReason — which of four distinct facts applies", () => { - it("names no-declaration for a session whose journal never declared a task", () => { - expect(taskUnattributedReason([], "2026-08-17T10:00:00Z")).toBe("no-declaration"); - }); - - it("names precedes-declaration for a record before the session's only declaration", () => { - const intervals = buildTaskIntervals(journalOf([WANTED], [TURN_END])); - - expect(taskUnattributedReason(intervals, "2026-08-17T09:00:00Z")).toBe("precedes-declaration"); - }); - - // Deleted on 2026-09-04, not moved: it asserted a reason for a moment that is now - // attributed, so it passed while proving nothing. It described a gap a `turn_end` left - // between two declarations — and a `turn_end` no longer closes one, so intervals run - // contiguously from each declaration to the next and no such gap can arise. - // `precedes-declaration` stays reachable only before a session's first declaration, - // which the test above covers. - - // The live case, and the reason this reason exists. A resumed transcript carries turns - // billed days before the session that read them ever started, so the sink dates them - // before its journal witnessed anything. Measured on 2026-09-04: 96.2% of a real period - // fell here and read `precedes-declaration`, which asserts the flow declared late. - it("names precedes-journal for a record older than everything its journal witnessed", () => { - const intervals = buildTaskIntervals(journalOf([WANTED], [TURN_END])); - const journalFromMs = Date.parse("2026-08-17T09:30:00Z"); - - expect(taskUnattributedReason(intervals, "2026-08-10T12:00:00Z", journalFromMs)).toBe( - "precedes-journal" - ); - }); - - it("still names precedes-declaration inside the span, before the first declaration", () => { - const intervals = buildTaskIntervals(journalOf([WANTED], [TURN_END])); - const journalFromMs = Date.parse("2026-08-17T09:30:00Z"); - - expect(taskUnattributedReason(intervals, "2026-08-17T09:45:00Z", journalFromMs)).toBe( - "precedes-declaration" - ); - }); - - // Why the coverage check runs first: a journal that declared nothing and never covered - // this record is described by the coverage fact, which is the one that explains why no - // declaration could have covered it. - it("names precedes-journal, not no-declaration, when the journal declared nothing either", () => { - const journalFromMs = Date.parse("2026-08-17T09:30:00Z"); - - expect(taskUnattributedReason([], "2026-08-10T12:00:00Z", journalFromMs)).toBe( - "precedes-journal" - ); - }); - - it("never claims coverage for a journal that carries no readable moment", () => { - const intervals = buildTaskIntervals(journalOf([WANTED], [TURN_END])); - - expect(taskUnattributedReason(intervals, "2026-08-10T12:00:00Z", undefined)).toBe( - "precedes-declaration" - ); - }); - - it("names journal-silent for a record after the last declared interval's own end", () => { - const intervals = buildTaskIntervals(journalOf([WANTED], [TURN_END])); - - expect(taskUnattributedReason(intervals, "2026-08-17T11:00:00Z")).toBe("journal-silent"); - }); - - it("names journal-silent for a record with no moment, once a task was declared", () => { - const intervals = buildTaskIntervals(journalOf([WANTED], [TURN_END])); - - expect(taskUnattributedReason(intervals, undefined)).toBe("journal-silent"); - expect(taskUnattributedReason(intervals, "not-a-date")).toBe("journal-silent"); - }); -}); diff --git a/cli/tests/domain/models/telemetry-host-registration.unit.test.ts b/cli/tests/domain/models/telemetry-host-registration.unit.test.ts deleted file mode 100644 index 4eb30cb17..000000000 --- a/cli/tests/domain/models/telemetry-host-registration.unit.test.ts +++ /dev/null @@ -1,105 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { - buildHostRegistration, - type TelemetryHostRegistrationEvidence, -} from "../../../src/domain/models/telemetry-setup.js"; - -const REGISTRY = "/home/dev/.claude/plugins/installed_plugins.json"; - -function evidence( - overrides: Partial = {} -): TelemetryHostRegistrationEvidence { - return { - tool: "claude", - plugins: [{ name: "aidd-telemetry", marketplace: "aidd-framework" }], - reading: { location: REGISTRY, refs: new Map([["aidd-telemetry@aidd-framework", true]]) }, - ...overrides, - }; -} - -function only(input: TelemetryHostRegistrationEvidence) { - const entry = buildHostRegistration([input]).entries[0]; - if (entry === undefined) throw new Error("expected exactly one entry"); - return entry; -} - -describe("what a host's own registry says about a plugin AIDD installed", () => { - it("is registered when the registry carries its ref", () => { - expect(only(evidence()).answer).toBe("registered"); - }); - - // The #703 failure itself: the declaration is perfectly good and the host drops it, - // because the host consults its registry and nothing else. - it("is not registered when the registry was read and lacks the ref", () => { - const entry = only(evidence({ reading: { location: REGISTRY, refs: new Map() } })); - - expect(entry.answer).toBe("not-registered"); - expect(entry.detail).toContain(REGISTRY); - expect(entry.detail).toContain("orphaned"); - }); - - // Folding this into `registered` would report a plugin that will not load as one that - // will, which is the whole defect being fixed, one layer down. - it("tells a disabled registration from an absent one", () => { - const reading = { - location: REGISTRY, - refs: new Map([["aidd-telemetry@aidd-framework", false]]), - }; - - expect(only(evidence({ reading })).answer).toBe("registered-disabled"); - }); - - it("is unanswerable when the registry could not be read, never `not-registered`", () => { - const reading = { location: REGISTRY, unreadable: "ENOENT" }; - const entry = only(evidence({ reading })); - - expect(entry.answer).toBe("unanswerable"); - expect(entry.detail).toContain("ENOENT"); - }); - - it("is unanswerable for a host nothing here knows how to ask", () => { - expect(only(evidence({ reading: undefined })).answer).toBe("unanswerable"); - }); - - /** - * Two silences, two different things for a person to do, so never one sentence. A tool - * that drives its own CLI keeps a registry somebody could go and measure; a tool that - * declares no native activation has none to look for. Getting these the wrong way round - * sends someone hunting for a file that does not exist, or tells them nothing is knowable - * about a file that is sitting there. - */ - it("says a declared registry is unmeasured, not that none exists", () => { - const entry = only(evidence({ reading: undefined, declaresNativeActivation: true })); - - expect(entry.detail).toContain("has established its shape"); - }); - - it("says a host declaring no registry has none to read", () => { - const entry = only(evidence({ reading: undefined, declaresNativeActivation: false })); - - expect(entry.detail).toContain("declares no plugin registry"); - }); - - // Every measured host keys its registry on `@`, so a plugin with no - // marketplace recorded cannot be looked up anywhere — unanswerable at the source, not a - // lookup that came back empty. - it("is unanswerable when no ref can be built at all, and names no ref", () => { - const entry = only(evidence({ plugins: [{ name: "hand-copied" }] })); - - expect(entry.answer).toBe("unanswerable"); - expect(entry.ref).toBeUndefined(); - }); - - it("gives every plugin its own entry, across tools", () => { - const result = buildHostRegistration([ - evidence(), - evidence({ plugins: [{ name: "aidd-dev", marketplace: "aidd-framework" }] }), - ]); - - expect(result.entries.map((e) => e.plugin)).toEqual(["aidd-telemetry", "aidd-dev"]); - }); - - it("reports no entry for a project with nothing installed", () => { - expect(buildHostRegistration([])).toEqual({ entries: [] }); - }); -}); diff --git a/cli/tests/domain/models/telemetry-setup.unit.test.ts b/cli/tests/domain/models/telemetry-setup.unit.test.ts deleted file mode 100644 index 0293862b6..000000000 --- a/cli/tests/domain/models/telemetry-setup.unit.test.ts +++ /dev/null @@ -1,66 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { buildTelemetryAllowedSetup } from "../../../src/domain/models/telemetry-setup.js"; - -const SWITCH_PATH = "/repo/.aidd/config.json"; - -describe("buildTelemetryAllowedSetup — whose choice this was", () => { - it("reads a project's own switch, turned on, as the project's decision", () => { - const setup = buildTelemetryAllowedSetup( - { path: SWITCH_PATH, enabled: true, readable: true }, - {} - ); - expect(setup).toEqual({ - allowed: true, - decidedBy: "project-switch", - location: SWITCH_PATH, - readable: true, - }); - }); - - it("reads a project never switched on as the project's own decision, not a refusal", () => { - const setup = buildTelemetryAllowedSetup( - { path: SWITCH_PATH, enabled: false, readable: true }, - {} - ); - expect(setup.allowed).toBe(false); - expect(setup.decidedBy).toBe("project-switch"); - }); - - it("reads AIDD_TELEMETRY=0 as this person's own refusal, whatever the project file says", () => { - const setup = buildTelemetryAllowedSetup( - { path: SWITCH_PATH, enabled: true, readable: true }, - { AIDD_TELEMETRY: "0" } - ); - expect(setup).toEqual({ - allowed: false, - decidedBy: "person-refusal", - location: "AIDD_TELEMETRY", - readable: true, - }); - }); - - it("never lets a damaged switch file masquerade as a refusal", () => { - const setup = buildTelemetryAllowedSetup( - { path: SWITCH_PATH, enabled: false, readable: false }, - {} - ); - expect(setup.decidedBy).toBe("project-switch"); - expect(setup.readable).toBe(false); - }); - - it("reads a person's refusal as always readable — an env var never fails to read", () => { - const setup = buildTelemetryAllowedSetup( - { path: SWITCH_PATH, enabled: false, readable: false }, - { AIDD_TELEMETRY: "0" } - ); - expect(setup.readable).toBe(true); - }); - - it("never treats an unset AIDD_TELEMETRY as a refusal", () => { - const setup = buildTelemetryAllowedSetup( - { path: SWITCH_PATH, enabled: true, readable: true }, - { AIDD_TELEMETRY: "" } - ); - expect(setup.decidedBy).toBe("project-switch"); - }); -}); diff --git a/cli/tests/domain/models/telemetry-sink-record.unit.test.ts b/cli/tests/domain/models/telemetry-sink-record.unit.test.ts deleted file mode 100644 index 54597b674..000000000 --- a/cli/tests/domain/models/telemetry-sink-record.unit.test.ts +++ /dev/null @@ -1,154 +0,0 @@ -import { readFileSync } from "node:fs"; -import { fileURLToPath } from "node:url"; -import { describe, expect, it } from "vitest"; -import { UnknownTelemetrySinkSchemaVersionError } from "../../../src/domain/errors.js"; -import { - parseTelemetrySinkLine, - SINK_SCHEMA_VERSION, - type TelemetrySinkRecord, - telemetrySinkRecordDayKey, -} from "../../../src/domain/models/telemetry-sink-record.js"; - -describe("parseTelemetrySinkLine()", () => { - it("rejects an unknown sink_schema_version rather than guessing its shape", () => { - expect(() => - parseTelemetrySinkLine(JSON.stringify({ sink_schema_version: 999, kind: "request" })) - ).toThrow(UnknownTelemetrySinkSchemaVersionError); - }); - - // The literal version this schema moved past — v1 carried no `provenance`, so guessing - // one for it would be exactly the false "old route" default the field exists to forbid. - it("rejects the v1 shape specifically, not just an unrecognised number", () => { - expect(() => - parseTelemetrySinkLine( - JSON.stringify({ sink_schema_version: 1, kind: "request", vendor_id: "s-1" }) - ) - ).toThrow(UnknownTelemetrySinkSchemaVersionError); - }); - - it("parses a hand-written fixture the mapper never produced", () => { - const url = new URL("../../fixtures/telemetry-sink/expected.jsonl", import.meta.url); - const lines = readFileSync(fileURLToPath(url), "utf8").trim().split("\n"); - const records = lines.map(parseTelemetrySinkLine); - - const requestLine = records.find((r) => r.kind === "request"); - expect(requestLine?.vendor_id).toBeTruthy(); - expect(requestLine?.vendor_field).toBeTruthy(); - expect(requestLine?.cost_usd).toBeGreaterThan(0); - expect(requestLine?.model).toBeTruthy(); - - const sessionLine = records.find((r) => r.kind === "session" && r.active_time_s !== undefined); - expect(sessionLine?.active_time_s).toBeGreaterThan(0); - expect(sessionLine?.turn_id).toBeUndefined(); - }); - - // `user_id` predates the rule that an export-provenance record carries no identity, and - // the fixture still carries it on purpose: the sink is append-only, so a line a pre-removal - // build already wrote keeps the field forever. Parsing must not choke on it, and nothing - // reads it back out now that it is off the type. - it("parses a stored line that still carries the now-removed user_id, inertly", () => { - const url = new URL("../../fixtures/telemetry-sink/expected.jsonl", import.meta.url); - const lines = readFileSync(fileURLToPath(url), "utf8").trim().split("\n"); - expect(lines.some((line) => line.includes("user_id"))).toBe(true); - - const records = lines.map(parseTelemetrySinkLine); - const legacy = records.find((r) => "user_id" in r); - // `in` narrows the parsed record to one that still carries the field, so the value - // below is read off a type - and the fixture losing the line fails here, loudly. - if (legacy === undefined || !("user_id" in legacy)) { - throw new Error("fixture no longer carries a user_id line"); - } - expect(legacy.user_id).toBe("user_example_hash_0000000000000000"); - }); - - it("carries provenance for both routes, on the same fixture", () => { - const url = new URL("../../fixtures/telemetry-sink/expected.jsonl", import.meta.url); - const lines = readFileSync(fileURLToPath(url), "utf8").trim().split("\n"); - const records = lines.map(parseTelemetrySinkLine); - expect(records.some((r) => r.provenance === "export")).toBe(true); - expect(records.some((r) => r.provenance === "local-read")).toBe(true); - }); - - // This fixture predates cli_version entirely - the hand-written stand-in for a record a - // build before this field existed actually wrote. Parsing it must not choke, and every - // record on it must still be there to count: an unknown version costs a field, never a - // figure. - it("parses a line written before cli_version existed, losing no figure to the gap", () => { - const url = new URL("../../fixtures/telemetry-sink/expected.jsonl", import.meta.url); - const lines = readFileSync(fileURLToPath(url), "utf8").trim().split("\n"); - expect(lines.some((line) => line.includes("cli_version"))).toBe(false); - - const records = lines.map(parseTelemetrySinkLine); - expect(records).toHaveLength(lines.length); - for (const record of records) { - expect("cli_version" in record).toBe(false); - } - }); -}); - -describe("telemetrySinkRecordDayKey()", () => { - const BASE: TelemetrySinkRecord = { - sink_schema_version: SINK_SCHEMA_VERSION, - kind: "request", - provenance: "local-read", - tool: "claude", - vendor_id: "s-1", - vendor_field: "sessionId", - step_attribution: "unattributed", - }; - - it("answers the UTC day for a real moment, the fast path and the parsed one alike", () => { - expect(telemetrySinkRecordDayKey({ ...BASE, event_timestamp: "2026-08-18T01:00:00Z" })).toBe( - "2026-08-18" - ); - // No `Z` offset - the parsed path, not the sliced one. - expect( - telemetrySinkRecordDayKey({ ...BASE, event_timestamp: "2026-08-18T01:00:00+05:00" }) - ).toBe("2026-08-17"); - }); - - it("answers undefined for no moment at all", () => { - expect(telemetrySinkRecordDayKey({ ...BASE })).toBeUndefined(); - }); - - // The latent defect: ten-or-more characters ending in "Z" took the fast slice path - // unconditionally, so a string merely shaped like a moment answered a fragment nothing on - // the calendar matches ("not-a-mome") instead of the `undefined` this function's own - // docstring promises. A day file mistakenly filed under a fragment like that would leave - // the record in `totals` while it vanished from `byDays` - the two silently disagreeing. - it("answers undefined for a string merely shaped like a moment, never a sliced fragment", () => { - expect( - telemetrySinkRecordDayKey({ ...BASE, event_timestamp: "not-a-momentZ" }) - ).toBeUndefined(); - }); -}); - -describe("telemetrySinkRecordDayKey() — a line holds whatever it holds", () => { - const BASE: TelemetrySinkRecord = { - sink_schema_version: SINK_SCHEMA_VERSION, - kind: "request", - provenance: "local-read", - tool: "claude", - vendor_id: "s-1", - vendor_field: "sessionId", - step_attribution: "unattributed", - }; - - // `parseTelemetrySinkLine` validates the schema version and casts the rest, so this field - // is only a string by convention. A number used to pass the absence check and be read as - // epoch milliseconds: the record landed on 1970-01-01, fell outside every real period, - // and disappeared without ever counting as undated. - /** Built through the real parse, which is the only way a record of the wrong shape ever - * reaches this function: it checks `sink_schema_version` and casts the rest. */ - function recordFromLine(overrides: Record): TelemetrySinkRecord { - return parseTelemetrySinkLine(JSON.stringify({ ...BASE, ...overrides })); - } - - it("answers nothing for a moment stored as a number, never 1970", () => { - expect(telemetrySinkRecordDayKey(recordFromLine({ event_timestamp: 12_345 }))).toBeUndefined(); - }); - - it("answers nothing for a moment stored as null", () => { - expect(telemetrySinkRecordDayKey(recordFromLine({ event_timestamp: null }))).toBeUndefined(); - }); -}); diff --git a/cli/tests/domain/models/tool-ids.unit.test.ts b/cli/tests/domain/models/tool-ids.unit.test.ts deleted file mode 100644 index d1a8e8f87..000000000 --- a/cli/tests/domain/models/tool-ids.unit.test.ts +++ /dev/null @@ -1,58 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { UnknownAiToolIdError } from "../../../src/domain/errors.js"; -import { - assertValidAiToolId, - isAiToolId, - parseToolOption, -} from "../../../src/domain/models/tool-ids.js"; - -describe("isAiToolId", () => { - it("returns true for known AI tool IDs", () => { - expect(isAiToolId("claude")).toBe(true); - expect(isAiToolId("cursor")).toBe(true); - expect(isAiToolId("copilot")).toBe(true); - expect(isAiToolId("opencode")).toBe(true); - expect(isAiToolId("codex")).toBe(true); - }); - - it("returns false for unknown strings", () => { - expect(isAiToolId("unknown")).toBe(false); - expect(isAiToolId("vscode")).toBe(false); - expect(isAiToolId("")).toBe(false); - }); -}); - -describe("parseToolOption", () => { - it("returns 'all' when argument is undefined", () => { - expect(parseToolOption(undefined)).toBe("all"); - }); - - it("returns 'all' when argument is the string 'all'", () => { - expect(parseToolOption("all")).toBe("all"); - }); - - it("returns a single-element array for a named tool", () => { - expect(parseToolOption("claude")).toEqual(["claude"]); - expect(parseToolOption("cursor")).toEqual(["cursor"]); - }); -}); - -describe("assertValidAiToolId", () => { - it("does not throw when id is undefined", () => { - expect(() => assertValidAiToolId(undefined)).not.toThrow(); - }); - - it("does not throw when id is 'all'", () => { - expect(() => assertValidAiToolId("all")).not.toThrow(); - }); - - it("does not throw for valid AI tool IDs", () => { - expect(() => assertValidAiToolId("claude")).not.toThrow(); - expect(() => assertValidAiToolId("cursor")).not.toThrow(); - }); - - it("throws UnknownAiToolIdError for invalid IDs", () => { - expect(() => assertValidAiToolId("invalid-tool")).toThrow(UnknownAiToolIdError); - expect(() => assertValidAiToolId("vscode")).toThrow(UnknownAiToolIdError); - }); -}); diff --git a/cli/tests/domain/tools/ai/claude.unit.test.ts b/cli/tests/domain/tools/ai/claude.unit.test.ts deleted file mode 100644 index be96528a8..000000000 --- a/cli/tests/domain/tools/ai/claude.unit.test.ts +++ /dev/null @@ -1,132 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { claude } from "../../../../src/domain/tools/ai/claude.js"; - -describe("claude", () => { - describe("capabilities.mcp", () => { - it("outputs to .mcp.json", () => { - expect(claude.capabilities.mcp.params.outputPath).toBe(".mcp.json"); - }); - - it("consumes the mcp config name", () => { - expect(claude.capabilities.mcp.consumes).toContain("mcp"); - }); - - it("does not consume unknown config names", () => { - expect(claude.capabilities.mcp.consumes).not.toContain("vscodeDir"); - }); - - it("mcp config preserves user customizations during update", () => { - expect(claude.capabilities.mcp.params.mergeStrategy ?? "user-prime").toBe("user-prime"); - }); - }); - - describe("capabilities.rules.convertFrontmatter()", () => { - it("preserves paths: list when already in Claude format", () => { - const fm = { paths: ["src/**/*.ts"] }; - const result = claude.capabilities.rules?.convertFrontmatter(fm); - expect(result).toEqual({ paths: ["src/**/*.ts"] }); - }); - - it("strips extra fields when paths key is present", () => { - const fm = { paths: ["src/**/*.ts"], description: "extra", alwaysApply: false }; - const result = claude.capabilities.rules?.convertFrontmatter(fm); - expect(result).toEqual({ paths: ["src/**/*.ts"] }); - }); - - it("converts cursor-style globs to paths", () => { - const fm = { globs: ["src/**/*.ts"], alwaysApply: false, description: "desc" }; - const result = claude.capabilities.rules?.convertFrontmatter(fm); - expect(result).toEqual({ paths: ["src/**/*.ts"] }); - }); - - it("returns empty frontmatter for always-apply rules (no paths field = unconditional load)", () => { - const fm = { description: "desc", alwaysApply: true }; - const result = claude.capabilities.rules?.convertFrontmatter(fm); - expect(result).toEqual({}); - }); - - it("keeps description when alwaysApply is false and no paths are specified", () => { - const fm = { description: "Apply when editing command files.", alwaysApply: false }; - const result = claude.capabilities.rules?.convertFrontmatter(fm); - expect(result).toEqual({ description: "Apply when editing command files." }); - }); - }); - - describe("capabilities.agents.convertFrontmatter()", () => { - it("strips extra fields for agents sections — only name and description", () => { - const fm = { name: "alexia", description: "Agent", model: "opus" }; - const result = claude.capabilities.agents.convertFrontmatter(fm); - expect(result).toEqual({ name: "alexia", description: "Agent" }); - }); - }); - - describe("capabilities.commands.convertFrontmatter()", () => { - it("prefixes name with aidd:{phase}:", () => { - const fm = { name: "implement", description: "Implement a plan" }; - const result = claude.capabilities.commands?.convertFrontmatter(fm, "04_code/implement.md"); - expect(result).toEqual({ name: "aidd:04:implement", description: "Implement a plan" }); - }); - - it("preserves argument-hint when present", () => { - const fm = { name: "implement", description: "Implement a plan", "argument-hint": "task" }; - const result = claude.capabilities.commands?.convertFrontmatter(fm, "04_code/implement.md"); - expect(result).toEqual({ - name: "aidd:04:implement", - description: "Implement a plan", - "argument-hint": "task", - }); - }); - }); - - describe("capabilities.agents.buildInstallPath()", () => { - it("builds path for agents section", () => { - const path = claude.capabilities.agents.buildInstallPath("code-reviewer.md"); - expect(path).toBe(".claude/agents/code-reviewer.md"); - }); - }); - - describe("capabilities.rules.buildInstallPath()", () => { - it("builds path for rules section with subdirectory", () => { - const path = claude.capabilities.rules?.buildInstallPath("01-standards/naming.md"); - expect(path).toBe(".claude/rules/01-standards/naming.md"); - }); - }); - - describe("capabilities.commands.buildInstallPath()", () => { - it("builds commands path with aidd brand prefix and phase number", () => { - const path = claude.capabilities.commands?.buildInstallPath("04_code/implement.md"); - expect(path).toBe(".claude/commands/aidd/04/implement.md"); - }); - - it("handles two-digit phase in commands", () => { - const path = claude.capabilities.commands?.buildInstallPath( - "02_context/create_user_stories.md" - ); - expect(path).toBe(".claude/commands/aidd/02/create_user_stories.md"); - }); - }); - - describe("capabilities.plugins", () => { - it("has a plugins capability", () => { - expect("plugins" in claude.capabilities).toBe(true); - }); - - it("is native mode", () => { - expect(claude.capabilities.plugins.mode).toBe("native"); - }); - - it("uses .claude/plugins/ as plugins directory", () => { - expect(claude.capabilities.plugins.pluginsDir).toBe(".claude/plugins/"); - }); - - it("uses plugin.json as plugin manifest path", () => { - expect(claude.capabilities.plugins.pluginManifestRelativePath).toBe("plugin.json"); - }); - - it("pluginOutputDir returns correct path for a plugin name", () => { - expect(claude.capabilities.plugins.pluginOutputDir("my-plugin")).toBe( - ".claude/plugins/my-plugin/" - ); - }); - }); -}); diff --git a/cli/tests/domain/tools/ai/codex.unit.test.ts b/cli/tests/domain/tools/ai/codex.unit.test.ts deleted file mode 100644 index ec4daf67b..000000000 --- a/cli/tests/domain/tools/ai/codex.unit.test.ts +++ /dev/null @@ -1,271 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { codex, mergeCodexConfigToml } from "../../../../src/domain/tools/ai/codex.js"; -import { getToolConfig } from "../../../../src/domain/tools/registry.js"; - -describe("codex", () => { - it("has toolId codex", () => { - expect(codex.toolId).toBe("codex"); - }); - - it("has .codex/ directory", () => { - expect(codex.directory).toBe(".codex/"); - }); - - it("has .codex.md tool suffix", () => { - expect(codex.toolSuffix).toBe(".codex.md"); - }); - - it("has signalDir pointing at .codex/commands", () => { - expect(codex.signalDir).toBe(".codex/commands"); - }); - - it("is registered in the tool registry", () => { - const config = getToolConfig("codex"); - expect(config.toolId).toBe("codex"); - }); - - describe("capabilities.skills.buildInstallPath()", () => { - it("builds path under .agents/skills/aidd-{name}/SKILL.md", () => { - const path = codex.capabilities.skills.buildInstallPath("my-skill/SKILL.md"); - expect(path).toBe(".agents/skills/aidd-my-skill/SKILL.md"); - }); - - it("strips .codex.md tool suffix", () => { - const path = codex.capabilities.skills.buildInstallPath("my-skill.codex.md"); - expect(path).toBe(".agents/skills/aidd-my-skill/SKILL.md"); - }); - - it("strips plain .md suffix from skill name", () => { - const path = codex.capabilities.skills.buildInstallPath("my-skill.md"); - expect(path).toBe(".agents/skills/aidd-my-skill/SKILL.md"); - }); - }); - - describe("capabilities.agents.buildInstallPath()", () => { - it("builds .toml path under .codex/agents/", () => { - const path = codex.capabilities.agents.buildInstallPath("alexia.md"); - expect(path).toBe(".codex/agents/alexia.toml"); - }); - }); - - describe("capabilities.mcp", () => { - it("outputs to .codex/config.toml", () => { - expect(codex.capabilities.mcp.params.outputPath).toBe(".codex/config.toml"); - }); - - it("consumes the mcp config name", () => { - expect(codex.capabilities.mcp.consumes).toContain("mcp"); - }); - - it("uses user-prime merge strategy", () => { - expect(codex.capabilities.mcp.params.mergeStrategy ?? "user-prime").toBe("user-prime"); - }); - - it("uses mcp_servers as entry section", () => { - expect(codex.capabilities.mcp.params.entrySection).toBe("mcp_servers"); - }); - }); - - describe("capabilities.hooks", () => { - it("outputs to .codex/hooks.json", () => { - expect(codex.capabilities.hooks.buildOutputPath()).toBe(".codex/hooks.json"); - }); - - it("consumes the codex-hooks config name", () => { - expect(codex.capabilities.hooks.consumes).toContain("codex-hooks"); - }); - - it("uses user-prime merge strategy", () => { - expect(codex.capabilities.hooks.getMergeStrategy()).toBe("user-prime"); - }); - - it("uses SessionStart as entry section", () => { - expect(codex.capabilities.hooks.getEntrySection()).toBe("SessionStart"); - }); - - it("returns null entry section for unknown config names", () => { - const cap = [codex.capabilities.mcp, codex.capabilities.hooks].find((c) => - c.consumes.includes("unknown") - ); - expect(cap).toBeUndefined(); - }); - }); - - describe("capabilities.commands.buildInstallPath()", () => { - it("maps phase-prefixed path to .codex/commands/aidd// subfolder", () => { - const path = codex.capabilities.commands.buildInstallPath("04_code/implement.md"); - expect(path).toBe(".codex/commands/aidd/04/implement.md"); - }); - - it("maps top-level file to .codex/commands/aidd/ without phase", () => { - const path = codex.capabilities.commands.buildInstallPath("commit.md"); - expect(path).toBe(".codex/commands/aidd/commit.md"); - }); - }); - - describe("capabilities.commands.convertFrontmatter()", () => { - it("prefixes name with aidd:: and strips extra fields", () => { - const fm = { name: "implement", description: "Implement", model: "sonnet" }; - const result = codex.capabilities.commands.convertFrontmatter(fm, "04_code/implement.md"); - expect(result).toEqual({ name: "aidd:04:implement", description: "Implement" }); - }); - }); - - describe("capabilities.commands.reverseConvertFrontmatter()", () => { - it("strips aidd:: prefix from name", () => { - const result = codex.capabilities.commands.reverseConvertFrontmatter({ - name: "aidd:04:implement", - description: "Impl", - }); - expect(result).toEqual({ name: "implement", description: "Impl" }); - }); - }); - - describe("capabilities.rules.buildInstallPath()", () => { - it("builds path for rules under .codex/rules/", () => { - const path = codex.capabilities.rules.buildInstallPath("01-standards/naming.md"); - expect(path).toBe(".codex/rules/01-standards/naming.md"); - }); - - it("strips .codex.md tool suffix from rules path", () => { - const path = codex.capabilities.rules.buildInstallPath("01-standards/naming.codex.md"); - expect(path).toBe(".codex/rules/01-standards/naming.md"); - }); - }); - - describe("capabilities.rules.convertFrontmatter()", () => { - it("passes frontmatter through unchanged", () => { - const fm = { paths: ["src/**/*.ts"], description: "TS rules" }; - const result = codex.capabilities.rules.convertFrontmatter(fm); - expect(result).toEqual(fm); - }); - }); - - describe("detectUserFileSectionKey()", () => { - it("detects agents section for .codex/agents/ paths", () => { - const key = codex.detectUserFileSectionKey(".codex/agents/alexia.toml"); - expect(key).toEqual({ section: "agents", key: "alexia.toml" }); - }); - - it("detects skills section for .agents/skills/aidd- paths", () => { - const key = codex.detectUserFileSectionKey(".agents/skills/aidd-my-skill/SKILL.md"); - expect(key).toEqual({ section: "skills", key: "my-skill/SKILL.md" }); - }); - - it("detects commands section for .codex/commands/aidd/ paths", () => { - const key = codex.detectUserFileSectionKey(".codex/commands/aidd/04/implement.md"); - expect(key).toEqual({ section: "commands", key: "04/implement.md" }); - }); - - it("detects rules section for .codex/rules/ paths", () => { - const key = codex.detectUserFileSectionKey(".codex/rules/01-standards/naming.md"); - expect(key).toEqual({ section: "rules", key: "01-standards/naming.md" }); - }); - - it("returns null for unrecognised paths", () => { - expect(codex.detectUserFileSectionKey("AGENTS.md")).toBeNull(); - expect(codex.detectUserFileSectionKey("unknown.json")).toBeNull(); - }); - }); - - describe("capabilities.plugins", () => { - it("declares native codex CLI activation", () => { - expect(codex.capabilities.plugins.nativeActivation).toEqual({ binary: "codex" }); - }); - - it("does not write a project-local marketplace settings file", () => { - expect(codex.capabilities.plugins.marketplaceSettings).toBeNull(); - }); - - it("keeps the marketplace translation mode", () => { - expect(codex.capabilities.plugins.translationMode).toBe("marketplace"); - }); - }); -}); - -const MCP_PAYLOAD = ` -[mcp_servers.playwright] -command = "npx" -args = ["-y", "@anthropic-ai/mcp-playwright"] -`; - -describe("mergeCodexConfigToml", () => { - it("writes full payload into empty file", () => { - const result = mergeCodexConfigToml("", MCP_PAYLOAD); - expect(result).toContain("mcp_servers"); - expect(result).toContain("playwright"); - expect(result).toContain("project_doc_max_bytes = 262144"); - expect(result).toContain("hooks = true"); - }); - - it("preserves user keys not managed by AIDD", () => { - const existing = ` -[user_section] -custom_key = "user value" -`; - const result = mergeCodexConfigToml(existing, MCP_PAYLOAD); - expect(result).toContain('custom_key = "user value"'); - expect(result).toContain("playwright"); - }); - - it("is idempotent on second run", () => { - const first = mergeCodexConfigToml("", MCP_PAYLOAD); - const second = mergeCodexConfigToml(first, MCP_PAYLOAD); - expect(second).toContain("playwright"); - const mcpCount = (second.match(/\[mcp_servers\.playwright\]/g) ?? []).length; - expect(mcpCount).toBe(1); - }); - - it("existing MCP server wins on conflict (user-prime)", () => { - const existing = ` -[mcp_servers.playwright] -command = "user-command" -`; - const result = mergeCodexConfigToml(existing, MCP_PAYLOAD); - expect(result).toContain('command = "user-command"'); - expect(result).not.toContain('"npx"'); - }); - - it("preserves user project_doc_max_bytes when above minimum", () => { - const existing = `project_doc_max_bytes = 999999`; - const result = mergeCodexConfigToml(existing, MCP_PAYLOAD); - expect(result).toContain("project_doc_max_bytes = 999999"); - expect(result).not.toContain("262144"); - }); - - it("sets minimum project_doc_max_bytes when absent", () => { - const result = mergeCodexConfigToml("", MCP_PAYLOAD); - expect(result).toContain("project_doc_max_bytes = 262144"); - }); - - it("ensures hooks feature when absent", () => { - const result = mergeCodexConfigToml("", MCP_PAYLOAD); - expect(result).toContain("hooks = true"); - }); - - it("preserves user codex_hooks value when already set", () => { - const existing = ` -[features] -codex_hooks = false -`; - const result = mergeCodexConfigToml(existing, MCP_PAYLOAD); - expect(result).toContain("codex_hooks = false"); - expect(result).not.toContain("hooks = true"); - }); - - it("does NOT emit [[skills.config]] — discovery is by placement", () => { - const result = mergeCodexConfigToml("", MCP_PAYLOAD); - expect(result).not.toContain(".agents/skills"); - expect(result).not.toContain("skills.config"); - }); - - it("preserves existing skills.config if user has one", () => { - const existing = ` -[skills.config] -path = ".agents/skills" -enabled = true -`; - const result = mergeCodexConfigToml(existing, MCP_PAYLOAD); - expect(result).toContain(".agents/skills"); - }); -}); diff --git a/cli/tests/domain/tools/ai/copilot.unit.test.ts b/cli/tests/domain/tools/ai/copilot.unit.test.ts deleted file mode 100644 index ffd558000..000000000 --- a/cli/tests/domain/tools/ai/copilot.unit.test.ts +++ /dev/null @@ -1,250 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { copilot } from "../../../../src/domain/tools/ai/copilot.js"; - -describe("copilot", () => { - describe("capabilities.rules.convertFrontmatter()", () => { - it("converts paths: list to applyTo: comma-joined string", () => { - const result = copilot.capabilities.rules?.convertFrontmatter({ - paths: ["src/**/*.ts"], - }); - expect(result).toHaveProperty("applyTo", "src/**/*.ts"); - expect(result).not.toHaveProperty("paths"); - }); - - it("returns empty frontmatter when paths is empty", () => { - const result = copilot.capabilities.rules?.convertFrontmatter({ paths: [] }); - expect(result).toEqual({}); - }); - - it("returns empty frontmatter when no paths or globs (always apply)", () => { - const result = copilot.capabilities.rules?.convertFrontmatter({}); - expect(result).toEqual({}); - }); - }); - - describe("capabilities.agents.convertFrontmatter()", () => { - it("strips extra fields for agents sections — only name and description", () => { - const fm = { name: "alexia", description: "Agent", model: "opus" }; - const result = copilot.capabilities.agents.convertFrontmatter(fm); - expect(result).toEqual({ name: "alexia", description: "Agent" }); - }); - }); - - describe("capabilities.mcp", () => { - it("maps mcp to .vscode/mcp.json", () => { - expect(copilot.capabilities.mcp.params.outputPath).toBe(".vscode/mcp.json"); - }); - - it("consumes the mcp config name", () => { - expect(copilot.capabilities.mcp.consumes).toContain("mcp"); - }); - }); - - describe("capabilities.settings", () => { - const settings = Array.isArray(copilot.capabilities.settings) - ? copilot.capabilities.settings[0] - : copilot.capabilities.settings; - - it("writes to .vscode/settings.json", () => { - expect(settings.params.outputPath).toBe(".vscode/settings.json"); - }); - - it("uses framework-prime merge strategy", () => { - expect(settings.getMergeStrategy()).toBe("framework-prime"); - }); - - it("references vscode-settings.json asset file (not hardcoded staticContent)", () => { - expect(settings.staticContentAssetFile).toBe("vscode-settings.json"); - expect(settings.staticContent).toBeUndefined(); - }); - - it("does not consume framework signals (content is CLI-owned)", () => { - expect(settings.consumes).toHaveLength(0); - }); - - it("declares requiresTool: vscode (gate merge to IDE-present context)", () => { - expect(settings.requiresTool).toBe("vscode"); - }); - }); - - describe("capabilities.commands.buildInstallPath()", () => { - it("flattens commands: prefixes with phase number", () => { - const path = copilot.capabilities.commands?.buildInstallPath("04_code/implement.md"); - expect(path).toBe(".github/prompts/04-implement.prompt.md"); - }); - - it("flattens commands: converts underscores to hyphens in filename", () => { - const path = copilot.capabilities.commands?.buildInstallPath("00_behavior/auto_accept.md"); - expect(path).toBe(".github/prompts/00-auto-accept.prompt.md"); - }); - - it("handles top-level commands file without subdirectory", () => { - const path = copilot.capabilities.commands?.buildInstallPath("commit.md"); - expect(path).toBe(".github/prompts/commit.prompt.md"); - }); - }); - - describe("capabilities.rules.buildInstallPath()", () => { - it("flattens rules: prefixes with category number, strips file numeric prefix", () => { - const path = copilot.capabilities.rules?.buildInstallPath("01-standards/1-mermaid.md"); - expect(path).toBe(".github/instructions/01-mermaid.instructions.md"); - }); - - it("flattens rules: no numeric prefix in filename — unchanged", () => { - const path = copilot.capabilities.rules?.buildInstallPath("01-standards/naming.md"); - expect(path).toBe(".github/instructions/01-naming.instructions.md"); - }); - - it("flattens rules: strips .copilot tool suffix from filename", () => { - const path = copilot.capabilities.rules?.buildInstallPath( - "04-tooling/ide-mapping.copilot.md" - ); - expect(path).toBe(".github/instructions/04-ide-mapping.instructions.md"); - }); - - it("returns null for .gitkeep files", () => { - expect(copilot.capabilities.rules?.buildInstallPath("00-architecture/.gitkeep")).toBeNull(); - }); - }); - - describe("capabilities.agents.buildInstallPath()", () => { - it("adds .agent.md extension", () => { - const path = copilot.capabilities.agents.buildInstallPath("code-reviewer.md"); - expect(path).toBe(".github/agents/code-reviewer.agent.md"); - }); - - it("returns null for .gitkeep files", () => { - expect(copilot.capabilities.agents.buildInstallPath(".gitkeep")).toBeNull(); - }); - }); - - describe("capabilities.skills.buildInstallPath()", () => { - it("preserves directory structure without flattening", () => { - const path = copilot.capabilities.skills.buildInstallPath("commit/SKILL.md"); - expect(path).toBe(".github/skills/commit/SKILL.md"); - }); - }); - - describe("capabilities.rules.convertFrontmatter() — alwaysApply", () => { - it("returns empty frontmatter when alwaysApply is false without patterns and no description", () => { - expect(copilot.capabilities.rules?.convertFrontmatter({ alwaysApply: false })).toEqual({}); - }); - - it("keeps description when alwaysApply is false and no patterns are specified", () => { - expect( - copilot.capabilities.rules?.convertFrontmatter({ - description: "Apply when editing command files.", - alwaysApply: false, - }) - ).toEqual({ description: "Apply when editing command files." }); - }); - - it("converts globs + alwaysApply: false from framework to applyTo", () => { - expect( - copilot.capabilities.rules?.convertFrontmatter({ - globs: ["{{TOOLS}}/rules/**/*.md"], - alwaysApply: false, - }) - ).toEqual({ applyTo: "{{TOOLS}}/rules/**/*.md" }); - }); - }); - - describe("capabilities.plugins", () => { - it("has a plugins capability", () => { - expect("plugins" in copilot.capabilities).toBe(true); - }); - - it("is native mode", () => { - expect(copilot.capabilities.plugins.mode).toBe("native"); - }); - - it("uses .github/plugins/ as plugins directory", () => { - expect(copilot.capabilities.plugins.pluginsDir).toBe(".github/plugins/"); - }); - - it("uses plugin.json as plugin manifest path", () => { - expect(copilot.capabilities.plugins.pluginManifestRelativePath).toBe("plugin.json"); - }); - - it("pluginOutputDir returns correct path for a plugin name", () => { - expect(copilot.capabilities.plugins.pluginOutputDir("my-plugin")).toBe( - ".github/plugins/my-plugin/" - ); - }); - }); - - describe("capabilities.plugins.marketplaceSettings", () => { - const ms = copilot.capabilities.plugins.marketplaceSettings; - - it("has marketplaceSettings configured", () => { - expect(ms).not.toBeNull(); - }); - - it("writes to .github/copilot/settings.json", () => { - expect(ms?.settingsPath).toBe(".github/copilot/settings.json"); - }); - - it("uses extraKnownMarketplaces as settings key", () => { - expect(ms?.settingsKey).toBe("extraKnownMarketplaces"); - }); - - it("uses enabledPlugins as enabled plugins key", () => { - expect(ms?.enabledPluginsKey).toBe("enabledPlugins"); - }); - - describe("toEntry()", () => { - it("returns map entry with github source shape for github source", () => { - const result = ms?.toEntry({ - name: "aidd-framework", - source: { kind: "github", repo: "ai-driven-dev/framework" }, - }); - expect(result).toEqual({ - valueShape: "map", - key: "aidd-framework", - value: { source: { source: "github", repo: "ai-driven-dev/framework" } }, - }); - }); - - it("does not include ref in github source (ref dropped per VSCode spec)", () => { - const result = ms?.toEntry({ - name: "aidd-framework", - source: { kind: "github", repo: "ai-driven-dev/framework", ref: "v1.0.0" }, - }); - expect(result).not.toBeNull(); - if (result?.valueShape === "map") { - const src = result.value.source as Record; - expect(src).not.toHaveProperty("ref"); - expect(src).toEqual({ source: "github", repo: "ai-driven-dev/framework" }); - } - }); - - it("returns map entry with directory source for local source", () => { - const result = ms?.toEntry({ - name: "my-marketplace", - source: { kind: "local", path: "/Users/dev/aidd-framework" }, - }); - expect(result).toEqual({ - valueShape: "map", - key: "my-marketplace", - value: { source: { source: "directory", path: "/Users/dev/aidd-framework" } }, - }); - }); - - it("returns null for unsupported source kind (npm)", () => { - const result = ms?.toEntry({ - name: "my-plugin", - source: { kind: "npm", package: "my-plugin" }, - }); - expect(result).toBeNull(); - }); - - it("returns null for unsupported source kind (url)", () => { - const result = ms?.toEntry({ - name: "my-plugin", - source: { kind: "url", url: "https://example.com/plugin.zip" }, - }); - expect(result).toBeNull(); - }); - }); - }); -}); diff --git a/cli/tests/domain/tools/ai/cursor.unit.test.ts b/cli/tests/domain/tools/ai/cursor.unit.test.ts deleted file mode 100644 index 33e8bf321..000000000 --- a/cli/tests/domain/tools/ai/cursor.unit.test.ts +++ /dev/null @@ -1,178 +0,0 @@ -import { join } from "node:path"; -import { describe, expect, it } from "vitest"; -import { cursor } from "../../../../src/domain/tools/ai/cursor.js"; - -describe("cursor", () => { - describe("capabilities.rules.convertFrontmatter()", () => { - it("converts paths: to globs: as JSON inline string and adds alwaysApply: false", () => { - const result = cursor.capabilities.rules?.convertFrontmatter({ - paths: ["src/**/*.ts"], - }); - expect(result).toEqual({ globs: '["src/**/*.ts"]', alwaysApply: false }); - }); - - it("returns empty frontmatter for rules without paths (always apply)", () => { - const result = cursor.capabilities.rules?.convertFrontmatter({ - description: "desc", - alwaysApply: true, - }); - expect(result).toEqual({}); - }); - - it("keeps description and alwaysApply false when no globs are specified", () => { - const result = cursor.capabilities.rules?.convertFrontmatter({ - description: "Apply when editing command files.", - alwaysApply: false, - }); - expect(result).toEqual({ - description: "Apply when editing command files.", - alwaysApply: false, - }); - }); - }); - - describe("capabilities.agents.convertFrontmatter()", () => { - it("strips extra fields for agents sections — only name and description", () => { - const fm = { name: "alexia", description: "Agent", model: "opus" }; - const result = cursor.capabilities.agents.convertFrontmatter(fm); - expect(result).toEqual({ name: "alexia", description: "Agent" }); - }); - }); - - describe("capabilities.commands.convertFrontmatter()", () => { - it("prefixes name with aidd:: and strips extra fields", () => { - const fm = { name: "implement", description: "Implement", model: "sonnet" }; - const result = cursor.capabilities.commands?.convertFrontmatter(fm, "04_code/implement.md"); - expect(result).toEqual({ name: "aidd:04:implement", description: "Implement" }); - }); - }); - - describe("capabilities.commands.buildInstallPath()", () => { - it("maps phase-prefixed path to aidd// subfolder", () => { - const path = cursor.capabilities.commands?.buildInstallPath("04_code/implement.md"); - expect(path).toBe(".cursor/commands/aidd/04/implement.md"); - }); - - it("maps top-level file to aidd/ subfolder without phase", () => { - const path = cursor.capabilities.commands?.buildInstallPath("commit.md"); - expect(path).toBe(".cursor/commands/aidd/commit.md"); - }); - }); - - describe("capabilities.commands.reverseConvertFrontmatter()", () => { - it("strips aidd:: prefix from name", () => { - const result = cursor.capabilities.commands?.reverseConvertFrontmatter({ - name: "aidd:04:implement", - description: "Impl", - }); - expect(result).toEqual({ name: "implement", description: "Impl" }); - }); - }); - - describe("capabilities.rules.buildInstallPath()", () => { - it("builds path for rules section with .mdc extension", () => { - const path = cursor.capabilities.rules?.buildInstallPath("01-standards/naming.md"); - expect(path).toBe(".cursor/rules/01-standards/naming.mdc"); - }); - }); - - describe("capabilities.agents.buildInstallPath()", () => { - it("keeps .md extension for agents", () => { - const path = cursor.capabilities.agents.buildInstallPath("code-reviewer.md"); - expect(path).toBe(".cursor/agents/code-reviewer.md"); - }); - }); - - describe("capabilities.skills.buildInstallPath()", () => { - it("builds path under .cursor/skills/ without tool suffix", () => { - const path = cursor.capabilities.skills.buildInstallPath("commit/SKILL.md"); - expect(path).toBe(".cursor/skills/commit/SKILL.md"); - }); - - it("strips .cursor.md tool suffix from skill name", () => { - const path = cursor.capabilities.skills.buildInstallPath("commit.cursor.md"); - expect(path).toBe(".cursor/skills/commit.md"); - }); - }); - - describe("capabilities.rules.reverseConvertFrontmatter()", () => { - it("reverses globs string back to paths array", () => { - const result = cursor.capabilities.rules?.reverseConvertFrontmatter({ - globs: '["src/**/*.ts"]', - alwaysApply: false, - }); - expect(result).toEqual({ paths: ["src/**/*.ts"] }); - }); - - it("returns empty object when globs is absent (always apply)", () => { - const result = cursor.capabilities.rules?.reverseConvertFrontmatter({}); - expect(result).toEqual({}); - }); - }); - - describe("detectUserFileSectionKey()", () => { - it("detects agents section for .cursor/agents/ paths", () => { - const key = cursor.detectUserFileSectionKey(".cursor/agents/alexia.md"); - expect(key).toEqual({ section: "agents", key: "alexia.md" }); - }); - - it("detects commands section for .cursor/commands/aidd/ paths", () => { - const key = cursor.detectUserFileSectionKey(".cursor/commands/aidd/04/implement.md"); - expect(key).toEqual({ section: "commands", key: "04/implement.md" }); - }); - - it("detects skills section for .cursor/skills/ paths", () => { - const key = cursor.detectUserFileSectionKey(".cursor/skills/commit/SKILL.md"); - expect(key).toEqual({ section: "skills", key: "commit/SKILL.md" }); - }); - - it("detects rules section for .cursor/rules/ paths and normalises .mdc to .md", () => { - const key = cursor.detectUserFileSectionKey(".cursor/rules/01-standards/naming.mdc"); - expect(key).toEqual({ section: "rules", key: "01-standards/naming.md" }); - }); - - it("returns null for unrecognised paths", () => { - expect(cursor.detectUserFileSectionKey(".cursor/settings.json")).toBeNull(); - expect(cursor.detectUserFileSectionKey("unknown.md")).toBeNull(); - }); - }); - - describe("capabilities.plugins", () => { - it("has a plugins capability", () => { - expect("plugins" in cursor.capabilities).toBe(true); - }); - - it("is native mode", () => { - expect(cursor.capabilities.plugins.mode).toBe("native"); - }); - - it("pluginsDir is empty string (base-relative path prefix)", () => { - expect(cursor.capabilities.plugins.pluginsDir).toBe(""); - }); - - it("pluginManifestRelativePath is null (no manifest file written into plugin dir)", () => { - expect(cursor.capabilities.plugins.pluginManifestRelativePath).toBeNull(); - }); - - it("installScope is user", () => { - expect(cursor.capabilities.plugins.installScope).toBe("user"); - }); - - it("acceptsHooks is true (Cursor auto-discovers hooks.json at plugin root)", () => { - expect(cursor.capabilities.plugins.acceptsHooks).toBe(true); - }); - - it("acceptsMcp is true (Cursor auto-discovers mcp.json at plugin root)", () => { - expect(cursor.capabilities.plugins.acceptsMcp).toBe(true); - }); - - it("marketplaceSettings is null", () => { - expect(cursor.capabilities.plugins.marketplaceSettings).toBeNull(); - }); - - it("resolvePluginsBaseDir returns ~/.cursor/plugins/local resolved from given homedir", () => { - const result = cursor.capabilities.plugins.resolvePluginsBaseDir("/proj", "/home/user"); - expect(result).toBe(join("/home/user", ".cursor", "plugins", "local")); - }); - }); -}); diff --git a/cli/tests/domain/tools/build-hooks-support-declaration.unit.test.ts b/cli/tests/domain/tools/build-hooks-support-declaration.unit.test.ts deleted file mode 100644 index cf04ba0ba..000000000 --- a/cli/tests/domain/tools/build-hooks-support-declaration.unit.test.ts +++ /dev/null @@ -1,56 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { - buildClaudeFlatContract, - buildCodexFlatContract, - buildCopilotFlatContract, - buildCursorFlatContract, - buildOpencodeFlatContract, -} from "../../../src/application/use-cases/framework/strategies/tool-contracts.js"; -import { claude } from "../../../src/domain/tools/ai/claude.js"; -import { codex } from "../../../src/domain/tools/ai/codex.js"; -import { copilot } from "../../../src/domain/tools/ai/copilot.js"; -import { cursor } from "../../../src/domain/tools/ai/cursor.js"; -import { opencode } from "../../../src/domain/tools/ai/opencode.js"; -import type { - ArtifactContract, - ToolBuildContract, -} from "../../../src/domain/tools/build-contract.js"; - -interface HooksDeclaringTool { - readonly toolId: string; - readonly capabilities: { readonly plugins: { readonly acceptsHooks: boolean } }; -} - -/** - * A tool declares whether it runs a delivered hook once, on its own PluginsCapability - * (acceptsHooks). The flat build contract used by `aidd setup` and `aidd framework build` - * has to agree — this is the state OpenCode was in: acceptsHooks: true declared, and a - * build contract that still hard-coded `hooks: { supported: false }` on a route no - * declaration change could reach. - */ -const FLAT_CONTRACTS: ReadonlyArray<[HooksDeclaringTool, () => ToolBuildContract]> = [ - [claude, buildClaudeFlatContract], - [cursor, buildCursorFlatContract], - [copilot, buildCopilotFlatContract], - [codex, buildCodexFlatContract], - [opencode, buildOpencodeFlatContract], -]; - -function isSupported(artifact: ArtifactContract): boolean { - return artifact.supported; -} - -describe("the flat build contract's hooks support", () => { - it("matches the tool's own acceptsHooks declaration, for every flat-mode tool", () => { - let examined = 0; - for (const [tool, buildContract] of FLAT_CONTRACTS) { - examined++; - const declared = tool.capabilities.plugins.acceptsHooks; - const delivered = isSupported(buildContract().artifacts.hooks); - expect(delivered, tool.toolId).toBe(declared); - } - // A tool list that stopped naming any flat-mode tool would pass by never reaching - // the assertion above, which is the failure shape this file exists to catch. - expect(examined).not.toBe(0); - }); -}); diff --git a/cli/tests/domain/tools/plugin-root-token-declaration.unit.test.ts b/cli/tests/domain/tools/plugin-root-token-declaration.unit.test.ts deleted file mode 100644 index a016bfd5e..000000000 --- a/cli/tests/domain/tools/plugin-root-token-declaration.unit.test.ts +++ /dev/null @@ -1,103 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { - buildClaudeContract, - buildCodexContract, - buildCopilotMarketplaceContract, - buildCursorContract, -} from "../../../src/application/use-cases/framework/strategies/tool-contracts.js"; -import { rewritePluginRootToken } from "../../../src/domain/formats/plugin-root-token-rewrite.js"; -import { AI_TOOL_IDS, type AiToolId } from "../../../src/domain/models/tool-ids.js"; -import "../../../src/domain/tools/ai/claude.js"; -import "../../../src/domain/tools/ai/codex.js"; -import "../../../src/domain/tools/ai/copilot.js"; -import "../../../src/domain/tools/ai/cursor.js"; -import "../../../src/domain/tools/ai/opencode.js"; -import type { PluginsCapability } from "../../../src/domain/capabilities/plugins-capability.js"; -import { getAiToolConfig } from "../../../src/domain/tools/registry.js"; - -/** - * A hook whose command names a variable the host does not expand installs cleanly, runs on - * every event, and silently does nothing. That is how it went unnoticed on three tools, so - * the variable each one expands is declared beside the rest of what that tool supports — - * and these hold the two install routes to that single declaration. - */ - -const CONTRACTS: ReadonlyArray<[AiToolId, () => { pluginRootToken?: string | null }]> = [ - ["claude", buildClaudeContract], - ["cursor", buildCursorContract], - ["copilot", buildCopilotMarketplaceContract], - ["codex", buildCodexContract], -]; - -function pluginsOf(tool: AiToolId): PluginsCapability | undefined { - const capabilities = getAiToolConfig(tool).capabilities as { plugins?: PluginsCapability }; - return capabilities.plugins; -} - -describe("which variable a tool expands to its installed plugin's directory", () => { - it("declares one for every tool that hosts a plugin as its own directory", () => { - let examined = 0; - for (const tool of AI_TOOL_IDS) { - const plugins = pluginsOf(tool); - if (plugins?.mode !== "native") continue; - examined++; - expect(plugins.pluginRootToken, tool).toBeTruthy(); - } - // A tool list that stopped naming any native-mode tool would pass by never reaching - // the assertion above, which is the failure shape this whole file exists to catch. - expect(examined).not.toBe(0); - }); - - it("declares none for a tool with no plugin directory to point at", () => { - let examined = 0; - for (const tool of AI_TOOL_IDS) { - const plugins = pluginsOf(tool); - if (!plugins || plugins.mode === "native") continue; - examined++; - expect(plugins.pluginRootToken, tool).toBeNull(); - } - expect(examined).not.toBe(0); - }); - - // The state this ticket existed to remove: a tool that declares the variable it expands - // and still does not receive the hooks that would use it. - it("pairs the declaration with actually receiving hooks", () => { - let examined = 0; - for (const tool of AI_TOOL_IDS) { - const plugins = pluginsOf(tool); - if (plugins?.mode !== "native") continue; - examined++; - expect(Boolean(plugins.pluginRootToken), tool).toBe(plugins.acceptsHooks); - } - expect(examined).not.toBe(0); - }); - - it("names a variable a host can expand, never a path", () => { - let examined = 0; - for (const tool of AI_TOOL_IDS) { - const token = pluginsOf(tool)?.pluginRootToken; - if (token === null || token === undefined) continue; - examined++; - expect(token, tool).toMatch(/^\$\{[A-Z_]+\}$/u); - } - expect(examined).not.toBe(0); - }); -}); - -describe("the route that builds a marketplace bundle", () => { - // Two places naming the same variable is how they start disagreeing, and the failure - // would be silent on the side nobody looks at. - it("substitutes the token the tool itself declared", () => { - for (const [tool, buildContract] of CONTRACTS) { - expect(buildContract().pluginRootToken, tool).toBe(pluginsOf(tool)?.pluginRootToken); - } - }); - - it("leaves a command alone for the tool whose variable is the one authors write", () => { - const authored = `node ${pluginsOf("claude")?.pluginRootToken}/hooks/journal.cjs`; - - const token = buildClaudeContract().pluginRootToken; - - expect(rewritePluginRootToken(authored, token ?? "")).toBe(authored); - }); -}); diff --git a/cli/tests/domain/tools/registry-conformance.unit.test.ts b/cli/tests/domain/tools/registry-conformance.unit.test.ts deleted file mode 100644 index e1aedcf1b..000000000 --- a/cli/tests/domain/tools/registry-conformance.unit.test.ts +++ /dev/null @@ -1,302 +0,0 @@ -import { describe, expect, it } from "vitest"; -// Side-effect imports: registering every shipped tool is what makes this suite meaningful. -// A tool missing here would silently escape conformance, so the list must stay complete. -import "../../../src/domain/tools/ai/claude.js"; -import "../../../src/domain/tools/ai/codex.js"; -import "../../../src/domain/tools/ai/copilot.js"; -import "../../../src/domain/tools/ai/cursor.js"; -import "../../../src/domain/tools/ai/opencode.js"; -import { FRAMEWORK_BUILD_TARGET_MODES } from "../../../src/domain/models/framework-build.js"; -import { - MARKETPLACE_PROBES, - PLUGIN_MANIFEST_PROBES, -} from "../../../src/domain/models/plugin-format.js"; -import { AI_TOOL_IDS } from "../../../src/domain/models/tool-ids.js"; -import type { AiTool } from "../../../src/domain/tools/contracts.js"; -import { hasRules } from "../../../src/domain/tools/contracts.js"; -import { - getAllRegisteredTools, - getToolConfig, - isAiTool, - journalHostToAiToolId, -} from "../../../src/domain/tools/registry.js"; -import { journalHost } from "../../helpers/telemetry-journal-hook.js"; - -/** - * Conformance suite for the AiTool contract. - * - * Every assertion iterates the registry rather than a hardcoded list, so adding a tool file - * automatically subjects it to all of them: omitting that tool from a parallel list elsewhere - * fails a test instead of misbehaving at runtime. - * - * The probe tables (plugin-format.ts) and the build registry (deps.ts) keep their own literal - * entries — "a format aidd can read" and "a tool aidd installs into" are distinct concepts - * that happen to share members. These assertions check the two agree, not that one derives - * from the other. - */ - -const registeredAiTools: [string, AiTool][] = [ - ...getAllRegisteredTools().entries(), -].flatMap(([id, config]) => - isAiTool(config) ? [[id as string, config] as [string, AiTool]] : [] -); - -describe("AiTool contract conformance", () => { - it("the registry actually contains tools (guards against a no-op suite)", () => { - expect(registeredAiTools.length).toBeGreaterThan(0); - }); - - describe.each(registeredAiTools)("%s", (toolId, tool) => { - it("has a well-formed AiTool shape", () => { - expect(tool.kind, `${toolId}: kind must be "ai"`).toBe("ai"); - expect(tool.toolId, `${toolId}: toolId must match its registry key`).toBe(toolId); - expect( - typeof tool.directory === "string" && tool.directory.length > 0, - `${toolId}: directory must be a non-empty string` - ).toBe(true); - expect(tool.directory.endsWith("/"), `${toolId}: directory must end with "/"`).toBe(true); - expect( - typeof tool.toolSuffix === "string" && tool.toolSuffix.startsWith("."), - `${toolId}: toolSuffix must be a string starting with "."` - ).toBe(true); - expect( - tool.signalDir === null || typeof tool.signalDir === "string", - `${toolId}: signalDir must be a string or null` - ).toBe(true); - expect( - typeof tool.capabilities === "object" && tool.capabilities !== null, - `${toolId}: capabilities must be an object` - ).toBe(true); - }); - - it("implements every required content method", () => { - for (const method of [ - "rewriteContent", - "reverseRewriteContent", - "detectUserFileSectionKey", - ] as const) { - expect(typeof tool[method], `${toolId}: ${method} must be a function`).toBe("function"); - } - }); - - it("is declared in AI_TOOL_IDS", () => { - expect( - (AI_TOOL_IDS as readonly string[]).includes(toolId), - `${toolId} is registered but missing from AI_TOOL_IDS (domain/models/tool-ids.ts)` - ).toBe(true); - }); - - it("is reachable by at least one framework build target/mode", () => { - expect( - FRAMEWORK_BUILD_TARGET_MODES.some((entry) => entry.target === toolId), - `${toolId} is registered but has no entry in FRAMEWORK_BUILD_TARGET_MODES (domain/models/framework-build.ts) — 'aidd framework build --target ${toolId}' would be rejected` - ).toBe(true); - }); - - it("is ingestible when it declares a plugins capability", () => { - const declaresPlugins = "plugins" in (tool.capabilities as object); - if (!declaresPlugins) return; - expect( - MARKETPLACE_PROBES.some((probe) => probe.format === toolId), - `${toolId} declares a plugins capability but has no MARKETPLACE_PROBES entry (domain/models/plugin-format.ts) — its native marketplace would never be detected` - ).toBe(true); - }); - - // #703: a tool that declares `marketplaceSettings` writes a project-local - // extraKnownMarketplaces/enabledPlugins declaration — that alone was proven, for - // Claude, to load nothing under `claude -p` (nor even interactively): the runtime - // reads its own user-global registry, not the project file. `nativeActivation` - // is what drives that registry via the tool's own CLI. Its absence here is exactly - // the two-install-surfaces disagreement #703 measured: settings.json says a plugin - // is enabled, the runtime that actually loads plugins was never told. - it("drives native CLI activation when its plugins capability declares marketplaceSettings", () => { - const caps = tool.capabilities as { - plugins?: { marketplaceSettings?: unknown; nativeActivation?: unknown }; - }; - if (caps.plugins?.marketplaceSettings == null) return; - expect( - caps.plugins.nativeActivation, - `${toolId} declares marketplaceSettings without nativeActivation — its settings.json declaration is never registered with the runtime that resolves plugins` - ).not.toBeNull(); - }); - - // Same shape guard for local-read: the type system requires `telemetryLocalRead` to - // exist, but not that its `kind` is one of the three this union defines. - it("declares its local-read shape as declared, unmeasured, or explicitly unsupported", () => { - expect( - ["declared", "unmeasured", "unsupported"], - `${toolId} declares an unrecognized telemetryLocalRead kind: ${tool.telemetryLocalRead.kind}` - ).toContain(tool.telemetryLocalRead.kind); - if (tool.telemetryLocalRead.kind === "unsupported") { - expect( - tool.telemetryLocalRead.reason.length, - `${toolId}: telemetryLocalRead.reason must not be empty` - ).toBeGreaterThan(0); - } - }); - }); -}); - -// Cursor's local-read reason is a measured fact (see spec.md non-goals), not a guess. -// Claude and Codex are declared as of phase 2: read via TranscriptCostReaderAdapter, see -// claude-code-transcript.ts and codex-rollout.ts for their measurements. OpenCode is -// declared as of phase 3: read via OpencodeCostReaderAdapter. Copilot is declared as of -// #697: read via TranscriptCostReaderAdapter and copilot-events.ts, at session rather than -// request granularity - see copilot-events.unit.test.ts for the measurement. -describe("telemetryLocalRead — exact declarations, phase 2 of local-cost-read", () => { - const EXPECTED: Record< - string, - { kind: "declared" | "unmeasured" | "unsupported"; reason?: string } - > = { - claude: { kind: "declared" }, - codex: { kind: "declared" }, - opencode: { kind: "declared" }, - copilot: { kind: "declared" }, - cursor: { kind: "unsupported", reason: "token count" }, - }; - - it.each(Object.entries(EXPECTED))("%s", (toolId, expected) => { - const tool = registeredAiTools.find(([id]) => id === toolId)?.[1]; - if (!tool) throw new Error(`${toolId} is not registered`); - - const shape = tool.telemetryLocalRead; - expect(shape.kind).toBe(expected.kind); - if (shape.kind === "unsupported" && expected.reason) { - expect(shape.reason).toContain(expected.reason); - } - }); - - it("covers exactly the five registered AI tools — no tool escapes this check", () => { - expect(Object.keys(EXPECTED).sort()).toEqual(registeredAiTools.map(([id]) => id).sort()); - }); -}); - -describe("no parallel list references an unregistered tool", () => { - it("every AI_TOOL_IDS entry resolves to a registered AI tool", () => { - for (const id of AI_TOOL_IDS) { - const config = getToolConfig(id); - expect(isAiTool(config), `AI_TOOL_IDS lists "${id}" but its config is not an AI tool`).toBe( - true - ); - } - }); - - it("every FRAMEWORK_BUILD_TARGET_MODES target is a registered AI tool", () => { - const registered = new Set(registeredAiTools.map(([id]) => id)); - for (const { target } of FRAMEWORK_BUILD_TARGET_MODES) { - expect( - registered.has(target), - `FRAMEWORK_BUILD_TARGET_MODES has an entry for "${target}", which is not a registered AI tool (stale entry?)` - ).toBe(true); - } - }); - - it("every probe-table format is a registered AI tool", () => { - const registered = new Set(registeredAiTools.map(([id]) => id)); - for (const [label, probes] of [ - ["PLUGIN_MANIFEST_PROBES", PLUGIN_MANIFEST_PROBES], - ["MARKETPLACE_PROBES", MARKETPLACE_PROBES], - ] as const) { - for (const probe of probes) { - expect( - registered.has(probe.format), - `${label} has an entry for format "${probe.format}" (${probe.relativePath}), which is not a registered AI tool (stale entry?)` - ).toBe(true); - } - } - }); - - it("every host the journal hook writes for is claimed by exactly one tool declaration", () => { - // The hook spells Claude Code "claude-code" while its toolId is "claude", so a report - // joining a journal line to a stored record has to relate the two. It relates them by - // reading these declarations, which is only safe while every host has one — a fifth - // host added to the hook and not declared here would join to nothing, silently. - for (const host of journalHost.DECLARED_HOSTS) { - expect( - journalHostToAiToolId(host), - `the journal hook writes for host "${host}", which no registered AI tool declares as its telemetryJournalHost` - ).not.toBeNull(); - } - }); - - it("declares no journal host the hook does not write for", () => { - for (const [toolId, config] of registeredAiTools) { - const declared = config.telemetryJournalHost; - if (declared === undefined) continue; - expect( - journalHost.DECLARED_HOSTS.has(declared), - `"${toolId}" declares telemetryJournalHost "${declared}", which the journal hook never writes` - ).toBe(true); - } - }); - - it("resolves an unknown host to null rather than to a nearby tool", () => { - expect(journalHostToAiToolId("not-a-host")).toBeNull(); - }); - - it("declares task attributability exactly where journal attribution is possible at all", () => { - // A task no longer needs a written-path extractor: it can be declared instead, read off - // any tool call's own arguments the way `declaredTaskPath` reads it - free text, scanned - // for a task-folder path - with no per-host gate the way `WRITTEN_PATH_EXTRACTOR_BY_HOST` - // gates a written path, or `stepStart` gates a step. That is why this assertion collapses - // to `telemetryTaskAttributable === (telemetryJournalHost !== undefined)`: once a host's - // events reach the journal hook *at all*, `handleTaskDeclared` runs unconditionally on - // every one of them, task declaration included. It does not, on its own, pin OpenCode's - // own dispatch mechanism (`hooks/opencode-plugin.js`, an ESM file this suite does not - // import) - that fact is exercised live in `scripts/__tests__/aidd-telemetry-opencode- - // payloads.test.js` instead, against the plugin file itself. - for (const [toolId, config] of registeredAiTools) { - const host = config.telemetryJournalHost; - const hookReachesToolUse = host !== undefined; - - expect( - config.telemetryTaskAttributable, - `"${toolId}" declares telemetryTaskAttributable ${config.telemetryTaskAttributable}, but the journal hook ${hookReachesToolUse ? "does" : "never"} dispatch a tool-used event for host "${host}"` - ).toBe(hookReachesToolUse); - } - }); - - it("declares what its local-read route supplies, for every tool", () => { - for (const [toolId, config] of registeredAiTools) { - const declaration = config.telemetryLocalRead; - if (declaration.kind !== "declared") continue; - expect( - declaration.supplies, - `"${toolId}" declares a telemetryLocalRead route without saying what it supplies` - ).toBeDefined(); - } - }); -}); - -/** - * Where each tool installs a rule, pinned as a table rather than described. - * - * The plugin script this replaced carried its own copy of these five rows and was missing - * one: it stated "Codex CLI: rules not supported, skipped" while `.codex/rules/` is exactly - * where a Codex rule lands. A reader on a Codex project asking what rules it had was - * answered "none", silently and wrongly, because the copy had drifted from the installer. - * - * Written out here so the drift cannot come back quietly: a tool whose install path moves, - * or a sixth tool added with rules, fails this and is read by whoever changes it. - */ -describe("every tool says where its own installed rules live", () => { - const EXPECTED: Readonly> = { - claude: { directory: ".claude/rules/", extension: ".md" }, - codex: { directory: ".codex/rules/", extension: ".md" }, - copilot: { directory: ".github/instructions/", extension: ".instructions.md" }, - cursor: { directory: ".cursor/rules/", extension: ".mdc" }, - opencode: { directory: ".opencode/rules/", extension: ".md" }, - }; - - it("answers the directory and extension each one actually installs into", () => { - const answered = Object.fromEntries( - AI_TOOL_IDS.map((id) => { - const tool = getToolConfig(id); - const rules = isAiTool(tool) && hasRules(tool) ? tool.capabilities.rules : undefined; - return [id, rules?.installedLocation() ?? null]; - }) - ); - - expect(answered).toEqual(EXPECTED); - }); -}); diff --git a/cli/tests/domain/tools/telemetry-route-supply.unit.test.ts b/cli/tests/domain/tools/telemetry-route-supply.unit.test.ts deleted file mode 100644 index 2ad6024e4..000000000 --- a/cli/tests/domain/tools/telemetry-route-supply.unit.test.ts +++ /dev/null @@ -1,148 +0,0 @@ -import { readFileSync } from "node:fs"; -import { fileURLToPath } from "node:url"; -import { describe, expect, it } from "vitest"; -import "../../../src/domain/tools/ai/claude.js"; -import "../../../src/domain/tools/ai/codex.js"; -import "../../../src/domain/tools/ai/copilot.js"; -import "../../../src/domain/tools/ai/cursor.js"; -import "../../../src/domain/tools/ai/opencode.js"; -import type { TelemetryRouteSupply } from "../../../src/domain/capabilities/telemetry-capability.js"; -import { mapClaudeCodeTranscriptToSinkRecords } from "../../../src/domain/formats/claude-code-transcript.js"; -import { mapCodexRolloutToSinkRecords } from "../../../src/domain/formats/codex-rollout.js"; -import { mapCopilotEventsToSinkRecords } from "../../../src/domain/formats/copilot-events.js"; -import { mapOpencodeExportToSinkRecords } from "../../../src/domain/formats/opencode-export.js"; -import type { TelemetrySinkRecord } from "../../../src/domain/models/telemetry-sink-record.js"; -import { AI_TOOL_IDS, type AiToolId } from "../../../src/domain/models/tool-ids.js"; -import { getAiToolConfig } from "../../../src/domain/tools/registry.js"; - -/** Everything the local-read route was measured to produce, from the captures this - * repository holds. A declaration is checked against these rather than against the - * documentation, so a route claiming an amount its reader never sets fails here rather - * than downstream. Local read is the only route this system still reads — the export - * route (and its own declaration) was deleted in "one route, and every sentence about it - * true" (aidd_docs/tasks/2026_08/2026_08_28_one-route-that-is-true/). */ -type Route = "local"; - -function fixture(relativePath: string): string { - return readFileSync(fileURLToPath(new URL(`../../fixtures/${relativePath}`, import.meta.url)), { - encoding: "utf8", - }); -} - -const CLAUDE_SESSION = "22222222-2222-4222-8222-222222222222"; -const CODEX_SESSION = "019fae6f-2009-7cd3-86b2-b8f83481b160"; -const COPILOT_SESSION = "33333333-3333-4333-8333-333333333333"; - -/** Whatever a capture yields, reduced to the four facts a route declares. */ -function observe(records: readonly Partial[]): TelemetryRouteSupply { - const some = (has: (record: Partial) => boolean) => records.some(has); - return { - tokenCounters: some( - (record) => - record.input_tokens !== undefined || - record.output_tokens !== undefined || - record.cache_read_tokens !== undefined || - record.cache_creation_tokens !== undefined - ), - amount: some((record) => record.cost_usd !== undefined), - toolStatedStep: some((record) => record.step !== undefined), - agentName: some((record) => record.agent_name !== undefined), - }; -} - -const CAPTURES: ReadonlyMap TelemetryRouteSupply> = new Map([ - [ - // Both files, because both are this session's local read: the adapter walks the main - // transcript and the subagent's own file, and only the second carries the field the - // tool uses to name the running skill. - "claude:local", - () => - observe([ - ...mapClaudeCodeTranscriptToSinkRecords( - fixture(`local-cost/.claude/projects/fake-project/${CLAUDE_SESSION}.jsonl`) - ), - ...mapClaudeCodeTranscriptToSinkRecords( - fixture( - `local-cost/.claude/projects/fake-project/${CLAUDE_SESSION}/subagents/agent-aa81cdef3bb58820c.jsonl` - ) - ), - ]), - ], - [ - "codex:local", - () => - observe( - mapCodexRolloutToSinkRecords( - fixture( - `local-cost/.codex/sessions/2026/07/29/rollout-2026-07-29T17-12-26-${CODEX_SESSION}.jsonl` - ) - ) - ), - ], - [ - "copilot:local", - () => - observe( - mapCopilotEventsToSinkRecords( - fixture(`local-cost/.copilot/session-state/${COPILOT_SESSION}/events.jsonl`), - COPILOT_SESSION - ) - ), - ], - [ - "opencode:local", - () => - observe( - mapOpencodeExportToSinkRecords( - JSON.parse(fixture("telemetry-sink/opencode-export.json")), - "ses_probe" - ) - ), - ], -]); - -function declarationOf(tool: AiToolId) { - return getAiToolConfig(tool).telemetryLocalRead; -} - -const route: Route = "local"; - -describe("what a route declares it supplies, against what its reader actually produces", () => { - for (const tool of AI_TOOL_IDS) { - const declaration = declarationOf(tool); - if (declaration.kind !== "declared") continue; - const capture = CAPTURES.get(`${tool}:${route}`); - - if (!capture) { - it(`${tool} declares a ${route} route with no capture, so it may claim nothing`, () => { - // A declared route nobody ever captured has been measured to carry an identifier - // and nothing else. Letting it claim a capability would be documenting a guess as - // a fact, which is the one thing this layer exists to prevent. - expect(declaration.supplies).toEqual({ - tokenCounters: false, - amount: false, - toolStatedStep: false, - agentName: false, - }); - }); - continue; - } - - it(`${tool}'s ${route} route supplies exactly what it declares`, () => { - expect(capture()).toEqual(declaration.supplies); - }); - } - - it("has a capture for every route that claims to supply anything", () => { - for (const tool of AI_TOOL_IDS) { - const declaration = declarationOf(tool); - if (declaration.kind !== "declared") continue; - const claimsSomething = Object.values(declaration.supplies).some(Boolean); - - expect( - !claimsSomething || CAPTURES.has(`${tool}:${route}`), - `"${tool}" claims its ${route} route supplies something, with no capture to check it against` - ).toBe(true); - } - }); -}); diff --git a/cli/tests/e2e/ai-rules.e2e.test.ts b/cli/tests/e2e/ai-rules.e2e.test.ts deleted file mode 100644 index 1bf24732f..000000000 --- a/cli/tests/e2e/ai-rules.e2e.test.ts +++ /dev/null @@ -1,59 +0,0 @@ -import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; -import { afterAll, beforeAll, describe, expect, it } from "vitest"; -import { CLI_PATH, execFileAsync } from "./helpers.js"; - -/** - * The command the explore skill runs. Exercised against the built binary because that is - * what the skill invokes: a use case passing in isolation says nothing about whether the - * subcommand is reachable, and the script this replaced was reachable by construction. - */ -describe("aidd ai rules — the inventory a rule scan reads", () => { - let project: string; - - beforeAll(async () => { - project = await mkdtemp(join(tmpdir(), "aidd-ai-rules-")); - await mkdir(join(project, ".claude/rules/01-standards"), { recursive: true }); - await mkdir(join(project, ".codex/rules"), { recursive: true }); - await mkdir(join(project, ".cursor/rules"), { recursive: true }); - await writeFile( - join(project, ".claude/rules/01-standards/1-naming.md"), - '---\ndescription: Names files\npaths:\n - "src/**/*.ts"\n---\n\n# Naming\n' - ); - await writeFile(join(project, ".codex/rules/2-imports.md"), "---\n---\n\n# Imports\n"); - // Beside a rule, and not one: only the extension the tool installs makes it a rule. - await writeFile(join(project, ".cursor/rules/README.md"), "# not a rule\n"); - }); - - afterAll(async () => { - await rm(project, { recursive: true, force: true }); - }); - - it("answers with every rule installed, whatever tool installed it", async () => { - const { stdout } = await execFileAsync("node", [CLI_PATH, "ai", "rules", "--json"], { - cwd: project, - }); - - expect(JSON.parse(stdout)).toEqual([ - { - tool: "claude", - path: ".claude/rules/01-standards/1-naming.md", - name: "1-naming", - description: "Names files", - paths: ["src/**/*.ts"], - }, - { tool: "codex", path: ".codex/rules/2-imports.md", name: "2-imports", description: "" }, - ]); - }); - - it("says a project holds none rather than printing nothing", async () => { - const empty = await mkdtemp(join(tmpdir(), "aidd-ai-rules-empty-")); - try { - const { stdout } = await execFileAsync("node", [CLI_PATH, "ai", "rules"], { cwd: empty }); - expect(stdout).toContain("No rules installed"); - } finally { - await rm(empty, { recursive: true, force: true }); - } - }); -}); diff --git a/cli/tests/e2e/clean-native-cache.e2e.test.ts b/cli/tests/e2e/clean-native-cache.e2e.test.ts new file mode 100644 index 000000000..20113360c --- /dev/null +++ b/cli/tests/e2e/clean-native-cache.e2e.test.ts @@ -0,0 +1,55 @@ +import { existsSync } from "node:fs"; +import { mkdir, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; +import { createTestEnv, runCli } from "./helpers.js"; + +const AIDD_DIR = ".aidd"; +const MARKETPLACE = "aidd-e2e-cache-mkt"; + +/** As if a previous run, on a machine carrying `claude` on PATH, had recorded a native + * registration: this sandbox never puts a real host binary on PATH to produce one. */ +async function seedManifestWithClaudeNativeRegistrations(projectDir: string): Promise { + await mkdir(join(projectDir, AIDD_DIR), { recursive: true }); + await writeFile( + join(projectDir, AIDD_DIR, "manifest.json"), + JSON.stringify({ + version: 8, + tools: { + claude: { + toolId: "claude", + version: "1.0.0", + files: [], + nativeRegistrations: { + binary: "claude", + marketplaces: [{ alias: MARKETPLACE, hostName: MARKETPLACE }], + pluginRefs: [], + }, + }, + }, + }), + "utf-8" + ); +} + +describe.concurrent("E2E: aidd clean and a host's own plugin cache", () => { + it("leaves a seeded claude cache tree in place when the claude CLI is not on PATH", async () => { + // This sandbox's own PATH never carries a real `claude`, so this proves the + // binary-absent branch alone, never that the purge runs against a real one. + const { projectDir, fakeHome, cleanup } = await createTestEnv("clean-cache-binary-missing"); + try { + await seedManifestWithClaudeNativeRegistrations(projectDir); + const cacheDir = join(fakeHome, ".claude", "plugins", "cache", MARKETPLACE, "plugin-a"); + await mkdir(cacheDir, { recursive: true }); + const cacheFile = join(cacheDir, "plugin.json"); + await writeFile(cacheFile, "{}", "utf-8"); + + const { exitCode } = await runCli(["clean", "--force"], projectDir, fakeHome); + + expect(exitCode).toBe(0); + expect(existsSync(cacheFile)).toBe(true); + } finally { + await cleanup(); + } + }); +}); diff --git a/cli/tests/e2e/clean-scope-user.e2e.test.ts b/cli/tests/e2e/clean-scope-user.e2e.test.ts new file mode 100644 index 000000000..8dbbf8c99 --- /dev/null +++ b/cli/tests/e2e/clean-scope-user.e2e.test.ts @@ -0,0 +1,230 @@ +import { execFile } from "node:child_process"; +import { mkdir, readdir, readFile, writeFile } from "node:fs/promises"; +import { join, resolve } from "node:path"; +import { promisify } from "node:util"; +import { describe, expect, it } from "vitest"; +import { environmentWithoutGitVariables } from "../../src/runtime/git/git-environment.js"; +import { createTestEnv, gitInit, runCli } from "./helpers.js"; + +// CI runners carry no git identity; a commit made by a test brings its own. +const GIT_TEST_IDENTITY = ["-c", "user.email=t@t.com", "-c", "user.name=t"]; + +const execFileAsync = promisify(execFile); +const FRAMEWORK_REAL_PATH = resolve(process.cwd(), "tests/fixtures/framework-real"); + +async function gitStatusPorcelain(cwd: string): Promise { + const { stdout } = await execFileAsync("git", ["status", "--porcelain"], { + cwd, + env: environmentWithoutGitVariables(process.env), + }); + return stdout; +} + +/** Every file under `dir`, relative to it, recursively — `[]` for a directory that no + * longer exists, the honest "nothing left" answer rather than a thrown ENOENT. */ +async function listFilesUnder(dir: string): Promise { + let entries: Array<{ name: string; isDirectory(): boolean }>; + try { + entries = await readdir(dir, { withFileTypes: true }); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return []; + throw error; + } + const results: string[] = []; + for (const entry of entries) { + const full = join(dir, entry.name); + if (entry.isDirectory()) { + for (const nested of await listFilesUnder(full)) results.push(join(entry.name, nested)); + } else { + results.push(entry.name); + } + } + return results; +} + +/** Whether a directory is still there at all — `listFilesUnder` answers `[]` for an empty + * one and a removed one alike, so proving a shell was removed needs its own question. */ +async function directoryExists(dir: string): Promise { + try { + await readdir(dir); + return true; + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return false; + throw error; + } +} + +async function readJson(path: string): Promise> { + return JSON.parse(await readFile(path, "utf-8")) as Record; +} + +describe("E2E: clean --scope user purges the shared source", () => { + it("purges its own whitelist only, leaving auth.json, telemetry/ and an unrelated marketplace entry untouched, after setup --scope user", async () => { + const { projectDir, fakeHome, cleanup } = await createTestEnv("clean-scope-user"); + try { + await gitInit(projectDir); + await execFileAsync("git", [...GIT_TEST_IDENTITY, "commit", "--allow-empty", "-m", "empty"], { + cwd: projectDir, + env: environmentWithoutGitVariables(process.env), + }); + + const setupResult = await runCli( + [ + "setup", + "--source", + "local", + "--path", + FRAMEWORK_REAL_PATH, + "--ai", + "claude", + "--plugins", + "none", + "--yes", + "--scope", + "user", + ], + projectDir, + fakeHome + ); + expect(setupResult.exitCode).toBe(0); + + const userConfigDir = join(fakeHome, ".config", "aidd"); + const marketplacesPath = join(userConfigDir, "marketplaces.json"); + const beforeMarketplaces = await readJson(marketplacesPath); + const beforeNames = (beforeMarketplaces.marketplaces as Array<{ name: string }>).map( + (m) => m.name + ); + expect(beforeNames).toContain("aidd-framework"); + + // A second, unrelated registration, plus the two files this whitelist has no + // business touching — `clean --scope user` must leave every one of them alone. + const marketplaces = beforeMarketplaces.marketplaces as Array>; + marketplaces.push({ + name: "other-marketplace", + source: { kind: "local", path: "/src/other" }, + scope: "user", + addedAt: "2026-01-01T00:00:00.000Z", + }); + await writeFile( + marketplacesPath, + JSON.stringify({ ...beforeMarketplaces, marketplaces }, null, 2), + "utf-8" + ); + await writeFile(join(userConfigDir, "auth.json"), '{"version":1}', "utf-8"); + const telemetryFile = join(userConfigDir, "telemetry", "2026-01-01.jsonl"); + await mkdir(join(userConfigDir, "telemetry"), { recursive: true }); + await writeFile(telemetryFile, '{"kind":"session"}\n', "utf-8"); + + // This sandbox reaches no real `claude` binary, so `setup --scope user` never builds + // the shared source's tree here; the fixture stands in for what a host would leave. + const builtDir = join(userConfigDir, "cache", "built", "9.9.9", "aidd-framework", "claude"); + await mkdir(builtDir, { recursive: true }); + await writeFile(join(builtDir, "marker.json"), "{}", "utf-8"); + // The self-update check cache, aidd's own file beside `built/`, so the whitelist takes + // it too — and only then is the `cache/` shell around both empty enough to go. + await writeFile( + join(userConfigDir, "cache", "update-check.json"), + '{"checkedAt":0,"latest":"9.9.9"}', + "utf-8" + ); + + const before = (await listFilesUnder(userConfigDir)).sort(); + expect(before).toContain( + join("cache", "built", "9.9.9", "aidd-framework", "claude", "marker.json") + ); + expect(before).toContain(join("cache", "update-check.json")); + + const cleanResult = await runCli( + ["clean", "--scope", "user", "--force"], + projectDir, + fakeHome + ); + expect(cleanResult.exitCode).toBe(0); + + // `setup --scope user` never touches the project, and neither does + // `clean --scope user` — both are machine-scope operations. + expect(await gitStatusPorcelain(projectDir)).toBe(""); + + const after = (await listFilesUnder(userConfigDir)).sort(); + // The full delta, not a list of paths this test happens to think of, so a file the + // whitelist has no business under fails here rather than going unnoticed. + expect(before).not.toEqual(after); + expect(after).toEqual( + ["auth.json", "marketplaces.json", join("telemetry", "2026-01-01.jsonl")].sort() + ); + const afterMarketplaces = await readJson(marketplacesPath); + const afterNames = (afterMarketplaces.marketplaces as Array<{ name: string }>).map( + (m) => m.name + ); + expect(afterNames).not.toContain("aidd-framework"); + expect(afterNames).toContain("other-marketplace"); + expect(await readFile(telemetryFile, "utf-8")).toBe('{"kind":"session"}\n'); + // `listFilesUnder` sees files, never an empty directory, so the shell itself has to + // be asked for by name. + expect(await directoryExists(join(userConfigDir, "cache"))).toBe(false); + } finally { + await cleanup(); + } + }); + + it("still purges the whitelist when no user manifest exists, the state a plain project-scope setup leaves", async () => { + const { projectDir, fakeHome, cleanup } = await createTestEnv("clean-scope-user-no-manifest"); + try { + await gitInit(projectDir); + await execFileAsync("git", [...GIT_TEST_IDENTITY, "commit", "--allow-empty", "-m", "empty"], { + cwd: projectDir, + env: environmentWithoutGitVariables(process.env), + }); + + // A plain, project-scope setup — no `--scope user` — never writes a user manifest, + // yet still registers the shared source machine-wide. + const setupResult = await runCli( + [ + "setup", + "--source", + "local", + "--path", + FRAMEWORK_REAL_PATH, + "--ai", + "claude", + "--plugins", + "none", + "--yes", + ], + projectDir, + fakeHome + ); + expect(setupResult.exitCode).toBe(0); + + const userConfigDir = join(fakeHome, ".config", "aidd"); + const before = (await listFilesUnder(userConfigDir)).sort(); + expect(before.some((f) => f.startsWith(join("cache", "built")))).toBe(true); + expect(before).toContain("references.json"); + expect(await readJson(join(userConfigDir, "manifest.json")).catch(() => null)).toBeNull(); + + const statusBeforeClean = await gitStatusPorcelain(projectDir); + + const cleanResult = await runCli( + ["clean", "--scope", "user", "--force"], + projectDir, + fakeHome + ); + + expect(cleanResult.exitCode).toBe(0); + expect(cleanResult.stdout).toContain("No host registration was undone"); + expect(await gitStatusPorcelain(projectDir)).toBe(statusBeforeClean); + + const after = (await listFilesUnder(userConfigDir)).sort(); + expect(after).not.toContain("references.json"); + expect(after.some((f) => f.startsWith("cache"))).toBe(false); + expect(await directoryExists(join(userConfigDir, "cache"))).toBe(false); + const afterMarketplaces = await readJson(join(userConfigDir, "marketplaces.json")); + const afterNames = (afterMarketplaces.marketplaces as Array<{ name: string }>).map( + (m) => m.name + ); + expect(afterNames).not.toContain("aidd-framework"); + } finally { + await cleanup(); + } + }); +}); diff --git a/cli/tests/e2e/clean-shared-ref-codex.e2e.test.ts b/cli/tests/e2e/clean-shared-ref-codex.e2e.test.ts new file mode 100644 index 000000000..aa53b0ec3 --- /dev/null +++ b/cli/tests/e2e/clean-shared-ref-codex.e2e.test.ts @@ -0,0 +1,77 @@ +/** + * Codex enables a plugin machine-wide (no `NativeActivation.scopeArgs`), so a `clean` here + * must not disable it. `--plugins none` records no `pluginRefs`, so a real name is passed. + */ +import { readFile, realpath } from "node:fs/promises"; +import { delimiter, join, resolve } from "node:path"; +import { describe, expect, it } from "vitest"; +import { createTestEnv, pathWithoutAidd, runCli, writeFakeToolBinary } from "./helpers.js"; + +const FRAMEWORK_REAL_PATH = resolve(process.cwd(), "tests/fixtures/framework-real"); +const PLUGIN_NAME = "aidd-vcs"; + +async function readJson(path: string): Promise> { + return JSON.parse(await readFile(path, "utf-8")) as Record; +} + +describe("E2E: clean leaves a codex ref enabled while another project still shares it", () => { + it("keeps the plugin's ref enabled, names the other project, and never calls plugin remove", async () => { + const first = await createTestEnv("clean-shared-ref-codex-first"); + const second = await createTestEnv("clean-shared-ref-codex-second"); + try { + const logFile = join(first.tempDir, "codex-invocations.log"); + const binDir = join(first.tempDir, "bin"); + await writeFakeToolBinary(binDir, "codex", logFile); + const env = { PATH: `${binDir}${delimiter}${pathWithoutAidd()}` }; + + const setupArgs = [ + "setup", + "--source", + "local", + "--path", + FRAMEWORK_REAL_PATH, + "--ai", + "codex", + "--plugins", + PLUGIN_NAME, + "--yes", + ]; + + // Both projects share one machine: one `fakeHome` (`first.fakeHome`), never + // `second.fakeHome`. + const firstSetup = await runCli(setupArgs, first.projectDir, first.fakeHome, { env }); + expect(firstSetup.exitCode).toBe(0); + const secondSetup = await runCli(setupArgs, second.projectDir, first.fakeHome, { env }); + expect(secondSetup.exitCode).toBe(0); + + const secondRoot = await realpath(second.projectDir); + const referencesBefore = await readJson( + join(first.fakeHome, ".config", "aidd", "references.json") + ); + expect(Object.values(referencesBefore).flat()).toContain(secondRoot); + + const cleanResult = await runCli(["clean", "--force"], first.projectDir, first.fakeHome, { + env, + }); + + expect(cleanResult.exitCode).toBe(0); + expect(cleanResult.stderr).toContain("left enabled"); + expect(cleanResult.stderr).toContain(secondRoot); + + const log = await readFile(logFile, "utf-8"); + expect(log).not.toContain("plugin remove"); + + // Second project's own claim survives this project's own clean — it never ran + // `clean` itself, and the guard is exactly what keeps its plugin loaded too. + const referencesAfter = await readJson( + join(first.fakeHome, ".config", "aidd", "references.json") + ); + expect(Object.values(referencesAfter).flat()).toContain(secondRoot); + const firstRoot = await realpath(first.projectDir); + expect(Object.values(referencesAfter).flat()).not.toContain(firstRoot); + } finally { + await first.cleanup(); + await second.cleanup(); + } + }); +}); diff --git a/cli/tests/e2e/clean.e2e.test.ts b/cli/tests/e2e/clean.e2e.test.ts index 5e3c2c1e5..4be36ebef 100644 --- a/cli/tests/e2e/clean.e2e.test.ts +++ b/cli/tests/e2e/clean.e2e.test.ts @@ -10,7 +10,7 @@ async function seedManifest(projectDir: string): Promise { await mkdir(join(projectDir, AIDD_DIR), { recursive: true }); await writeFile( join(projectDir, AIDD_DIR, "manifest.json"), - JSON.stringify({ version: 5, tools: {}, marketplaces: {} }), + JSON.stringify({ version: 8, tools: {} }), "utf-8" ); } @@ -41,7 +41,7 @@ describe.concurrent("E2E: aidd clean", () => { const { projectDir, fakeHome, cleanup } = await createTestEnv("clean-dry-run"); try { await seedManifest(projectDir); - await runCli(["ai", "install", "claude"], projectDir, fakeHome); + await runCli(["framework", "install", "--tool", "claude"], projectDir, fakeHome); // runCli runs non-TTY (child process without TTY), so dry-run shows Would remove const { stdout, exitCode } = await runCli(["clean"], projectDir, fakeHome); @@ -58,7 +58,7 @@ describe.concurrent("E2E: aidd clean", () => { const { projectDir, fakeHome, cleanup } = await createTestEnv("clean-force"); try { await seedManifest(projectDir); - await runCli(["ai", "install", "claude"], projectDir, fakeHome); + await runCli(["framework", "install", "--tool", "claude"], projectDir, fakeHome); const { stdout, exitCode } = await runCli(["clean", "--force"], projectDir, fakeHome); @@ -75,7 +75,7 @@ describe.concurrent("E2E: aidd clean", () => { const { projectDir, fakeHome, cleanup } = await createTestEnv("clean-preview"); try { await seedManifest(projectDir); - await runCli(["ai", "install", "claude"], projectDir, fakeHome); + await runCli(["framework", "install", "--tool", "claude"], projectDir, fakeHome); const { stdout, exitCode } = await runCli(["clean"], projectDir, fakeHome); @@ -92,7 +92,7 @@ describe.concurrent("E2E: aidd clean", () => { try { await seedManifest(projectDir); await seedTelemetryConfig(projectDir); - await runCli(["ai", "install", "claude"], projectDir, fakeHome); + await runCli(["framework", "install", "--tool", "claude"], projectDir, fakeHome); const { stdout, exitCode } = await runCli(["clean", "--force"], projectDir, fakeHome); @@ -110,8 +110,8 @@ describe.concurrent("E2E: aidd clean", () => { const { projectDir, fakeHome, cleanup } = await createTestEnv("clean-multi"); try { await seedManifest(projectDir); - await runCli(["ai", "install", "claude"], projectDir, fakeHome); - await runCli(["ai", "install", "cursor"], projectDir, fakeHome); + await runCli(["framework", "install", "--tool", "claude"], projectDir, fakeHome); + await runCli(["framework", "install", "--tool", "cursor"], projectDir, fakeHome); const { stdout, exitCode } = await runCli(["clean", "--force"], projectDir, fakeHome); expect(exitCode).toBe(0); @@ -125,9 +125,27 @@ describe.concurrent("E2E: aidd clean", () => { } }); + it("removes .claude/settings.local.json, a file install writes outside the manifest", async () => { + // The file is machine-local and never tracked, so a clean rejoining only the manifest + // leaves it behind. + const { projectDir, fakeHome, cleanup } = await createTestEnv("clean-settings-local"); + try { + await seedManifest(projectDir); + await runCli(["framework", "install", "--tool", "claude"], projectDir, fakeHome); + const settingsLocalPath = join(projectDir, ".claude", "settings.local.json"); + await writeFile(settingsLocalPath, "{}", "utf-8"); + + const { exitCode } = await runCli(["clean", "--force"], projectDir, fakeHome); + + expect(exitCode).toBe(0); + expect(existsSync(settingsLocalPath)).toBe(false); + expect(existsSync(join(projectDir, ".claude"))).toBe(false); + } finally { + await cleanup(); + } + }); + it("leaves no .aidd behind when a marketplace was registered", async () => { - // The state the CLI wrote itself. Left behind, `.aidd` survived a run that had just - // printed that it cleaned all AIDD files. const { projectDir, fakeHome, cleanup } = await createTestEnv("clean-registry"); try { await seedManifest(projectDir); diff --git a/cli/tests/e2e/command-matrix-ai.e2e.test.ts b/cli/tests/e2e/command-matrix-ai.e2e.test.ts deleted file mode 100644 index 4037b2962..000000000 --- a/cli/tests/e2e/command-matrix-ai.e2e.test.ts +++ /dev/null @@ -1,334 +0,0 @@ -/** - * Command Matrix E2E — AI & IDE tools surface - * Automated counterpart of: aidd_docs/tasks/2026_05/2026_05_06-cli-v5-cleanup-command-matrix.md - * - * Already covered by existing E2E journeys (not duplicated here): - * greenfield-setup.e2e.test.ts — ai install claude/cursor (+ --force, idempotent), - * ide install vscode, manifest structure assertions - * sync-plugins.e2e.test.ts — ai sync variants (missing source, noop, agent, force) - * - * See also: command-matrix-help.e2e.test.ts, command-matrix-plugin.e2e.test.ts - */ - -import { mkdir, writeFile } from "node:fs/promises"; -import { join } from "node:path"; -import { describe, expect, it } from "vitest"; -import { createTestEnv, runCli } from "./helpers.js"; - -const AIDD_DIR = ".aidd"; -const EMPTY_MANIFEST = { version: 5, tools: {}, marketplaces: {} }; - -async function seedManifest(projectDir: string): Promise { - await mkdir(join(projectDir, AIDD_DIR), { recursive: true }); - await writeFile( - join(projectDir, AIDD_DIR, "manifest.json"), - JSON.stringify(EMPTY_MANIFEST), - "utf-8" - ); -} - -async function seedWithClaude(projectDir: string, fakeHome: string): Promise { - await seedManifest(projectDir); - await runCli(["ai", "install", "claude"], projectDir, fakeHome); -} - -async function seedWithVscode(projectDir: string, fakeHome: string): Promise { - await seedManifest(projectDir); - await runCli(["ide", "install", "vscode"], projectDir, fakeHome); -} - -// --------------------------------------------------------------------------- -// AI Tools — install/uninstall for tools not covered in greenfield-setup -// (claude and cursor installs are in greenfield-setup.e2e.test.ts) -// --------------------------------------------------------------------------- - -describe.concurrent("Command Matrix: AI install/uninstall (copilot, codex, opencode)", () => { - it("ai install copilot exits 0 and reports installed", async () => { - const { projectDir, fakeHome, cleanup } = await createTestEnv("ai-copilot-install"); - try { - await seedManifest(projectDir); - const { stdout, exitCode } = await runCli(["ai", "install", "copilot"], projectDir, fakeHome); - expect(exitCode).toBe(0); - expect(stdout).toContain("copilot"); - } finally { - await cleanup(); - } - }); - - it("ai install copilot --force reinstalls over existing", async () => { - const { projectDir, fakeHome, cleanup } = await createTestEnv("ai-copilot-force"); - try { - await seedManifest(projectDir); - await runCli(["ai", "install", "copilot"], projectDir, fakeHome); - const { stdout, exitCode } = await runCli( - ["ai", "install", "copilot", "--force"], - projectDir, - fakeHome - ); - expect(exitCode).toBe(0); - expect(stdout).toContain("copilot"); - } finally { - await cleanup(); - } - }); - - it("ai uninstall copilot exits 0 and reports removed", async () => { - const { projectDir, fakeHome, cleanup } = await createTestEnv("ai-copilot-uninstall"); - try { - await seedManifest(projectDir); - await runCli(["ai", "install", "copilot"], projectDir, fakeHome); - const { stdout, exitCode } = await runCli( - ["ai", "uninstall", "copilot"], - projectDir, - fakeHome - ); - expect(exitCode).toBe(0); - expect(stdout).toContain("copilot"); - } finally { - await cleanup(); - } - }); - - it("ai install codex exits 0 and reports installed", async () => { - const { projectDir, fakeHome, cleanup } = await createTestEnv("ai-codex-install"); - try { - await seedManifest(projectDir); - const { stdout, exitCode } = await runCli(["ai", "install", "codex"], projectDir, fakeHome); - expect(exitCode).toBe(0); - expect(stdout).toContain("codex"); - } finally { - await cleanup(); - } - }); - - it("ai uninstall codex exits 0", async () => { - const { projectDir, fakeHome, cleanup } = await createTestEnv("ai-codex-uninstall"); - try { - await seedManifest(projectDir); - await runCli(["ai", "install", "codex"], projectDir, fakeHome); - const { stdout, exitCode } = await runCli(["ai", "uninstall", "codex"], projectDir, fakeHome); - expect(exitCode).toBe(0); - expect(stdout).toContain("codex"); - } finally { - await cleanup(); - } - }); - - it("ai install opencode exits 0 and reports installed", async () => { - const { projectDir, fakeHome, cleanup } = await createTestEnv("ai-opencode-install"); - try { - await seedManifest(projectDir); - const { stdout, exitCode } = await runCli( - ["ai", "install", "opencode"], - projectDir, - fakeHome - ); - expect(exitCode).toBe(0); - expect(stdout).toContain("opencode"); - } finally { - await cleanup(); - } - }); - - it("ai uninstall opencode exits 0", async () => { - const { projectDir, fakeHome, cleanup } = await createTestEnv("ai-opencode-uninstall"); - try { - await seedManifest(projectDir); - await runCli(["ai", "install", "opencode"], projectDir, fakeHome); - const { stdout, exitCode } = await runCli( - ["ai", "uninstall", "opencode"], - projectDir, - fakeHome - ); - expect(exitCode).toBe(0); - expect(stdout).toContain("opencode"); - } finally { - await cleanup(); - } - }); - - it("ai install vscode exits 1 — cross-category rejection", async () => { - const { projectDir, fakeHome, cleanup } = await createTestEnv("ai-cross-category"); - try { - const { stderr, exitCode } = await runCli(["ai", "install", "vscode"], projectDir, fakeHome); - expect(exitCode).toBe(1); - expect(stderr).toContain("Unknown AI tool: vscode"); - expect(stderr).toContain("claude"); - } finally { - await cleanup(); - } - }); -}); - -// --------------------------------------------------------------------------- -// AI Tools — list / status / update / doctor / restore -// (not covered by any existing journey) -// --------------------------------------------------------------------------- - -describe.concurrent("Command Matrix: AI list/status/update/doctor/restore", () => { - it("ai list exits 0 and shows installed tool name", async () => { - const { projectDir, fakeHome, cleanup } = await createTestEnv("ai-list"); - try { - await seedWithClaude(projectDir, fakeHome); - const { stdout, exitCode } = await runCli(["ai", "list"], projectDir, fakeHome); - expect(exitCode).toBe(0); - expect(stdout).toContain("claude"); - } finally { - await cleanup(); - } - }); - - it("ai status exits 0 and reports files in sync", async () => { - const { projectDir, fakeHome, cleanup } = await createTestEnv("ai-status"); - try { - await seedWithClaude(projectDir, fakeHome); - const { stdout, exitCode } = await runCli(["ai", "status"], projectDir, fakeHome); - expect(exitCode).toBe(0); - expect(stdout).toContain("in sync"); - } finally { - await cleanup(); - } - }); - - it("ai update exits 0 and reports updated", async () => { - const { projectDir, fakeHome, cleanup } = await createTestEnv("ai-update"); - try { - await seedWithClaude(projectDir, fakeHome); - const { stdout, exitCode } = await runCli(["ai", "update"], projectDir, fakeHome); - expect(exitCode).toBe(0); - expect(stdout).toMatch(/[Uu]pdated|up to date/); - } finally { - await cleanup(); - } - }); - - it("ai update claude exits 0 and reports updated for specific tool", async () => { - const { projectDir, fakeHome, cleanup } = await createTestEnv("ai-update-tool"); - try { - await seedWithClaude(projectDir, fakeHome); - const { stdout, exitCode } = await runCli(["ai", "update", "claude"], projectDir, fakeHome); - expect(exitCode).toBe(0); - expect(stdout).toMatch(/[Uu]pdated.*claude|claude.*[Uu]pdated/); - } finally { - await cleanup(); - } - }); - - it("ai doctor exits 0 with healthy message", async () => { - const { projectDir, fakeHome, cleanup } = await createTestEnv("ai-doctor"); - try { - await seedWithClaude(projectDir, fakeHome); - const { stdout, exitCode } = await runCli(["ai", "doctor"], projectDir, fakeHome); - expect(exitCode).toBe(0); - expect(stdout).toContain("healthy"); - } finally { - await cleanup(); - } - }); - - it("ai restore exits 0 reporting nothing to restore when files unmodified", async () => { - const { projectDir, fakeHome, cleanup } = await createTestEnv("ai-restore"); - try { - await seedWithClaude(projectDir, fakeHome); - const { stdout, exitCode } = await runCli(["ai", "restore"], projectDir, fakeHome); - expect(exitCode).toBe(0); - expect(stdout).toContain("Nothing to restore"); - } finally { - await cleanup(); - } - }); -}); - -// --------------------------------------------------------------------------- -// IDE Tools — uninstall / list / status / update / doctor -// (ide install vscode is covered in greenfield-setup.e2e.test.ts) -// --------------------------------------------------------------------------- - -describe.concurrent("Command Matrix: IDE list/status/update/doctor/uninstall", () => { - it("ide uninstall vscode exits 0 and reports removed", async () => { - const { projectDir, fakeHome, cleanup } = await createTestEnv("ide-uninstall"); - try { - await seedWithVscode(projectDir, fakeHome); - const { stdout, exitCode } = await runCli( - ["ide", "uninstall", "vscode"], - projectDir, - fakeHome - ); - expect(exitCode).toBe(0); - expect(stdout).toContain("vscode"); - } finally { - await cleanup(); - } - }); - - it("ide list exits 0 and shows installed tool", async () => { - const { projectDir, fakeHome, cleanup } = await createTestEnv("ide-list"); - try { - await seedWithVscode(projectDir, fakeHome); - const { stdout, exitCode } = await runCli(["ide", "list"], projectDir, fakeHome); - expect(exitCode).toBe(0); - expect(stdout).toContain("vscode"); - } finally { - await cleanup(); - } - }); - - it("ide status exits 0 and reports files in sync", async () => { - const { projectDir, fakeHome, cleanup } = await createTestEnv("ide-status"); - try { - await seedWithVscode(projectDir, fakeHome); - const { stdout, exitCode } = await runCli(["ide", "status"], projectDir, fakeHome); - expect(exitCode).toBe(0); - expect(stdout).toContain("in sync"); - } finally { - await cleanup(); - } - }); - - it("ide update exits 0 and reports updated", async () => { - const { projectDir, fakeHome, cleanup } = await createTestEnv("ide-update"); - try { - await seedWithVscode(projectDir, fakeHome); - const { stdout, exitCode } = await runCli(["ide", "update"], projectDir, fakeHome); - expect(exitCode).toBe(0); - expect(stdout).toContain("vscode"); - } finally { - await cleanup(); - } - }); - - it("ide doctor exits 0 with healthy message", async () => { - const { projectDir, fakeHome, cleanup } = await createTestEnv("ide-doctor"); - try { - await seedWithVscode(projectDir, fakeHome); - const { stdout, exitCode } = await runCli(["ide", "doctor"], projectDir, fakeHome); - expect(exitCode).toBe(0); - expect(stdout).toContain("healthy"); - } finally { - await cleanup(); - } - }); - - it("ide restore exits 0 reporting nothing to restore when files unmodified", async () => { - const { projectDir, fakeHome, cleanup } = await createTestEnv("ide-restore"); - try { - await seedWithVscode(projectDir, fakeHome); - const { stdout, exitCode } = await runCli(["ide", "restore"], projectDir, fakeHome); - expect(exitCode).toBe(0); - expect(stdout).toContain("Nothing to restore"); - } finally { - await cleanup(); - } - }); - - it("ide install claude exits 1 — cross-category rejection", async () => { - const { projectDir, fakeHome, cleanup } = await createTestEnv("ide-cross-category"); - try { - const { stderr, exitCode } = await runCli(["ide", "install", "claude"], projectDir, fakeHome); - expect(exitCode).toBe(1); - expect(stderr).toContain("Unknown IDE tool: claude"); - } finally { - await cleanup(); - } - }); -}); diff --git a/cli/tests/e2e/command-matrix-help.e2e.test.ts b/cli/tests/e2e/command-matrix-help.e2e.test.ts index ce501b650..8b0fb18a2 100644 --- a/cli/tests/e2e/command-matrix-help.e2e.test.ts +++ b/cli/tests/e2e/command-matrix-help.e2e.test.ts @@ -1,22 +1,10 @@ -/** - * Command Matrix E2E — Help & Globals surface - * Automated counterpart of: aidd_docs/tasks/2026_05/2026_05_06-cli-v5-cleanup-command-matrix.md - * - * Already covered by existing E2E journeys (not duplicated here): - * clean.e2e.test.ts — clean, clean --force, clean dry-run - * update-global.e2e.test.ts — update, update re-install, update multi-tool - * sync-plugins.e2e.test.ts — ai sync variants (missing source, noop, force) - * - * See also: command-matrix-ai.e2e.test.ts, command-matrix-plugin.e2e.test.ts - */ - import { mkdir, writeFile } from "node:fs/promises"; import { join } from "node:path"; import { describe, expect, it } from "vitest"; import { createTestEnv, runCli } from "./helpers.js"; const AIDD_DIR = ".aidd"; -const EMPTY_MANIFEST = { version: 5, tools: {}, marketplaces: {} }; +const EMPTY_MANIFEST = { version: 8, tools: {} }; async function seedManifest(projectDir: string): Promise { await mkdir(join(projectDir, AIDD_DIR), { recursive: true }); @@ -29,13 +17,9 @@ async function seedManifest(projectDir: string): Promise { async function seedWithClaude(projectDir: string, fakeHome: string): Promise { await seedManifest(projectDir); - await runCli(["ai", "install", "claude"], projectDir, fakeHome); + await runCli(["framework", "install", "--tool", "claude"], projectDir, fakeHome); } -// --------------------------------------------------------------------------- -// Help surface -// --------------------------------------------------------------------------- - describe.concurrent("Command Matrix: Help", () => { it("aidd --help exits 0 and lists top-level commands", async () => { const { projectDir, fakeHome, cleanup } = await createTestEnv("help-root"); @@ -43,37 +27,32 @@ describe.concurrent("Command Matrix: Help", () => { const { stdout, exitCode } = await runCli(["--help"], projectDir, fakeHome); expect(exitCode).toBe(0); expect(stdout).toContain("setup"); - expect(stdout).toContain("ai"); - expect(stdout).toContain("ide"); + expect(stdout).toContain("framework"); expect(stdout).toContain("plugin"); expect(stdout).toContain("marketplace"); expect(stdout).toContain("auth"); + expect(stdout).toContain("doctor"); + expect(stdout).toContain("sync"); + expect(stdout).toContain("translate"); + expect(stdout).toContain("update"); + // `ai`/`ide` are retired behind `--tool`. + expect(stdout).not.toMatch(/^\s*ai\s/m); + expect(stdout).not.toMatch(/^\s*ide\s/m); } finally { await cleanup(); } }); - it("aidd ai --help exits 0 and lists ai subcommands", async () => { - const { projectDir, fakeHome, cleanup } = await createTestEnv("help-ai"); - try { - const { stdout, exitCode } = await runCli(["ai", "--help"], projectDir, fakeHome); - expect(exitCode).toBe(0); - expect(stdout).toContain("install"); - expect(stdout).toContain("uninstall"); - expect(stdout).toContain("list"); - } finally { - await cleanup(); - } - }); - - it("aidd ide --help exits 0 and lists ide subcommands", async () => { - const { projectDir, fakeHome, cleanup } = await createTestEnv("help-ide"); + it("aidd framework --help exits 0 and lists framework subcommands", async () => { + const { projectDir, fakeHome, cleanup } = await createTestEnv("help-framework"); try { - const { stdout, exitCode } = await runCli(["ide", "--help"], projectDir, fakeHome); + const { stdout, exitCode } = await runCli(["framework", "--help"], projectDir, fakeHome); expect(exitCode).toBe(0); expect(stdout).toContain("install"); - expect(stdout).toContain("uninstall"); - expect(stdout).toContain("vscode"); + expect(stdout).toContain("remove"); + expect(stdout).toContain("update"); + // The framework verbs are install/remove/update only. + expect(stdout).not.toMatch(/^\s*build\s/m); } finally { await cleanup(); } @@ -88,6 +67,8 @@ describe.concurrent("Command Matrix: Help", () => { expect(stdout).toContain("remove"); expect(stdout).toContain("install"); expect(stdout).toContain("search"); + // `plugin doctor` folded into `doctor --plugin`. + expect(stdout).not.toMatch(/^\s*doctor\s/m); } finally { await cleanup(); } @@ -191,8 +172,6 @@ describe.concurrent("Command Matrix: Help", () => { }); it("aidd install --help exits 0 (Commander.js intercepts --help before unknown command check)", async () => { - // NOTE from matrix: `--help` on unknown command shows top-level help with exit 0. - // The bare `aidd install` (above) correctly exits 1. const { projectDir, fakeHome, cleanup } = await createTestEnv("help-unknown-install-flag"); try { const { stdout, exitCode } = await runCli(["install", "--help"], projectDir, fakeHome); @@ -202,27 +181,24 @@ describe.concurrent("Command Matrix: Help", () => { await cleanup(); } }); -}); - -// --------------------------------------------------------------------------- -// Globals — status / doctor / restore / self-update --check -// (update and clean are in update-global.e2e.test.ts and clean.e2e.test.ts) -// --------------------------------------------------------------------------- -describe.concurrent("Command Matrix: Globals", () => { - it("status exits 0 and reports files in sync", async () => { - // matrix row: "status" → exit 0, "All files are in sync" - const { projectDir, fakeHome, cleanup } = await createTestEnv("global-status"); - try { - await seedWithClaude(projectDir, fakeHome); - const { stdout, exitCode } = await runCli(["status"], projectDir, fakeHome); - expect(exitCode).toBe(0); - expect(stdout).toMatch(/[Aa]ll files are in sync|in sync/); - } finally { - await cleanup(); + // Retired spellings must answer "unknown command", not silently do something else. + it.each(["ai", "ide", "status", "restore", "self-update"])( + "aidd %s exits 1 with unknown command error (retired in phase 18)", + async (retired) => { + const { projectDir, fakeHome, cleanup } = await createTestEnv(`help-retired-${retired}`); + try { + const { stderr, exitCode } = await runCli([retired], projectDir, fakeHome); + expect(exitCode).toBe(1); + expect(stderr).toMatch(/unknown command/i); + } finally { + await cleanup(); + } } - }); + ); +}); +describe.concurrent("Command Matrix: Globals", () => { it("doctor exits 0 and reports installation is healthy", async () => { const { projectDir, fakeHome, cleanup } = await createTestEnv("global-doctor"); try { @@ -235,11 +211,11 @@ describe.concurrent("Command Matrix: Globals", () => { } }); - it("restore exits 0 reporting nothing to restore when files unmodified", async () => { - const { projectDir, fakeHome, cleanup } = await createTestEnv("global-restore"); + it("sync exits 0 reporting nothing to restore when files unmodified", async () => { + const { projectDir, fakeHome, cleanup } = await createTestEnv("global-sync"); try { await seedWithClaude(projectDir, fakeHome); - const { stdout, exitCode } = await runCli(["restore"], projectDir, fakeHome); + const { stdout, exitCode } = await runCli(["sync"], projectDir, fakeHome); expect(exitCode).toBe(0); expect(stdout).toContain("Nothing to restore"); } finally { @@ -247,24 +223,12 @@ describe.concurrent("Command Matrix: Globals", () => { } }); - it("sync exits 1 with unknown command error (sync feature removed)", async () => { - const { projectDir, fakeHome, cleanup } = await createTestEnv("global-sync-removed"); - try { - await seedWithClaude(projectDir, fakeHome); - const { stderr, exitCode } = await runCli(["sync"], projectDir, fakeHome); - expect(exitCode).toBe(1); - expect(stderr).toMatch(/unknown command/i); - } finally { - await cleanup(); - } - }); - - it("self-update --check works without authentication", async () => { + it("update --check works without authentication", async () => { // --check performs a real npm lookup, so the exit code tracks network reachability; // assert only that authentication is never demanded. - const { projectDir, fakeHome, cleanup } = await createTestEnv("global-self-update-check"); + const { projectDir, fakeHome, cleanup } = await createTestEnv("global-update-check"); try { - const { stderr } = await runCli(["self-update", "--check"], projectDir, fakeHome); + const { stderr } = await runCli(["update", "--check"], projectDir, fakeHome); expect(stderr).not.toMatch(/[Nn]ot authenticated|auth login/); } finally { await cleanup(); diff --git a/cli/tests/e2e/command-matrix-plugin.e2e.test.ts b/cli/tests/e2e/command-matrix-plugin.e2e.test.ts index f8b4266c6..283dac2c0 100644 --- a/cli/tests/e2e/command-matrix-plugin.e2e.test.ts +++ b/cli/tests/e2e/command-matrix-plugin.e2e.test.ts @@ -1,21 +1,10 @@ -/** - * Command Matrix E2E — Plugin, Marketplace & Auth surface - * Automated counterpart of: aidd_docs/tasks/2026_05/2026_05_06-cli-v5-cleanup-command-matrix.md - * - * Already covered by existing E2E journeys (not duplicated here): - * plugin-install.e2e.test.ts — marketplace add/list/remove/browse/check/overwrite, - * plugin search/install - * - * See also: command-matrix-help.e2e.test.ts, command-matrix-ai.e2e.test.ts - */ - import { appendFile, mkdir, readFile, writeFile } from "node:fs/promises"; import { join, resolve } from "node:path"; import { describe, expect, it } from "vitest"; import { createTestEnv, runCli } from "./helpers.js"; const AIDD_DIR = ".aidd"; -const EMPTY_MANIFEST = { version: 5, tools: {}, marketplaces: {} }; +const EMPTY_MANIFEST = { version: 8, tools: {} }; const PLUGIN_FIXTURE = resolve(process.cwd(), "tests/fixtures/plugins/claude-format/sample-plugin"); async function seedManifest(projectDir: string): Promise { @@ -29,7 +18,7 @@ async function seedManifest(projectDir: string): Promise { async function seedWithClaude(projectDir: string, fakeHome: string): Promise { await seedManifest(projectDir); - await runCli(["ai", "install", "claude"], projectDir, fakeHome); + await runCli(["framework", "install", "--tool", "claude"], projectDir, fakeHome); } async function writeMarketplace( @@ -40,11 +29,6 @@ async function writeMarketplace( await writeFile(join(dir, ".claude-plugin", "marketplace.json"), JSON.stringify({ plugins })); } -// --------------------------------------------------------------------------- -// Plugin — install / remove / list / doctor / update / restore -// (plugin search/install from marketplace are in plugin-install.e2e.test.ts) -// --------------------------------------------------------------------------- - describe.concurrent("Command Matrix: Plugin lifecycle (local install)", () => { it("plugin install exits 0 with success message", async () => { const { projectDir, fakeHome, cleanup } = await createTestEnv("plugin-install-local"); @@ -108,11 +92,11 @@ describe.concurrent("Command Matrix: Plugin lifecycle (local install)", () => { } }); - it("plugin doctor exits 0 with healthy message when tool is installed", async () => { + it("doctor exits 0 with healthy message when tool is installed", async () => { const { projectDir, fakeHome, cleanup } = await createTestEnv("plugin-doctor"); try { await seedWithClaude(projectDir, fakeHome); - const { stdout, exitCode } = await runCli(["plugin", "doctor"], projectDir, fakeHome); + const { stdout, exitCode } = await runCli(["doctor"], projectDir, fakeHome); expect(exitCode).toBe(0); expect(stdout).toContain("healthy"); } finally { @@ -120,15 +104,13 @@ describe.concurrent("Command Matrix: Plugin lifecycle (local install)", () => { } }); - it("plugin doctor stays 0/healthy when non-plugin drift exists (regression: silent exit 1)", async () => { - // Regression for a silent exit-1: plugin doctor used to gate on the FULL - // doctor health (tracked-file / reference / layout warnings included) while - // only rendering pluginIssues — so unrelated drift made it exit 1 printing - // nothing. Here a tracked file is mutated (non-plugin drift): global doctor - // must flag it (exit 1), plugin doctor must stay scoped (exit 0 + healthy). + it("doctor --plugin stays 0/healthy when non-plugin drift exists (regression: silent exit 1)", async () => { + // A tracked file is mutated here — non-plugin drift — so unscoped doctor must flag it while + // `doctor --plugin` stays scoped to plugin issues. const { projectDir, fakeHome, cleanup } = await createTestEnv("plugin-doctor-scope"); try { await seedWithClaude(projectDir, fakeHome); + await runCli(["plugin", "install", PLUGIN_FIXTURE, "--tool", "claude"], projectDir, fakeHome); const manifest = JSON.parse( await readFile(join(projectDir, AIDD_DIR, "manifest.json"), "utf-8") ); @@ -136,10 +118,14 @@ describe.concurrent("Command Matrix: Plugin lifecycle (local install)", () => { await appendFile(join(projectDir, tracked), "\n\n"); const global = await runCli(["doctor"], projectDir, fakeHome); - expect(global.exitCode).toBe(1); // full doctor sees the drift + expect(global.exitCode).toBe(1); // unscoped doctor sees the drift - const { stdout, exitCode } = await runCli(["plugin", "doctor"], projectDir, fakeHome); - expect(exitCode).toBe(0); // plugin doctor is plugin-scoped + const { stdout, exitCode } = await runCli( + ["doctor", "--plugin", "sample-plugin"], + projectDir, + fakeHome + ); + expect(exitCode).toBe(0); // plugin-scoped doctor stays scoped expect(stdout).toContain("healthy"); } finally { await cleanup(); @@ -176,12 +162,12 @@ describe.concurrent("Command Matrix: Plugin lifecycle (local install)", () => { } }); - it("ai restore exits 0 and restores plugin files when a tracked file is deleted", async () => { - const { projectDir, fakeHome, cleanup } = await createTestEnv("ai-restore-plugin"); + it("sync --tool claude exits 0 and restores plugin files when a tracked file is deleted", async () => { + const { projectDir, fakeHome, cleanup } = await createTestEnv("sync-restore-plugin"); try { await seedWithClaude(projectDir, fakeHome); await runCli(["plugin", "install", PLUGIN_FIXTURE, "--tool", "claude"], projectDir, fakeHome); - const { stdout, exitCode } = await runCli(["ai", "restore"], projectDir, fakeHome); + const { stdout, exitCode } = await runCli(["sync", "--tool", "claude"], projectDir, fakeHome); expect(exitCode).toBe(0); expect(stdout).toMatch(/[Rr]estor|[Nn]othing to restore/); } finally { @@ -220,10 +206,6 @@ describe.concurrent("Command Matrix: Plugin lifecycle (local install)", () => { }); }); -// --------------------------------------------------------------------------- -// Marketplace — refresh / cache (add/list/browse/check/remove in plugin-install.e2e.test.ts) -// --------------------------------------------------------------------------- - describe.concurrent("Command Matrix: Marketplace cache + refresh", () => { it("marketplace refresh exits 0 (no-op when no marketplaces registered)", async () => { const { projectDir, fakeHome, cleanup } = await createTestEnv("mkt-refresh-empty"); @@ -274,7 +256,7 @@ describe.concurrent("Command Matrix: Marketplace cache + refresh", () => { }); it("marketplace add with file:// URI exits 1 — unsupported format", async () => { - // NOTE from matrix: file:// URI format not supported; use absolute path instead + // `file://` URIs are unsupported; a marketplace source is an absolute path. const { projectDir, fakeHome, cleanup } = await createTestEnv("mkt-add-file-uri"); try { await seedManifest(projectDir); @@ -291,10 +273,6 @@ describe.concurrent("Command Matrix: Marketplace cache + refresh", () => { }); }); -// --------------------------------------------------------------------------- -// Auth — offline operations only -// --------------------------------------------------------------------------- - describe.concurrent("Command Matrix: Auth (offline)", () => { it("auth status exits 0 and reports authentication state", async () => { // Runs against real user credentials env — test only checks exit 0 and presence diff --git a/cli/tests/e2e/framework-build.e2e.test.ts b/cli/tests/e2e/framework-build.e2e.test.ts index c735388e1..659058548 100644 --- a/cli/tests/e2e/framework-build.e2e.test.ts +++ b/cli/tests/e2e/framework-build.e2e.test.ts @@ -1,6 +1,6 @@ import { createHash } from "node:crypto"; import { existsSync } from "node:fs"; -import { mkdir, readdir, readFile } from "node:fs/promises"; +import { mkdir, readdir, readFile, writeFile } from "node:fs/promises"; import { join } from "node:path"; import { describe, expect, it } from "vitest"; import { createTestEnv, FRAMEWORK_PATH, initProject, runCli } from "./helpers.js"; @@ -21,16 +21,16 @@ async function hashDirectory(dir: string): Promise> { return result; } -describe.concurrent("E2E: aidd framework build", () => { +describe.concurrent("E2E: aidd translate", () => { it("AC #1 + #4: build → marketplace add → plugin install runs without error", async () => { const { tempDir, projectDir, fakeHome, cleanup } = await createTestEnv("fw-build-install"); try { await initProject(projectDir, FRAMEWORK_PATH); - await runCli(["ai", "install", "claude"], projectDir, fakeHome); + await runCli(["framework", "install", "--tool", "claude"], projectDir, fakeHome); const outDir = join(tempDir, "dist"); const build = await runCli( - ["framework", "build", "--source", FRAMEWORK_PATH, "--target", "copilot", "--out", outDir], + ["translate", FRAMEWORK_PATH, "--to", "copilot", "--out", outDir], projectDir, fakeHome ); @@ -38,7 +38,6 @@ describe.concurrent("E2E: aidd framework build", () => { expect(build.stdout).toContain("Built"); expect(build.stdout).toContain("files written to"); - // AC #1: verify OpenPlugin layout const marketplacePath = join(outDir, ".plugin", "marketplace.json"); expect(existsSync(marketplacePath)).toBe(true); @@ -71,7 +70,7 @@ describe.concurrent("E2E: aidd framework build", () => { const outDir = join(tempDir, "dist"); const run1 = await runCli( - ["framework", "build", "--source", FRAMEWORK_PATH, "--target", "copilot", "--out", outDir], + ["translate", FRAMEWORK_PATH, "--to", "copilot", "--out", outDir], projectDir, fakeHome ); @@ -79,8 +78,9 @@ describe.concurrent("E2E: aidd framework build", () => { const snapshot1 = await hashDirectory(outDir); + // outDir already holds run1's output, so a second build needs --force. const run2 = await runCli( - ["framework", "build", "--source", FRAMEWORK_PATH, "--target", "copilot", "--out", outDir], + ["translate", FRAMEWORK_PATH, "--to", "copilot", "--out", outDir, "--force"], projectDir, fakeHome ); @@ -105,14 +105,13 @@ describe.concurrent("E2E: aidd framework build", () => { const sourceDir = join(tempDir, "source"); await cp(FRAMEWORK_PATH, sourceDir, { recursive: true }); - // Corrupt the plugin manifest (remove required 'name' field) const manifestPath = join(sourceDir, "plugins", "aidd-test", ".claude-plugin", "plugin.json"); await mkdir(join(sourceDir, "plugins", "aidd-test", ".claude-plugin"), { recursive: true }); await writeFile(manifestPath, JSON.stringify({ version: "1.0.0" }), "utf-8"); const outDir = join(tempDir, "dist"); const result = await runCli( - ["framework", "build", "--source", sourceDir, "--target", "copilot", "--out", outDir], + ["translate", sourceDir, "--to", "copilot", "--out", outDir], projectDir, fakeHome ); @@ -129,13 +128,12 @@ describe.concurrent("E2E: aidd framework build", () => { try { const outDir = join(tempDir, "dist"); const build = await runCli( - ["framework", "build", "--source", FRAMEWORK_PATH, "--target", "copilot", "--out", outDir], + ["translate", FRAMEWORK_PATH, "--to", "copilot", "--out", outDir], projectDir, fakeHome ); expect(build.exitCode).toBe(0); - // AC #6: agent keeps .md extension (no rename to .agent.md) const agentPath = join(outDir, "plugins", "aidd-test", "agents", "code-reviewer.md"); expect(existsSync(agentPath)).toBe(true); const agentPathRenamed = join( @@ -151,7 +149,7 @@ describe.concurrent("E2E: aidd framework build", () => { expect(agentContent).toContain("name:"); expect(agentContent).toContain("description:"); - // AC #5: @./ rewritten to markdown link in skill + // `@./` rewritten to a markdown link in a skill. const skillPath = join(outDir, "plugins", "aidd-test", "skills", "hello.md"); expect(existsSync(skillPath)).toBe(true); const skillContent = await readFile(skillPath, "utf-8"); @@ -171,7 +169,7 @@ describe.concurrent("E2E: aidd framework build", () => { try { const outDir = join(tempDir, "dist"); const build = await runCli( - ["framework", "build", "--source", FRAMEWORK_PATH, "--target", "copilot", "--out", outDir], + ["translate", FRAMEWORK_PATH, "--to", "copilot", "--out", outDir], projectDir, fakeHome ); @@ -199,7 +197,7 @@ describe.concurrent("E2E: aidd framework build", () => { try { const outDir = join(tempDir, "dist"); const build = await runCli( - ["framework", "build", "--source", FRAMEWORK_PATH, "--target", "copilot", "--out", outDir], + ["translate", FRAMEWORK_PATH, "--to", "copilot", "--out", outDir], projectDir, fakeHome ); @@ -223,8 +221,6 @@ describe.concurrent("E2E: aidd framework build", () => { } }); - // ── Flat mode (AC #1, #2, #4, #9 flat variant) ──────────────────────────── - it("flat AC #1: --flat writes agents, skills, hooks, mcp under canonical paths", async () => { const { tempDir, projectDir, fakeHome, cleanup } = await createTestEnv("fw-flat-tree"); try { @@ -232,17 +228,7 @@ describe.concurrent("E2E: aidd framework build", () => { await mkdir(projRoot, { recursive: true }); const build = await runCli( - [ - "framework", - "build", - "--source", - FRAMEWORK_PATH, - "--target", - "copilot", - "--flat", - "--out", - projRoot, - ], + ["translate", FRAMEWORK_PATH, "--to", "copilot", "--as", "flat", "--out", projRoot], projectDir, fakeHome ); @@ -261,7 +247,7 @@ describe.concurrent("E2E: aidd framework build", () => { expect(existsSync(join(projRoot, ".vscode", "mcp.json"))).toBe(true); expect(existsSync(join(projRoot, ".github", "plugin", "marketplace.json"))).toBe(false); - // AC #5: agent frontmatter restricted to Copilot allowlist (name, description, model, tools, agents, argument-hint) + // Agent frontmatter is restricted to Copilot's own allowlist. const COPILOT_ALLOWED_KEYS = new Set([ "name", "description", @@ -296,17 +282,7 @@ describe.concurrent("E2E: aidd framework build", () => { await mkdir(projRoot, { recursive: true }); const run1 = await runCli( - [ - "framework", - "build", - "--source", - FRAMEWORK_PATH, - "--target", - "copilot", - "--flat", - "--out", - projRoot, - ], + ["translate", FRAMEWORK_PATH, "--to", "copilot", "--as", "flat", "--out", projRoot], projectDir, fakeHome ); @@ -316,13 +292,12 @@ describe.concurrent("E2E: aidd framework build", () => { const run2 = await runCli( [ - "framework", - "build", - "--source", + "translate", FRAMEWORK_PATH, - "--target", + "--to", "copilot", - "--flat", + "--as", + "flat", "--force", "--out", projRoot, @@ -340,17 +315,7 @@ describe.concurrent("E2E: aidd framework build", () => { } const run3 = await runCli( - [ - "framework", - "build", - "--source", - FRAMEWORK_PATH, - "--target", - "copilot", - "--flat", - "--out", - projRoot, - ], + ["translate", FRAMEWORK_PATH, "--to", "copilot", "--as", "flat", "--out", projRoot], projectDir, fakeHome ); @@ -367,7 +332,7 @@ describe.concurrent("E2E: aidd framework build", () => { const projRoot = join(tempDir, "proj"); await mkdir(projRoot, { recursive: true }); - // AC #7: pre-seed .vscode/mcp.json with a user-owned server to assert preservation + // Pre-seeded with a user-owned server, which the merge must preserve. const vscodePath = join(projRoot, ".vscode"); await mkdir(vscodePath, { recursive: true }); const existingMcp = { @@ -378,17 +343,7 @@ describe.concurrent("E2E: aidd framework build", () => { ); const build = await runCli( - [ - "framework", - "build", - "--source", - FRAMEWORK_PATH, - "--target", - "copilot", - "--flat", - "--out", - projRoot, - ], + ["translate", FRAMEWORK_PATH, "--to", "copilot", "--as", "flat", "--out", projRoot], projectDir, fakeHome ); @@ -409,21 +364,17 @@ describe.concurrent("E2E: aidd framework build", () => { const mcpRaw = await readFile(join(projRoot, ".vscode", "mcp.json"), "utf-8"); expect(mcpRaw).not.toContain(varRef); - // The written value is "/"-separated on purpose (see resolveClaudeRootAbsolute): a - // backslash-native path embedded in JSON comes back doubly escaped, and a forward - // slash is a valid path separator on Windows too. The claim is unchanged - the MCP - // command names this project root - so the expected value is spelled the way the - // file spells it, rather than the assertion being dropped (#707). + // The written value is "/"-separated on purpose: a backslash-native path embedded in + // JSON comes back doubly escaped, and a forward slash is valid on Windows too. expect(mcpRaw).toContain(projRoot.replace(/\\/g, "/")); - // AC #7: top-level key must be "servers"; plugin keys prefixed with "aidd-test-" + // The top-level key is "servers", and plugin keys carry the plugin's own prefix. const mcpParsed = JSON.parse(mcpRaw) as { servers: Record }; expect(typeof mcpParsed.servers).toBe("object"); const serverKeys = Object.keys(mcpParsed.servers); const pluginKeys = serverKeys.filter((k) => k.startsWith("aidd-test-")); expect(pluginKeys.length).toBeGreaterThan(0); - // AC #7: user-owned server must survive expect(mcpParsed.servers["my-existing-server"]).toBeDefined(); } finally { await cleanup(); @@ -436,7 +387,7 @@ describe.concurrent("E2E: aidd framework build", () => { const outDir = join(tempDir, "dist-codex"); await mkdir(outDir, { recursive: true }); const build = await runCli( - ["framework", "build", "--source", FRAMEWORK_PATH, "--target", "codex", "--out", outDir], + ["translate", FRAMEWORK_PATH, "--to", "codex", "--out", outDir], projectDir, fakeHome ); @@ -462,7 +413,7 @@ describe.concurrent("E2E: aidd framework build", () => { const outDir = join(tempDir, "dist-claude"); await mkdir(outDir, { recursive: true }); const build = await runCli( - ["framework", "build", "--source", FRAMEWORK_PATH, "--target", "claude", "--out", outDir], + ["translate", FRAMEWORK_PATH, "--to", "claude", "--out", outDir], projectDir, fakeHome ); @@ -488,7 +439,7 @@ describe.concurrent("E2E: aidd framework build", () => { const outDir = join(tempDir, "dist-cursor"); await mkdir(outDir, { recursive: true }); const build = await runCli( - ["framework", "build", "--source", FRAMEWORK_PATH, "--target", "cursor", "--out", outDir], + ["translate", FRAMEWORK_PATH, "--to", "cursor", "--out", outDir], projectDir, fakeHome ); @@ -515,7 +466,7 @@ describe.concurrent("E2E: aidd framework build", () => { try { const outDir = join(tempDir, "dist"); const result = await runCli( - ["framework", "build", "--source", FRAMEWORK_PATH, "--target", "opencode", "--out", outDir], + ["translate", FRAMEWORK_PATH, "--to", "opencode", "--out", outDir], projectDir, fakeHome ); @@ -526,59 +477,35 @@ describe.concurrent("E2E: aidd framework build", () => { } }); - it("flat guard: --force without --flat exits non-zero with hint", async () => { - const { tempDir, projectDir, fakeHome, cleanup } = await createTestEnv("fw-flat-guard-force"); + it("--force without --as builds marketplace mode normally into a fresh --out", async () => { + const { tempDir, projectDir, fakeHome, cleanup } = await createTestEnv("fw-market-force-fresh"); try { const outDir = join(tempDir, "dist"); const result = await runCli( - [ - "framework", - "build", - "--source", - FRAMEWORK_PATH, - "--target", - "copilot", - "--force", - "--out", - outDir, - ], + ["translate", FRAMEWORK_PATH, "--to", "copilot", "--force", "--out", outDir], projectDir, fakeHome ); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("--force requires --flat"); + expect(result.exitCode).toBe(0); + expect(existsSync(join(outDir, ".plugin", "marketplace.json"))).toBe(true); } finally { await cleanup(); } }); - // ── New flat targets (P4-P6) ───────────────────────────────────────────────── - it("AC #2: --target claude --flat materializes .claude/skills, .claude/agents, .mcp.json", async () => { const { tempDir, projectDir, fakeHome, cleanup } = await createTestEnv("fw-flat-claude"); try { const projRoot = join(tempDir, "proj"); await mkdir(projRoot, { recursive: true }); const result = await runCli( - [ - "framework", - "build", - "--source", - FRAMEWORK_PATH, - "--target", - "claude", - "--flat", - "--out", - projRoot, - ], + ["translate", FRAMEWORK_PATH, "--to", "claude", "--as", "flat", "--out", projRoot], projectDir, fakeHome ); expect(result.exitCode).toBe(0); expect(result.stdout).toContain("Flat-installed"); - // flat agents are bare (no plugin segment) under .claude/agents/ expect(existsSync(join(projRoot, ".claude", "agents"))).toBe(true); - // flat skills are bare (no plugin segment) under .claude/skills/ expect(existsSync(join(projRoot, ".claude", "skills"))).toBe(true); expect(existsSync(join(projRoot, ".mcp.json"))).toBe(true); const mcp = JSON.parse(await readFile(join(projRoot, ".mcp.json"), "utf-8")) as Record< @@ -597,24 +524,12 @@ describe.concurrent("E2E: aidd framework build", () => { const projRoot = join(tempDir, "proj"); await mkdir(projRoot, { recursive: true }); const result = await runCli( - [ - "framework", - "build", - "--source", - FRAMEWORK_PATH, - "--target", - "cursor", - "--flat", - "--out", - projRoot, - ], + ["translate", FRAMEWORK_PATH, "--to", "cursor", "--as", "flat", "--out", projRoot], projectDir, fakeHome ); expect(result.exitCode).toBe(0); - // flat agents are bare (no plugin segment) under .cursor/agents/ expect(existsSync(join(projRoot, ".cursor", "agents"))).toBe(true); - // flat skills are bare (no plugin segment) under .cursor/skills/ expect(existsSync(join(projRoot, ".cursor", "skills"))).toBe(true); expect(existsSync(join(projRoot, ".cursor", "mcp.json"))).toBe(true); const agents = await readdir(join(projRoot, ".cursor", "agents")); @@ -632,31 +547,20 @@ describe.concurrent("E2E: aidd framework build", () => { const projRoot = join(tempDir, "proj"); await mkdir(projRoot, { recursive: true }); const result = await runCli( - [ - "framework", - "build", - "--source", - FRAMEWORK_PATH, - "--target", - "codex", - "--flat", - "--out", - projRoot, - ], + ["translate", FRAMEWORK_PATH, "--to", "codex", "--as", "flat", "--out", projRoot], projectDir, fakeHome ); expect(result.exitCode).toBe(0); expect(existsSync(join(projRoot, ".codex", "agents"))).toBe(true); - // Codex scans .agents/skills/ for workspace skills (documented project skill root, - // verified live on 0.136); plugin-prefixed at one level, e.g. .agents/skills/aidd-context-00-onboard/ + // Codex scans `.agents/skills/` for workspace skills, verified live on 0.136, and + // plugin-prefixed at one level - never `.codex/skills/`. expect(existsSync(join(projRoot, ".agents", "skills"))).toBe(true); expect(existsSync(join(projRoot, ".codex", "skills"))).toBe(false); const config = await readFile(join(projRoot, ".codex", "config.toml"), "utf-8"); // [[skills.config]] is intentionally NOT emitted — discovery is by placement expect(config).not.toContain("[[skills.config]]"); expect(config).not.toContain("skills.config"); - // AC #4: merges mcp_servers into config.toml expect(config).toContain("mcp_servers"); } finally { await cleanup(); @@ -669,23 +573,12 @@ describe.concurrent("E2E: aidd framework build", () => { const projRoot = join(tempDir, "proj"); await mkdir(projRoot, { recursive: true }); const result = await runCli( - [ - "framework", - "build", - "--source", - FRAMEWORK_PATH, - "--target", - "opencode", - "--flat", - "--out", - projRoot, - ], + ["translate", FRAMEWORK_PATH, "--to", "opencode", "--as", "flat", "--out", projRoot], projectDir, fakeHome ); expect(result.exitCode).toBe(0); expect(existsSync(join(projRoot, ".opencode", "agents"))).toBe(true); - // flat skills are bare (no plugin segment) under .opencode/skills/ expect(existsSync(join(projRoot, ".opencode", "skills"))).toBe(true); expect(existsSync(join(projRoot, "opencode.json"))).toBe(true); const opencode = JSON.parse( @@ -705,7 +598,7 @@ describe.concurrent("E2E: aidd framework build", () => { try { const outDir = join(tempDir, "dist"); const result = await runCli( - ["framework", "build", "--source", FRAMEWORK_PATH, "--target", "opencode", "--out", outDir], + ["translate", FRAMEWORK_PATH, "--to", "opencode", "--out", outDir], projectDir, fakeHome ); @@ -716,3 +609,52 @@ describe.concurrent("E2E: aidd framework build", () => { } }); }); + +describe.concurrent("E2E: aidd translate — marketplace mode never erases what it did not write", () => { + it("refuses a non-empty --out without --force, and leaves the foreign file untouched", async () => { + const { tempDir, projectDir, fakeHome, cleanup } = await createTestEnv("fw-market-preserve"); + try { + const outDir = join(tempDir, "dist"); + await mkdir(outDir, { recursive: true }); + const keepPath = join(outDir, "keep.txt"); + await writeFile(keepPath, "keepme", "utf-8"); + + const build = await runCli( + ["translate", FRAMEWORK_PATH, "--to", "copilot", "--out", outDir], + projectDir, + fakeHome + ); + + expect(build.exitCode).not.toBe(0); + expect(build.stderr).toContain(outDir); + expect(build.stderr).toContain("--force"); + expect(existsSync(keepPath)).toBe(true); + expect(await readFile(keepPath, "utf-8")).toBe("keepme"); + } finally { + await cleanup(); + } + }); + + it("with --force, writes its own output but keeps the foreign file", async () => { + const { tempDir, projectDir, fakeHome, cleanup } = await createTestEnv("fw-market-force"); + try { + const outDir = join(tempDir, "dist"); + await mkdir(outDir, { recursive: true }); + const keepPath = join(outDir, "keep.txt"); + await writeFile(keepPath, "keepme", "utf-8"); + + const build = await runCli( + ["translate", FRAMEWORK_PATH, "--to", "copilot", "--out", outDir, "--force"], + projectDir, + fakeHome + ); + + expect(build.exitCode).toBe(0); + expect(existsSync(keepPath)).toBe(true); + expect(await readFile(keepPath, "utf-8")).toBe("keepme"); + expect(existsSync(join(outDir, ".plugin", "marketplace.json"))).toBe(true); + } finally { + await cleanup(); + } + }); +}); diff --git a/cli/tests/e2e/framework-rules.e2e.test.ts b/cli/tests/e2e/framework-rules.e2e.test.ts new file mode 100644 index 000000000..3ebb38af8 --- /dev/null +++ b/cli/tests/e2e/framework-rules.e2e.test.ts @@ -0,0 +1,60 @@ +import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { cliPath, execFileAsync } from "./helpers.js"; + +/** + * Run against the built binary, which is what the skill invokes: a use case passing in + * isolation says nothing about the subcommand being reachable. + */ +describe("aidd framework rules — the inventory a rule scan reads", () => { + let project: string; + + beforeAll(async () => { + project = await mkdtemp(join(tmpdir(), "aidd-framework-rules-")); + await mkdir(join(project, ".claude/rules/01-standards"), { recursive: true }); + await mkdir(join(project, ".codex/rules"), { recursive: true }); + await mkdir(join(project, ".cursor/rules"), { recursive: true }); + await writeFile( + join(project, ".claude/rules/01-standards/1-naming.md"), + '---\ndescription: Names files\npaths:\n - "src/**/*.ts"\n---\n\n# Naming\n' + ); + await writeFile(join(project, ".codex/rules/2-imports.md"), "---\n---\n\n# Imports\n"); + // Beside a rule, and not one: only the extension the tool installs makes it a rule. + await writeFile(join(project, ".cursor/rules/README.md"), "# not a rule\n"); + }); + + afterAll(async () => { + await rm(project, { recursive: true, force: true }); + }); + + it("answers with every rule installed, whatever tool installed it", async () => { + const { stdout } = await execFileAsync("node", [cliPath(), "framework", "rules", "--json"], { + cwd: project, + }); + + expect(JSON.parse(stdout)).toEqual([ + { + tool: "claude", + path: ".claude/rules/01-standards/1-naming.md", + name: "1-naming", + description: "Names files", + paths: ["src/**/*.ts"], + }, + { tool: "codex", path: ".codex/rules/2-imports.md", name: "2-imports", description: "" }, + ]); + }); + + it("says a project holds none rather than printing nothing", async () => { + const empty = await mkdtemp(join(tmpdir(), "aidd-framework-rules-empty-")); + try { + const { stdout } = await execFileAsync("node", [cliPath(), "framework", "rules"], { + cwd: empty, + }); + expect(stdout).toContain("No rules installed"); + } finally { + await rm(empty, { recursive: true, force: true }); + } + }); +}); diff --git a/cli/tests/e2e/global-setup.ts b/cli/tests/e2e/global-setup.ts new file mode 100644 index 000000000..353e2de5b --- /dev/null +++ b/cli/tests/e2e/global-setup.ts @@ -0,0 +1,42 @@ +/** + * Builds this run's own binary, so two vitest invocations never share one `dist/cli.js`. + * Under `cli/`, never the OS temp dir: Node resolves the external dependencies by walking up. + */ +import { execFile } from "node:child_process"; +import { mkdir, mkdtemp, rm } from "node:fs/promises"; +import { join, resolve } from "node:path"; +import { promisify } from "node:util"; +import type { TestProject } from "vitest/node"; + +declare module "vitest" { + export interface ProvidedContext { + cliPath: string; + } +} + +const execFileAsync = promisify(execFile); +const CLI_ROOT = resolve(import.meta.dirname, "..", ".."); +// tsup's own entry, run through this very node: the `.bin/tsup` shim is an extensionless +// shell script Windows cannot spawn without a shell. +const TSUP_ENTRY = join(CLI_ROOT, "node_modules", "tsup", "dist", "cli-default.js"); +/** Gitignored: one directory per run, removed on teardown. */ +const BUILD_ROOT = join(CLI_ROOT, ".e2e-build"); +let outDir: string | undefined; + +export async function setup(project: TestProject): Promise { + await mkdir(BUILD_ROOT, { recursive: true }); + outDir = await mkdtemp(join(BUILD_ROOT, "run-")); + + await execFileAsync(process.execPath, [TSUP_ENTRY], { + cwd: CLI_ROOT, + env: { ...process.env, AIDD_BUILD_OUT_DIR: outDir }, + }); + + project.provide("cliPath", join(outDir, "cli.js")); +} + +export async function teardown(): Promise { + const built = outDir; + if (built === undefined) return; + await rm(built, { recursive: true, force: true }); +} diff --git a/cli/tests/e2e/greenfield-setup.e2e.test.ts b/cli/tests/e2e/greenfield-setup.e2e.test.ts index 659a665b7..fb7d332ed 100644 --- a/cli/tests/e2e/greenfield-setup.e2e.test.ts +++ b/cli/tests/e2e/greenfield-setup.e2e.test.ts @@ -5,7 +5,7 @@ import { describe, expect, it } from "vitest"; import { createTestEnv, runCli } from "./helpers.js"; const AIDD_DIR = ".aidd"; -const EMPTY_MANIFEST = { version: 5, tools: {}, marketplaces: {} }; +const EMPTY_MANIFEST = { version: 8, tools: {} }; async function seedManifest(projectDir: string): Promise { await mkdir(join(projectDir, AIDD_DIR), { recursive: true }); @@ -16,13 +16,17 @@ async function seedManifest(projectDir: string): Promise { ); } -describe.concurrent("E2E: aidd ai install — individual tool install", () => { - it("ai install claude writes settings.json and manifest from bundled assets", async () => { +describe.concurrent("E2E: aidd framework install --tool — individual tool install", () => { + it("framework install --tool claude writes settings.json and manifest from bundled assets", async () => { const { projectDir, fakeHome, cleanup } = await createTestEnv("greenfield-claude"); try { await seedManifest(projectDir); - const { stdout, exitCode } = await runCli(["ai", "install", "claude"], projectDir, fakeHome); + const { stdout, exitCode } = await runCli( + ["framework", "install", "--tool", "claude"], + projectDir, + fakeHome + ); expect(exitCode).toBe(0); expect(stdout).toContain("Installed claude"); @@ -33,12 +37,16 @@ describe.concurrent("E2E: aidd ai install — individual tool install", () => { } }); - it("ide install vscode writes .vscode/settings.json from bundled assets", async () => { + it("framework install --tool vscode writes .vscode/settings.json from bundled assets", async () => { const { projectDir, fakeHome, cleanup } = await createTestEnv("greenfield-vscode"); try { await seedManifest(projectDir); - const { stdout, exitCode } = await runCli(["ide", "install", "vscode"], projectDir, fakeHome); + const { stdout, exitCode } = await runCli( + ["framework", "install", "--tool", "vscode"], + projectDir, + fakeHome + ); expect(exitCode).toBe(0); expect(stdout).toContain("Installed vscode"); @@ -48,12 +56,16 @@ describe.concurrent("E2E: aidd ai install — individual tool install", () => { } }); - it("ai install cursor writes .cursor directory from bundled assets", async () => { + it("framework install --tool cursor writes .cursor directory from bundled assets", async () => { const { projectDir, fakeHome, cleanup } = await createTestEnv("greenfield-cursor"); try { await seedManifest(projectDir); - const { stdout, exitCode } = await runCli(["ai", "install", "cursor"], projectDir, fakeHome); + const { stdout, exitCode } = await runCli( + ["framework", "install", "--tool", "cursor"], + projectDir, + fakeHome + ); expect(exitCode).toBe(0); expect(stdout).toContain("Installed cursor"); @@ -63,13 +75,17 @@ describe.concurrent("E2E: aidd ai install — individual tool install", () => { } }); - it("ai install claude is idempotent — second run warns already installed", async () => { + it("framework install --tool claude is idempotent — second run warns already installed", async () => { const { projectDir, fakeHome, cleanup } = await createTestEnv("greenfield-install-idempotent"); try { await seedManifest(projectDir); - await runCli(["ai", "install", "claude"], projectDir, fakeHome); + await runCli(["framework", "install", "--tool", "claude"], projectDir, fakeHome); - const { stderr, exitCode } = await runCli(["ai", "install", "claude"], projectDir, fakeHome); + const { stderr, exitCode } = await runCli( + ["framework", "install", "--tool", "claude"], + projectDir, + fakeHome + ); expect(exitCode).toBe(0); expect(stderr).toContain("already installed"); @@ -78,14 +94,14 @@ describe.concurrent("E2E: aidd ai install — individual tool install", () => { } }); - it("ai install claude --force reinstalls over existing files", async () => { + it("framework install --tool claude --force reinstalls over existing files", async () => { const { projectDir, fakeHome, cleanup } = await createTestEnv("greenfield-force"); try { await seedManifest(projectDir); - await runCli(["ai", "install", "claude"], projectDir, fakeHome); + await runCli(["framework", "install", "--tool", "claude"], projectDir, fakeHome); const { stdout, exitCode } = await runCli( - ["ai", "install", "claude", "--force"], + ["framework", "install", "--tool", "claude", "--force"], projectDir, fakeHome ); @@ -97,11 +113,11 @@ describe.concurrent("E2E: aidd ai install — individual tool install", () => { } }); - it("manifest tracks installed files after ai install claude", async () => { + it("manifest tracks installed files after framework install --tool claude", async () => { const { projectDir, fakeHome, cleanup } = await createTestEnv("greenfield-manifest"); try { await seedManifest(projectDir); - await runCli(["ai", "install", "claude"], projectDir, fakeHome); + await runCli(["framework", "install", "--tool", "claude"], projectDir, fakeHome); const raw = await readFile(join(projectDir, AIDD_DIR, "manifest.json"), "utf-8"); const manifest = JSON.parse(raw) as { tools: Record }; @@ -112,12 +128,16 @@ describe.concurrent("E2E: aidd ai install — individual tool install", () => { } }); - it("ai install copilot without vscode — no .vscode directory created", async () => { + it("framework install --tool copilot without vscode — no .vscode directory created", async () => { const { projectDir, fakeHome, cleanup } = await createTestEnv("greenfield-copilot-no-vscode"); try { await seedManifest(projectDir); - const { exitCode } = await runCli(["ai", "install", "copilot"], projectDir, fakeHome); + const { exitCode } = await runCli( + ["framework", "install", "--tool", "copilot"], + projectDir, + fakeHome + ); expect(exitCode).toBe(0); expect(existsSync(join(projectDir, ".vscode"))).toBe(false); @@ -126,13 +146,17 @@ describe.concurrent("E2E: aidd ai install — individual tool install", () => { } }); - it("ai install copilot with vscode — .vscode/settings.json has copilot keys", async () => { + it("framework install --tool copilot with vscode — .vscode/settings.json has copilot keys", async () => { const { projectDir, fakeHome, cleanup } = await createTestEnv("greenfield-copilot-with-vscode"); try { await seedManifest(projectDir); - await runCli(["ide", "install", "vscode"], projectDir, fakeHome); + await runCli(["framework", "install", "--tool", "vscode"], projectDir, fakeHome); - const { exitCode } = await runCli(["ai", "install", "copilot"], projectDir, fakeHome); + const { exitCode } = await runCli( + ["framework", "install", "--tool", "copilot"], + projectDir, + fakeHome + ); expect(exitCode).toBe(0); const settingsPath = join(projectDir, ".vscode", "settings.json"); diff --git a/cli/tests/e2e/helpers.ts b/cli/tests/e2e/helpers.ts index 9b58aaeb9..751f7dc11 100644 --- a/cli/tests/e2e/helpers.ts +++ b/cli/tests/e2e/helpers.ts @@ -1,13 +1,14 @@ import { execFile } from "node:child_process"; -import { existsSync } from "node:fs"; -import { copyFile, cp, mkdir, mkdtemp, rm } from "node:fs/promises"; +import { accessSync, constants, existsSync } from "node:fs"; +import { copyFile, cp, mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; import { homedir, tmpdir } from "node:os"; import { delimiter, dirname, join, resolve } from "node:path"; import { promisify } from "node:util"; -import { CLIOutput } from "../../src/application/output.js"; -import { InitUseCase } from "../../src/application/use-cases/init-use-case.js"; -import { createDeps } from "../../src/infrastructure/deps.js"; -import { environmentWithoutGitVariables as withoutGitEnv } from "../../src/infrastructure/git-environment.js"; +import { inject } from "vitest"; +import { InitUseCase } from "../../src/contexts/framework/application/init-use-case.js"; +import { CLIOutput } from "../../src/presentation/output.js"; +import { environmentWithoutGitVariables as withoutGitEnv } from "../../src/runtime/git/git-environment.js"; +import { createDeps } from "../../src/runtime/wiring/framework.js"; export const execFileAsync = promisify(execFile); @@ -22,7 +23,31 @@ export async function gitSetOriginRemote(cwd: string, url: string): Promise { - await cp(sourceDir, destDir, { recursive: true }); +/** + * A sandboxed run must reach none of these: the CLI registers marketplaces through a tool's + * own command when its binary is there, making recorded output depend on the machine. + */ +const DRIVABLE_TOOL_BINARIES = ["claude", "codex", "copilot", "cursor-agent"]; + +/** + * Judged by what a directory holds, never by a keep-list: `node` and `copilot` share + * `/opt/homebrew/bin` on macOS, so callers reach node through `process.execPath`. + */ +function withoutDrivableToolBinary(dir: string): boolean { + return DRIVABLE_TOOL_BINARIES.every((binary) => { + try { + accessSync(join(dir, binary), constants.X_OK); + return false; + } catch { + return true; + } + }); } function hasExecutable(dir: string, name: string): boolean { @@ -64,10 +102,8 @@ function hasExecutable(dir: string, name: string): boolean { return existsSync(join(dir, exeName)); } -/** The first `PATH` directory carrying `git` but not `aidd` — skipping, never stopping at, - * one that carries both. On this very machine `git` and a globally-linked `aidd` sit in the - * same Homebrew directory; returning it unfiltered would silently readmit the binary these - * tests exist to prove unnecessary. */ +/** Skips, never stops at, a directory carrying both: `git` and a globally-linked `aidd` can + * share one Homebrew directory, which would readmit the binary the sandbox excludes. */ function findGitDirWithoutAidd(): string | undefined { for (const dir of (process.env.PATH ?? "").split(delimiter)) { if (dir === "") continue; @@ -77,22 +113,8 @@ function findGitDirWithoutAidd(): string | undefined { } /** - * The directories already on `PATH` that hold a shell, minus any that also hold `aidd`. - * - * Git runs a hook by reading its shebang and looking the interpreter up **by name on - * `PATH`** — `#!/bin/sh` sends it looking for `sh.exe`. `pathWithoutAidd` hands every - * sandboxed run a deliberately narrow `PATH`, and on POSIX that list already carries `/bin`, - * so the dependency stayed invisible. On Windows there was no shell on it at all, and every - * hook failed with `cannot spawn …: No such file or directory` — a message that reads like a - * missing hook file and is nothing of the sort. - * - * Selected by what a directory actually contains, never derived from where `git.exe` lives. - * An earlier attempt walked one level up from the git directory and looked for `bin` and - * `usr\bin`, which was measured wrong on the runner this exists for: git resolves there to - * `C:\Program Files\Git\mingw64\bin`, whose parent holds neither, while the shells sit in - * `C:\Program Files\Git\usr\bin` and `C:\Program Files\Git\bin` — two levels away and - * on a different branch. Filtering what is already on `PATH` needs no theory of the layout - * and cannot be wrong about one. + * Git looks a hook's shebang interpreter up by name on `PATH`, so the narrow sandbox `PATH` + * must carry a shell — selected by what a directory holds, never from where `git.exe` lives. */ export function shellDirsWithoutAidd( pathDirs: readonly string[], @@ -106,15 +128,11 @@ export interface PathWithoutAiddInputs { readonly nodeDir: string; readonly gitDir: string | undefined; readonly systemRoot: string; - /** Every directory the ambient `PATH` names — where the shell is picked from on Windows. */ readonly pathDirs: readonly string[]; readonly holds: (dir: string, name: string) => boolean; } -/** The list itself, from stated inputs. Pure so the Windows shape is asserted from whatever - * platform a person happens to be developing on — the branch that broke was one nobody - * running these tests could execute, and a line only a remote runner ever reaches is a line - * nobody is really maintaining. */ +/** Pure so the Windows shape can be asserted from any platform. */ export function pathDirsWithoutAidd({ platform, nodeDir, @@ -133,6 +151,10 @@ export function pathDirsWithoutAidd({ return dirs; } +/** + * Narrow by construction, then filtered of any directory holding a drivable tool binary — + * without the filter a tool shipped into `/usr/bin` stays reachable. + */ export function pathWithoutAidd(): string { return pathDirsWithoutAidd({ platform: process.platform, @@ -141,14 +163,17 @@ export function pathWithoutAidd(): string { systemRoot: process.env.SystemRoot ?? process.env.windir ?? "C:\\Windows", pathDirs: (process.env.PATH ?? "").split(delimiter).filter(Boolean), holds: hasExecutable, - }).join(delimiter); + }) + .filter(withoutDrivableToolBinary) + .join(delimiter); } -// Where a person's own identity file lands under sandboxedEnv, restated rather than -// imported from the adapter: a test that asked the code where it wrote the file could not -// catch the code writing it somewhere else. It was once held to the plugin's own identity -// script as well (#707); the CLI is the only writer now, so this pins `person-identity-adapter.ts` -// alone. +export async function copyFixtureTree(sourceDir: string, destDir: string): Promise { + await cp(sourceDir, destDir, { recursive: true }); +} + +// Derived here and never imported from the adapter: a test that asked the code where it +// wrote the file could not catch it writing somewhere else. export function identityFileIn(fakeHome: string): string { return process.platform === "win32" ? join(fakeHome, "AppData", "Roaming", "aidd", "identity.json") @@ -156,19 +181,11 @@ export function identityFileIn(fakeHome: string): string { } /** - * Where a sandboxed run's figures actually land, which is not the same directory on every - * platform. `sandboxedEnv` below points `APPDATA` inside the fake home, so a Windows run - * writes under `AppData\Roaming\aidd`, never under `.config`. - * - * A test that seeds the sink itself before running is insulated from this by the adapter's - * legacy-data fallback — a home that already journalled under `.config` keeps landing there. - * A test that lets the CLI create the sink from nothing is not, and asserting the POSIX path - * there reads as "nothing was stored" on Windows rather than as a wrong lookup. + * `sandboxedEnv` always sets `AIDD_USER_CONFIG_DIR`, which `TelemetrySinkAdapter` honours + * ahead of its platform default — so the sink is under the fake home on every platform. */ export function sinkDirIn(fakeHome: string): string { - return process.platform === "win32" - ? join(fakeHome, "AppData", "Roaming", "aidd", "telemetry") - : join(fakeHome, ".config", "aidd", "telemetry"); + return join(fakeHome, ".config", "aidd", "telemetry"); } export function sandboxedEnv( @@ -176,39 +193,34 @@ export function sandboxedEnv( extra?: Record, options?: { realHome?: boolean } ): NodeJS.ProcessEnv { - // A minimal PATH, not the runner's own. What a spawned `aidd` can reach decides what it - // does: the OpenCode reader shells out to an `opencode` binary and waits up to 10s for it, - // so a machine that happens to have the tool installed pays a cost a machine without it - // does not. That is how `records stored before opting in stay unnamed` swung between 14s - // and a 60s timeout across three runs with no code change. A test's result must not depend - // on which AI tools the person running it happens to have. + // A minimal PATH, not the runner's own: the OpenCode reader shells out to an `opencode` + // binary and waits up to 10s for it, so a machine carrying the tool pays a cost one + // without it does not. const base = { ...withoutGitEnv(process.env), PATH: pathWithoutAidd(), Path: pathWithoutAidd() }; if (options?.realHome) { return { ...base, ...extra, AIDD_USER_CONFIG_DIR: join(fakeHome, ".config", "aidd") }; } return { ...base, + // Defaults, before `extra`: a caller passing one means to change it. The sandbox's own + // home variables come after, where nothing can override them. + AIDD_USER_CONFIG_DIR: join(fakeHome, ".config", "aidd"), + // The check asks GitHub for the latest release and prints a notice from the answer, so + // captured stderr would change with every publish. + AIDD_SKIP_UPDATE_CHECK: "1", ...extra, HOME: fakeHome, XDG_CONFIG_HOME: join(fakeHome, ".config"), - // `HOME` alone only sandboxes POSIX: `os.homedir()` never reads it on Windows, where it - // reads `USERPROFILE` instead, and the CLI/plugin's own Windows config-dir rule reads - // `APPDATA` ahead of that. Left at the real runner's values, both leak every test's - // "sandboxed" home straight onto the machine's real profile. Harmless on POSIX, where - // neither variable is consulted. + // `HOME` alone only sandboxes POSIX: `os.homedir()` reads `USERPROFILE` on Windows, and + // the CLI's own Windows config-dir rule reads `APPDATA` ahead of that. USERPROFILE: fakeHome, APPDATA: join(fakeHome, "AppData", "Roaming"), }; } -// `realHome: true` leaves `HOME` real so network tools (gh, git, npm) keep their real -// credentials — `resolveAiddConfigDir` (identity) and `resolveHomeDir` (local cost readers) -// never honor `AIDD_USER_CONFIG_DIR`, by design, so both resolve to the developer's real -// profile under this option. `forget --yes` is the one command that deletes what it finds -// there. No test passes `realHome: true` today, but nothing before this line stopped one -// from combining it with `forget` — this makes that specific combination throw instead of -// reaching a real profile's identity file. A deliberate test that genuinely needs both -// should sandbox identity/local-cost resolution some other way rather than removing this. +// Under `realHome: true` neither `resolveAiddConfigDir` (identity) nor `resolveHomeDir` +// (local cost readers) honors `AIDD_USER_CONFIG_DIR`, so `forget --yes` would delete what it +// finds in the developer's real profile. function refuseRealHomeForget(args: readonly string[], options?: { realHome?: boolean }): void { if (options?.realHome && args.includes("forget")) { throw new Error( @@ -227,7 +239,10 @@ export async function runCli( refuseRealHomeForget(args, options); const env = sandboxedEnv(fakeHome, options?.env, options); try { - const { stdout, stderr } = await execFileAsync("node", [CLI_PATH, ...args], { cwd, env }); + const { stdout, stderr } = await execFileAsync(process.execPath, [cliPath(), ...args], { + cwd, + env, + }); return { stdout, stderr, exitCode: 0 }; } catch (error) { const err = error as { stdout?: string; stderr?: string; code?: number }; @@ -239,7 +254,6 @@ export async function runCli( } } -/** Skips marketplace refresh (network call) for fast/flake-prone tests. */ export async function runCliFast( args: string[], cwd: string, @@ -247,7 +261,10 @@ export async function runCliFast( ): Promise<{ stdout: string; stderr: string; exitCode: number }> { const env = sandboxedEnv(fakeHome, { AIDD_SKIP_MARKETPLACE_REFRESH: "1" }); try { - const { stdout, stderr } = await execFileAsync("node", [CLI_PATH, ...args], { cwd, env }); + const { stdout, stderr } = await execFileAsync(process.execPath, [cliPath(), ...args], { + cwd, + env, + }); return { stdout, stderr, exitCode: 0 }; } catch (error) { const err = error as { stdout?: string; stderr?: string; code?: number }; @@ -259,11 +276,6 @@ export async function runCliFast( } } -/** - * Initializes a project with a manifest. Used to set up e2e test fixtures. - * The frameworkPath parameter is kept for API compatibility but no longer used - * (init no longer copies framework files). - */ export async function initProject(projectDir: string, _frameworkPath: string): Promise { const output = new CLIOutput(false); const deps = await createDeps(projectDir, { verbose: false }, output); @@ -271,3 +283,25 @@ export async function initProject(projectDir: string, _frameworkPath: string): P projectRoot: projectDir, }); } + +/** + * On Windows a bare shell script is not executable; what a `PATH` really holds is a `.cmd` + * shim, so that is what the stand-in is written as. + */ +export async function writeFakeToolBinary( + binDir: string, + name: string, + logFile: string +): Promise { + await mkdir(binDir, { recursive: true }); + if (process.platform === "win32") { + await writeFile( + join(binDir, `${name}.cmd`), + `@echo off\r\necho %* >> "${logFile}"\r\nexit /b 0\r\n` + ); + return; + } + await writeFile(join(binDir, name), `#!/bin/sh\necho "$@" >> "${logFile}"\nexit 0\n`, { + mode: 0o755, + }); +} diff --git a/cli/tests/e2e/helpers.unit.test.ts b/cli/tests/e2e/helpers.unit.test.ts index 585e4ae7a..da59cec8c 100644 --- a/cli/tests/e2e/helpers.unit.test.ts +++ b/cli/tests/e2e/helpers.unit.test.ts @@ -3,23 +3,8 @@ import { describe, expect, it } from "vitest"; import { pathDirsWithoutAidd, shellDirsWithoutAidd } from "./helpers.js"; /** - * The Windows half of `pathWithoutAidd`, provable from any platform. - * - * Git runs a hook by reading its shebang and looking the interpreter up by name on `PATH`: - * `#!/bin/sh` sends it looking for `sh.exe`. That helper hands every sandboxed run a - * deliberately narrow `PATH`; on POSIX it already carries `/bin`, so the dependency stayed - * invisible, and on Windows there was no shell on it at all. Six commit-trailer e2e tests - * failed there and only there with `cannot spawn …: No such file or directory`, which reads - * like a missing hook file and is nothing of the sort. - * - * The layout below is not invented — it is what the runner reported when asked: - * - * git.exe C:\Program Files\Git\mingw64\bin - * sh.exe C:\Program Files\Git\usr\bin, C:\Program Files\Git\bin - * - * which is why deriving the shell from the git directory failed: those are two levels away - * and on a different branch. Selecting by what a directory holds needs no theory of the - * layout and cannot be wrong about one. + * Git runs a hook by looking its shebang interpreter up by name on `PATH`, so a narrow + * `PATH` with no shell on it fails as `cannot spawn …: No such file or directory`. */ const GIT_ROOT = "C:\\Program Files\\Git"; const GIT_BIN = join(GIT_ROOT, "mingw64", "bin"); diff --git a/cli/tests/e2e/issue-271-setup-cache-version.e2e.test.ts b/cli/tests/e2e/issue-271-setup-cache-version.e2e.test.ts index 452ca18a2..18d89f747 100644 --- a/cli/tests/e2e/issue-271-setup-cache-version.e2e.test.ts +++ b/cli/tests/e2e/issue-271-setup-cache-version.e2e.test.ts @@ -10,7 +10,7 @@ async function seedManifest(projectDir: string): Promise { await mkdir(join(projectDir, AIDD_DIR), { recursive: true }); await writeFile( join(projectDir, AIDD_DIR, "manifest.json"), - JSON.stringify({ version: 5, tools: {}, marketplaces: {} }), + JSON.stringify({ version: 8, tools: {} }), "utf-8" ); } @@ -105,7 +105,7 @@ describe.concurrent("E2E: issue-271 — setup cache resolution and propagation v try { await seedManifest(projectDir); - // Install claude with aidd-dev plugin from local fixture (catalog version 1.0.0) + // The local fixture's catalog declares aidd-dev 1.0.0. await runCli( [ "setup", @@ -123,7 +123,6 @@ describe.concurrent("E2E: issue-271 — setup cache resolution and propagation v fakeHome ); - // Drift the aidd-dev version in the manifest to simulate an older pinned version const manifestBefore = await readManifest(projectDir); const tools = manifestBefore.tools as Record< string, @@ -139,9 +138,8 @@ describe.concurrent("E2E: issue-271 — setup cache resolution and propagation v "utf-8" ); - // Install cursor — this triggers plugin propagation with prefer-catalog policy const { exitCode, stdout, stderr } = await runCli( - ["ai", "install", "cursor"], + ["framework", "install", "--tool", "cursor"], projectDir, fakeHome ); @@ -168,16 +166,14 @@ describe.concurrent("E2E: issue-271 — setup cache resolution and propagation v const { projectDir, fakeHome, cleanup } = await createTestEnv("271-scenario-c"); try { await seedManifest(projectDir); - await runCli(["ai", "install", "claude"], projectDir, fakeHome); + await runCli(["framework", "install", "--tool", "claude"], projectDir, fakeHome); - // Register local marketplace so aidd-dev is resolvable (catalog version 1.0.0) await runCli( ["marketplace", "add", "fixture-market", FRAMEWORK_REAL_PATH, "--yes"], projectDir, fakeHome ); - // plugin install aidd-dev@0.9.0 — catalog says 1.0.0, strict mode should reject const { exitCode, stderr } = await runCli( ["plugin", "install", "aidd-dev@0.9.0", "--tool", "claude"], projectDir, diff --git a/cli/tests/e2e/marketplace-add-conflict.e2e.test.ts b/cli/tests/e2e/marketplace-add-conflict.e2e.test.ts new file mode 100644 index 000000000..96589fe99 --- /dev/null +++ b/cli/tests/e2e/marketplace-add-conflict.e2e.test.ts @@ -0,0 +1,160 @@ +import { cp, mkdir, realpath, writeFile } from "node:fs/promises"; +import { delimiter, join, resolve } from "node:path"; +import { describe, expect, it } from "vitest"; +import { + createTestEnv, + FRAMEWORK_PATH, + initProject, + pathWithoutAidd, + runCli, + writeFakeToolBinary, +} from "./helpers.js"; + +const PLUGIN_FIXTURE = resolve(process.cwd(), "tests/fixtures/plugins/claude-format/sample-plugin"); + +/** A claude-shaped catalog at the exact path `readMarketplaceCatalogIdentity` reads. + * Identity is `pluginName`, never the version, which two writes of one catalog may differ on. */ +async function writeCatalog(dir: string, name: string, pluginName: string): Promise { + await mkdir(join(dir, ".claude-plugin"), { recursive: true }); + await writeFile( + join(dir, ".claude-plugin", "marketplace.json"), + JSON.stringify({ + name, + version: "1.0.0", + plugins: [ + { + name: pluginName, + source: `./plugins/${pluginName}`, + version: "1.0.0", + description: "sample plugin", + }, + ], + }) + ); + await cp(PLUGIN_FIXTURE, join(dir, "plugins", pluginName), { recursive: true }); +} + +/** Stands in for what `claude plugin marketplace add` would have written to + * `known_marketplaces.json`; only `installLocation` is read by this project's own guard. */ +async function writeKnownMarketplaces( + fakeHome: string, + name: string, + installLocation: string +): Promise { + const dir = join(fakeHome, ".claude", "plugins"); + await mkdir(dir, { recursive: true }); + await writeFile( + join(dir, "known_marketplaces.json"), + JSON.stringify({ [name]: { installLocation } }) + ); +} + +describe("E2E: marketplace add surfaces the source-conflict guard's refusal instead of a false success", () => { + it("exits non-zero and names both sources and the plugin difference when the host's registry already holds this name under a different catalog", async () => { + const { tempDir, projectDir, fakeHome, cleanup } = await createTestEnv( + "marketplace-add-conflict" + ); + try { + const logFile = join(tempDir, "claude-invocations.log"); + const binDir = join(tempDir, "bin"); + await writeFakeToolBinary(binDir, "claude", logFile); + const env = { PATH: `${binDir}${delimiter}${pathWithoutAidd()}` }; + + await initProject(projectDir, FRAMEWORK_PATH); + await runCli(["framework", "install", "--tool", "claude"], projectDir, fakeHome, { env }); + + const registeredDir = join(tempDir, "registered"); + await writeCatalog(registeredDir, "shared-catalog", "sample-plugin"); + await writeKnownMarketplaces(fakeHome, "shared-catalog", await realpath(registeredDir)); + + const requestedDir = join(tempDir, "requested"); + await writeCatalog(requestedDir, "shared-catalog", "different-plugin"); + + const { stdout, stderr, exitCode } = await runCli( + ["marketplace", "add", "shared-catalog", requestedDir, "--yes"], + projectDir, + fakeHome, + { env } + ); + + const output = stdout + stderr; + expect(exitCode).not.toBe(0); + expect(output).toMatch(/shared-catalog/); + expect(output).toMatch(/\+different-plugin/); + expect(output).toMatch(/-sample-plugin/); + expect(output).not.toMatch(/^Marketplace 'shared-catalog' registered\.$/m); + } finally { + await cleanup(); + } + }); + + it("registers freely when the same catalog is registered again with only its version changed — an upgrade, not a conflict", async () => { + const { tempDir, projectDir, fakeHome, cleanup } = await createTestEnv( + "marketplace-add-version-upgrade" + ); + try { + const logFile = join(tempDir, "claude-invocations.log"); + const binDir = join(tempDir, "bin"); + await writeFakeToolBinary(binDir, "claude", logFile); + const env = { PATH: `${binDir}${delimiter}${pathWithoutAidd()}` }; + + await initProject(projectDir, FRAMEWORK_PATH); + await runCli(["framework", "install", "--tool", "claude"], projectDir, fakeHome, { env }); + + const registeredDir = join(tempDir, "registered"); + await writeCatalog(registeredDir, "shared-catalog", "sample-plugin"); // version 1.0.0 + await writeKnownMarketplaces(fakeHome, "shared-catalog", await realpath(registeredDir)); + + const requestedDir = join(tempDir, "requested"); + await writeCatalog(requestedDir, "shared-catalog", "sample-plugin"); + await writeFile( + join(requestedDir, ".claude-plugin", "marketplace.json"), + JSON.stringify({ + name: "shared-catalog", + version: "2.0.0", + plugins: [{ name: "sample-plugin", source: "./plugins/sample-plugin", version: "2.0.0" }], + }) + ); + + const { stdout, stderr, exitCode } = await runCli( + ["marketplace", "add", "shared-catalog", requestedDir, "--yes"], + projectDir, + fakeHome, + { env } + ); + + expect(exitCode).toBe(0); + expect(stdout + stderr).toMatch(/^Marketplace 'shared-catalog' registered\.$/m); + } finally { + await cleanup(); + } + }); + + it("registers freely when this project's own local alias differs from what its catalog declares itself — a supported capability, not a fault", async () => { + const { tempDir, projectDir, fakeHome, cleanup } = await createTestEnv("marketplace-add-alias"); + try { + const logFile = join(tempDir, "claude-invocations.log"); + const binDir = join(tempDir, "bin"); + await writeFakeToolBinary(binDir, "claude", logFile); + const env = { PATH: `${binDir}${delimiter}${pathWithoutAidd()}` }; + + await initProject(projectDir, FRAMEWORK_PATH); + await runCli(["framework", "install", "--tool", "claude"], projectDir, fakeHome, { env }); + + const marketDir = join(tempDir, "market"); + await writeCatalog(marketDir, "upstream-catalog", "sample-plugin"); + + const { stdout, stderr, exitCode } = await runCli( + ["marketplace", "add", "project-chosen-name", marketDir, "--yes"], + projectDir, + fakeHome, + { env } + ); + + expect(exitCode).toBe(0); + expect(stdout + stderr).toMatch(/^Marketplace 'project-chosen-name' registered\.$/m); + } finally { + await cleanup(); + } + }); +}); diff --git a/cli/tests/e2e/opencode-flat-hooks-loader-guard.e2e.test.ts b/cli/tests/e2e/opencode-flat-hooks-loader-guard.e2e.test.ts new file mode 100644 index 000000000..a6b5fac51 --- /dev/null +++ b/cli/tests/e2e/opencode-flat-hooks-loader-guard.e2e.test.ts @@ -0,0 +1,86 @@ +/** OpenCode's own plugin loader imports every file one level under `.opencode/plugin/` + * in-process, so a script calling `process.exit` there kills the host uncatchably. */ +import { execFile } from "node:child_process"; +import { existsSync } from "node:fs"; +import { cp, mkdir, readdir, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { pathToFileURL } from "node:url"; +import { promisify } from "node:util"; +import { describe, expect, it } from "vitest"; +import { createTestEnv, FRAMEWORK_PATH, runCli } from "./helpers.js"; + +const execFileAsync = promisify(execFile); + +// A `process.exit` at module scope, no export: OpenCode's loader catches a thrown error, +// never a process exit. +const HOSTILE_SCRIPT = "(() => {\n process.exit(1);\n})();\n"; + +// A well-formed OpenCode plugin module: a function export, nothing else. +const BENIGN_PLUGIN_SCRIPT = "export const OpencodePlugin = async () => ({});\n"; + +// Imports its one argument as a module in THIS process, under a non-empty `process.argv` — +// the same shape a real host provides, inherited by what the import evaluates. +const IMPORT_HARNESS = + 'import(process.argv[2]).then(() => { console.log("HOST ALIVE"); process.exit(0); })' + + ".catch((err) => { console.error(String(err)); process.exit(1); });\n"; + +async function importSurvives( + harnessPath: string, + modulePath: string +): Promise<{ exitCode: number; stdout: string }> { + try { + const { stdout } = await execFileAsync( + process.execPath, + [harnessPath, pathToFileURL(modulePath).href, "some-non-empty-argv"], + { timeout: 5000 } + ); + return { exitCode: 0, stdout }; + } catch (error) { + const err = error as { stdout?: string; code?: number }; + return { exitCode: err.code ?? 1, stdout: err.stdout ?? "" }; + } +} + +describe("opencode flat build — nothing hostile lands where the loader imports it", () => { + it("relocates a hook script out of .opencode/plugin/; whatever remains survives import with a live argv", async () => { + const { tempDir, projectDir, fakeHome, cleanup } = await createTestEnv("oc-hooks-guard"); + try { + const sourceDir = join(tempDir, "source"); + await cp(FRAMEWORK_PATH, sourceDir, { recursive: true }); + + const hooksDir = join(sourceDir, "plugins", "aidd-test", "hooks"); + await writeFile(join(hooksDir, "hostile.js"), HOSTILE_SCRIPT, "utf-8"); + await writeFile(join(hooksDir, "opencode-plugin.js"), BENIGN_PLUGIN_SCRIPT, "utf-8"); + + const harnessPath = join(tempDir, "import-harness.mjs"); + await writeFile(harnessPath, IMPORT_HARNESS, "utf-8"); + + const outDir = join(tempDir, "dist"); + await mkdir(outDir, { recursive: true }); + const build = await runCli( + ["translate", sourceDir, "--to", "opencode", "--as", "flat", "--out", outDir], + projectDir, + fakeHome + ); + expect(build.exitCode).toBe(0); + + // (a) namespaced under .opencode/hooks//, never under .opencode/plugin/. + expect(existsSync(join(outDir, ".opencode", "hooks", "aidd-test", "hostile.js"))).toBe(true); + expect(existsSync(join(outDir, ".opencode", "plugin", "hostile.js"))).toBe(false); + + // (b) exactly the renamed loader entry remains — not zero files, which would make the + // import loop below pass over nothing — and it survives import with a live argv. + const pluginDir = join(outDir, ".opencode", "plugin"); + const remaining = existsSync(pluginDir) ? await readdir(pluginDir) : []; + expect(remaining).toEqual(["aidd-test.js"]); + + for (const name of remaining) { + const result = await importSurvives(harnessPath, join(pluginDir, name)); + expect(result.stdout, name).toContain("HOST ALIVE"); + expect(result.exitCode, name).toBe(0); + } + } finally { + await cleanup(); + } + }); +}); diff --git a/cli/tests/e2e/opencode-hooks-bridge-generated.e2e.test.ts b/cli/tests/e2e/opencode-hooks-bridge-generated.e2e.test.ts new file mode 100644 index 000000000..ab1485454 --- /dev/null +++ b/cli/tests/e2e/opencode-hooks-bridge-generated.e2e.test.ts @@ -0,0 +1,99 @@ +/** + * The bridge is only observable in a real translate output tree. The fixture plugin is + * extended in a private copy of the source, never in the checked-in one other suites share. + */ +import { execFile } from "node:child_process"; +import { existsSync } from "node:fs"; +import { cp, mkdir, readFile, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { pathToFileURL } from "node:url"; +import { promisify } from "node:util"; +import { describe, expect, it } from "vitest"; +import { createTestEnv, FRAMEWORK_PATH, runCli } from "./helpers.js"; + +const execFileAsync = promisify(execFile); + +const STOP_SCRIPT = 'require("node:fs").writeFileSync("marker.txt", "spawned");\n'; + +// Concatenated, since biome reads a plain string holding "${...}" as a forgotten template +// literal. +const ROOT = "$" + "{CLAUDE_PLUGIN_ROOT}"; + +const IMPORT_ONLY_HARNESS = + 'import(process.argv[2]).then(() => { console.log("HOST ALIVE"); process.exit(0); })' + + ".catch((err) => { console.error(String(err)); process.exit(1); });\n"; + +async function waitForFile(path: string, timeoutMs: number): Promise { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + if (existsSync(path)) return true; + await new Promise((r) => setTimeout(r, 50)); + } + return existsSync(path); +} + +describe("opencode's generated event bridge, against the real build", () => { + it("imports safely with a live argv, spawns nothing on import, and spawns the Stop script on session.idle", async () => { + const { tempDir, projectDir, fakeHome, cleanup } = await createTestEnv("oc-hooks-bridge"); + try { + const sourceDir = join(tempDir, "source"); + await cp(FRAMEWORK_PATH, sourceDir, { recursive: true }); + + const hooksDir = join(sourceDir, "plugins", "aidd-test", "hooks"); + await writeFile(join(hooksDir, "marker.js"), STOP_SCRIPT, "utf-8"); + await writeFile( + join(hooksDir, "hooks.json"), + JSON.stringify({ + hooks: { + PreToolUse: [{ hooks: [{ type: "command", command: `${ROOT}/hooks/check.sh` }] }], + Stop: [{ hooks: [{ type: "command", command: `node ${ROOT}/hooks/marker.js` }] }], + }, + }), + "utf-8" + ); + + const outDir = join(tempDir, "dist"); + await mkdir(outDir, { recursive: true }); + const build = await runCli( + ["translate", sourceDir, "--to", "opencode", "--as", "flat", "--out", outDir], + projectDir, + fakeHome + ); + expect(build.exitCode).toBe(0); + + const bridgePath = join(outDir, ".opencode", "plugin", "aidd-test-hooks.js"); + expect(existsSync(bridgePath)).toBe(true); + + // A live, non-empty argv is the shape a real host provides. + const harnessPath = join(tempDir, "import-only.mjs"); + await writeFile(harnessPath, IMPORT_ONLY_HARNESS, "utf-8"); + const { stdout } = await execFileAsync( + process.execPath, + [harnessPath, pathToFileURL(bridgePath).href, "some-non-empty-argv"], + { cwd: outDir, timeout: 5000 } + ); + expect(stdout).toContain("HOST ALIVE"); + expect(existsSync(join(outDir, "marker.txt"))).toBe(false); + + // Driven in its own child, so the marker file lands relative to a cwd this test + // controls. + const driverPath = join(tempDir, "drive-session-idle.mjs"); + await writeFile( + driverPath, + `const mod = await import(${JSON.stringify(pathToFileURL(bridgePath).href)}); + const factories = Object.keys(mod).filter((k) => typeof mod[k] === "function"); + const hooks = await mod[factories[0]]({ directory: process.argv[2] }); + await hooks.event({ event: { type: "session.idle", properties: { sessionID: "s1" } } }); + `, + "utf-8" + ); + await execFileAsync(process.execPath, [driverPath, outDir], { timeout: 5000 }); + + const markerWritten = await waitForFile(join(outDir, "marker.txt"), 4000); + expect(markerWritten).toBe(true); + expect(await readFile(join(outDir, "marker.txt"), "utf-8")).toBe("spawned"); + } finally { + await cleanup(); + } + }); +}); diff --git a/cli/tests/e2e/persona.e2e.test.ts b/cli/tests/e2e/persona.e2e.test.ts index 189af3fbb..a25d9ccf5 100644 --- a/cli/tests/e2e/persona.e2e.test.ts +++ b/cli/tests/e2e/persona.e2e.test.ts @@ -1,40 +1,30 @@ /** - * Persona-driven E2E tests — deterministic, zero network, fixture-based. - * - * TTY scenarios (persona 1 & 5) use /usr/bin/expect to emulate a real terminal. - * All other scenarios use runCli() (non-interactive flags, no TTY needed). - * - * Marketplace source: tests/fixtures/framework-real (pinned snapshot, no network). + * TTY scenarios use /usr/bin/expect; every other scenario uses runCli(). The marketplace + * source is a pinned fixture snapshot, so no scenario reaches the network. */ import { execFile } from "node:child_process"; -import { mkdir, readFile, writeFile } from "node:fs/promises"; +import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join, resolve } from "node:path"; import { promisify } from "node:util"; import { describe, expect, it } from "vitest"; -import { createTestEnv, runCli } from "./helpers.js"; +import { cliPath, createTestEnv, runCli } from "./helpers.js"; const execFileAsync = promisify(execFile); const REAL_FW = resolve(process.cwd(), "tests/fixtures/framework-real"); -const CLI_PATH = resolve(process.cwd(), "dist/cli.js"); const EXPECT_BIN = "/usr/bin/expect"; const AIDD_DIR = ".aidd"; -/** - * Runs an expect(1) script that emulates TTY interaction with the CLI. - * The script is written to a temp file then executed via /usr/bin/expect. - */ +/** Runs an expect(1) script that emulates TTY interaction with the CLI. */ async function runInteractive( projectDir: string, fakeHome: string, script: string ): Promise<{ stdout: string; exitCode: number }> { - const scriptPath = join( - tmpdir(), - `aidd-expect-${Date.now()}-${process.pid}-${Math.random().toString(36).slice(2)}.exp` - ); + const scriptDir = await mkdtemp(join(tmpdir(), "aidd-expect-")); + const scriptPath = join(scriptDir, "interaction.exp"); const fullScript = ` set timeout 15 set env(HOME) "${fakeHome}" @@ -52,7 +42,7 @@ ${script} const e = err as { stdout?: string; stderr?: string; code?: number }; return { stdout: (e.stdout ?? "") + (e.stderr ?? ""), exitCode: e.code ?? 1 }; } finally { - await execFileAsync("rm", ["-f", scriptPath]).catch(() => undefined); + await rm(scriptDir, { recursive: true, force: true }); } } @@ -64,7 +54,7 @@ describe.concurrent("E2E: persona journeys", () => { projectDir, fakeHome, ` -spawn node ${CLI_PATH} +spawn node ${cliPath()} expect { -re {AI-Driven Development CLI} { puts "BANNER_OK" } timeout { puts "TIMEOUT"; exit 1 } @@ -112,11 +102,16 @@ exit 0 const raw = await readFile(join(projectDir, AIDD_DIR, "manifest.json"), "utf-8"); const manifest = JSON.parse(raw) as Record; - expect(manifest.version).toBe(6); + expect(manifest.version).toBe(8); const tools = manifest.tools as Record; expect(tools).toHaveProperty("claude"); - const mktRaw = await readFile(join(projectDir, AIDD_DIR, "marketplaces.json"), "utf-8"); + // The framework marketplace is machine-scope: every project on this machine shares one + // registration, so it lives under the user config dir, never under the project's `.aidd/`. + const mktRaw = await readFile( + join(fakeHome, ".config", "aidd", "marketplaces.json"), + "utf-8" + ); const marketplaces = JSON.parse(mktRaw) as { marketplaces: Array<{ name: string; source: { kind: string; path: string } }>; }; @@ -132,7 +127,6 @@ exit 0 it("Persona 3 — setup re-run: second run with extra tool updates manifest", async () => { const { projectDir, fakeHome, cleanup } = await createTestEnv("persona-rerun"); try { - // First run: claude only const first = await runCli( [ "setup", @@ -156,7 +150,6 @@ exit 0 expect(manifestAfterFirst.tools).toHaveProperty("claude"); expect(manifestAfterFirst.tools).not.toHaveProperty("cursor"); - // Second run: add cursor const second = await runCli( [ "setup", @@ -228,12 +221,11 @@ exit 0 it("Persona 5 — returning user: aidd alone with manifest shows full menu", async () => { const { projectDir, fakeHome, cleanup } = await createTestEnv("persona-returning"); try { - // Seed a minimal manifest so the menu knows the project is initialized await mkdir(join(projectDir, AIDD_DIR), { recursive: true }); await writeFile( join(projectDir, AIDD_DIR, "manifest.json"), JSON.stringify({ - version: 5, + version: 8, tools: { claude: { toolId: "claude", @@ -242,7 +234,6 @@ exit 0 mergeFiles: [], }, }, - marketplaces: {}, }) ); @@ -250,7 +241,7 @@ exit 0 projectDir, fakeHome, ` -spawn bash -c "cd '${projectDir}' && node ${CLI_PATH}" +spawn bash -c "cd '${projectDir}' && node ${cliPath()}" expect { -re {AI-Driven Development CLI} { puts "BANNER_OK" } timeout { puts "TIMEOUT"; exit 1 } diff --git a/cli/tests/e2e/plugin-create.e2e.test.ts b/cli/tests/e2e/plugin-create.e2e.test.ts deleted file mode 100644 index 2d9a2b826..000000000 --- a/cli/tests/e2e/plugin-create.e2e.test.ts +++ /dev/null @@ -1,92 +0,0 @@ -/** - * E2E — plugin create round-trip - * AC#6: aidd plugin create demo --yes → scaffold at plugins/demo/ → - * aidd plugin install → manifest tracks plugin → doctor exits 0. - * AC#9: non-TTY with no name arg → exit 1. - */ - -import { access } from "node:fs/promises"; -import { join } from "node:path"; -import { describe, expect, it } from "vitest"; -import { createTestEnv, runCli } from "./helpers.js"; - -async function seedWithClaude(projectDir: string, fakeHome: string): Promise { - await runCli(["ai", "install", "claude"], projectDir, fakeHome); -} - -async function pathExists(p: string): Promise { - try { - await access(p); - return true; - } catch { - return false; - } -} - -describe.concurrent("E2E: plugin create round-trip", () => { - it("plugin create demo --yes scaffolds at plugins/demo/ with expected files", async () => { - const { projectDir, fakeHome, cleanup } = await createTestEnv("plugin-create-scaffold"); - try { - await seedWithClaude(projectDir, fakeHome); - const { stdout, exitCode } = await runCli( - ["plugin", "create", "demo", "--yes"], - projectDir, - fakeHome - ); - expect(exitCode).toBe(0); - expect(stdout).toContain("demo"); - - const pluginDir = join(projectDir, "plugins", "demo"); - expect(await pathExists(pluginDir)).toBe(true); - expect(await pathExists(join(pluginDir, ".claude-plugin", "plugin.json"))).toBe(true); - expect(await pathExists(join(pluginDir, "README.md"))).toBe(true); - expect(await pathExists(join(pluginDir, "CHANGELOG.md"))).toBe(true); - expect(await pathExists(join(pluginDir, "hooks", "hooks.json"))).toBe(true); - expect(await pathExists(join(pluginDir, ".mcp.json"))).toBe(true); - expect(await pathExists(join(pluginDir, "agents", "example.md"))).toBe(true); - expect(await pathExists(join(pluginDir, "skills", "00-example", "SKILL.md"))).toBe(true); - } finally { - await cleanup(); - } - }); - - it("plugin create → plugin install → doctor exits 0 (full round-trip)", async () => { - const { projectDir, fakeHome, cleanup } = await createTestEnv("plugin-create-roundtrip"); - try { - await seedWithClaude(projectDir, fakeHome); - - const createResult = await runCli( - ["plugin", "create", "demo", "--yes"], - projectDir, - fakeHome - ); - expect(createResult.exitCode).toBe(0); - - const pluginDir = join(projectDir, "plugins", "demo"); - const installResult = await runCli( - ["plugin", "install", pluginDir, "--tool", "claude"], - projectDir, - fakeHome - ); - expect(installResult.exitCode).toBe(0); - expect(installResult.stdout).toContain("Plugin added successfully"); - - const doctorResult = await runCli(["plugin", "doctor"], projectDir, fakeHome); - expect(doctorResult.exitCode).toBe(0); - expect(doctorResult.stdout).toContain("healthy"); - } finally { - await cleanup(); - } - }); - - it("plugin create with no name in non-TTY mode exits 1 with error message", async () => { - const { projectDir, fakeHome, cleanup } = await createTestEnv("plugin-create-noname"); - try { - const { stderr, exitCode } = await runCli(["plugin", "create"], projectDir, fakeHome); - expect(exitCode).toBe(1); - expect(stderr).toContain("name is required"); - } finally { - await cleanup(); - } - }); -}); diff --git a/cli/tests/e2e/plugin-install.e2e.test.ts b/cli/tests/e2e/plugin-install.e2e.test.ts index d25255a77..90e33ff83 100644 --- a/cli/tests/e2e/plugin-install.e2e.test.ts +++ b/cli/tests/e2e/plugin-install.e2e.test.ts @@ -13,14 +13,12 @@ async function writeMarketplace( await writeFile(join(dir, ".claude-plugin", "marketplace.json"), JSON.stringify({ plugins })); } -// TODO(feat/cli-v5-cleanup follow-up): replace `install ai --path` setup -// with `aidd ai install ` in all tests that use it. describe.concurrent("E2E: aidd plugin marketplace", () => { it("marketplace add → registers a project-scope marketplace and skips trust prompt with --yes", async () => { const { tempDir, projectDir, fakeHome, cleanup } = await createTestEnv("mkt-add"); try { await initProject(projectDir, FRAMEWORK_PATH); - await runCli(["ai", "install", "claude"], projectDir, fakeHome); + await runCli(["framework", "install", "--tool", "claude"], projectDir, fakeHome); const marketDir = join(tempDir, "market"); await writeMarketplace(marketDir, [ { @@ -93,7 +91,7 @@ describe.concurrent("E2E: aidd plugin marketplace", () => { const { tempDir, projectDir, fakeHome, cleanup } = await createTestEnv("mkt-install"); try { await initProject(projectDir, FRAMEWORK_PATH); - await runCli(["ai", "install", "claude"], projectDir, fakeHome); + await runCli(["framework", "install", "--tool", "claude"], projectDir, fakeHome); const marketDir = join(tempDir, "market"); await writeMarketplace(marketDir, [ { diff --git a/cli/tests/e2e/restore-force.e2e.test.ts b/cli/tests/e2e/restore-force.e2e.test.ts deleted file mode 100644 index 9f46edec9..000000000 --- a/cli/tests/e2e/restore-force.e2e.test.ts +++ /dev/null @@ -1,88 +0,0 @@ -import { mkdir, readFile, writeFile } from "node:fs/promises"; -import { join } from "node:path"; -import { describe, expect, it } from "vitest"; -import { createTestEnv, runCli } from "./helpers.js"; - -const MODIFIED = '{"MODIFIED":true}'; - -async function seedManifest(projectDir: string): Promise { - await mkdir(join(projectDir, ".aidd"), { recursive: true }); - await writeFile( - join(projectDir, ".aidd", "manifest.json"), - JSON.stringify({ version: 5, tools: {}, marketplaces: {} }), - "utf-8" - ); -} - -/** The first regular file `ai install claude` tracked, as the manifest records it. */ -async function firstTrackedFile(projectDir: string): Promise { - const raw = await readFile(join(projectDir, ".aidd", "manifest.json"), "utf-8"); - const manifest = JSON.parse(raw) as { - tools: Record }>; - }; - const relativePath = manifest.tools.claude?.files[0]?.relativePath; - if (relativePath === undefined) throw new Error("no tracked file to modify"); - return relativePath; -} - -async function installAndModify( - projectDir: string, - fakeHome: string -): Promise<{ tracked: string; installed: string }> { - await seedManifest(projectDir); - await runCli(["ai", "install", "claude"], projectDir, fakeHome); - const tracked = await firstTrackedFile(projectDir); - const installed = await readFile(join(projectDir, tracked), "utf-8"); - await writeFile(join(projectDir, tracked), MODIFIED, "utf-8"); - return { tracked, installed }; -} - -describe.concurrent("E2E: aidd restore --force", () => { - it("repairs a modified tracked file instead of reporting nothing to restore", async () => { - const { projectDir, fakeHome, cleanup } = await createTestEnv("restore-force-modified"); - try { - const { tracked, installed } = await installAndModify(projectDir, fakeHome); - - const { stdout, exitCode } = await runCli(["restore", "--force"], projectDir, fakeHome); - - expect(exitCode).toBe(0); - expect(stdout).not.toContain("Nothing to restore"); - expect(stdout).toContain("Restored 1 file"); - expect(await readFile(join(projectDir, tracked), "utf-8")).toBe(installed); - } finally { - await cleanup(); - } - }); - - it("without --force and without a TTY, fails loudly instead of claiming success", async () => { - const { projectDir, fakeHome, cleanup } = await createTestEnv("restore-no-force-modified"); - try { - const { tracked } = await installAndModify(projectDir, fakeHome); - - const { stdout, stderr, exitCode } = await runCli(["restore"], projectDir, fakeHome); - - expect(exitCode).not.toBe(0); - expect(stdout).not.toContain("Nothing to restore"); - expect(`${stdout}${stderr}`).toContain("--force"); - // It refused, so it must not have touched the file either. - expect(await readFile(join(projectDir, tracked), "utf-8")).toBe(MODIFIED); - } finally { - await cleanup(); - } - }); - - it("still reports nothing to restore when nothing drifted", async () => { - const { projectDir, fakeHome, cleanup } = await createTestEnv("restore-force-clean"); - try { - await seedManifest(projectDir); - await runCli(["ai", "install", "claude"], projectDir, fakeHome); - - const { stdout, exitCode } = await runCli(["restore", "--force"], projectDir, fakeHome); - - expect(exitCode).toBe(0); - expect(stdout).toContain("Nothing to restore"); - } finally { - await cleanup(); - } - }); -}); diff --git a/cli/tests/e2e/sandbox-reaches-no-tool-binary.e2e.test.ts b/cli/tests/e2e/sandbox-reaches-no-tool-binary.e2e.test.ts index b64ed6ef4..b53b30983 100644 --- a/cli/tests/e2e/sandbox-reaches-no-tool-binary.e2e.test.ts +++ b/cli/tests/e2e/sandbox-reaches-no-tool-binary.e2e.test.ts @@ -6,25 +6,10 @@ import { createTestEnv, sandboxedEnv } from "./helpers.js"; const execFileAsync = promisify(execFile); -/** - * What a spawned `aidd` can reach decides what a test measures. - * - * The OpenCode reader shells out to an `opencode` binary and waits up to ten seconds for it. - * While `sandboxedEnv` inherited the runner's own `PATH`, a machine with that tool installed - * paid a cost a machine without it did not — and `records stored before opting in stay - * unnamed` swung between 14 seconds and a 60-second timeout across three runs with no code - * change at all. Pinning the sandbox's `PATH` took that file from 13.9s to a steady 5.1s. - * - * This is the guard on that, rather than on the symptom: a test's result must not depend on - * which AI tools the person running it happens to have installed. - */ -/** - * AI tools only. `gh` deliberately absent: it is not a tool whose files anything here reads, - * it is the CLI's own auth dependency (`gh-cli-adapter.ts` spawns `gh auth token`), the same - * standing as `git` and `node` below. Listing it made this guard fail on any runner that - * ships GitHub CLI at `/usr/bin/gh` — measured on `cli / Test`, where a machine having `gh` - * is the normal case, not the deviation this test exists to catch. - */ +/** A test's result must not depend on which AI tools the runner happens to have installed — + * the OpenCode reader shells out to a binary and waits ten seconds for it. */ +// AI tools only: `gh` is the CLI's own auth dependency, the same standing as `git` and `node` +// below, and a runner shipping it is the normal case, not a deviation. const TOOL_BINARIES = ["opencode", "claude", "codex", "copilot", "cursor-agent"] as const; async function whichUnderSandbox(binary: string, cwd: string, env: NodeJS.ProcessEnv) { diff --git a/cli/tests/e2e/setup-scope-user.e2e.test.ts b/cli/tests/e2e/setup-scope-user.e2e.test.ts new file mode 100644 index 000000000..2f446caf6 --- /dev/null +++ b/cli/tests/e2e/setup-scope-user.e2e.test.ts @@ -0,0 +1,186 @@ +import { execFile } from "node:child_process"; +import { readFile } from "node:fs/promises"; +import { join, resolve } from "node:path"; +import { promisify } from "node:util"; +import { describe, expect, it } from "vitest"; +import { environmentWithoutGitVariables } from "../../src/runtime/git/git-environment.js"; +import { createTestEnv, gitInit, runCli } from "./helpers.js"; + +// CI runners carry no git identity; a commit made by a test brings its own. +const GIT_TEST_IDENTITY = ["-c", "user.email=t@t.com", "-c", "user.name=t"]; + +const execFileAsync = promisify(execFile); +const FRAMEWORK_REAL_PATH = resolve(process.cwd(), "tests/fixtures/framework-real"); + +async function gitStatusPorcelain(cwd: string): Promise { + const { stdout } = await execFileAsync("git", ["status", "--porcelain"], { + cwd, + env: environmentWithoutGitVariables(process.env), + }); + return stdout; +} + +async function readJson(path: string): Promise> { + return JSON.parse(await readFile(path, "utf-8")) as Record; +} + +describe("E2E: setup --scope user writes nothing under the project", () => { + it("leaves the git-tracked project untouched and writes the user manifest under fakeHome", async () => { + const { projectDir, fakeHome, cleanup } = await createTestEnv("scope-user-setup"); + try { + await gitInit(projectDir); + // A commit so `git status --porcelain` starts clean: in a freshly `git init`ed directory + // every file reads as untracked regardless of what `setup` wrote. + await execFileAsync("git", [...GIT_TEST_IDENTITY, "commit", "--allow-empty", "-m", "empty"], { + cwd: projectDir, + env: environmentWithoutGitVariables(process.env), + }); + + const setupResult = await runCli( + [ + "setup", + "--source", + "local", + "--path", + FRAMEWORK_REAL_PATH, + "--ai", + "claude", + "--plugins", + "none", + "--yes", + "--scope", + "user", + ], + projectDir, + fakeHome + ); + expect(setupResult.exitCode).toBe(0); + + // A full-repository delta, not a list of paths this test happens to think of: + // `setup --scope user` must land nothing under the project. + expect(await gitStatusPorcelain(projectDir)).toBe(""); + + const manifest = await readJson(join(fakeHome, ".config", "aidd", "manifest.json")); + expect(manifest.version).toBe(8); + expect(Object.keys(manifest.tools as Record)).toContain("claude"); + + const marketplaces = await readJson(join(fakeHome, ".config", "aidd", "marketplaces.json")); + const names = (marketplaces.marketplaces as Array<{ name: string }>).map((m) => m.name); + expect(names).toContain("aidd-framework"); + + // `--scope user` records no shared-source claim; asserted as absence-or-empty since another + // project on the same fakeHome could in principle have written one. + const references = await readJson(join(fakeHome, ".config", "aidd", "references.json")).catch( + () => ({}) + ); + const allProjectRoots = Object.values(references).flat() as string[]; + expect(allProjectRoots).toEqual([]); + + // This sandbox has no `claude`/`codex`/`copilot` binary on PATH, so these two prove only + // that the commands resolve the user manifest and exit cleanly with native activation unrun. + const doctorResult = await runCli(["doctor", "--scope", "user"], projectDir, fakeHome); + expect(doctorResult.exitCode).toBe(0); + + const syncResult = await runCli(["sync", "--scope", "user"], projectDir, fakeHome); + expect(syncResult.exitCode).toBe(0); + expect(await gitStatusPorcelain(projectDir)).toBe(""); + } finally { + await cleanup(); + } + }); + + it("honors --tool and refuses --plugin at --scope user for doctor and sync, rather than silently ignoring either", async () => { + const { projectDir, fakeHome, cleanup } = await createTestEnv("scope-user-flags"); + try { + await gitInit(projectDir); + await execFileAsync("git", [...GIT_TEST_IDENTITY, "commit", "--allow-empty", "-m", "empty"], { + cwd: projectDir, + env: environmentWithoutGitVariables(process.env), + }); + + const setupResult = await runCli( + [ + "setup", + "--source", + "local", + "--path", + FRAMEWORK_REAL_PATH, + "--ai", + "claude,codex", + "--plugins", + "none", + "--yes", + "--scope", + "user", + ], + projectDir, + fakeHome + ); + expect(setupResult.exitCode).toBe(0); + + // --tool narrows doctor's own user-scope tool inventory to the one tool named, + // rather than being read and discarded. + const doctorScoped = await runCli( + ["doctor", "--scope", "user", "--tool", "claude"], + projectDir, + fakeHome + ); + expect(doctorScoped.exitCode).toBe(0); + expect(doctorScoped.stdout).toContain("claude"); + expect(doctorScoped.stdout).not.toContain("codex"); + + // --plugin has nothing to narrow at user scope — no plugin is tracked there yet — + // so it is refused with a real message rather than silently dropped. + const doctorPlugin = await runCli( + ["doctor", "--scope", "user", "--plugin", "aidd-context"], + projectDir, + fakeHome + ); + expect(doctorPlugin.exitCode).toBe(1); + expect(doctorPlugin.stderr).toContain("--plugin"); + + const syncPlugin = await runCli( + ["sync", "--scope", "user", "--plugin", "aidd-context"], + projectDir, + fakeHome + ); + expect(syncPlugin.exitCode).toBe(1); + expect(syncPlugin.stderr).toContain("--plugin"); + + // A file argument has nothing to narrow either — sync's own project-scope file + // list has no user-scope counterpart. + const syncFiles = await runCli( + ["sync", "--scope", "user", "some-file.md"], + projectDir, + fakeHome + ); + expect(syncFiles.exitCode).toBe(1); + } finally { + await cleanup(); + } + }); + + it("names the branch where nothing is registered at user scope yet, for both doctor and sync", async () => { + const { projectDir, fakeHome, cleanup } = await createTestEnv("scope-user-nothing-yet"); + try { + await gitInit(projectDir); + await execFileAsync("git", [...GIT_TEST_IDENTITY, "commit", "--allow-empty", "-m", "empty"], { + cwd: projectDir, + env: environmentWithoutGitVariables(process.env), + }); + + // No `setup --scope user` ever ran on this fakeHome — the user-scope manifest + // does not exist yet. + const doctorResult = await runCli(["doctor", "--scope", "user"], projectDir, fakeHome); + expect(doctorResult.exitCode).toBe(0); + expect(doctorResult.stdout).toContain("Nothing registered at user scope yet"); + expect(doctorResult.stdout).toContain("aidd setup --scope user"); + + const syncResult = await runCli(["sync", "--scope", "user"], projectDir, fakeHome); + expect(syncResult.exitCode).toBe(0); + expect(syncResult.stdout).toContain("Nothing to sync"); + } finally { + await cleanup(); + } + }); +}); diff --git a/cli/tests/e2e/sync-force.e2e.test.ts b/cli/tests/e2e/sync-force.e2e.test.ts new file mode 100644 index 000000000..493522450 --- /dev/null +++ b/cli/tests/e2e/sync-force.e2e.test.ts @@ -0,0 +1,99 @@ +import { readFile, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; +import { createTestEnv, FRAMEWORK_PATH, runCli } from "./helpers.js"; + +const MODIFIED = '{"MODIFIED":true}'; + +/** A setup that installs one tool, so the manifest tracks something `sync` can repair. */ +async function installClaude(projectDir: string, fakeHome: string): Promise { + const setup = ["setup", "--source", "local", "--path", FRAMEWORK_PATH]; + await runCli([...setup, "--ai", "claude", "--plugins", "none", "--yes"], projectDir, fakeHome); +} + +/** The first regular file the Claude install tracked, as the manifest records it. */ +async function firstTrackedFile(projectDir: string): Promise { + const raw = await readFile(join(projectDir, ".aidd", "manifest.json"), "utf-8"); + const manifest = JSON.parse(raw) as { + tools: Record }>; + }; + const relativePath = manifest.tools.claude?.files[0]?.relativePath; + if (relativePath === undefined) throw new Error("no tracked file to modify"); + return relativePath; +} + +async function installAndModify( + projectDir: string, + fakeHome: string +): Promise<{ tracked: string; installed: string }> { + await installClaude(projectDir, fakeHome); + const tracked = await firstTrackedFile(projectDir); + const installed = await readFile(join(projectDir, tracked), "utf-8"); + await writeFile(join(projectDir, tracked), MODIFIED, "utf-8"); + return { tracked, installed }; +} + +describe.concurrent("E2E: aidd sync --force", () => { + it("repairs a modified tracked file instead of reporting nothing to restore", async () => { + const { projectDir, fakeHome, cleanup } = await createTestEnv("sync-force-modified"); + try { + const { tracked, installed } = await installAndModify(projectDir, fakeHome); + + const { stdout, exitCode } = await runCli(["sync", "--force"], projectDir, fakeHome); + + expect(exitCode).toBe(0); + expect(stdout).not.toContain("Nothing to restore"); + expect(stdout).toContain("Restored 1 file"); + expect(await readFile(join(projectDir, tracked), "utf-8")).toBe(installed); + } finally { + await cleanup(); + } + }); + + it("without --force and without a TTY, fails loudly instead of claiming success", async () => { + const { projectDir, fakeHome, cleanup } = await createTestEnv("sync-no-force-modified"); + try { + const { tracked } = await installAndModify(projectDir, fakeHome); + + const { stdout, stderr, exitCode } = await runCli(["sync"], projectDir, fakeHome); + + expect(exitCode).not.toBe(0); + expect(stdout).not.toContain("Nothing to restore"); + expect(`${stdout}${stderr}`).toContain("--force"); + // It refused, so it must not have touched the file either. + expect(await readFile(join(projectDir, tracked), "utf-8")).toBe(MODIFIED); + } finally { + await cleanup(); + } + }); + + it("still reports nothing to restore when nothing drifted", async () => { + const { projectDir, fakeHome, cleanup } = await createTestEnv("sync-force-clean"); + try { + await installClaude(projectDir, fakeHome); + + const { stdout, exitCode } = await runCli(["sync", "--force"], projectDir, fakeHome); + + expect(exitCode).toBe(0); + expect(stdout).toContain("Nothing to restore"); + } finally { + await cleanup(); + } + }); + + // The sandbox reaches no real `claude` binary, so the binary-missing path is the one native + // activation case a real built-binary run can prove — and an absent binary is not a failure. + it("warns that the plugin will not load until the claude CLI has run, and still exits 0", async () => { + const { projectDir, fakeHome, cleanup } = await createTestEnv("sync-force-native-activation"); + try { + await installClaude(projectDir, fakeHome); + + const { stderr, exitCode } = await runCli(["sync", "--force"], projectDir, fakeHome); + + expect(exitCode).toBe(0); + expect(stderr).toContain("claude: the plugin will not load until the claude CLI has run."); + } finally { + await cleanup(); + } + }); +}); diff --git a/cli/tests/e2e/sync-migrates-project-scope-installation.e2e.test.ts b/cli/tests/e2e/sync-migrates-project-scope-installation.e2e.test.ts new file mode 100644 index 000000000..299bdf536 --- /dev/null +++ b/cli/tests/e2e/sync-migrates-project-scope-installation.e2e.test.ts @@ -0,0 +1,133 @@ +import { cp, mkdir, readdir, readFile, rm, stat, writeFile } from "node:fs/promises"; +import { join, resolve } from "node:path"; +import { describe, expect, it } from "vitest"; +import { createTestEnv, runCli } from "./helpers.js"; + +const FRAMEWORK_REAL_PATH = resolve(process.cwd(), "tests/fixtures/framework-real"); + +async function readJson(path: string): Promise> { + return JSON.parse(await readFile(path, "utf-8")) as Record; +} + +async function exists(path: string): Promise { + return await stat(path) + .then(() => true) + .catch(() => false); +} + +/** + * The pre-migration shape, which no code path still writes: a project-scope registry entry, + * its build under `.aidd/cache/built/`, and a shared registry that knows nothing of it. + */ +async function seedPreMigrationState(projectDir: string, fakeHome: string): Promise { + const userConfigDir = join(fakeHome, ".config", "aidd"); + const builtRoot = join(userConfigDir, "cache", "built"); + const [version] = await readdir(builtRoot); + if (version === undefined) throw new Error("setup did not build the shared source"); + const sharedBuiltClaude = join(builtRoot, version, "aidd-framework", "claude"); + const projectCacheClaude = join( + projectDir, + ".aidd", + "cache", + "built", + "aidd-framework", + "claude" + ); + await mkdir(join(projectDir, ".aidd", "cache", "built", "aidd-framework"), { recursive: true }); + await cp(sharedBuiltClaude, projectCacheClaude, { recursive: true }); + + await writeFile( + join(userConfigDir, "marketplaces.json"), + JSON.stringify({ version: 1, marketplaces: [] }, null, 2) + ); + await rm(join(userConfigDir, "references.json"), { force: true }); + + await writeFile( + join(projectDir, ".aidd", "marketplaces.json"), + JSON.stringify( + { + version: 1, + marketplaces: [ + { + name: "aidd-framework", + source: { kind: "local", path: FRAMEWORK_REAL_PATH }, + scope: "project", + addedAt: "2020-01-01T00:00:00.000Z", + }, + ], + }, + null, + 2 + ) + ); +} + +describe("E2E: sync migrates a project installed before the shared source", () => { + it("moves the registry entry to user scope, keeps this project's own stale cache while claude's binary stays off PATH in this sandbox, and warns why", async () => { + const env = await createTestEnv("sync-migrates-project-scope"); + try { + const setupResult = await runCli( + [ + "setup", + "--source", + "local", + "--path", + FRAMEWORK_REAL_PATH, + "--ai", + "claude", + "--plugins", + "none", + "--yes", + ], + env.projectDir, + env.fakeHome + ); + expect(setupResult.exitCode).toBe(0); + + await seedPreMigrationState(env.projectDir, env.fakeHome); + + const userConfigDir = join(env.fakeHome, ".config", "aidd"); + const projectCacheDir = join(env.projectDir, ".aidd", "cache", "built", "aidd-framework"); + expect( + ( + (await readJson(join(env.fakeHome, ".config", "aidd", "marketplaces.json"))) + .marketplaces as unknown[] + ).length + ).toBe(0); + expect(await exists(projectCacheDir)).toBe(true); + + const syncResult = await runCli(["sync"], env.projectDir, env.fakeHome); + + expect(syncResult.exitCode).toBe(0); + expect(syncResult.stderr).toContain( + "claude: the plugin will not load until the claude CLI has run." + ); + + const userMarketplaces = await readJson(join(userConfigDir, "marketplaces.json")); + const migrated = ( + userMarketplaces.marketplaces as Array<{ name: string; scope: string }> + ).find((m) => m.name === "aidd-framework"); + expect(migrated?.scope).toBe("user"); + + const projectMarketplaces = await readJson( + join(env.projectDir, ".aidd", "marketplaces.json") + ); + expect( + (projectMarketplaces.marketplaces as Array<{ name: string }>).some( + (m) => m.name === "aidd-framework" + ) + ).toBe(false); + + // claude's binary never ran in this sandbox, so a host registration still naming + // this cache never moved off it: purging it would leave nothing to resolve. + expect(await exists(projectCacheDir)).toBe(true); + expect(syncResult.stderr).toContain("pre-migration framework cache kept"); + + const doctorResult = await runCli(["doctor"], env.projectDir, env.fakeHome); + expect(doctorResult.exitCode).toBe(0); + expect(doctorResult.stdout + doctorResult.stderr).not.toContain("aidd sync"); + } finally { + await env.cleanup(); + } + }); +}); diff --git a/cli/tests/e2e/sync-recreates-machine-scope-registration.e2e.test.ts b/cli/tests/e2e/sync-recreates-machine-scope-registration.e2e.test.ts new file mode 100644 index 000000000..cf3bfb9e8 --- /dev/null +++ b/cli/tests/e2e/sync-recreates-machine-scope-registration.e2e.test.ts @@ -0,0 +1,129 @@ +import { cp, readFile, realpath, rm } from "node:fs/promises"; +import { join, resolve } from "node:path"; +import { describe, expect, it } from "vitest"; +import { createTestEnv, runCli } from "./helpers.js"; + +const FRAMEWORK_REAL_PATH = resolve(process.cwd(), "tests/fixtures/framework-real"); + +/** + * A clone never carries what its own `.gitignore` excludes — `.aidd/cache/` chief among them + * — so stripping it is what makes `projectDir` a faithful stand-in for a fresh `git clone`. + */ +async function cloneProjectWithoutCache(sourceDir: string, destDir: string): Promise { + await cp(sourceDir, destDir, { recursive: true }); + await rm(join(destDir, ".aidd", "cache"), { recursive: true, force: true }); +} + +async function readJson(path: string): Promise> { + return JSON.parse(await readFile(path, "utf-8")) as Record; +} + +describe("E2E: sync recreates a machine-scope registration a fresh clone never carried", () => { + it("registers the shared source and this project's own reference, with native activation unavailable", async () => { + const origin = await createTestEnv("machine-scope-sync-origin"); + const clone = await createTestEnv("machine-scope-sync-clone"); + try { + // The machine that first ran `setup`: registers the shared source under its own + // user config dir, and writes the committed project files a clone will carry. + const setupResult = await runCli( + [ + "setup", + "--source", + "local", + "--path", + FRAMEWORK_REAL_PATH, + "--ai", + "claude", + "--plugins", + "none", + "--yes", + ], + origin.projectDir, + origin.fakeHome + ); + expect(setupResult.exitCode).toBe(0); + + // A second machine checking out the same project for the first time: its own user + // config dir has never run `setup`, so `marketplaces.json` does not exist there. + await cloneProjectWithoutCache(origin.projectDir, clone.projectDir); + + const syncResult = await runCli(["sync"], clone.projectDir, clone.fakeHome); + + expect(syncResult.exitCode).toBe(0); + // Native activation genuinely could not run in this sandbox (no drivable tool binary + // on PATH), so the marketplace is registered but the plugin will not load yet. + expect(syncResult.stderr).toContain( + "claude: the plugin will not load until the claude CLI has run." + ); + + const marketplaces = await readJson( + join(clone.fakeHome, ".config", "aidd", "marketplaces.json") + ); + const names = (marketplaces.marketplaces as Array<{ name: string }>).map((m) => m.name); + expect(names).toContain("aidd-framework"); + + const references = await readJson(join(clone.fakeHome, ".config", "aidd", "references.json")); + const allProjectRoots = Object.values(references).flat() as string[]; + const expectedRoot = await realpath(clone.projectDir); + expect(allProjectRoots).toContain(expectedRoot); + } finally { + await origin.cleanup(); + await clone.cleanup(); + } + }); + + it("a second project's own setup on a machine that already registered the source adds its own reference, never replacing the first", async () => { + const first = await createTestEnv("machine-scope-shared-home-first"); + const second = await createTestEnv("machine-scope-shared-home-second"); + try { + // Both projects share one machine — one `fakeHome`, never `second.fakeHome` — so the + // second `setup` finds the source registered, and must count two claims, not replace one. + const firstSetup = await runCli( + [ + "setup", + "--source", + "local", + "--path", + FRAMEWORK_REAL_PATH, + "--ai", + "claude", + "--plugins", + "none", + "--yes", + ], + first.projectDir, + first.fakeHome + ); + expect(firstSetup.exitCode).toBe(0); + + const secondSetup = await runCli( + [ + "setup", + "--source", + "local", + "--path", + FRAMEWORK_REAL_PATH, + "--ai", + "claude", + "--plugins", + "none", + "--yes", + ], + second.projectDir, + first.fakeHome + ); + expect(secondSetup.exitCode).toBe(0); + + const references = await readJson(join(first.fakeHome, ".config", "aidd", "references.json")); + const allProjectRoots = Object.values(references).flat() as string[]; + const firstRoot = await realpath(first.projectDir); + const secondRoot = await realpath(second.projectDir); + expect(allProjectRoots).toContain(firstRoot); + expect(allProjectRoots).toContain(secondRoot); + expect(allProjectRoots).toHaveLength(2); + } finally { + await first.cleanup(); + await second.cleanup(); + } + }); +}); diff --git a/cli/tests/e2e/telemetry-backlog-axis.e2e.test.ts b/cli/tests/e2e/telemetry-backlog-axis.e2e.test.ts index b8fc81825..24aa4ac0d 100644 --- a/cli/tests/e2e/telemetry-backlog-axis.e2e.test.ts +++ b/cli/tests/e2e/telemetry-backlog-axis.e2e.test.ts @@ -4,14 +4,9 @@ import { join, relative } from "node:path"; import { afterEach, describe, expect, it } from "vitest"; import { createTestEnv, gitInit, runCli } from "./helpers.js"; -/** - * The upward link, end to end: a task folder's `backlog-link.json` changes what - * `aidd telemetry report` groups by, through the real CLI binary and real disk — never the - * in-memory doubles the domain unit tests exercise. Two properties this level alone can - * prove: the report never writes into a task folder while reading it, and two tasks - * declaring the same item merge into one row through the real adapter, not just the pure - * function. - */ +/** The upward link, end to end, through the real CLI binary and real disk. Two properties this + * level alone proves: the report never writes into a task folder while reading it, and two + * tasks declaring the same item merge into one row through the real adapter. */ const RUN_ID = "01ARZ3NDEKTSV4RRFFQ69G5FBX"; const VENDOR_ID = "55555555-5555-4555-8555-555555555555"; const PROJECT_ID = "acme/widgets"; @@ -90,9 +85,8 @@ function backlogLink(backlog: string, writtenBy: string): string { )}\n`; } -/** Every file under `dir`, hashed by its own bytes — the whole set, not only files a caller - * already knows about, so a file the report *created* is caught exactly as one it modified - * would be. */ +/** Every file under `dir`, hashed by its bytes: the whole set, not only files a caller knows + * about, so a file the report *created* is caught exactly as a modified one would be. */ async function snapshot(dir: string): Promise> { const files = new Map(); const walk = async (current: string): Promise => { diff --git a/cli/tests/e2e/telemetry-check-skill-commands.e2e.test.ts b/cli/tests/e2e/telemetry-check-skill-commands.e2e.test.ts index 2d7dc6b9d..a56e039fd 100644 --- a/cli/tests/e2e/telemetry-check-skill-commands.e2e.test.ts +++ b/cli/tests/e2e/telemetry-check-skill-commands.e2e.test.ts @@ -1,21 +1,14 @@ import { readdirSync, readFileSync } from "node:fs"; -import { join, resolve } from "node:path"; +import { join } from "node:path"; import { describe, expect, it } from "vitest"; +import { REPOSITORY_ROOT } from "../helpers/repository-root.js"; import { createTestEnv, gitInit, runCli } from "./helpers.js"; /** - * What `02-check` promises, held to what the CLI actually accepts — the same guard - * `telemetry-init-skill-commands.e2e.test.ts` and `telemetry-cost-skill-commands.e2e.test.ts` - * run for `00-init` and `01-cost`. The failure this guards against is exactly what deleting - * `02-check/scripts/` puts at risk: a command the skill's markdown names that the CLI never - * accepts, which the plugin script used to make impossible by construction — the skill and - * the script shipped together. - * - * `telemetry-check.e2e.test.ts` pins the command's *behaviour* on fixed fixtures; it never - * reads `02-check`'s own markdown, so it cannot catch the markdown naming a command the CLI - * does not have. This is the guard that reads the markdown. + * Holds `02-check`'s own markdown to what the CLI accepts: nothing else reads that markdown, + * so a command it names that the CLI does not have would ship unnoticed. */ -const REPO_ROOT = resolve(process.cwd(), ".."); +const REPO_ROOT = REPOSITORY_ROOT; const SKILL_DIR = join(REPO_ROOT, "plugins", "aidd-telemetry", "skills", "02-check"); /** Every `aidd telemetry …` command the skill's own markdown tells an agent to run. */ diff --git a/cli/tests/e2e/telemetry-check.e2e.test.ts b/cli/tests/e2e/telemetry-check.e2e.test.ts index b55d9b0ed..b63cbad1e 100644 --- a/cli/tests/e2e/telemetry-check.e2e.test.ts +++ b/cli/tests/e2e/telemetry-check.e2e.test.ts @@ -1,28 +1,16 @@ import { execFileSync } from "node:child_process"; import { cp, mkdir, writeFile } from "node:fs/promises"; -import { dirname, join, resolve } from "node:path"; +import { dirname, join } from "node:path"; import { describe, expect, it } from "vitest"; -import { environmentWithoutGitVariables } from "../../src/infrastructure/git-environment.js"; +import { environmentWithoutGitVariables } from "../../src/runtime/git/git-environment.js"; +import { REPOSITORY_ROOT } from "../helpers/repository-root.js"; import { createTestEnv, gitInit, identityFileIn, runCli, sinkDirIn } from "./helpers.js"; -/** - * `aidd telemetry check` — the local route alone (hook fired, session journalled, tool - * files readable, records join). The export route (export configured, identifier - * joinable) was deleted in "one route, and every sentence about it true" - * (aidd_docs/tasks/2026_08/2026_08_28_one-route-that-is-true/): the OTLP export writer, - * and every diagnostic claim that graded it, are gone — a healthy install has nothing - * left to grade but the route that actually produces a record. An earlier phase pinned - * this same suite's claims against the plugin's own `telemetry-check.cjs` while both - * existed; that script is long deleted (`02-check` calls `aidd telemetry check` instead), - * so this covers the gate and edge cases without a second process to compare against. - */ const LOCAL_COST_FIXTURES = join(process.cwd(), "tests", "fixtures", "local-cost"); -const REPO_ROOT = resolve(process.cwd(), ".."); +const REPO_ROOT = REPOSITORY_ROOT; const JOURNAL_HOOK = join(REPO_ROOT, "plugins", "aidd-telemetry", "hooks", "journal.cjs"); // Built from two literals, so this definition itself holds no literal `${...}`: biome's -// noTemplateCurlyInString flags a bare `${CLAUDE_PLUGIN_ROOT}` inside a plain string as an -// accidental template placeholder — the same reason the source plugin's own path-rewrite -// token is built the same way (`plugin-root-token-rewrite.ts`). +// noTemplateCurlyInString flags a bare `${CLAUDE_PLUGIN_ROOT}` inside a plain string. const CLAUDE_PLUGIN_ROOT_TOKEN = "$" + "{CLAUDE_PLUGIN_ROOT}"; const CLAUDE_SESSION = "22222222-2222-4222-8222-222222222222"; @@ -77,10 +65,8 @@ async function seedUnrecognisedPayload(projectDir: string, at: string): Promise< ); } -/** `~/.codex/config.toml`'s own trust table shape, for one event name. Approving a hook - * under `eventName` and then checking under a *different* one — the "renamed event" edge - * case — is exactly why this takes the event name as a parameter rather than hardcoding - * `session_start`. */ +/** `~/.codex/config.toml`'s own trust table shape. The event name is a parameter because + * the renamed-event case approves a hook under one name and checks under another. */ async function writeCodexHookTrust(fakeHome: string, eventName: string): Promise { await mkdir(join(fakeHome, ".codex"), { recursive: true }); await writeFile( @@ -91,13 +77,12 @@ trusted_hash = "deadbeef" ); } -// Matched by label, not by position: phase 1 prints a "what is in place" section ahead of -// the four claims, so slicing the first four lines of stdout no longer lands on them. +// Matched by label, not by position: a "what is in place" section prints ahead of the +// four claims, so slicing the first four lines of stdout does not land on them. const CLAIM_LINE = /^ {2}(hook fired|session journalled|tool files readable|records join)\b/u; -/** Every claim line the union covers — all four, in the fixed order `diagnoseTelemetryClaims` - * prints in — never the "not covered" lines after them, whose count depends on which tools - * this machine happens to have wired. */ +/** Every claim line the union covers, in the fixed order `diagnoseTelemetryClaims` prints + * in - never the "not covered" lines after them, whose count depends on the machine. */ function allClaimLines(stdout: string): string[] { return stdout.split("\n").filter((line) => CLAIM_LINE.test(line)); } @@ -134,6 +119,21 @@ describe("aidd telemetry check — the journey and its edge cases", () => { } }); + it("exits 1 when a claim fails, not 0 with the failure only printed", async () => { + const { projectDir, fakeHome, cleanup } = await createTestEnv("check-exit-code-fail"); + try { + await gitInit(projectDir); + await writeSwitch(projectDir, true); + + const result = await runCli(["telemetry", "check"], projectDir, fakeHome); + + expect(result.stdout).toContain("FAIL"); + expect(result.exitCode).toBe(1); + } finally { + await cleanup(); + } + }); + it("names the hook never firing when measurement is on and no run file appears", async () => { const { projectDir, fakeHome, cleanup } = await createTestEnv("check-never-fired"); try { @@ -142,12 +142,11 @@ describe("aidd telemetry check — the journey and its edge cases", () => { const result = await runCli(["telemetry", "check"], projectDir, fakeHome); - expect(result.exitCode, result.stderr).toBe(0); + expect(result.exitCode, result.stderr).toBe(1); expect(result.stdout).toMatch(/hook fired\s+FAIL\s+no run file/u); expect(result.stdout).toMatch(/never been observed firing/u); // "Nothing has run yet" is not "everything is broken": with no journal at all, the - // three claims that read from it have no material to judge, and say so - never a - // cascade of failures downstream of the one genuine one. + // three claims that read from it have no material to judge, and say so. expect(result.stdout).toMatch(/session journalled\s+--/u); expect(result.stdout).toMatch(/tool files readable\s+--/u); expect(result.stdout).toMatch(/records join\s+--/u); @@ -165,7 +164,7 @@ describe("aidd telemetry check — the journey and its edge cases", () => { const result = await runCli(["telemetry", "check"], projectDir, fakeHome); - expect(result.exitCode, result.stderr).toBe(0); + expect(result.exitCode, result.stderr).toBe(1); expect(result.stdout).toMatch(/matched no known host/u); expect(result.stdout).toContain("2026-08-22T09:00:00Z"); } finally { @@ -182,10 +181,9 @@ describe("aidd telemetry check — the journey and its edge cases", () => { const result = await runCli(["telemetry", "check"], projectDir, fakeHome); - expect(result.exitCode, result.stderr).toBe(0); + expect(result.exitCode, result.stderr).toBe(1); // A run file demonstrably exists here (torn though it is) — this is never read as - // "no run file", declared or not: that would say a file this build can see does not - // exist. + // "no run file": that would say a file this build can see does not exist. expect(result.stdout).toMatch(/hook fired\s+FAIL\s+1 run file\(s\)/u); expect(result.stdout).toMatch(/none carry a readable session_start/u); expect(result.stdout).not.toMatch(/matched no known host/u); @@ -243,7 +241,7 @@ describe("aidd telemetry check — the journey and its edge cases", () => { env: { CODEX_THREAD_ID: "codex-1" }, }); - expect(result.exitCode, result.stderr).toBe(0); + expect(result.exitCode, result.stderr).toBe(1); expect(result.stdout).toMatch( /hook fired\s+FAIL\s+Codex has not trusted this plugin's hook/u ); @@ -264,7 +262,7 @@ describe("aidd telemetry check — the journey and its edge cases", () => { env: { CODEX_THREAD_ID: "codex-1" }, }); - expect(result.exitCode, result.stderr).toBe(0); + expect(result.exitCode, result.stderr).toBe(1); expect(result.stdout).toMatch( /hook fired\s+FAIL\s+Codex has not trusted this plugin's hook/u ); @@ -283,7 +281,7 @@ describe("aidd telemetry check — the journey and its edge cases", () => { env: { CODEX_THREAD_ID: "codex-1" }, }); - expect(result.exitCode, result.stderr).toBe(0); + expect(result.exitCode, result.stderr).toBe(1); expect(result.stdout).toMatch(/hook fired\s+FAIL\s+no run file/u); expect(result.stdout).toMatch(/never been observed firing/u); expect(result.stdout).toMatch(/could not be read either/u); @@ -292,30 +290,15 @@ describe("aidd telemetry check — the journey and its edge cases", () => { await cleanup(); } }); - /** - * The one fact this command shares with the hook, proven by making the hook state it. - * - * `unrecognised_payload` is written in `hooks/lib/record.cjs` (plain CommonJS, no CLI) and - * read in `telemetry-evidence-adapter.ts` (TypeScript, a different package). Every other - * case in this file writes the marker by hand, which checks the reader against a literal - * the same file typed — it passes whatever the hook actually writes. Measured: renaming the - * hook's own literal left this suite 11/11 green and the plugin's 186/186 green, because - * the plugin side asserts only that the marker file exists, never its `type`. - * - * The cost of that blind spot is not a failed run, it is a wrong answer: with the marker - * unread, a payload that did arrive reports as "the hook has never been observed firing" — - * an unknown printed as a nothing, which is the one thing this layer promises never to do. - * So this case seeds nothing. It runs the hook the plugin ships, on a payload matching no - * declared host, and lets the file the hook writes be the fixture. - */ + /** `unrecognised_payload` is written by the hook and read by `telemetry-evidence-adapter.ts`; + * seeding the marker by hand would only check the reader against a literal this file typed. */ it("names an unrecognised payload the real hook wrote, not one this test typed", async () => { const { projectDir, fakeHome, cleanup } = await createTestEnv("check-unrecognised-real"); try { await gitInit(projectDir); await writeSwitch(projectDir, true); - // Neither a transcript path nor a timestamp: the shape no declared host matches, and - // the same one `aidd-telemetry-journal.test.js` uses to drive this branch. + // Neither a transcript path nor a timestamp: the shape no declared host matches. execFileSync(process.execPath, [JOURNAL_HOOK, "session-start"], { input: JSON.stringify({ session_id: "not-a-known-host", cwd: projectDir }), cwd: projectDir, @@ -324,7 +307,7 @@ describe("aidd telemetry check — the journey and its edge cases", () => { const result = await runCli(["telemetry", "check"], projectDir, fakeHome); - expect(result.exitCode, result.stderr).toBe(0); + expect(result.exitCode, result.stderr).toBe(1); expect(result.stdout).toMatch(/matched no known host/u); expect(result.stdout).not.toMatch(/never been observed firing/u); } finally { @@ -348,8 +331,7 @@ async function writeEnabledPlugin(projectDir: string, pluginKey: string): Promis } // A hooks block a headless CI, or `aidd framework build --target claude --flat`'s own -// output, can declare directly — never through `enabledPlugins` at all. The route this -// suite's "declared nowhere" case used to miss entirely (see the defect this covers). +// output, can declare directly — never through `enabledPlugins` at all. async function writeClaudeHooksBlock(projectDir: string, command: string): Promise { await mkdir(join(projectDir, ".claude"), { recursive: true }); await writeFile( @@ -358,9 +340,8 @@ async function writeClaudeHooksBlock(projectDir: string, command: string): Promi ); } -// Cursor's own plugin-scope hooks never fire (see `cursor-hooks-project-merge.ts`'s doc -// comment) — this project-scope flat file is the only place a Cursor install's hook -// declaration is ever real. +// Cursor's own plugin-scope hooks never fire — this project-scope flat file is the only +// place a Cursor install's hook declaration is ever real. async function writeCursorHooksBlock(projectDir: string, command: string): Promise { await mkdir(join(projectDir, ".cursor"), { recursive: true }); await writeFile( @@ -370,10 +351,8 @@ async function writeCursorHooksBlock(projectDir: string, command: string): Promi } /** - * The "what is in place" section phase 1 adds ahead of the four claims — a machine that - * has never been measured still gets an answer, and a person switched off still sees - * everything but the verdicts. See spec.md's own Done-when: this is the half that states, - * never grades. + * A machine that has never been measured still gets an answer, and a person switched + * off still sees everything but the verdicts: this half states, it never grades. */ describe("aidd telemetry check — what is in place, before any verdict", () => { it("states what is in place on a machine that has never measured anything, naming the file behind each fact", async () => { @@ -429,7 +408,7 @@ describe("aidd telemetry check — what is in place, before any verdict", () => const result = await runCli(["telemetry", "check"], projectDir, fakeHome); - expect(result.exitCode, result.stderr).toBe(0); + expect(result.exitCode, result.stderr).toBe(1); expect(result.stdout).toMatch(/identity attached\s+could not be read/u); expect(result.stdout).toContain(identityFileIn(fakeHome)); // Every other stated fact still appears — one damaged file costs only itself. @@ -561,7 +540,7 @@ describe("aidd telemetry check — what is in place, before any verdict", () => const result = await runCli(["telemetry", "check"], projectDir, fakeHome); - expect(result.exitCode, result.stderr).toBe(0); + expect(result.exitCode, result.stderr).toBe(1); expect(result.stdout).toMatch(/recorder declared\s+nowhere this build checks/u); } finally { await cleanup(); @@ -610,10 +589,8 @@ describe("aidd telemetry check — what is in place, before any verdict", () => }); /** - * Phase 2: the same absence — no run file — reads two different ways depending on the - * one thing that tells them apart: whether the recorder is declared. Proven here by - * mutation, on the same project, so the distinction is shown to actually bite rather than - * asserted from two unrelated fixtures. + * The same absence — no run file — reads two different ways depending on whether the + * recorder is declared. Proven by mutation on one project, never two fixtures. */ describe("aidd telemetry check — not yet stops being a failure", () => { it("reports nothing to evaluate, never a failure, once the recorder is declared — and a failure naming it before that", async () => { @@ -623,7 +600,7 @@ describe("aidd telemetry check — not yet stops being a failure", () => { await writeSwitch(projectDir, true); const beforeDeclaring = await runCli(["telemetry", "check"], projectDir, fakeHome); - expect(beforeDeclaring.exitCode, beforeDeclaring.stderr).toBe(0); + expect(beforeDeclaring.exitCode, beforeDeclaring.stderr).toBe(1); expect(beforeDeclaring.stdout).toMatch(/hook fired\s+FAIL\s+no run file/u); expect(beforeDeclaring.stdout).toMatch(/recorder is declared nowhere/u); @@ -646,8 +623,7 @@ describe("aidd telemetry check — not yet stops being a failure", () => { await gitInit(projectDir); await writeSwitch(projectDir, true); // No settings, no manifest — the recorder is declared nowhere — yet a run file - // already exists for this very session, so the claim it earns is "ok", never the - // new "declared nowhere" failure. + // already exists for this very session, so the claim it earns is "ok". await seedJournal( projectDir, CLAUDE_RUN_ID, @@ -660,7 +636,7 @@ describe("aidd telemetry check — not yet stops being a failure", () => { env: { CLAUDE_CODE_SESSION_ID: CLAUDE_SESSION }, }); - expect(result.exitCode, result.stderr).toBe(0); + expect(result.exitCode, result.stderr).toBe(1); expect(result.stdout).toMatch(/hook fired\s+ok/u); expect(result.stdout).not.toMatch(/recorder is declared nowhere/u); } finally { @@ -677,14 +653,12 @@ describe("aidd telemetry check — not yet stops being a failure", () => { await writeSwitch(projectDir, true); await writeEnabledPlugin(projectDir, "aidd-telemetry@ai-driven-dev/framework"); // Reachable, not synthetic: a hooks block registering PostToolUse/Stop but never - // SessionStart writes exactly this shape — journal lines with no session_start to - // anchor them. The recorder demonstrably ran (it wrote the file); the claim must - // never say "no run file … yet, nothing to evaluate" about a file this build can see. + // SessionStart writes exactly this shape — journal lines with no session_start. await seedTornRunFile(projectDir); const result = await runCli(["telemetry", "check"], projectDir, fakeHome); - expect(result.exitCode, result.stderr).toBe(0); + expect(result.exitCode, result.stderr).toBe(1); expect(result.stdout).toMatch(/hook fired\s+FAIL\s+1 run file\(s\)/u); expect(result.stdout).toMatch(/none carry a readable session_start/u); expect(result.stdout).not.toMatch(/nothing to evaluate/u); diff --git a/cli/tests/e2e/telemetry-commit-trailer.e2e.test.ts b/cli/tests/e2e/telemetry-commit-trailer.e2e.test.ts index 115414c68..0ff4b8b77 100644 --- a/cli/tests/e2e/telemetry-commit-trailer.e2e.test.ts +++ b/cli/tests/e2e/telemetry-commit-trailer.e2e.test.ts @@ -4,31 +4,21 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { promisify } from "node:util"; import { describe, expect, it } from "vitest"; -import { SESSION_TRAILER_TOKEN } from "../../src/domain/formats/commit-session-trailer.js"; -import { environmentWithoutGitVariables } from "../../src/infrastructure/git-environment.js"; -import { CLI_PATH, pathWithoutAidd } from "./helpers.js"; +import { SESSION_TRAILER_TOKEN } from "../../src/contexts/telemetry/domain/formats/commit-session-trailer.js"; +import { environmentWithoutGitVariables } from "../../src/runtime/git/git-environment.js"; +import { cliPath, pathWithoutAidd } from "./helpers.js"; const execFileAsync = promisify(execFile); /** - * The last link of the chain, held to a real commit. - * - * Every other test of this feature reads a string the CLI produced. None of them prove git - * runs the hook — which is the entire question, and the one an install written to a - * directory git ignores answers wrongly while reporting success. So this makes actual - * commits and reads their actual messages back with `git log`. - * - * The identifier a commit carries is the one a record already carries: - * `CLAUDE_CODE_SESSION_ID` is the transcript filename the local reader resolves a Claude - * Code session by, and `telemetry-claim.ts`'s own `firedForSession` has compared the two as - * equal since the "hook fired" claim existed. + * Real commits, read back with `git log`: an install written to a directory git ignores + * reports success while the hook never runs, and only git itself can tell the two apart. */ const SESSION = "33333333-3333-4333-8333-333333333333"; const OTHER_SESSION = "44444444-4444-4444-8444-444444444444"; -/** Every variable `session-anchor.ts` reads, removed. A test that means "no session made - * this commit" has to say so to the process it spawns, not merely refrain from mentioning - * it: the runner's own environment already carries one. */ +/** Every variable `session-anchor.ts` reads, removed: the runner's own environment already + * carries one, so "no session made this commit" has to be stated to the spawned process. */ function withoutSessionVariables(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv { const { CODEX_THREAD_ID, CLAUDE_CODE_SESSION_ID, ...rest } = env; return rest; @@ -36,18 +26,15 @@ function withoutSessionVariables(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv { interface Repo { readonly dir: string; - /** `aidd`, run from the repository, with a sandboxed home and no `aidd` on PATH. */ readonly aidd: (args: readonly string[]) => Promise<{ stdout: string }>; - /** One real commit, carrying whatever session variables are passed — including none, - * which is how a commit nobody's session made gets written. */ + /** Passing no session variables is how a commit nobody's session made gets written. */ readonly commit: (message: string, sessionEnv?: NodeJS.ProcessEnv) => Promise; readonly git: (args: readonly string[], sessionEnv?: NodeJS.ProcessEnv) => Promise; readonly messageOf: (ref: string) => Promise; } -/** A repository of its own per test, torn down in `finally`. Each test owns its directory - * rather than sharing one through a hook: these run concurrently, and shared state here - * meant one test's cleanup pulling the ground out from under another. */ +/** A repository per test rather than one shared through a hook: these run concurrently, and + * one test's cleanup would pull the ground out from under another. */ async function withRepo(use: (repo: Repo) => Promise): Promise { const tempDir = await mkdtemp(join(tmpdir(), "aidd-commit-trailer-")); const dir = join(tempDir, "project"); @@ -56,14 +43,8 @@ async function withRepo(use: (repo: Repo) => Promise): Promise { env: environmentWithoutGitVariables(process.env), }); - // No `aidd` on PATH: the hook has to stand on a shell and git alone, which is what it - // promises. `HOME` and the config dir are sandboxed, so nothing here reaches the - // machine's own profile. - // - // Both session variables are stripped before anything is added back. This suite runs - // inside a real Claude Code session, so a bare `process.env` carries a real - // `CLAUDE_CODE_SESSION_ID` — and the case that matters most here, a commit no session - // made, passed a trailer straight through while appearing to prove the opposite. + // No `aidd` on PATH: the hook stands on a shell and git alone. Both session variables are + // stripped first, since this suite may itself run inside a real session of its own. const env = (extra: NodeJS.ProcessEnv = {}): NodeJS.ProcessEnv => ({ ...withoutSessionVariables(environmentWithoutGitVariables(process.env)), PATH: pathWithoutAidd(), @@ -84,7 +65,7 @@ async function withRepo(use: (repo: Repo) => Promise): Promise { await use({ dir, aidd: (args) => - execFileAsync(process.execPath, [CLI_PATH, ...args], { cwd: dir, env: env() }), + execFileAsync(process.execPath, [cliPath(), ...args], { cwd: dir, env: env() }), commit: async (message, sessionEnv = {}) => { await writeFile(join(dir, `${message}.txt`), `${message}\n`); await git(["add", "-A"], sessionEnv); @@ -184,10 +165,8 @@ describe.concurrent("a commit names the session that made it", () => { await withRepo(async (repo) => { const hook = join(repo.dir, ".git", "hooks", "prepare-commit-msg"); await writeFile(hook, '#!/bin/sh\necho "theirs ran" >> "$(dirname "$1")/theirs.log"\n'); - // `fs.chmod`, never a spawned `chmod`: that binary is Git for Windows' own, reached - // only if its `usr/bin` happens to be on PATH, and this test has no business depending - // on that. Node's own call is a no-op for the execute bit on Windows, which is right — - // git runs a hook there through the shell it ships, not through the file's mode. + // `fs.chmod`, never a spawned `chmod`: on Windows that binary is Git's own and may be + // off PATH, while git runs a hook there through its shell, not through the file's mode. await chmod(hook, 0o755); await repo.aidd(["telemetry", "on", "--yes"]); diff --git a/cli/tests/e2e/telemetry-cost-skill-commands.e2e.test.ts b/cli/tests/e2e/telemetry-cost-skill-commands.e2e.test.ts index d81406bdd..abce4a623 100644 --- a/cli/tests/e2e/telemetry-cost-skill-commands.e2e.test.ts +++ b/cli/tests/e2e/telemetry-cost-skill-commands.e2e.test.ts @@ -2,23 +2,12 @@ import { readdirSync, readFileSync } from "node:fs"; import { cp, mkdir } from "node:fs/promises"; import { join, resolve } from "node:path"; import { describe, expect, it } from "vitest"; +import { REPOSITORY_ROOT } from "../helpers/repository-root.js"; import { createTestEnv, runCli } from "./helpers.js"; -/** - * What `01-cost` promises, held to what the CLI actually accepts and answers. - * - * Two failures this guards, and they are different: an answer that changed when the plugin's - * own copy of the report was deleted, and a command the skill names that the CLI never - * accepts. The second is what the plugin script used to make impossible by construction — - * the skill and the script shipped together — and is exactly what the move to `aidd` puts at - * risk. - * - * The fixture is synthetic on purpose. It has to be reproducible in CI, and it must not be - * somebody's real usage: this repository is public, and the layer's own rule is that nothing - * leaves the machine. The confrontation with real data is a separate, uncommitted step, - * recorded in the phase's notes — a synthetic fixture agrees with the code that reads it. - */ -const REPO_ROOT = resolve(process.cwd(), ".."); +/** Two different failures are guarded here: an answer that changed when the plugin's own copy + * of the report was deleted, and a command the skill names that the CLI never accepts. */ +const REPO_ROOT = REPOSITORY_ROOT; const FIXTURE = resolve(process.cwd(), "tests/fixtures/cli-owns-read"); const SKILL_DIR = join(REPO_ROOT, "plugins", "aidd-telemetry", "skills", "01-cost"); const PERIOD = ["--from", "2026-01-01", "--to", "2026-01-31"]; @@ -45,11 +34,8 @@ function commandsNamedBySkill(): string[] { return [...found]; } -/** `` and friends stand for a choice the agent makes; a placeholder is expanded to its - * first alternative so the command can actually be run, rather than skipped. Any `` - * is expanded by its own shape rather than by a copy of one skill's list: pinning the list - * here meant the skill could not name a new axis without this regex being edited in the same - * breath, and the failure it produced named the placeholder, not the drift. */ +/** A placeholder stands for a choice the agent makes, expanded by its own shape rather than + * by a copy of one skill's list, so the skill can name a new axis without editing this. */ function runnable(command: string): string[] { return command .replace(/<([^<>|]+)(?:\|[^<>]+)>/gu, "$1") diff --git a/cli/tests/e2e/telemetry-flow-axis.e2e.test.ts b/cli/tests/e2e/telemetry-flow-axis.e2e.test.ts index cff44ff69..737e100f4 100644 --- a/cli/tests/e2e/telemetry-flow-axis.e2e.test.ts +++ b/cli/tests/e2e/telemetry-flow-axis.e2e.test.ts @@ -3,27 +3,16 @@ import { join } from "node:path"; import { afterEach, describe, expect, it } from "vitest"; import { createTestEnv, gitInit, runCli } from "./helpers.js"; -/** - * The flow axis, end to end: a period breaks down by the orchestrated run the journal's own - * step sequence already names, through the real CLI binary and real disk - never the - * in-memory doubles the domain unit tests exercise. Two properties this level alone can - * prove: two runs of the *same* orchestrating skill in one session stay two rows, not one - * merged by name, and a hand-run skill mid-flow counts inside it exactly as the journal's - * own flat sequence forces it to. - */ const RUN_ID = "01ARZ3NDEKTSV4RRFFQ69G5FBY"; const VENDOR_ID = "66666666-6666-4666-8666-666666666666"; -// A second session with no run journal on disk at all - the shape a session resumed after -// its context was compacted leaves behind: nothing is invoked again, so no `step_start` -// hook fires, while the transcript goes on stating the step on every record it produces. +// A second session with no run journal on disk at all - the shape a session resumed after its +// context was compacted leaves: no `step_start` fires, while the transcript still states the step. const NO_JOURNAL_VENDOR_ID = "77777777-7777-4777-8777-777777777777"; const PROJECT_ID = "acme/widgets"; const PERIOD = ["--from", "2026-03-01", "--to", "2026-03-31"]; // One session running the same orchestrating skill twice, with a hand-run skill inside the -// first run, and work before either ever opens - the shape phase-1.md's own test scope -// names: two orchestrated runs, a hand-run skill counted inside one of them, and work -// outside any flow. +// first run, and work before either ever opens. const JOURNAL_LINES = [ { type: "session_start", @@ -65,9 +54,8 @@ const RECORDS = [ }), // Inside the first sdlc run, but from the hand-run skill - the journal cannot tell it apart. record({ turn_id: "first-run-hand-run", event_timestamp: "2026-03-10T09:35:00Z", cost_usd: 3 }), - // After the turn ended, before the next orchestrating step opens - still the first sdlc - // run, which a pause does not end. The separating record for this axis: while a turn_end - // closed a flow, this one fell outside every flow. + // After the turn ended, before the next orchestrating step opens - still the first sdlc run, + // which a pause does not end. The separating record for this axis. record({ turn_id: "first-run-after-pause", event_timestamp: "2026-03-10T09:55:00Z", @@ -179,10 +167,8 @@ describe("aidd telemetry report — by_flow through the real adapter, on real di }); it("keeps a record made after the turn ended inside the flow that was still running", async () => { - // The rule this axis changed on 2026-09-04, end to end: a `turn_end` is a pause, not the - // end of an orchestration. This record sits between the pause at 09:50 and the next - // orchestrating step at 10:00, so where it lands is the whole difference between the two - // rules - it used to be counted outside every flow. + // A `turn_end` is a pause, not the end of an orchestration. This record sits between the + // pause at 09:50 and the next orchestrating step at 10:00, where the two rules differ. const { projectDir, fakeHome } = await seed(); const result = await runCli(["telemetry", "report", ...PERIOD, "--json"], projectDir, fakeHome); @@ -252,7 +238,6 @@ describe("aidd telemetry report — by_flow through the real adapter, on real di expect(stated?.totals.cost_micro_usd).toBe(13_000_000); // 6 + 7 // A name is not a run: the row is a bucket drawn from however many runs the tool named. expect(stated?.started_at).toBeUndefined(); - // And it never swallows the runs the journal did witness. const witnessed = envelope.by_flow.filter((row) => row.attribution === "journal-interval"); expect(witnessed).toHaveLength(2); }); diff --git a/cli/tests/e2e/telemetry-forget.e2e.test.ts b/cli/tests/e2e/telemetry-forget.e2e.test.ts index 4bc4e637a..4d96c55cf 100644 --- a/cli/tests/e2e/telemetry-forget.e2e.test.ts +++ b/cli/tests/e2e/telemetry-forget.e2e.test.ts @@ -3,23 +3,11 @@ import { mkdir, readdir, readFile, writeFile } from "node:fs/promises"; import { dirname, join } from "node:path"; import { promisify } from "node:util"; import { describe, expect, it } from "vitest"; -import { environmentWithoutGitVariables } from "../../src/infrastructure/git-environment.js"; +import { environmentWithoutGitVariables } from "../../src/runtime/git/git-environment.js"; import { createTestEnv, gitInit, identityFileIn, runCli, sinkDirIn } from "./helpers.js"; const execFileAsync = promisify(execFile); -/** - * `aidd telemetry forget` — the seventh command, and the only one that deletes data. - * Covers phase-2.md's own Test Scope: preview vs. confirm, counts matching what was - * shown, a damaged record file, a location that refuses removal (a directory named - * `*.jsonl`) leaving the rest untouched, a relocated `AIDD_RUNS_DIR` and a relocated - * `AIDD_USER_CONFIG_DIR` each reaching only the location named. The structural "removal - * cannot resolve its own locations" guarantee is proven by mutation at the unit level, for - * all three locations (`forget-telemetry-use-case.unit.test.ts`) and at the adapter level - * for the journal and the identity (`run-journal-reader-adapter.integration.test.ts`, - * `person-identity-adapter.integration.test.ts`) — this file proves the CLI journey a - * person actually sees. - */ const RUN_ID = "01ARZ3NDEKTSV4RRFFQ69G5FAV"; const VENDOR_ID = "vendorabc"; const JOURNAL_FILE = `${RUN_ID}__${VENDOR_ID}.jsonl`; @@ -168,10 +156,8 @@ describe("aidd telemetry forget — shows, confirms, removes, and names what his } }); - // Finding 3: `git ls-files` reads the index, not history — a journal `git add`ed and - // never committed used to be relayed as "history certainly holds it", which is false in - // a repository with zero commits. This reproduces exactly that and asserts the honest - // reading instead. + // `git ls-files` reads the index, not history: a staged journal in a repository with no + // commits is not held by history at all. it("previews a staged-but-never-committed journal honestly, never as certainly held", async () => { const { projectDir, fakeHome, cleanup } = await createTestEnv("forget-staged-preview"); try { @@ -192,9 +178,6 @@ describe("aidd telemetry forget — shows, confirms, removes, and names what his } }); - // Finding 1's journal leg, at the CLI: a relocated `AIDD_RUNS_DIR` must reach only the - // directory it names, the same shape the existing relocated-`AIDD_USER_CONFIG_DIR` test - // proves for the sink. it("a relocated AIDD_RUNS_DIR touches only the relocated location, never the project's own runs dir", async () => { const { projectDir, fakeHome, cleanup } = await createTestEnv("forget-runs-relocated"); try { @@ -212,16 +195,14 @@ describe("aidd telemetry forget — shows, confirms, removes, and names what his expect(result.exitCode, result.stderr).toBe(0); expect(result.stdout).toContain(relocated); expect(await entries(relocated)).toEqual([]); - // The project's own runs directory is untouched. expect(await readFile(realJournalPath, "utf8")).toContain(VENDOR_ID); } finally { await cleanup(); } }); - // Finding 5: the claimed "a refused deletion leaving the rest untouched" case did not - // exist. A directory named `*.jsonl` refuses removal portably (`EISDIR` on POSIX, - // access-denied-shaped on Windows) with no `chmod` needed, through the real CLI. + // A directory named `*.jsonl` refuses removal portably — `EISDIR` on POSIX, + // access-denied-shaped on Windows — with no `chmod` needed. it("a run file that refuses removal (a directory named *.jsonl) is reported, and the rest is still removed", async () => { const { projectDir, fakeHome, cleanup } = await createTestEnv("forget-refused-real"); try { @@ -238,7 +219,6 @@ describe("aidd telemetry forget — shows, confirms, removes, and names what his /This project's run journal: 0 removed, 1 could not be removed/u ); expect(result.stderr).toMatch(/Could not remove journal run file adir\.jsonl/u); - // The directory itself was never removed, and every other location still emptied. expect(await entries(runsDir)).toEqual(["adir.jsonl"]); expect(result.stdout).toMatch(/This machine's stored records: 1 removed/u); expect(result.stdout).toMatch(/This machine's identity: 1 removed/u); @@ -293,7 +273,6 @@ describe("aidd telemetry forget — shows, confirms, removes, and names what his expect(await entries(sinkDirIn(fakeHome))).toEqual([]); await expect(readFile(identityFileIn(fakeHome), "utf8")).rejects.toThrow(); - // The switch is untouched, and measurement can be turned on again. expect(JSON.parse(await readSwitch(projectDir))).toEqual({ telemetry: { enabled: true } }); const onAgain = await runCli(["telemetry", "on", "--yes"], projectDir, fakeHome); expect(onAgain.exitCode, onAgain.stderr).toBe(0); @@ -374,7 +353,6 @@ describe("aidd telemetry forget — shows, confirms, removes, and names what his expect(result.exitCode, result.stderr).toBe(0); expect(result.stdout).toContain(relocated); expect(await entries(join(relocated, "telemetry"))).toEqual([]); - // The real profile's own record is untouched. expect(await readFile(realSinkPath, "utf8")).toContain("real"); } finally { await cleanup(); diff --git a/cli/tests/e2e/telemetry-hook-install.e2e.test.ts b/cli/tests/e2e/telemetry-hook-install.e2e.test.ts index 4f61c23ab..6ef56274f 100644 --- a/cli/tests/e2e/telemetry-hook-install.e2e.test.ts +++ b/cli/tests/e2e/telemetry-hook-install.e2e.test.ts @@ -1,13 +1,14 @@ import { execFile } from "node:child_process"; import { readdirSync, readFileSync } from "node:fs"; import { mkdir, writeFile } from "node:fs/promises"; -import { join, resolve } from "node:path"; +import { join } from "node:path"; import { promisify } from "node:util"; import { describe, expect, it } from "vitest"; +import { REPOSITORY_ROOT } from "../helpers/repository-root.js"; import { createTestEnv, gitInit, gitSetOriginRemote, runCli } from "./helpers.js"; const execFileAsync = promisify(execFile); -const REPO_ROOT = resolve(process.cwd(), ".."); +const REPO_ROOT = REPOSITORY_ROOT; const PLUGIN_SOURCE = join(REPO_ROOT, "plugins", "aidd-telemetry"); const SESSION_FIXTURE = join( REPO_ROOT, @@ -17,17 +18,17 @@ const SESSION_FIXTURE = join( "claude-code-session-start.json" ); -// Every other test runs the journal hook from the source tree. Installation moves it, and -// a move that drops hooks/lib/ leaves a hook that throws on its first require — silently, -// since a hook that fails is a hook that never records. Only running the installed copy -// catches that. +// Installation moves the journal hook, and a move dropping `hooks/lib/` leaves one that +// throws on its first require - silently, since a hook that fails never records. describe("E2E: the journal hook runs from where installation puts it", () => { it("records a session through the installed plugin, not the source tree", async () => { const { projectDir, fakeHome, cleanup } = await createTestEnv("telemetry-hook-install"); try { await gitInit(projectDir); await gitSetOriginRemote(projectDir, "git@github.com:aidd-lab/hook-install.git"); - expect((await runCli(["ai", "install", "claude"], projectDir, fakeHome)).exitCode).toBe(0); + expect( + (await runCli(["framework", "install", "--tool", "claude"], projectDir, fakeHome)).exitCode + ).toBe(0); expect( (await runCli(["plugin", "install", PLUGIN_SOURCE, "--yes"], projectDir, fakeHome)).exitCode ).toBe(0); diff --git a/cli/tests/e2e/telemetry-host-registration.e2e.test.ts b/cli/tests/e2e/telemetry-host-registration.e2e.test.ts index 6ec5e5cd6..20e856880 100644 --- a/cli/tests/e2e/telemetry-host-registration.e2e.test.ts +++ b/cli/tests/e2e/telemetry-host-registration.e2e.test.ts @@ -4,19 +4,12 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { promisify } from "node:util"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; -import { CLI_PATH, pathWithoutAidd } from "./helpers.js"; +import { cliPath, pathWithoutAidd } from "./helpers.js"; const execFileAsync = promisify(execFile); -/** - * #703 end to end, on the built binary: a project whose plugins are declared, and a host - * registry that carries one of them and not the other. - * - * The point of running it here rather than only in unit tests is the cost of the answer. The - * whole design exists so this question is answerable **before** anyone has spent a session: - * no AI tool on `PATH`, no network, no account, no money — only files already on disk. If - * that ever stopped being true, it would stop here first. - */ +/** On the built binary: plugins a project declares against a host registry carrying one of + * them and not the other, answerable with no tool on `PATH`, no network and no money. */ const PLUGIN_SOURCE = { kind: "github", repo: "ai-driven-dev/framework" } as const; function pluginEntry(name: string, marketplace?: string) { @@ -26,6 +19,7 @@ function pluginEntry(name: string, marketplace?: string) { version: "1.0.0", strict: true, files: {}, + scope: "project", ...(marketplace === undefined ? {} : { marketplace }), }; } @@ -47,7 +41,7 @@ describe("check says whether the host will load what aidd installed", () => { await writeFile( join(projectDir, ".aidd", "manifest.json"), JSON.stringify({ - version: 1, + version: 8, tools: { claude: { files: [], @@ -62,7 +56,7 @@ describe("check says whether the host will load what aidd installed", () => { }) ); // Only the first is registered, and it names this project — which is how `aidd` installs, - // at project scope (`claude-cli-adapter.ts`'s own `--scope project`). + // at project scope (`native-plugin-cli-adapter.ts`'s own `--scope project`). await writeFile( join(fakeHome, ".claude", "plugins", "installed_plugins.json"), JSON.stringify({ @@ -79,7 +73,7 @@ describe("check says whether the host will load what aidd installed", () => { }); it("names each plugin's answer, with no AI tool on PATH and nothing to spend", async () => { - const { stdout } = await execFileAsync(process.execPath, [CLI_PATH, "telemetry", "check"], { + const { stdout } = await execFileAsync(process.execPath, [cliPath(), "telemetry", "check"], { cwd: projectDir, env: { PATH: pathWithoutAidd(), @@ -89,7 +83,7 @@ describe("check says whether the host will load what aidd installed", () => { }); expect(stdout).toContain("plugins registered"); - // The failure #703 is about: declared, and the host will drop it as orphaned. + // Declared, and the host will drop it as orphaned. expect(stdout).toContain("claude/aidd-dev: not-registered"); // No marketplace recorded, so no registry keys on it — unanswerable, never "not there". expect(stdout).toContain("claude/hand-copied: unanswerable"); @@ -109,7 +103,7 @@ describe("check says whether the host will load what aidd installed", () => { }) ); - const { stdout } = await execFileAsync(process.execPath, [CLI_PATH, "telemetry", "check"], { + const { stdout } = await execFileAsync(process.execPath, [cliPath(), "telemetry", "check"], { cwd: projectDir, env: { PATH: pathWithoutAidd(), diff --git a/cli/tests/e2e/telemetry-identity-resolution.e2e.test.ts b/cli/tests/e2e/telemetry-identity-resolution.e2e.test.ts index 1ee3e6198..87295b2f6 100644 --- a/cli/tests/e2e/telemetry-identity-resolution.e2e.test.ts +++ b/cli/tests/e2e/telemetry-identity-resolution.e2e.test.ts @@ -4,25 +4,8 @@ import { afterEach, describe, expect, it } from "vitest"; import { createTestEnv, gitInit, identityFileIn, runCli, sinkDirIn } from "./helpers.js"; /** - * The guarantees #661 exists to prove, now resolved against the one identity file rather - * than a separate declaration - see spec.md and phase-3.md's own Test Scope: one human - * counted once across tools and machines, every unplaced identity visible and counted on - * its own, and the rows always reconciling to the period total. The two-machines journey, - * the never-merge assertion and the reconciliation assertion are unchanged from the - * previous delivery - they are the point this rework must not disturb. - * - * Declaring who this machine's user is now goes through `identity use`/`identity link`, - * never by writing a separate file directly - the file this suite used to seed by hand - * (`person-mapping.json`) no longer exists as its own shape at all. - * - * Every seed here writes straight into the sink and, where a raw file is unavoidable - * (a damaged or repository-supplied identity file), the identity file itself - the same - * way `telemetry-report.e2e.test.ts` and `telemetry-identity.e2e.test.ts` do: this file's - * subject is resolution, not any one tool's reader, and going through a real reader would - * make it depend on whether the machine running it happens to have that tool installed. - * `runCli` already sandboxes `PATH` down to node, git and the OS's own essentials — no AI - * tool binary is ever reachable from here, which is what proves every claim below holds - * with none present. + * Every seed writes straight into the sink: the subject here is resolution, not any one + * tool's reader, and `runCli` sandboxes `PATH` so no AI tool binary is reachable at all. */ const FROM_DAY = "2026-08-17"; const TO_DAY = "2026-08-21"; @@ -41,10 +24,8 @@ function record(overrides: Record): Record { }; } -/** `sinkDir` is where records actually land - `sinkDirIn(fakeHome)` for the default - * profile-resolved sink, or `join(someOtherConfigDir, "telemetry")` for a sink relocated by - * `AIDD_USER_CONFIG_DIR`, since that variable moves the sink the same way it moves nothing - * else covered by this suite's guarantees. */ +/** Where records actually land: `sinkDirIn(fakeHome)` for the profile-resolved sink, or + * `join(configDir, "telemetry")` for one relocated by `AIDD_USER_CONFIG_DIR`. */ async function seedSink( sinkDir: string, records: readonly Record[] @@ -105,9 +86,8 @@ describe("aidd telemetry report --axis person, and the identity commands that fe cleanup = undefined; }); - // Expected to already hold: the identity is machine-scoped, minted once per profile and - // shared by every tool that reads locally on it — never tool-scoped. This test exists to - // catch a change that would make it tool-scoped again, not to build the guarantee. + // The identity is machine-scoped, minted once per profile and shared by every tool that + // reads locally on it — never tool-scoped. it("two tools under one identifier print one person row", async () => { const { projectDir, fakeHome, cleanup: c } = await setUp("person-two-tools"); cleanup = c; @@ -296,10 +276,8 @@ describe("aidd telemetry report --axis person, and the identity commands that fe )}\n`, "utf8" ); - // AIDD_USER_CONFIG_DIR also relocates the sink (telemetry-sink-adapter.ts), so the - // records this test needs to survive the override have to be seeded under the decoy - // directory too - seeding them under the real profile instead would make the report - // read empty and let both assertions below pass on nothing. + // AIDD_USER_CONFIG_DIR also relocates the sink, so records seeded under the real + // profile instead would make the report read empty and pass both assertions on nothing. await seedSink(join(decoyDir, "telemetry"), [ record({ tool: "claude", vendor_id: "s-1", turn_id: "t-1", person_id: "person-a" }), record({ tool: "claude", vendor_id: "s-2", turn_id: "t-2", person_id: "machine-b-id" }), @@ -310,10 +288,8 @@ describe("aidd telemetry report --axis person, and the identity commands that fe }); expect(envelope.totals.requests).toBe(2); - // The real profile's own identity ("person-a") still resolves its own row, proving - // resolution ran at all - what the decoy must have no effect on is the *claim inside - // it*: `machine-b-id` is only listed under the decoy's `also_me`, never the real - // profile's, so it must stay unresolved rather than merge into person-a's row. + // `machine-b-id` is listed under the decoy's `also_me`, never the real profile's, so it + // must stay unresolved rather than merge into person-a's row. const mapped = envelope.by_person.find((row) => row.resolution === "mapped"); expect(mapped?.identities).not.toContain("machine-b-id"); const unresolved = envelope.by_person.find((row) => row.identities.includes("machine-b-id")); diff --git a/cli/tests/e2e/telemetry-identity.e2e.test.ts b/cli/tests/e2e/telemetry-identity.e2e.test.ts index b320b1c22..304084ddf 100644 --- a/cli/tests/e2e/telemetry-identity.e2e.test.ts +++ b/cli/tests/e2e/telemetry-identity.e2e.test.ts @@ -3,15 +3,6 @@ import { dirname, join } from "node:path"; import { describe, expect, it } from "vitest"; import { createTestEnv, gitInit, identityFileIn, runCli, sinkDirIn } from "./helpers.js"; -/** - * `aidd telemetry identity` — the CLI's own mint/name/forget of the person identifier that - * `aidd telemetry read` may stamp onto a record. Three concerns, three describe blocks: - * the journey and its edge cases (phase-2.md's own Test Scope), the two suites phase 1 - * moved out of the plugin's own test file once its reporter was deleted, and the on-disk - * format the deleted script pinned — phase 3 deletes `telemetry-identity.cjs` itself, so - * this is captured as a fixture rather than a live comparison. See `measurements.md`'s - * "Phase 3" section for what each of the six former parity tests became. - */ const LOCAL_COST_FIXTURES = join(process.cwd(), "tests", "fixtures", "local-cost"); const CLAUDE_SESSION = "22222222-2222-4222-8222-222222222222"; @@ -45,10 +36,8 @@ async function seedJournal( ); } -// `aidd telemetry read` now refuses the same way `report` and `check` already did (finding -// 4, review.md "one route, and every sentence about it true") — a sweep this file runs to -// prove what identity a record carries has to turn measurement on first, the same as a real -// project would, rather than exploit the gap that let it run unmeasured. +// `telemetry read` refuses unless measurement is on, so every sweep here turns it on +// first, the same as a real project would. async function writeSwitch(projectDir: string, enabled: boolean): Promise { await mkdir(join(projectDir, ".aidd"), { recursive: true }); await writeFile( @@ -121,10 +110,6 @@ describe("aidd telemetry identity — the journey and its edge cases", () => { } }); - // These four lines are what a person reads before deciding, and what 04-identify.md and - // 05-forget.md require the skill to relay. Nothing held them: all four could be deleted - // from telemetry-display.ts and the suite stayed green. In a feature whose whole value is - // that consent is explicit, the consent text is the last thing that should be unguarded. it("a minted identity discloses what it attaches to, and what it never attaches to", async () => { const { projectDir, fakeHome, cleanup } = await createTestEnv("identity-disclosure-on"); try { @@ -192,10 +177,8 @@ describe("aidd telemetry identity — the journey and its edge cases", () => { }); it("mints for a name given before anything stands, rather than refusing", async () => { - // The separate `name` verb refused here and had to: it could only decorate an identity - // that already existed. `use` is the verb that opts in, so a name given to it with - // nothing standing is a person saying who they are, not asking to rename a thing that - // is not there. One command, one intent, no error to route around. + // `use` is the verb that opts in, so a name given with nothing standing is a person + // saying who they are, not renaming a thing that is not there. const { projectDir, fakeHome, cleanup } = await createTestEnv("identity-name-first"); try { const result = await runCli( @@ -230,9 +213,8 @@ describe("aidd telemetry identity — the journey and its edge cases", () => { } }); - // Pins the adapter half of `forget()`'s answer, which the use-case tests cannot reach: - // with `force: true` restored on the `rm`, this prints the withdrawal message for a - // profile that never had a file, and every unit test stays green. + // Pins the adapter half of `forget()`, out of the use-case tests' reach: with `force: true` + // on the `rm`, a profile that never had a file still prints the withdrawal message. it("off on a profile that never had an identity says there was nothing to withdraw", async () => { const { projectDir, fakeHome, cleanup } = await createTestEnv("identity-off-absent"); try { @@ -246,8 +228,7 @@ describe("aidd telemetry identity — the journey and its edge cases", () => { } }); - // A file naming nobody parses to "nobody chose" while still sitting on disk. Deciding - // removal from that read left it there with no verb able to remove it. + // A file naming nobody parses to "nobody chose" while still sitting on disk. it("off removes an identity file that exists but names nobody", async () => { const { projectDir, fakeHome, cleanup } = await createTestEnv("identity-off-nameless"); try { @@ -265,9 +246,8 @@ describe("aidd telemetry identity — the journey and its edge cases", () => { } }); - // `status`, `on` and `name` are right to error on a damaged file — the edge case above. - // `off` is not: it is how a person gets out, and there must be no state a damaged file - // can put someone in that withdrawing cannot get them out of. + // `off` is how a person gets out: no state a damaged file can put someone in may be one + // withdrawing cannot get them out of. it("off still withdraws a damaged identity file, and says it was discarded", async () => { const { projectDir, fakeHome, cleanup } = await createTestEnv("identity-off-damaged"); try { @@ -307,12 +287,8 @@ describe("aidd telemetry identity — the journey and its edge cases", () => { } }); - // The test above seeds both the OS profile and the decoy, so an implementation that - // merely *prefers* the OS profile when both exist — falling back to - // AIDD_USER_CONFIG_DIR only when the profile is empty — would still pass it. This is - // the shape the deleted script suite actually asserted ("the choice belongs to the - // person, not the repository"): an empty OS profile beside a populated - // AIDD_USER_CONFIG_DIR must still read as no identity, never as the decoy's. + // The test above seeds both profiles, so an implementation that merely prefers the OS one + // would pass it too: an empty OS profile beside a populated AIDD_USER_CONFIG_DIR reads off. it("an empty OS profile beside a populated AIDD_USER_CONFIG_DIR still reads off", async () => { const { tempDir, projectDir, fakeHome, cleanup } = await createTestEnv( "identity-env-override-empty" @@ -424,14 +400,8 @@ describe("a choice made today does not reach backwards", () => { }); }); -// What `telemetry-identity.cjs` wrote to disk, captured 2026-08-26 — the run that produced -// each literal below is recorded in measurements.md's "Phase 3" section, before the script -// was deleted in this same phase. `off`, `status` (both states) and `on` against an -// existing identity are already asserted by the journey block above through the CLI alone; -// re-running them against the script here would be the duplication plan.md's own Decision -// forbids ("one equivalence pin, not a suite watching two implementations agree with -// themselves"). What survives is the one claim nothing else in this file owns: the exact -// on-disk byte format, and the file/directory modes. +// The one claim nothing else in this file owns: the exact on-disk byte format, and the +// file and directory modes. describe("the on-disk format the deleted script produced", () => { it("name: matches the exact bytes the script wrote from the same starting identity", async () => { const { projectDir, fakeHome, cleanup } = await createTestEnv("identity-format-name"); @@ -446,11 +416,8 @@ describe("the on-disk format the deleted script produced", () => { expect(result.exitCode, result.stderr).toBe(0); const file = await readFile(identityFileIn(fakeHome), "utf8"); - // `seedIdentity` wrote the script's own no-`origin` shape; reading it back defaults - // `origin` to `"minted"` (phase 2 of the identity-is-the-person rework), and every - // write from here on carries it - byte parity with the deleted script's own output - // ends here by design, not by regression: `origin` is new, required information the - // old shape never carried at all. + // `seedIdentity` writes the no-`origin` shape; reading it back defaults `origin` to + // `"minted"`, and every write from here on carries it. expect(file).toBe( '{\n "person_id": "shared-person-id",\n "origin": "minted",\n "display_name": "Baptiste"\n}\n' ); @@ -468,8 +435,8 @@ describe("the on-disk format the deleted script produced", () => { const file = await readFile(identityFileIn(fakeHome), "utf8"); const personId = (JSON.parse(file) as { person_id: string }).person_id; expect(personId).toMatch(UUID_V4); - // `mint()` records `origin: "minted"` - the only checkable fact about an identity, - // knowable only at the moment it is created, per the identity-is-the-person rework. + // `mint()` records `origin: "minted"`: how an identity was obtained is knowable only at + // the moment it is created. expect(file.replace(personId, "")).toBe( '{\n "person_id": "",\n "origin": "minted"\n}\n' ); diff --git a/cli/tests/e2e/telemetry-init-skill-commands.e2e.test.ts b/cli/tests/e2e/telemetry-init-skill-commands.e2e.test.ts index 834dffbdd..653557ceb 100644 --- a/cli/tests/e2e/telemetry-init-skill-commands.e2e.test.ts +++ b/cli/tests/e2e/telemetry-init-skill-commands.e2e.test.ts @@ -1,25 +1,12 @@ import { readdirSync, readFileSync } from "node:fs"; -import { join, resolve } from "node:path"; +import { join } from "node:path"; import { describe, expect, it } from "vitest"; +import { REPOSITORY_ROOT } from "../helpers/repository-root.js"; import { createTestEnv, runCli } from "./helpers.js"; -/** - * What `00-init` promises, held to what the CLI actually accepts — the same guard - * `telemetry-cost-skill-commands.e2e.test.ts` runs for `01-cost`. Three failures this - * guards: a command the skill names that the CLI never accepts (or the CLI stops - * accepting), `01-check`'s absent-CLI wording drifting from the copy `01-cost` already - * owns, and — since "the deletion path" extended `05-forget.md` to `aidd telemetry - * forget` — the skill's account of the seventh command going stale the same way the - * other six are already guarded against. - * - * `00-init`'s commands are stateful — `identity use --name` and `identity off` only make sense - * once `identity use` has run, and `forget --yes` deletes the very journal and identity - * every other command in this sweep might still need — so they are not run in whatever - * order the markdown walk happens to find them in; `orderForExecution` puts every `on` - * first, every `off` after that, and every `forget` last of all: the most destructive - * command runs only once nothing after it still depends on what it removes. - */ -const REPO_ROOT = resolve(process.cwd(), ".."); +/** `00-init`'s commands are stateful, so they are not run in the order the markdown walk + * finds them: the most destructive runs only once nothing after it depends on what it removes. */ +const REPO_ROOT = REPOSITORY_ROOT; const SKILL_DIR = join(REPO_ROOT, "plugins", "aidd-telemetry", "skills", "00-init"); const COST_LOCATE = join( REPO_ROOT, @@ -50,18 +37,8 @@ function commandsNamedBySkill(): string[] { return [...found]; } -/** - * The order every one of these commands can succeed in, stated rather than stumbled into. - * - * `telemetry on` first, `forget` last, `off` just before it. Between them the identity - * verbs have an order of their own: `link` refuses with `IdentityRequiredToLinkError` when - * nothing stands, so it has to come after `use`. That used to hold by accident — every - * identity command shared one rank and `link` merely happened to appear later in the file - * walk — and this test asserts exit 0 on all of them, so reordering the markdown would have - * turned it red for a reason that has nothing to do with what it guards. - * - * A stable sort keeps ties in the file walk's own order. - */ +/** `link` refuses with `IdentityRequiredToLinkError` when no identity stands, so it ranks + * after `use`; a stable sort keeps ties in the file walk's own order. */ function orderForExecution(commands: readonly string[]): string[] { const rank = (command: string): number => { if (/\bforget\b/u.test(command)) return 4; @@ -101,19 +78,16 @@ describe("E2E: 00-init calls the CLI", () => { }); it("the skill's account names both the preview and the confirmed removal", () => { - // Pinned on the exact strings, not a substring match: a skill that dropped `--yes` - // from its own account would still contain "aidd telemetry forget" and pass a looser - // check, while a person following it would never see how to actually remove anything. + // Pinned on the exact strings: an account that dropped `--yes` would still contain + // "aidd telemetry forget" and pass a substring check, showing nobody how to remove anything. const commands = commandsNamedBySkill(); expect(commands).toContain("aidd telemetry forget"); expect(commands).toContain("aidd telemetry forget --yes"); }); it("the sweep itself would fail if the skill's account named a command the CLI refuses", async () => { - // The guard's own proof, in the shape `telemetry-where-things-live.test.js`'s - // "detects a named-but-absent script" already uses: run the mechanism against text - // that names something wrong, and require it to be caught — rather than trusting that - // it would be, which is exactly the false confidence this phase exists to remove. + // The guard's own proof: run the mechanism against text naming something wrong and + // require it to be caught, rather than trusting that it would be. const { projectDir, fakeHome, cleanup } = await createTestEnv("init-skill-commands-drift"); try { const driftedCommand = "aidd telemetry forget --confirm"; // not a flag `forget` accepts diff --git a/cli/tests/e2e/telemetry-journal-gitignore.e2e.test.ts b/cli/tests/e2e/telemetry-journal-gitignore.e2e.test.ts index a7a66935b..8bdcc7428 100644 --- a/cli/tests/e2e/telemetry-journal-gitignore.e2e.test.ts +++ b/cli/tests/e2e/telemetry-journal-gitignore.e2e.test.ts @@ -3,11 +3,12 @@ import { readdir, readFile, writeFile } from "node:fs/promises"; import { resolve } from "node:path"; import { promisify } from "node:util"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; -import { environmentWithoutGitVariables } from "../../src/infrastructure/git-environment.js"; +import { environmentWithoutGitVariables } from "../../src/runtime/git/git-environment.js"; +import { REPOSITORY_ROOT } from "../helpers/repository-root.js"; import { createTestEnv, gitInit, runCliFast } from "./helpers.js"; const execFileAsync = promisify(execFile); -const REPO_ROOT = resolve(process.cwd(), ".."); +const REPO_ROOT = REPOSITORY_ROOT; const JOURNAL_HOOK = resolve(REPO_ROOT, "plugins/aidd-telemetry/hooks/journal.cjs"); const SETUP_ARGS = [ "setup", @@ -22,12 +23,6 @@ const SETUP_ARGS = [ "--yes", ] as const; -/** - * The defect this closes: `aidd setup` installing the telemetry plugin wrote exactly one - * gitignore entry, `.aidd/cache/`, and left `aidd_docs/runs/` offered to `git status` the - * moment a session journalled into it. Proven end to end, through `git status` itself, - * because reading the use-case is not the proof the task asked for. - */ describe("aidd setup never offers the run journal to a commit", () => { let projectDir: string; let fakeHome: string; diff --git a/cli/tests/e2e/telemetry-lifecycle.e2e.test.ts b/cli/tests/e2e/telemetry-lifecycle.e2e.test.ts index 2c79cca06..310c1385c 100644 --- a/cli/tests/e2e/telemetry-lifecycle.e2e.test.ts +++ b/cli/tests/e2e/telemetry-lifecycle.e2e.test.ts @@ -2,14 +2,15 @@ import { execFile, execFileSync } from "node:child_process"; import { existsSync, realpathSync } from "node:fs"; import { mkdir, mkdtemp, readdir, readFile, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; -import { dirname, join, resolve } from "node:path"; +import { dirname, join } from "node:path"; import { promisify } from "node:util"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; -import { environmentWithoutGitVariables } from "../../src/infrastructure/git-environment.js"; -import { CLI_PATH, copyFixtureTree, pathWithoutAidd } from "./helpers.js"; +import { environmentWithoutGitVariables } from "../../src/runtime/git/git-environment.js"; +import { REPOSITORY_ROOT } from "../helpers/repository-root.js"; +import { cliPath, copyFixtureTree, pathWithoutAidd } from "./helpers.js"; const execFileAsync = promisify(execFile); -const REPO_ROOT = resolve(process.cwd(), ".."); +const REPO_ROOT = REPOSITORY_ROOT; const PLUGIN = join(REPO_ROOT, "plugins", "aidd-telemetry"); const JOURNAL_HOOK = join(PLUGIN, "hooks", "journal.cjs"); const HOOK_FIXTURES = join(REPO_ROOT, "scripts", "__tests__", "fixtures"); @@ -29,12 +30,8 @@ interface Run { } /** - * The whole life of measurement on one project, in order, with nothing but node. - * - * Not a feature test: each step is only meaningful because of the one before it. Reporting - * before enabling has to answer nothing rather than fail; disabling has to stop the - * recording without erasing what was already measured; re-enabling has to resume rather - * than start over. A test per step would pass while the sequence was broken. + * Each step is only meaningful because of the one before it, so it runs as one sequence: + * a test per step would pass while the sequence was broken. */ describe("measurement, from nothing to off and back", () => { let projectDir: string; @@ -88,15 +85,11 @@ describe("measurement, from nothing to off and back", () => { } } - /** The switch moved from `telemetry-switch.cjs` to `aidd telemetry on|off` in phase 3; - * invoked the same way `measure` calls the CLI, by its own built path, so the `PATH` - * this file strips `aidd` from still proves nothing about the switch or the reader — - * only that the hooks below need neither. */ + /** Invoked by its own built path, like `measure`, so the `PATH` this file strips `aidd` + * from proves only that the hooks below need neither the switch nor the reader. */ const switchTo = (state: "on" | "off") => - run(CLI_PATH, state === "on" ? ["telemetry", "on", "--yes"] : ["telemetry", "off"]); - /** Reading is the CLI's, and only the CLI's, since the plugin's own reporter was deleted: - * one implementation answers, and this cycle exercises the one that ships. */ - const measure = (args: readonly string[]) => run(CLI_PATH, args.slice()); + run(cliPath(), state === "on" ? ["telemetry", "on", "--yes"] : ["telemetry", "off"]); + const measure = (args: readonly string[]) => run(cliPath(), args.slice()); /** One captured hook payload, retargeted at this project and session. The hook decides * the host from the payload's own shape, so nothing here tells it which tool it is. */ @@ -115,8 +108,6 @@ describe("measurement, from nothing to off and back", () => { }); } - /** A whole session as the host reports it: it starts, a skill opens, a file lands in a - * task folder, the turn ends. */ async function aSessionRuns(): Promise { await hook("claude-code-session-start", "session-start"); await hook("claude-code-post-tool-use-skill", "tool-used", { @@ -143,9 +134,8 @@ describe("measurement, from nothing to off and back", () => { return existsSync(dir) ? await readdir(dir) : []; } - /** Every line the journal holds, across every session. Counting files would miss a - * session that carries on: a run file is named for its session, so a second turn appends - * to the file the first turn opened rather than starting another. */ + /** Counting files would miss a session that carries on: a run file is named for its + * session, so a second turn appends to the file the first turn opened. */ async function journalLines(): Promise { const dir = join(projectDir, "aidd_docs", "runs"); let total = 0; @@ -156,64 +146,53 @@ describe("measurement, from nothing to off and back", () => { } it("lives the whole sequence, each step meaning what the one before it set up", async () => { - // 1. Nothing set up at all. Answering must be empty, not broken. const beforeAnything = await measure(["telemetry", "report", ...PERIOD]); expect(beforeAnything.exitCode, beforeAnything.stderr).toBe(0); expect(beforeAnything.stdout).toContain("nothing in this period"); - // Never a literal zero for what nothing measured: no config at all reads as off, the - // same as an explicit "off" would, and says so rather than reporting a bare "0". + // No config at all reads as off, the same as an explicit "off": never a bare "0" for + // what nothing measured. expect(beforeAnything.stdout).toMatch(/this project's own switch is off/u); expect(beforeAnything.stdout).not.toMatch(/\bsessions\s+0\b/u); expect(existsSync(join(projectDir, ".aidd", "config.json"))).toBe(false); - // 2. A session runs while measuring is off. The hook must write nothing at all. await aSessionRuns(); expect(await runFiles()).toEqual([]); - // 3. Allowed. expect((await switchTo("on")).exitCode).toBe(0); - // 4. A session runs. Now it is journalled. await aSessionRuns(); expect(await runFiles()).toHaveLength(1); - // 5. Read, and report. The figures carry the step the tool named and the task the - // journal recorded. const read = await measure(["telemetry", "read"]); expect(read.stdout).toContain("Claude Code: read (4 new of 4)"); const reported = await measure(["telemetry", "report", ...PERIOD]); expect(reported.stdout).toContain(SESSION_TOKENS); expect(reported.stdout).toContain("probe-echo"); - // On: no word about the switch at all — a person reading figures that are visibly - // working needs no separate sentence confirming it. + // While on, figures that visibly work need no sentence confirming the switch. expect(reported.stdout).not.toContain("measurement is off"); const byTask = await measure(["telemetry", "report", ...PERIOD, "--task", TASK]); expect(byTask.stdout).toContain(`task ${TASK}`); expect(byTask.stdout).toContain(SESSION_TOKENS); - // 6. Turned off. What was measured stays measured; only the recording stops. expect((await switchTo("off")).exitCode).toBe(0); const afterOff = await measure(["telemetry", "report", ...PERIOD]); expect(afterOff.stdout).toContain(SESSION_TOKENS); - // Off, but the period still holds real history: says the switch is off *and* still - // shows every figure already measured - neither fact stands in for the other. + // Off and holding real history: neither fact stands in for the other. expect(afterOff.stdout).toMatch(/this project's own switch is off/u); - // 7. A session runs while off. Not one line is journalled — the switch is read at the - // moment of every write, not once at startup. + // The switch is read at the moment of every write, not once at startup. const before = await journalLines(); expect(before).toBeGreaterThan(0); await aSessionRuns(); expect(await journalLines()).toBe(before); - // 8. Allowed again. Recording resumes into the journal that already exists rather than - // starting a new one, so nothing measured before is orphaned. + // Recording resumes into the journal that already exists, so nothing measured before + // is orphaned. await switchTo("on"); await aSessionRuns(); expect(await journalLines()).toBeGreaterThan(before); expect(await runFiles()).toHaveLength(1); - // 9. Reading again stores nothing twice. const second = await measure(["telemetry", "read"]); expect(second.stdout).toContain("Claude Code: read (0 new of 4)"); expect((await measure(["telemetry", "report", ...PERIOD])).stdout).toContain(SESSION_TOKENS); @@ -250,11 +229,7 @@ describe("measurement, from nothing to off and back", () => { ); // Turning measurement off changes what is recorded next, never what a past period - // answers — a consumer that cached a figure must not see any of them move. - // `measurement_enabled` is the one deliberate exception (finding 2/3, review.md "one - // route, and every sentence about it true"): it names the switch's own state right now, - // not a fact about the period, so it must move the instant the switch does, and would - // be a lie if it did not. + // answers. `measurement_enabled` is the one exception: it names the switch's state now. expect(envelope.measurement_enabled).toBe(true); expect(afterOff.measurement_enabled).toBe(false); const { measurement_enabled: _before, ...historicalFigures } = envelope; diff --git a/cli/tests/e2e/telemetry-multi-tool.e2e.test.ts b/cli/tests/e2e/telemetry-multi-tool.e2e.test.ts index bc21f56e0..59ff480b4 100644 --- a/cli/tests/e2e/telemetry-multi-tool.e2e.test.ts +++ b/cli/tests/e2e/telemetry-multi-tool.e2e.test.ts @@ -1,21 +1,15 @@ import { execFileSync } from "node:child_process"; import { realpathSync } from "node:fs"; import { chmod, cp, mkdir, readdir, readFile, writeFile } from "node:fs/promises"; -import { delimiter, join, resolve } from "node:path"; +import { delimiter, join } from "node:path"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { REPOSITORY_ROOT } from "../helpers/repository-root.js"; import { createTestEnv, gitInit, runCli, sinkDirIn } from "./helpers.js"; -/** - * Three readable tools through one report, from the files each of them actually writes. - * - * Everything here is a real capture. The Claude Code transcript and the Codex rollout come - * from `tests/fixtures/local-cost`; the hook payloads come from `scripts/__tests__/fixtures` - * and are replayed through the journal hook itself, so the writer is exercised rather than - * imitated. OpenCode is served by a stand-in `opencode` on the path answering with the - * captured export payload — the reader shells out, and an e2e must not depend on whether - * the machine running it happens to have that tool installed. - */ -const REPO_ROOT = resolve(process.cwd(), ".."); +/** Three readable tools through one report, from real captures: hook payloads replayed + * through the journal hook itself, and a stand-in `opencode` on the path, since the reader + * shells out and an e2e must not depend on the machine having that tool installed. */ +const REPO_ROOT = REPOSITORY_ROOT; const LOCAL_COST_FIXTURES = join(process.cwd(), "tests", "fixtures", "local-cost"); const HOOK_FIXTURES = join(REPO_ROOT, "scripts", "__tests__", "fixtures"); const JOURNAL_HOOK = join(REPO_ROOT, "plugins", "aidd-telemetry", "hooks", "journal.cjs"); @@ -78,17 +72,9 @@ describe("aidd telemetry, across every tool that can be read", () => { ); } - /** Answers `opencode export --sanitize` with the captured payload **for its own - * session and no other**, and nothing else with anything. A real binary would make this - * test depend on the machine it runs on. - * - * The session check is not decoration. Answering for any id at all is what a tool whose - * identifiers collided with another's would look like, and this stand-in used to do - * exactly that: every session read against it came back with OpenCode's figures, so one - * session's consumption was stored three times under three vendor ids, and this file - * asserted the tripled number under a comment claiming each tool's figures stayed its - * own. A real `opencode export` answers "session not found" for a foreign id — measured - * 2026-09-01, exit 1 — so this now does too. */ + /** Answers `opencode export --sanitize` with the captured payload for its own session + * and no other. Answering for any id at all is what colliding identifiers would look like, + * and would store one session's figures under every vendor id; a real binary exits 1. */ async function installOpencodeStandIn(): Promise { await mkdir(binDir, { recursive: true }); const standIn = join(binDir, "opencode"); @@ -156,9 +142,8 @@ describe("aidd telemetry, across every tool that can be read", () => { ); } - /** Hand-written, unlike the Claude Code one, and deliberately so: the hook stamps every - * line with the moment it runs, and the Codex rollout captured here is from July. A step - * interval that could reach it can only be constructed, never replayed. */ + /** Hand-written, unlike the Claude Code one: the hook stamps every line with the moment it + * runs, so an interval reaching the captured rollout can only be constructed. */ async function journalCodexSessionBackdated(): Promise { const runsDir = join(projectDir, "aidd_docs", "runs"); await mkdir(runsDir, { recursive: true }); @@ -209,10 +194,8 @@ describe("aidd telemetry, across every tool that can be read", () => { const out = await reportEverything(); - // Every tool reports tokens and none reports an amount: no tool's own files carry a - // dollar figure, on any reader wired today. Claude Code's cost reaches the sink only - // through its OTLP export, which this path does not use. A zero here would read as - // free, so the report says the amount is unknown for all three. + // No tool's own files carry a dollar figure on any reader wired today, and Claude Code's cost + // reaches the sink only through OTLP, so a zero here would read as free. expect(out).toMatch(/Claude Code\s+amount unknown/u); expect(out).toMatch(/Codex\s+amount unknown/u); expect(out).toMatch(/OpenCode\s+amount unknown/u); @@ -229,8 +212,8 @@ describe("aidd telemetry, across every tool that can be read", () => { const out = await reportEverything(); expect(out).toMatch(/Cursor\s+not covered — It writes no token count/u); - // Copilot is covered (#697), but no session of its own was journalled in this test - - // reading as "nothing in this period" is the correct answer, never "not covered". + // Copilot is covered, but no session of its own was journalled here, so "nothing in this + // period" is the correct answer, never "not covered". expect(out).toMatch(/GitHub Copilot\s+nothing in this period/u); expect(out).not.toMatch(/GitHub Copilot\s+not covered/u); }); @@ -303,16 +286,14 @@ describe("aidd telemetry, across every tool that can be read", () => { expect(result.exitCode, result.stderr).toBe(0); expect(result.stdout).toContain(`task ${TASK}`); - // Only Claude Code wrote into that folder; Codex's figures must not follow it there. - // "in this selection", not "in this period": --task narrows the record set before any - // breakdown runs, so Codex's zero here is the task filter's doing, not real idleness. + // Only Claude Code wrote into that folder. "In this selection", not "in this period": --task + // narrows the record set first, so Codex's zero is the filter's doing, not real idleness. expect(result.stdout).not.toContain("183,939"); expect(result.stdout).toMatch(/Codex\s+nothing in this selection/u); }); - // #686. The Claude Code transcript this reads carries a captured `` line — - // a notice the tool composed itself, not a call anyone was billed for. It used to reach - // the sink and sit in this breakdown beside the real models, as though it were one. + // The Claude Code transcript carries a captured `` line, a notice the tool composed + // itself rather than a call anyone was billed for; it must not sit beside the real models. it("breaks a real session down by model and lists only models", async () => { await journalClaudeSession(); await journalCodexSessionBackdated(); diff --git a/cli/tests/e2e/telemetry-on-runs-privacy.e2e.test.ts b/cli/tests/e2e/telemetry-on-runs-privacy.e2e.test.ts index c4668b22d..90260978d 100644 --- a/cli/tests/e2e/telemetry-on-runs-privacy.e2e.test.ts +++ b/cli/tests/e2e/telemetry-on-runs-privacy.e2e.test.ts @@ -4,18 +4,12 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { promisify } from "node:util"; import { describe, expect, it } from "vitest"; -import { environmentWithoutGitVariables } from "../../src/infrastructure/git-environment.js"; +import { environmentWithoutGitVariables } from "../../src/runtime/git/git-environment.js"; import { createTestEnv, gitInit, runCli } from "./helpers.js"; const execFileAsync = promisify(execFile); const RUNS_ENTRY = "aidd_docs/runs/"; -/** - * What `journal-privacy.cjs` did beyond flipping the switch, ported onto `aidd telemetry - * on`/`off` now that `telemetry-switch.cjs` — and the plugin suite that drove it - * (`aidd-telemetry-switch-gitignore.test.js`) — are deleted this same phase. Six - * distinguishable claims, one per test, the same split the deleted suite used. - */ async function git(args: readonly string[], cwd: string): Promise<{ stdout: string }> { return execFileAsync("git", [...args], { cwd, env: environmentWithoutGitVariables(process.env) }); } diff --git a/cli/tests/e2e/telemetry-plugin-standalone.e2e.test.ts b/cli/tests/e2e/telemetry-plugin-standalone.e2e.test.ts index e85704885..ce7b7765f 100644 --- a/cli/tests/e2e/telemetry-plugin-standalone.e2e.test.ts +++ b/cli/tests/e2e/telemetry-plugin-standalone.e2e.test.ts @@ -2,12 +2,13 @@ import { execFileSync } from "node:child_process"; import { readdirSync, readFileSync, realpathSync } from "node:fs"; import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; -import { dirname, join, resolve } from "node:path"; +import { dirname, join } from "node:path"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; -import { environmentWithoutGitVariables } from "../../src/infrastructure/git-environment.js"; +import { environmentWithoutGitVariables } from "../../src/runtime/git/git-environment.js"; +import { REPOSITORY_ROOT } from "../helpers/repository-root.js"; import { copyFixtureTree, pathWithoutAidd, runCli } from "./helpers.js"; -const REPO_ROOT = resolve(process.cwd(), ".."); +const REPO_ROOT = REPOSITORY_ROOT; const JOURNAL_HOOK = join(REPO_ROOT, "plugins", "aidd-telemetry", "hooks", "journal.cjs"); const LOCAL_COST_FIXTURES = join(process.cwd(), "tests", "fixtures", "local-cost"); const HOOK_FIXTURES = join(REPO_ROOT, "scripts", "__tests__", "fixtures"); @@ -51,12 +52,8 @@ describe("the plugin measures on its own", () => { }; } - /** What used to be `telemetry-switch.cjs on` — the switch moved behind `aidd telemetry - * on` in phase 3, so writing the file directly is what proves this section's actual - * claim (the hooks record with no CLI on the path) without depending on a binary this - * section's own environment deliberately excludes from `PATH`. The switch itself, and - * its own edge cases, are pinned in `cli/tests/e2e/telemetry.e2e.test.ts` and - * `cli/tests/e2e/telemetry-on-runs-privacy.e2e.test.ts`. */ + /** The switch is seeded by writing the file, never through `aidd telemetry on`: the claim + * here is that the hooks record with no CLI on the `PATH` this section strips. */ async function enableTelemetry(): Promise { await mkdir(join(projectDir, ".aidd"), { recursive: true }); await writeFile( @@ -87,13 +84,8 @@ describe("the plugin measures on its own", () => { }); } - // Recording, with no `aidd` anywhere. This is the half of the promise that survives the - // read path moving into the CLI, and the reason the hooks stayed plain node: a session - // measured now is readable later, by a CLI that was not installed when it ran. Answering is - // pinned separately, in telemetry-cost-skill-commands.e2e.test.ts. Turning measurement on - // moved behind `aidd telemetry on` in phase 3, so it is no longer this section's own claim - // — `enableTelemetry` seeds the switch directly, and only the journaling below runs with - // no CLI anywhere on `PATH`. + // The hooks are plain node so that a session measured now stays readable later, by a CLI + // that was not installed when it ran. it("journals a whole Claude Code session with no aidd on the path", async () => { await enableTelemetry(); await replayHook("claude-code-session-start", "session-start", {}); @@ -114,12 +106,8 @@ describe("the plugin measures on its own", () => { expect(lines.map((line) => line.type)).toContain("step_start"); }); - // The whole promise phase 6 exists to restate, proven end to end rather than in two - // halves that each assume the other: a session is journalled with `aidd` nowhere on - // this machine at all, and only afterwards is the CLI invoked to read it back. Neither - // `enableTelemetry` nor `replayHook` above ever calls it; `runCli` below is the first - // invocation of `dist/cli.js` in this test, and it runs after every write has already - // happened. + // Neither `enableTelemetry` nor `replayHook` invokes the binary: `runCli` below is this + // test's first, and it runs after every write has already happened. it("reads a session's figures complete, though the CLI did not exist when it ran", async () => { await enableTelemetry(); await replayHook("claude-code-session-start", "session-start", {}); @@ -141,22 +129,16 @@ describe("the plugin measures on its own", () => { }); await replayHook("claude-code-session-start", "turn-end", {}); - // Only now does the CLI run at all, against the exact project and home the hooks above - // wrote into with no CLI on the path and no CLI ever invoked. `read` has no session - // identifier of its own to look for — `ReadLocalCostOptions`'s own doc comment: absent - // one, it "reads every session the run journal knows about", the file just written - // with no CLI present. Finding anything at all below already answers the question; - // the `--task` assertion further down narrows to a fact the transcript itself could - // never state, and could only have come from that same journal. + // `read` takes no session identifier: absent one it reads every session the run journal + // knows about, which here is the file the hooks just wrote. const read = await runCli(["telemetry", "read"], projectDir, fakeHome, { env: { AIDD_USER_CONFIG_DIR: configDir }, }); expect(read.exitCode, read.stderr).toBe(0); expect(read.stdout).toContain("Claude Code: read"); - // The captured fixture's own transcript falls in August 2026, not whatever week this - // suite happens to run in - the same period `telemetry-lifecycle.e2e.test.ts` names for - // the identical fixture, wide enough to cover it regardless of today's date. + // The captured transcript falls in August 2026, so the period is fixed rather than + // relative to whatever week this suite runs in. const period = ["--from", "2026-08-01", "--to", "2026-08-31"]; const reported = await runCli( ["telemetry", "report", ...period, "--json"], @@ -175,19 +157,15 @@ describe("the plugin measures on its own", () => { expect(envelope.totals.input_tokens + envelope.totals.output_tokens).toBeGreaterThan(0); expect(envelope.by_step.map((row) => row.step)).toContain("probe-echo"); - // The same figures, exactly, as `telemetry-lifecycle.e2e.test.ts` pins from a session - // where the CLI was present throughout — this session's own numbers do not shrink for - // having been recorded without it. + // The same figures a session recorded with the CLI present yields: they do not shrink + // for having been measured without it. const reportedText = await runCli(["telemetry", "report", ...period], projectDir, fakeHome, { env: { AIDD_USER_CONFIG_DIR: configDir }, }); expect(reportedText.stdout).toContain(SESSION_TOKENS); - // The load-bearing assertion: task identity exists only in the journal's own - // `file_written` line (`aidd_docs/tasks/2026_08/2026_08_21_probe-task/notes.md`, - // written above with no CLI anywhere) — the transcript fixture has no notion of an - // AIDD task folder at all. `--task` narrowing to this exact figure, rather than - // "nothing in this selection", is possible only because `read` consulted that line. + // Task identity exists only in the journal's own `file_written` line: the transcript + // fixture has no notion of a task folder, so narrowing proves `read` consulted it. const byTask = await runCli( ["telemetry", "report", ...period, "--task", TASK], projectDir, diff --git a/cli/tests/e2e/telemetry-reference-week.e2e.test.ts b/cli/tests/e2e/telemetry-reference-week.e2e.test.ts index a0b14d7b8..76a729873 100644 --- a/cli/tests/e2e/telemetry-reference-week.e2e.test.ts +++ b/cli/tests/e2e/telemetry-reference-week.e2e.test.ts @@ -3,21 +3,13 @@ import { createRequire } from "node:module"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterAll, beforeAll, describe, expect, it } from "vitest"; -import { CLI_PATH } from "./helpers.js"; - -/** - * The reference week, held to the built command. - * - * Every other telemetry e2e proves one axis on data written straight into the sink. This - * one proves the axes reconcile to each other on a week produced by the shipped hook and - * read out of each tool's own session files — capture, join and analysis in one scenario. - * - * The scenario itself lives in `scripts/lib/telemetry-reference-week.cjs`, shared with - * `scripts/telemetry-reference-week.cjs`, which prints what this asserts. One builder, so - * the demo cannot drift from the test. - */ +import { REPOSITORY_ROOT } from "../helpers/repository-root.js"; +import { cliPath } from "./helpers.js"; + +// The scenario lives in `scripts/lib/telemetry-reference-week.cjs`, shared with +// `scripts/telemetry-reference-week.cjs`, so what the demo prints cannot drift from this. const week = createRequire(import.meta.url)( - "../../../scripts/lib/telemetry-reference-week.cjs" + join(REPOSITORY_ROOT, "scripts", "lib", "telemetry-reference-week.cjs") ) as ReferenceWeekModule; interface Totals { @@ -100,7 +92,7 @@ describe("the reference week", () => { beforeAll(() => { root = mkdtempSync(join(tmpdir(), "aidd-reference-week-e2e-")); - built = week.buildReferenceWeek({ root, cliPath: CLI_PATH }); + built = week.buildReferenceWeek({ root, cliPath: cliPath() }); envelope = JSON.parse(week.reportReferenceWeek(built, ["--json"])) as Envelope; }, 120_000); @@ -114,12 +106,8 @@ describe("the reference week", () => { }); it("reconciles every breakdown to that same total", () => { - // `by_tool` included, and its inclusion is the point: a session-total tool carries its - // figure in `session_totals`, a field of its own, so `totals` still sums like every - // other axis. Leaving it out — on the theory that Copilot's record could not belong to - // a request sum — would drop the one axis that catches a tool's figures being stored - // against another tool's sessions, which is exactly the defect this week's own harness - // was found to be asserting. + // `by_tool` included: a session-total tool carries its figure in `session_totals`, a field + // of its own, so `totals` still sums like every other axis. const breakdowns = [ envelope.by_day, envelope.by_flow, @@ -148,9 +136,8 @@ describe("the reference week", () => { }); it("leads with the run it can name, though more of the week fell outside every flow", () => { - // The week's own figures make this the case that matters: 4 requests outside every flow - // against 3 inside the one run. Ordered by size alone the remainder led the table, while - // `by_task` and `by_backlog` beside it led with their largest named row. + // The week's own figures: 4 requests outside every flow against 3 inside the one run, so + // ordering by size alone would put the unnamed remainder first. expect(envelope.by_flow.map((row) => row.flow)).toEqual([built.expected.flow, undefined]); }); @@ -167,19 +154,15 @@ describe("the reference week", () => { }); it("breaks the week down by task, and by the backlog item a task declared", () => { - // Distinct, because one task can hold two rows since `cost_report_version` 12 - one for - // what a declaration covered, one for what only a written file names, the same - // `(name x attribution)` shape `by_step` has always had. What this asserts is which - // tasks the week names, never how many routes named each. + // Distinct: one task can hold two rows — one for what a declaration covered, one for what + // only a written file names — so this asserts which tasks the week names, not how many. const tasks = [...new Set(envelope.by_task.map((row) => row.task).filter(Boolean))]; expect(tasks.sort()).toEqual([...built.expected.tasks].sort()); const declared = envelope.by_backlog.filter((row) => row.backlog !== undefined); expect(declared.map((row) => row.backlog)).toEqual([built.expected.backlogItem]); - // The task with a backlog link and the task without must not merge into one row. Three - // requests since version 12, not two: that session's 08:07 record precedes its own - // declaration at 08:12 by five minutes, its journal witnessed it, and it wrote into that - // one task folder and no other - so the written-file route names it too. + // Three requests, not two: that session's 08:07 record precedes its own declaration at + // 08:12, its journal witnessed it, and it wrote into that one task folder alone. expect(declared[0]?.totals.requests).toBe(3); expect(sumRequests(envelope.by_backlog)).toBe(envelope.totals.requests); }); @@ -215,9 +198,8 @@ describe("the reference week", () => { }); it("names a teammate's records as an identity it cannot resolve, never as nobody", () => { - // The report runs on Ada's machine. Bo's identifier is real and its records are here, - // but nothing local maps it to a person — so it gets its own row, named for what is - // known. Resolving people across machines is a destination's job, not this command's. + // The report runs on Ada's machine: Bo's identifier is real and his records are here, but + // nothing local maps it to a person, so it gets its own row named for what is known. const resolutions = envelope.by_person.map((row) => row.resolution); expect(resolutions).toContain("mapped"); expect(resolutions).toContain("unresolved"); @@ -236,22 +218,15 @@ describe("the reference week", () => { }); }); -/** - * The commands a person actually has to run. - * - * `report` reads the sink, and until this nothing filled the sink but `aidd telemetry read`. - * Forgetting that step was answered with "nothing in this period" — the one sentence - * indistinguishable from a week where nothing was spent. Held here against the built binary, - * on a week whose figures are known, because the whole point is what happens when a person - * runs one command instead of two. - */ +// `report` reads the sink, and a week nobody ran `aidd telemetry read` over used to answer +// "nothing in this period" — indistinguishable from a week where nothing was spent. describe("a report that needs no read first", () => { let root: string; let built: BuiltWeek; beforeAll(() => { root = mkdtempSync(join(tmpdir(), "aidd-reference-week-catchup-")); - built = week.buildReferenceWeek({ root, cliPath: CLI_PATH }); + built = week.buildReferenceWeek({ root, cliPath: cliPath() }); }, 120_000); afterAll(() => { @@ -274,24 +249,15 @@ describe("a report that needs no read first", () => { }); }); -/** - * Git exports `GIT_DIR` and its siblings into every process it spawns, so a suite run from - * inside a hook — `pre-push` running the tests, which is how this was found — inherits a - * pointer to the real repository. The builder's own `git init` then lands in a temp - * directory while `git remote add origin` operates on this repository, which already has - * one, and the whole week fails to build with `exited 3`. - * - * It passed by hand and failed from the hook, which is precisely the shape of that leak. - * Asserted here rather than left to whichever runner happens to be inside git, because a - * harness that only works outside one is a harness nobody can trust from CI either. - */ +// Git exports `GIT_DIR` into every process it spawns, so a suite run from inside a hook inherits +// a pointer to the real repository and the builder's own `git init` lands somewhere else. describe("the week builds inside a git hook's own environment", () => { it("ignores a leaked GIT_DIR rather than resolving the real repository", () => { const root = mkdtempSync(join(tmpdir(), "aidd-reference-week-gitdir-")); const saved = process.env.GIT_DIR; - process.env.GIT_DIR = join(process.cwd(), "..", ".git"); + process.env.GIT_DIR = join(REPOSITORY_ROOT, ".git"); try { - const built = week.buildReferenceWeek({ root, cliPath: CLI_PATH }); + const built = week.buildReferenceWeek({ root, cliPath: cliPath() }); const envelope = JSON.parse(week.reportReferenceWeek(built, ["--json"])) as Envelope; expect(envelope.totals.requests).toBe(built.expected.requests); diff --git a/cli/tests/e2e/telemetry-refusal.e2e.test.ts b/cli/tests/e2e/telemetry-refusal.e2e.test.ts index c1be458ce..e38079afd 100644 --- a/cli/tests/e2e/telemetry-refusal.e2e.test.ts +++ b/cli/tests/e2e/telemetry-refusal.e2e.test.ts @@ -3,19 +3,14 @@ import { readdir, readFile } from "node:fs/promises"; import { resolve } from "node:path"; import { promisify } from "node:util"; import { describe, expect, it } from "vitest"; -import { environmentWithoutGitVariables } from "../../src/infrastructure/git-environment.js"; +import { environmentWithoutGitVariables } from "../../src/runtime/git/git-environment.js"; +import { REPOSITORY_ROOT } from "../helpers/repository-root.js"; import { createTestEnv, gitInit, runCli } from "./helpers.js"; const execFileAsync = promisify(execFile); -const REPO_ROOT = resolve(process.cwd(), ".."); +const REPO_ROOT = REPOSITORY_ROOT; const JOURNAL_HOOK = resolve(REPO_ROOT, "plugins/aidd-telemetry/hooks/journal.cjs"); -/** - * Phase 1 of "one route, and every sentence about it true": a person can refuse - * measurement without touching a tracked file, the refusal wins over a project that turned - * measurement on, and turning measurement on for a whole repository needs the same explicit - * confirmation `endpoint --scope project` already demands. - */ describe("a person's own refusal, without touching a tracked file", () => { function hookEnv(fakeHome: string, extra?: Record): NodeJS.ProcessEnv { return { ...environmentWithoutGitVariables(process.env), HOME: fakeHome, ...extra }; diff --git a/cli/tests/e2e/telemetry-report.e2e.test.ts b/cli/tests/e2e/telemetry-report.e2e.test.ts index eb44e5422..a9ecc3ef4 100644 --- a/cli/tests/e2e/telemetry-report.e2e.test.ts +++ b/cli/tests/e2e/telemetry-report.e2e.test.ts @@ -3,16 +3,8 @@ import { join } from "node:path"; import { afterEach, describe, expect, it } from "vitest"; import { createTestEnv, gitInit, runCli } from "./helpers.js"; -/** - * Two records `aidd telemetry read` produced from the captured Codex rollout, seeded - * straight into the sink rather than re-read here. The read path has its own tests; this - * file's subject is the report, and going through the read would make it depend on whether - * the machine happens to have OpenCode installed — `aidd telemetry read` consults every - * declared tool, and OpenCode's reader shells out with a ten-second budget. - * - * Their moments are in July while the day file is named for a much later day. That gap is - * deliberate: it is what a period has to select through. - */ +/** Seeded straight into the sink: going through the read would depend on whether the machine + * has OpenCode installed. Their July moments sit in a day file named for a much later day. */ const CODEX_RECORDS = [ { kind: "request", @@ -52,9 +44,8 @@ const CODEX_RECORDS = [ }, ] as const; -/** Three records the filter tests narrow over: two inside the period, one on the same - * moment `CODEX_RECORDS` uses but carrying a model no in-period record ever names - known - * to a sweep of this file, but idle in every selection that stays inside the period. */ +/** Three records the filter tests narrow over: two inside the period, one carrying a model no + * in-period record names — known to a sweep of the file, idle in every in-period selection. */ const FILTER_RECORDS = [ { kind: "request", @@ -179,9 +170,7 @@ describe("aidd telemetry report", () => { expect(result.exitCode).toBe(0); // Recomputed by hand from the rollout's own `last_token_usage` increments, not from - // anything this codebase produces: turn 019fae6f contributes 8898 input (22229 minus - // its 20224 cached, per OpenAI's inclusive convention) + 827 output + 65792 cache - // reads = 75,517; turn 019fae71 contributes 5032 + 3550 + 99840 = 108,422. + // anything this codebase produces: 75,517 for one turn and 108,422 for the other. expect(result.stdout).toContain("183,939"); // Codex's own files carry no dollar figure; a zero here would read as free. expect(result.stdout).toContain("amount unknown"); diff --git a/cli/tests/e2e/telemetry-six-questions.e2e.test.ts b/cli/tests/e2e/telemetry-six-questions.e2e.test.ts index fa869f06d..74d6889ce 100644 --- a/cli/tests/e2e/telemetry-six-questions.e2e.test.ts +++ b/cli/tests/e2e/telemetry-six-questions.e2e.test.ts @@ -4,14 +4,8 @@ import { afterEach, describe, expect, it } from "vitest"; import { createTestEnv, gitInit, runCli } from "./helpers.js"; /** - * The core's own claim, held to the built command: a person asks what a period consumed - * and gets all six answers - total, by model, by framework task, by skill, by person and - * by project - each reconciling to the same total. - * - * One session, journalled with two declared tasks back to back plus a record before - * either was declared, so `by_task` has something real to break down: a named row per - * task, and the row for what fell in no declared interval, summing back to the total - * exactly like every other axis. + * One session, journalled with two declared tasks back to back plus a record predating + * both, so `by_task` has a named row per task and a row for what fell in no interval. */ const RUN_ID = "01ARZ3NDEKTSV4RRFFQ69G5FAV"; const VENDOR_ID = "22222222-2222-4222-8222-222222222222"; @@ -150,11 +144,9 @@ describe("aidd telemetry report — the six questions, over one period", () => { expect(result.exitCode).toBe(0); const envelope = JSON.parse(result.stdout) as Envelope; - // The total: what the whole period cost. expect(envelope.totals.requests).toBe(3); expect(envelope.totals.cost_micro_usd).toBe(7_000_000); - // Every breakdown sums back to the same total - the report's whole claim. for (const rows of [ envelope.by_model, envelope.by_task, @@ -166,8 +158,6 @@ describe("aidd telemetry report — the six questions, over one period", () => { expect(sumCost(rows)).toBe(envelope.totals.cost_micro_usd); } - // by_task specifically: one row per declared task, plus the remainder - the sixth - // question, the one a `--task` filter alone could never answer. const taskNames = envelope.by_task.map((row) => row.task); expect(taskNames).toContain(ALPHA_TASK); expect(taskNames).toContain(BETA_TASK); @@ -201,8 +191,8 @@ describe("aidd telemetry report — the six questions, over one period", () => { expect(result.exitCode).toBe(0); expect(result.stdout).toContain(ALPHA_TASK); expect(result.stdout).toContain(BETA_TASK); - // BEFORE_ANY_DECLARATION's own moment (08:30) is earlier than alpha's declaration - // (09:00) - one of the three named reasons, never the generic label it replaced. + // BEFORE_ANY_DECLARATION's moment (08:30) precedes alpha's declaration (09:00), which + // is one of the three named reasons a row carries no task. expect(result.stdout).toContain("before the next task this session declares"); }); }); diff --git a/cli/tests/e2e/telemetry-stored-export-record.e2e.test.ts b/cli/tests/e2e/telemetry-stored-export-record.e2e.test.ts index 31e842adf..815ca2cab 100644 --- a/cli/tests/e2e/telemetry-stored-export-record.e2e.test.ts +++ b/cli/tests/e2e/telemetry-stored-export-record.e2e.test.ts @@ -4,19 +4,8 @@ import { describe, expect, it } from "vitest"; import { createTestEnv, gitInit, runCli } from "./helpers.js"; /** - * Phase 2 of "one route, and every sentence about it true" - * (aidd_docs/tasks/2026_08/2026_08_28_one-route-that-is-true/): the export route's writer — - * the OTLP receiver, the export config reader, and the mapper that turned an exported - * payload into a stored record — is deleted. Its reader is not: a record an earlier version - * of this tool already wrote to someone's real sink, with `provenance: "export"`, must stay - * readable, countable, and reportable exactly as before. Removing a way of writing never - * removes a way of reading. - * - * This record is not hand-invented: it is the real shape the deleted production mapper - * produced from a captured Claude Code OTLP payload (see - * `tests/domain/models/cost-report.unit.test.ts`'s "one billed call, seen by both routes" - * describe block, which carries the same record and proves the double-count rule that - * needs it still holds). + * A record an earlier version of this tool wrote with `provenance: "export"` must stay + * readable, countable and reportable: the writer is gone, the reader is not. */ const SINK_DAY_FILE = "2026-08-18.jsonl"; const WORK_DAY = "2026-08-18"; @@ -124,9 +113,8 @@ describe("a record the removed export route already wrote stays readable", () => expect(result.exitCode, result.stderr).toBe(0); const envelope = JSON.parse(result.stdout); - // One billed call, seen by both routes, counts once — the third double-count rule, - // still holding with an export-provenance record read from disk rather than exported - // live. + // One billed call, seen by both routes, counts once — holding for an export-provenance + // record read from disk. expect(envelope.totals.requests).toBe(1); expect(envelope.totals.input_tokens).toBe(2); expect(envelope.totals.cost_micro_usd).toBe(13220); diff --git a/cli/tests/e2e/telemetry-task-midsession.e2e.test.ts b/cli/tests/e2e/telemetry-task-midsession.e2e.test.ts index 4f5b20fdc..a6ea6ab27 100644 --- a/cli/tests/e2e/telemetry-task-midsession.e2e.test.ts +++ b/cli/tests/e2e/telemetry-task-midsession.e2e.test.ts @@ -4,16 +4,8 @@ import { afterEach, describe, expect, it } from "vitest"; import { createTestEnv, gitInit, runCli } from "./helpers.js"; /** - * The ordinary state of a session still running - not a crash - and the fault it used to - * cause: `task-attribution.ts` used to close an unclosed declared interval at its own start, - * `[t, t)`, whenever nothing had yet named a `turn_end`. A declaration is exactly that kind - * of unclosed interval for as long as the session it belongs to keeps working, so every - * record after it was silently lost from `by_task` until this session's own journal - * happened to record a `turn_end` or another declaration. - * - * `RUN_ID`/`ALPHA_VENDOR_ID` never end their journal with a `turn_end`: the session this - * file's happy path reads from is still running when the report is asked for, exactly the - * state that used to lose every record after `TASK_DECLARED_AT`. + * The journals here never end with a `turn_end`: the session the happy path reads from is + * still running when the report is asked for, an unclosed declared interval throughout. */ const RUN_ID = "01ARZ3NDEKTSV4RRFFQ69G5FAX"; const ALPHA_VENDOR_ID = "33333333-3333-4333-8333-333333333333"; @@ -67,10 +59,8 @@ const SILENT_JOURNAL_LINES = [ }, ]; -/** A third session that declares a task and writes into TWO task folders. Two candidates - * and no reason to choose, so the written-file route infers nothing here - which is what - * keeps a record before this session's own declaration reading `precedes-declaration`, and - * proves the refusal bound end to end rather than only in a unit test. */ +/** A third session that declares a task and writes into TWO task folders: two candidates + * and no reason to choose, so the written-file route infers nothing here. */ const AMBIGUOUS_JOURNAL_LINES = [ { type: "session_start", @@ -117,8 +107,8 @@ const BEFORE_DECLARATION = record({ event_timestamp: "2026-02-10T09:05:00Z", cost_usd: 1, }); -// After the declaration, before the write - the record this bug used to lose. No -// `turn_end` has been written anywhere in this session's journal at this point. +// After the declaration, before the write. No `turn_end` has been written anywhere in +// this session's journal at this point. const DURING_ALPHA_NO_TURN_END = record({ vendor_id: ALPHA_VENDOR_ID, turn_id: "during", @@ -230,8 +220,7 @@ describe("aidd telemetry report — a task declared while the work is still goin const envelope = JSON.parse(result.stdout) as Envelope; // DURING_ALPHA_NO_TURN_END: after the declaration, before the write, no turn_end - // anywhere in this session's journal - the exact record the pre-fix `[t, t)` interval - // used to lose. + // anywhere in this session's journal. const alphaRow = taskRowOfCost(envelope, 2); expect(alphaRow?.task).toBe(ALPHA_TASK); @@ -250,10 +239,8 @@ describe("aidd telemetry report — a task declared while the work is still goin expect(taskRowOfCost(afterClose, 2)?.task).toBe(ALPHA_TASK); }); - // The record at 09:05 precedes its session's declaration, but that session wrote into one - // task folder and nothing else, and its journal witnessed 09:05 - so the written-file - // route names it, marked `inferred`. `precedes-declaration` is proven on the ambiguous - // session instead, where two written folders refuse that route. + // The 09:05 record precedes its session's declaration, but that session wrote into exactly + // one task folder, so the written-file route names it, marked `inferred`. it("names each unattributed reason distinctly, never collapsing two into one", async () => { const { projectDir, fakeHome } = await seed(); diff --git a/cli/tests/e2e/telemetry.e2e.test.ts b/cli/tests/e2e/telemetry.e2e.test.ts index 583dd12b7..5e807ef76 100644 --- a/cli/tests/e2e/telemetry.e2e.test.ts +++ b/cli/tests/e2e/telemetry.e2e.test.ts @@ -13,13 +13,13 @@ async function seedManifest(projectDir: string): Promise { await mkdir(join(projectDir, AIDD_DIR), { recursive: true }); await writeFile( join(projectDir, AIDD_DIR, "manifest.json"), - JSON.stringify({ version: 5, tools: {}, marketplaces: {} }), + JSON.stringify({ version: 8, tools: {}, marketplaces: {} }), "utf-8" ); } async function installClaude(projectDir: string, fakeHome: string): Promise { - const result = await runCli(["ai", "install", "claude"], projectDir, fakeHome); + const result = await runCli(["framework", "install", "--tool", "claude"], projectDir, fakeHome); expect(result.exitCode).toBe(0); } @@ -49,7 +49,11 @@ describe.concurrent("E2E: aidd telemetry on/off — the switch alone", () => { await gitInit(projectDir); await seedManifest(projectDir); await installClaude(projectDir, fakeHome); - const cursorInstall = await runCli(["ai", "install", "cursor"], projectDir, fakeHome); + const cursorInstall = await runCli( + ["framework", "install", "--tool", "cursor"], + projectDir, + fakeHome + ); expect(cursorInstall.exitCode).toBe(0); // Never turned on: `telemetry off` must leave every tool's config untouched. diff --git a/cli/tests/e2e/update-check.e2e.test.ts b/cli/tests/e2e/update-check.e2e.test.ts index 5d4711861..2f5fb33ff 100644 --- a/cli/tests/e2e/update-check.e2e.test.ts +++ b/cli/tests/e2e/update-check.e2e.test.ts @@ -3,12 +3,12 @@ import { existsSync, readFileSync } from "node:fs"; import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; import { createServer, type Server } from "node:http"; import { tmpdir } from "node:os"; -import { dirname, join, resolve } from "node:path"; +import { dirname, join } from "node:path"; import { promisify } from "node:util"; import { describe, expect, it } from "vitest"; +import { cliPath } from "./helpers.js"; const execFileAsync = promisify(execFile); -const CLI_PATH = resolve(process.cwd(), "dist/cli.js"); const FAKE_TAG = "v999.0.0"; // current CLI is 4.6.x → always outdated against this interface FakeRelease { @@ -18,9 +18,8 @@ interface FakeRelease { } /** - * Local stand-in for both upstreams the updater queries: the npm registry - * (source of truth for the version) and GitHub (best-effort changelog). - * Counts every hit. + * Local stand-in for both upstreams the updater queries: npm (the version) and GitHub + * (best-effort changelog). Counts every hit. */ function startFakeRelease(tag: string): Promise { let hits = 0; @@ -64,7 +63,7 @@ async function setupEnv(prefix: string): Promise { await mkdir(join(projectDir, ".aidd"), { recursive: true }); await writeFile( join(projectDir, ".aidd", "manifest.json"), - JSON.stringify({ version: 5, tools: {}, marketplaces: {} }), + JSON.stringify({ version: 8, tools: {} }), "utf-8" ); const server = await startFakeRelease(FAKE_TAG); @@ -95,7 +94,7 @@ async function runCli( env: Record ): Promise<{ stdout: string; stderr: string; exitCode: number }> { try { - const { stdout, stderr } = await execFileAsync("node", [CLI_PATH, ...args], { + const { stdout, stderr } = await execFileAsync("node", [cliPath(), ...args], { cwd, env: { ...process.env, ...env }, }); @@ -110,9 +109,9 @@ describe("E2E: update-check piggyback", () => { it("hot path is read-only and offline: cold offline command makes no request and writes no cache", async () => { const t = await setupEnv("cold"); try { - await runCli(["status"], t.projectDir, t.env); + await runCli(["doctor"], t.projectDir, t.env); - // `status` is not an online command → no piggyback refresh. + // `doctor` is not an online command → no piggyback refresh. expect(t.server.hits()).toBe(0); expect(existsSync(t.cachePath)).toBe(false); } finally { @@ -130,7 +129,9 @@ describe("E2E: update-check piggyback", () => { "utf-8" ); - const { stderr } = await runCli(["status"], t.projectDir, t.env); + // `update` is excluded from the preAction nag (it resolves the latest version + // itself), so any other command exercises the generic cache-only path. + const { stderr } = await runCli(["doctor"], t.projectDir, t.env); expect(stderr).toContain("CLI update available"); expect(t.server.hits()).toBe(0); // hot path never touches the network @@ -142,9 +143,9 @@ describe("E2E: update-check piggyback", () => { it("online command refreshes the cache via postAction (the regression guard)", async () => { const t = await setupEnv("refresh"); try { - // Cold cache. `update` IS an online command → postAction must fetch + persist - // BEFORE the process exits. The old fire-and-forget design left this file absent. - const { exitCode } = await runCli(["update"], t.projectDir, t.env); + // Cold cache, and `marketplace list` IS an online command → postAction must fetch and + // persist BEFORE the process exits. + const { exitCode } = await runCli(["marketplace", "list"], t.projectDir, t.env); expect(exitCode).toBe(0); expect(existsSync(t.cachePath)).toBe(true); @@ -157,9 +158,8 @@ describe("E2E: update-check piggyback", () => { }); it("still reads a cache written before it moved, rather than refetching every install at once", async () => { - // The file moved into `cache/`. Nothing rewrites the old one, but reading it is what - // keeps the move from costing every existing install a network call the first time it - // runs an online command. + // Nothing rewrites the old path, but reading it keeps the move from costing every + // existing install a network call on its first online command. const t = await setupEnv("legacy"); try { await mkdir(dirname(t.legacyCachePath), { recursive: true }); @@ -169,9 +169,9 @@ describe("E2E: update-check piggyback", () => { "utf-8" ); - const { stderr } = await runCli(["status"], t.projectDir, t.env); + const { stderr } = await runCli(["doctor"], t.projectDir, t.env); - // `status` is offline: the notice can only have come from the file on disk. + // `doctor` is offline: the notice can only have come from the file on disk. expect(t.server.hits()).toBe(0); expect(stderr).toContain("99.0.0"); } finally { diff --git a/cli/tests/e2e/update-force-conflict.e2e.test.ts b/cli/tests/e2e/update-force-conflict.e2e.test.ts index 9a473a39e..fd7c25bca 100644 --- a/cli/tests/e2e/update-force-conflict.e2e.test.ts +++ b/cli/tests/e2e/update-force-conflict.e2e.test.ts @@ -9,17 +9,17 @@ async function seedProject(projectDir: string): Promise { await mkdir(join(projectDir, AIDD_DIR), { recursive: true }); await writeFile( join(projectDir, AIDD_DIR, "manifest.json"), - JSON.stringify({ version: 5, tools: {}, marketplaces: {} }), + JSON.stringify({ version: 8, tools: {} }), "utf-8" ); } async function installClaude(projectDir: string, fakeHome: string): Promise { - await runCli(["ai", "install", "claude"], projectDir, fakeHome); + await runCli(["framework", "install", "--tool", "claude"], projectDir, fakeHome); } async function installVscode(projectDir: string, fakeHome: string): Promise { - await runCli(["ide", "install", "vscode"], projectDir, fakeHome); + await runCli(["framework", "install", "--tool", "vscode"], projectDir, fakeHome); } async function modifyFirstTrackedFile(projectDir: string, toolId: string): Promise { @@ -41,55 +41,10 @@ async function modifyTrackedFile(projectDir: string): Promise { return modifyFirstTrackedFile(projectDir, "claude"); } +// Bare `aidd update` is self-update and touches no tracked file, so the project-wide sweep +// under test is `framework update` without `--tool`. describe.concurrent("E2E: update conflict guard", () => { - describe("aidd update (top-level)", () => { - it("exits 1 when a tracked file is modified in non-TTY mode (no --force)", async () => { - const { projectDir, fakeHome, cleanup } = await createTestEnv("update-guard-all-exit1"); - try { - await seedProject(projectDir); - await installClaude(projectDir, fakeHome); - await modifyTrackedFile(projectDir); - - const { exitCode, stderr } = await runCli(["update"], projectDir, fakeHome); - - expect(exitCode).toBe(1); - expect(stderr.toLowerCase()).toMatch(/force|non-interactive/); - } finally { - await cleanup(); - } - }); - - it("exits 0 with --force when a tracked file is modified", async () => { - const { projectDir, fakeHome, cleanup } = await createTestEnv("update-guard-all-force"); - try { - await seedProject(projectDir); - await installClaude(projectDir, fakeHome); - await modifyTrackedFile(projectDir); - - const { exitCode } = await runCli(["update", "--force"], projectDir, fakeHome); - - expect(exitCode).toBe(0); - } finally { - await cleanup(); - } - }); - - it("exits 0 when all files are unmodified (no prompt, no --force needed)", async () => { - const { projectDir, fakeHome, cleanup } = await createTestEnv("update-guard-all-unmod"); - try { - await seedProject(projectDir); - await installClaude(projectDir, fakeHome); - - const { exitCode } = await runCli(["update"], projectDir, fakeHome); - - expect(exitCode).toBe(0); - } finally { - await cleanup(); - } - }); - }); - - describe("aidd ai update", () => { + describe("aidd framework update --tool claude", () => { it("exits 1 when a tracked AI tool file is modified in non-TTY mode (no --force)", async () => { const { projectDir, fakeHome, cleanup } = await createTestEnv("update-guard-ai-exit1"); try { @@ -97,7 +52,11 @@ describe.concurrent("E2E: update conflict guard", () => { await installClaude(projectDir, fakeHome); await modifyTrackedFile(projectDir); - const { exitCode, stderr } = await runCli(["ai", "update"], projectDir, fakeHome); + const { exitCode, stderr } = await runCli( + ["framework", "update", "--tool", "claude"], + projectDir, + fakeHome + ); expect(exitCode).toBe(1); expect(stderr.toLowerCase()).toMatch(/force|non-interactive/); @@ -113,7 +72,11 @@ describe.concurrent("E2E: update conflict guard", () => { await installClaude(projectDir, fakeHome); await modifyTrackedFile(projectDir); - const { exitCode } = await runCli(["ai", "update", "--force"], projectDir, fakeHome); + const { exitCode } = await runCli( + ["framework", "update", "--tool", "claude", "--force"], + projectDir, + fakeHome + ); expect(exitCode).toBe(0); } finally { @@ -127,7 +90,11 @@ describe.concurrent("E2E: update conflict guard", () => { await seedProject(projectDir); await installClaude(projectDir, fakeHome); - const { exitCode } = await runCli(["ai", "update"], projectDir, fakeHome); + const { exitCode } = await runCli( + ["framework", "update", "--tool", "claude"], + projectDir, + fakeHome + ); expect(exitCode).toBe(0); } finally { @@ -136,7 +103,7 @@ describe.concurrent("E2E: update conflict guard", () => { }); }); - describe("aidd ide update", () => { + describe("aidd framework update --tool vscode", () => { it("exits 1 when a tracked IDE tool file is modified in non-TTY mode (no --force)", async () => { const { projectDir, fakeHome, cleanup } = await createTestEnv("update-guard-ide-exit1"); try { @@ -144,7 +111,11 @@ describe.concurrent("E2E: update conflict guard", () => { await installVscode(projectDir, fakeHome); await modifyFirstTrackedFile(projectDir, "vscode"); - const { exitCode, stderr } = await runCli(["ide", "update"], projectDir, fakeHome); + const { exitCode, stderr } = await runCli( + ["framework", "update", "--tool", "vscode"], + projectDir, + fakeHome + ); expect(exitCode).toBe(1); expect(stderr.toLowerCase()).toMatch(/force|non-interactive/); @@ -160,7 +131,11 @@ describe.concurrent("E2E: update conflict guard", () => { await installVscode(projectDir, fakeHome); await modifyFirstTrackedFile(projectDir, "vscode"); - const { exitCode } = await runCli(["ide", "update", "--force"], projectDir, fakeHome); + const { exitCode } = await runCli( + ["framework", "update", "--tool", "vscode", "--force"], + projectDir, + fakeHome + ); expect(exitCode).toBe(0); } finally { @@ -174,7 +149,11 @@ describe.concurrent("E2E: update conflict guard", () => { await seedProject(projectDir); await installVscode(projectDir, fakeHome); - const { exitCode } = await runCli(["ide", "update"], projectDir, fakeHome); + const { exitCode } = await runCli( + ["framework", "update", "--tool", "vscode"], + projectDir, + fakeHome + ); expect(exitCode).toBe(0); } finally { diff --git a/cli/tests/e2e/update-global.e2e.test.ts b/cli/tests/e2e/update-global.e2e.test.ts index 4127f39cb..7f09bab8b 100644 --- a/cli/tests/e2e/update-global.e2e.test.ts +++ b/cli/tests/e2e/update-global.e2e.test.ts @@ -10,19 +10,19 @@ async function seedProject(projectDir: string): Promise { await mkdir(join(projectDir, AIDD_DIR), { recursive: true }); await writeFile( join(projectDir, AIDD_DIR, "manifest.json"), - JSON.stringify({ version: 5, tools: {}, marketplaces: {} }), + JSON.stringify({ version: 8, tools: {} }), "utf-8" ); } -describe.concurrent("E2E: aidd update", () => { +describe.concurrent("E2E: aidd framework update", () => { it("reports all tools up to date when no tools have drift", async () => { const { projectDir, fakeHome, cleanup } = await createTestEnv("update-noop"); try { await seedProject(projectDir); - await runCli(["ai", "install", "claude"], projectDir, fakeHome); + await runCli(["framework", "install", "--tool", "claude"], projectDir, fakeHome); - const { stdout, exitCode } = await runCli(["update"], projectDir, fakeHome); + const { stdout, exitCode } = await runCli(["framework", "update"], projectDir, fakeHome); expect(exitCode).toBe(0); expect(stdout.toLowerCase()).toMatch(/up to date|updated/); @@ -35,13 +35,12 @@ describe.concurrent("E2E: aidd update", () => { const { projectDir, fakeHome, cleanup } = await createTestEnv("update-force"); try { await seedProject(projectDir); - await runCli(["ai", "install", "claude"], projectDir, fakeHome); + await runCli(["framework", "install", "--tool", "claude"], projectDir, fakeHome); - const { stdout, exitCode } = await runCli(["update"], projectDir, fakeHome); + const { stdout, exitCode } = await runCli(["framework", "update"], projectDir, fakeHome); expect(exitCode).toBe(0); expect(stdout.toLowerCase()).toMatch(/updated|up to date/); - // manifest still exists after update expect(existsSync(join(projectDir, AIDD_DIR, "manifest.json"))).toBe(true); } finally { await cleanup(); @@ -51,11 +50,10 @@ describe.concurrent("E2E: aidd update", () => { it("exits zero when no manifest exists (no tools installed)", async () => { const { projectDir, fakeHome, cleanup } = await createTestEnv("update-empty"); try { - const { stdout, exitCode } = await runCli(["update"], projectDir, fakeHome); + const { stdout, exitCode } = await runCli(["framework", "update"], projectDir, fakeHome); - // update exits 0 and reports no tools expect(exitCode).toBe(0); - expect(stdout.toLowerCase()).toMatch(/up to date|no manifest|nothing/); + expect(stdout.toLowerCase()).toMatch(/up to date|no manifest|no tools|nothing/); } finally { await cleanup(); } @@ -65,13 +63,12 @@ describe.concurrent("E2E: aidd update", () => { const { projectDir, fakeHome, cleanup } = await createTestEnv("update-multi"); try { await seedProject(projectDir); - await runCli(["ai", "install", "claude"], projectDir, fakeHome); - await runCli(["ai", "install", "cursor"], projectDir, fakeHome); + await runCli(["framework", "install", "--tool", "claude"], projectDir, fakeHome); + await runCli(["framework", "install", "--tool", "cursor"], projectDir, fakeHome); - const { stdout, exitCode } = await runCli(["update"], projectDir, fakeHome); + const { stdout, exitCode } = await runCli(["framework", "update"], projectDir, fakeHome); expect(exitCode).toBe(0); - // Both tools should be mentioned expect(stdout).toContain("claude"); expect(stdout).toContain("cursor"); } finally { diff --git a/cli/tests/fixtures/manifests/full.json b/cli/tests/fixtures/manifests/full.json new file mode 100644 index 000000000..cd555c5b8 --- /dev/null +++ b/cli/tests/fixtures/manifests/full.json @@ -0,0 +1,69 @@ +{ + "version": 8, + "tools": { + "claude": { + "toolId": "claude", + "version": "1.2.3", + "files": [ + { + "relativePath": ".claude/CLAUDE.md", + "hash": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "frameworkPath": "framework/claude/CLAUDE.md" + }, + { + "relativePath": ".claude/settings.json", + "hash": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + } + ], + "mergeFiles": [ + { + "relativePath": ".claude/settings.json", + "sectionKey": "mcpServers", + "entries": { + "aidd-server": "eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee" + } + } + ], + "excludedMcp": [ + { + "configPath": ".claude/settings.json", + "entryKey": "old-server" + } + ], + "plugins": [ + { + "name": "aidd-dev", + "source": { + "kind": "github", + "repo": "ai-driven-dev/aidd-dev", + "ref": "main" + }, + "version": "1.0.0", + "strict": true, + "files": { + "commands/foo.md": "11111111111111111111111111111111" + }, + "scope": "project", + "componentPaths": { + "commands/foo.md": "commands/foo.md" + }, + "mcpEntries": { + "aidd-server": "22222222222222222222222222222222" + }, + "marketplace": "aidd-framework" + } + ] + }, + "cursor": { + "toolId": "cursor", + "version": "4.5.6", + "files": [ + { + "relativePath": ".cursor/rules/naming.mdc", + "hash": "cccccccccccccccccccccccccccccccc" + } + ], + "mergeFiles": [] + } + } +} diff --git a/cli/tests/fixtures/manifests/golden-real.json b/cli/tests/fixtures/manifests/golden-real.json new file mode 100644 index 000000000..02d8165e6 --- /dev/null +++ b/cli/tests/fixtures/manifests/golden-real.json @@ -0,0 +1,30 @@ +{ + "version": 8, + "tools": { + "claude": { + "toolId": "claude", + "version": "1.0.0", + "files": [ + { + "relativePath": ".claude/settings.json", + "hash": "267dba9c9c9dbe2190d91ae84213c77b" + } + ], + "mergeFiles": [], + "plugins": [ + { + "name": "aidd-test", + "source": { + "kind": "local", + "path": "/fixture/plugins/aidd-test" + }, + "version": "1.0.0", + "strict": false, + "files": {}, + "scope": "project", + "marketplace": "aidd-framework" + } + ] + } + } +} diff --git a/cli/tests/fixtures/manifests/mcp-exclusions.json b/cli/tests/fixtures/manifests/mcp-exclusions.json new file mode 100644 index 000000000..b7d002942 --- /dev/null +++ b/cli/tests/fixtures/manifests/mcp-exclusions.json @@ -0,0 +1,21 @@ +{ + "version": 8, + "tools": { + "claude": { + "toolId": "claude", + "version": "1.0.0", + "files": [], + "mergeFiles": [], + "excludedMcp": [ + { + "configPath": ".claude/settings.json", + "entryKey": "old-server" + }, + { + "configPath": ".claude/settings.json", + "entryKey": "legacy-server" + } + ] + } + } +} diff --git a/cli/tests/fixtures/manifests/merge-files.json b/cli/tests/fixtures/manifests/merge-files.json new file mode 100644 index 000000000..b15db5a8f --- /dev/null +++ b/cli/tests/fixtures/manifests/merge-files.json @@ -0,0 +1,31 @@ +{ + "version": 8, + "tools": { + "claude": { + "toolId": "claude", + "version": "1.0.0", + "files": [ + { + "relativePath": ".claude/settings.json", + "hash": "dddddddddddddddddddddddddddddddd" + } + ], + "mergeFiles": [ + { + "relativePath": ".claude/settings.json", + "sectionKey": "mcpServers", + "entries": { + "aidd-server": "eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee" + } + }, + { + "relativePath": ".claude/config.json", + "sectionKey": null, + "entries": { + "top": "ffffffffffffffffffffffffffffffff" + } + } + ] + } + } +} diff --git a/cli/tests/fixtures/manifests/multi-tool.json b/cli/tests/fixtures/manifests/multi-tool.json new file mode 100644 index 000000000..084a711a8 --- /dev/null +++ b/cli/tests/fixtures/manifests/multi-tool.json @@ -0,0 +1,32 @@ +{ + "version": 8, + "tools": { + "claude": { + "toolId": "claude", + "version": "1.2.3", + "files": [ + { + "relativePath": ".claude/CLAUDE.md", + "hash": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "frameworkPath": "framework/claude/CLAUDE.md" + }, + { + "relativePath": ".claude/settings.json", + "hash": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + } + ], + "mergeFiles": [] + }, + "cursor": { + "toolId": "cursor", + "version": "4.5.6", + "files": [ + { + "relativePath": ".cursor/rules/naming.mdc", + "hash": "cccccccccccccccccccccccccccccccc" + } + ], + "mergeFiles": [] + } + } +} diff --git a/cli/tests/fixtures/manifests/plugins.json b/cli/tests/fixtures/manifests/plugins.json new file mode 100644 index 000000000..444a94933 --- /dev/null +++ b/cli/tests/fixtures/manifests/plugins.json @@ -0,0 +1,44 @@ +{ + "version": 8, + "tools": { + "claude": { + "toolId": "claude", + "version": "1.0.0", + "files": [], + "mergeFiles": [], + "plugins": [ + { + "name": "aidd-dev", + "source": { + "kind": "local", + "path": "/fixtures/plugins/aidd-dev" + }, + "version": "1.0.0", + "strict": true, + "files": { + "commands/foo.md": "11111111111111111111111111111111" + }, + "scope": "project", + "componentPaths": { + "commands/foo.md": "commands/foo.md" + }, + "mcpEntries": { + "aidd-server": "22222222222222222222222222222222" + }, + "marketplace": "aidd-framework" + }, + { + "name": "aidd-vcs", + "source": { + "kind": "npm", + "package": "aidd-vcs-plugin" + }, + "version": "2.0.0", + "strict": false, + "files": {}, + "scope": "project" + } + ] + } + } +} diff --git a/cli/tests/golden/framework-build-golden.e2e.test.ts b/cli/tests/golden/framework-build-golden.e2e.test.ts index 424d665e9..1c9da1fd9 100644 --- a/cli/tests/golden/framework-build-golden.e2e.test.ts +++ b/cli/tests/golden/framework-build-golden.e2e.test.ts @@ -1,33 +1,15 @@ /** - * Framework build golden — machine-independent output snapshot for all targets and modes. - * - * Captures the file tree hash map from `framework build --target [--flat]` against - * tests/fixtures/framework-real and compares byte-for-byte against the stored - * baseline in snapshots/framework-build/golden.json. - * - * The stored JSON maps key → { relative-path → SHA-256 hex }. Key format: - * "" for marketplace mode, ":flat" for flat mode. - * All values are derived from file content only (no absolute paths, no timestamps). - * This makes the snapshot machine-independent. - * - * FROZEN CELLS (marketplace baseline, never regenerate casually): - * claude — re-baselined once in the agents-manifest-fix pass (see below), still frozen since. - * RE-BASELINED CELLS (flat-discovery-fix pass: bare paths, no plugin segment): - * claude:flat, cursor:flat, copilot:flat, codex:flat, opencode:flat - * RE-BASELINED CELLS (agents-manifest-fix pass: `agents` is now a list of - * ./agents/*.md file paths instead of the invalid `["./agents"]` dir form): - * claude, cursor, copilot (marketplace) - * - * USAGE: - * Capture all: UPDATE_FRAMEWORK_GOLDEN=1 pnpm test:e2e tests/golden/framework-build-golden.e2e.test.ts - * Verify: pnpm test:e2e tests/golden/framework-build-golden.e2e.test.ts + * Keyed `` or `:flat` → { relative-path → SHA-256 }, so it holds on any + * machine. Recapture with UPDATE_FRAMEWORK_GOLDEN=1; re-baselining a cell is deliberate. */ import { createHash } from "node:crypto"; +import { readFileSync } from "node:fs"; import { mkdir, readdir, readFile, writeFile } from "node:fs/promises"; import { join, resolve } from "node:path"; import { fileURLToPath } from "node:url"; import { describe, expect, it } from "vitest"; +import { AI_TOOL_IDS } from "../../src/kernel/tool.js"; import { createTestEnv, runCli } from "../e2e/helpers.js"; const ROOT = resolve(fileURLToPath(import.meta.url), "../../.."); @@ -37,32 +19,44 @@ const SNAPSHOT_FILE = join(ROOT, "tests/golden/snapshots/framework-build/golden. type TargetSnapshot = Record; // rel-path → sha256 type GoldenSnapshot = Record; // key → files -/** All marketplace targets */ const MARKETPLACE_TARGETS = ["copilot", "codex", "claude", "cursor"] as const; -/** All flat targets (including opencode which is flat-only) */ const FLAT_TARGETS = ["claude", "cursor", "copilot", "codex", "opencode"] as const; -/** - * Frozen marketplace cell: its fresh build is byte-compared to the stored hash on - * every run. Only claude is frozen — cursor/codex/copilot were re-baselined in the - * plugin-root-token-rewrite pass (${CLAUDE_PLUGIN_ROOT} → tool-native token), and - * copilot:flat in the flat-discovery-fix pass. claude itself was re-baselined once - * in the agents-manifest-fix pass (agents → ./agents/*.md file list) and is frozen - * at that value since. - */ -const FROZEN_CELLS = new Set(["claude"]); +const FROZEN_CELLS = new Set([ + ...MARKETPLACE_TARGETS, + ...FLAT_TARGETS.map((target) => `${target}:flat`), +]); -// This repo carries no .gitattributes, so a Windows checkout's core.autocrlf converts -// every text file's LF to CRLF on write to disk (#707) - hashing those raw bytes would -// diff on line endings alone against the LF-committed stored baseline. Fold CRLF -> LF -// before hashing; skip anything that doesn't round-trip through UTF-8 (this tree's -// outputs are all .md/.json/.yml/.js today) so a future binary asset isn't corrupted. +// A Windows checkout's core.autocrlf writes CRLF, which would diff against the LF-committed +// baseline on line endings alone; content that is not UTF-8 is hashed raw instead. function normalizeLineEndings(content: Buffer): Buffer { const text = content.toString("utf-8"); if (Buffer.byteLength(text, "utf-8") !== content.length) return content; return Buffer.from(text.replace(/\r\n/g, "\n"), "utf-8"); } +function canonical(snapshot: TargetSnapshot): string { + return JSON.stringify( + Object.keys(snapshot) + .sort() + .map((key) => [key, snapshot[key]]) + ); +} + +function describeDrift(stored: TargetSnapshot, captured: TargetSnapshot): string { + const keys = new Set([...Object.keys(stored), ...Object.keys(captured)]); + const moved = [...keys] + .filter((k) => stored[k] !== captured[k]) + .sort() + .slice(0, 6) + .map((k) => { + if (stored[k] === undefined) return `+${k}`; + if (captured[k] === undefined) return `-${k}`; + return `~${k}`; + }); + return moved.join(", "); +} + async function hashDirectory(dir: string): Promise { const result: TargetSnapshot = {}; const entries = await readdir(dir, { recursive: true }); @@ -89,22 +83,11 @@ async function captureTarget( const key = flat ? `${target}:flat` : target; const outDir = join(tempDir, `dist-${key.replace(":", "-")}`); await mkdir(outDir, { recursive: true }); - const args = [ - "framework", - "build", - "--source", - FRAMEWORK_FIXTURE, - "--target", - target, - "--out", - outDir, - ]; - if (flat) args.push("--flat"); + const args = ["translate", FRAMEWORK_FIXTURE, "--to", target, "--out", outDir]; + if (flat) args.push("--as", "flat"); const result = await runCli(args, projectDir, fakeHome); if (result.exitCode !== 0) { - throw new Error( - `framework build --target ${target}${flat ? " --flat" : ""} failed: ${result.stderr}` - ); + throw new Error(`translate --to ${target}${flat ? " --as flat" : ""} failed: ${result.stderr}`); } return hashDirectory(outDir); } @@ -125,10 +108,8 @@ async function captureAllCells( } describe.concurrent("Framework build golden — 9-cell matrix", () => { - // Two full 9-cell builds, concurrently with the other tests in this file - on a real - // windows-latest runner this measured at 60039ms and 60096ms, just over the 60s default, - // not a hang (#707 windows-probe, attempt 3, run 32596840364). Raised per-test rather - // than the e2e project's global testTimeout so every other e2e file's budget is unchanged. + // Two full 9-cell builds measured just past the 60s default on a Windows runner, not a + // hang. Raised per test so no other e2e file's budget moves. it("snapshot is deterministic (two captures of each target are byte-identical)", async () => { const env1 = await createTestEnv("fb-golden-det-1"); const env2 = await createTestEnv("fb-golden-det-2"); @@ -173,9 +154,7 @@ describe.concurrent("Framework build golden — 9-cell matrix", () => { } }, 120_000); - // Same reason as above: a full 9-cell build, concurrently with the other tests in this - // file, measured at 60096ms on a real windows-latest runner - just over the 60s default. - it("stored golden baseline covers all 9 cells and the frozen claude cell is byte-identical (AC #1)", async () => { + it("every one of the 9 cells is byte-identical to its stored baseline", async () => { const { tempDir, projectDir, fakeHome, cleanup } = await createTestEnv("fb-golden-baseline"); try { const captured = await captureAllCells(projectDir, fakeHome, tempDir); @@ -189,32 +168,39 @@ describe.concurrent("Framework build golden — 9-cell matrix", () => { const stored = JSON.parse(await readFile(SNAPSHOT_FILE, "utf-8")) as GoldenSnapshot; - // Assert all 9 cells exist in stored const expectedCells = [...MARKETPLACE_TARGETS, ...FLAT_TARGETS.map((t) => `${t}:flat`)]; for (const key of expectedCells) { expect(stored[key], `stored snapshot missing cell: ${key}`).toBeDefined(); expect(Object.keys(stored[key]).length, `cell ${key} must have files`).toBeGreaterThan(0); } - // Assert the frozen cell(s) are byte-identical to the stored baseline + // Every mismatching cell at once, compared as sorted entries: key order follows the + // platform's directory listing, the fact under test is which files exist and hash to what. + const drifted = [...FROZEN_CELLS].filter( + (key) => canonical(captured[key] ?? {}) !== canonical(stored[key] ?? {}) + ); + const detail = drifted + .map((key) => `${key}: ${describeDrift(stored[key] ?? {}, captured[key] ?? {})}`) + .join("\n"); + expect(drifted, `cells differing from their stored baseline\n${detail}`).toEqual([]); for (const key of FROZEN_CELLS) { - const capturedCell = captured[key]; - const storedCell = stored[key]; - expect( - capturedCell, - `cell ${key}: output differs from stored pre-change baseline` - ).toStrictEqual(storedCell); + expect(captured[key], `cell ${key}`).toStrictEqual(stored[key]); } } finally { await cleanup(); } }, 120_000); - it("all 9 cells are non-empty", async () => { - const stored = JSON.parse(await readFile(SNAPSHOT_FILE, "utf-8")) as GoldenSnapshot; + it("the matrix covers every tool the CLI builds for, and nothing else", () => { + const matrixTools = new Set([...MARKETPLACE_TARGETS, ...FLAT_TARGETS]); + for (const id of AI_TOOL_IDS) { + expect(matrixTools.has(id), `${id} is a registered AI tool with no golden cell`).toBe(true); + } + + const stored = JSON.parse(readFileSync(SNAPSHOT_FILE, "utf-8")) as GoldenSnapshot; const expectedCells = [...MARKETPLACE_TARGETS, ...FLAT_TARGETS.map((t) => `${t}:flat`)]; + expect(Object.keys(stored).sort()).toEqual([...expectedCells].sort()); for (const key of expectedCells) { - expect(stored[key], `missing cell: ${key}`).toBeDefined(); expect(Object.keys(stored[key]).length, `cell ${key} must have files`).toBeGreaterThan(0); } }); diff --git a/cli/tests/golden/golden-baseline.e2e.test.ts b/cli/tests/golden/golden-baseline.e2e.test.ts index b25597603..8464fd23b 100644 --- a/cli/tests/golden/golden-baseline.e2e.test.ts +++ b/cli/tests/golden/golden-baseline.e2e.test.ts @@ -1,15 +1,5 @@ -/** - * P1 Golden Baseline — behavior snapshot for the core command matrix. - * - * Each public CLI command is exercised against a hermetic fixture project. - * The captured snapshot (stdout, stderr, exitCode, filesWritten, manifest) - * is normalized (abs-paths → , version strings → ) then - * compared byte-for-byte against the stored baseline in snapshots/phase0/. - * - * USAGE: - * Capture: UPDATE_GOLDEN=1 pnpm test:e2e --reporter=verbose tests/golden/golden-baseline.e2e.test.ts - * Verify: pnpm test:e2e tests/golden/golden-baseline.e2e.test.ts - */ +/** Nothing here reaches the network or a prompt, so a capture never depends on a remote + * repository, a rate limit or a TTY. `translate` and `--help` have goldens of their own. */ import { createHash } from "node:crypto"; import { mkdir, readFile, writeFile } from "node:fs/promises"; @@ -22,10 +12,6 @@ const ROOT = resolve(fileURLToPath(import.meta.url), "../../.."); const FRAMEWORK_FIXTURE = join(ROOT, "tests/fixtures/framework"); const SNAPSHOT_FILE = join(ROOT, "tests/golden/snapshots/phase0/snapshot.json"); -// --------------------------------------------------------------------------- -// Types -// --------------------------------------------------------------------------- - interface CommandEntry { command: string; exitCode: number; @@ -33,26 +19,21 @@ interface CommandEntry { stderr: string; filesWritten: string[]; manifest: unknown; + /** The shared machine-scope tree lives under the sandbox's fake $HOME, never under + * `projectDir`, so `filesWritten` cannot see it at all. */ + userConfigFiles?: string[]; + /** `userConfigFiles` names `manifest.json` by filename alone, never what it holds, so a + * `--scope user` run registering the wrong tool there would pass this snapshot unnoticed. */ + userManifest?: unknown; } -// --------------------------------------------------------------------------- -// Normalization -// --------------------------------------------------------------------------- - -/** - * Replace non-deterministic tokens so two captures of the same run are - * byte-identical regardless of machine, home dir, version, or timestamp. - */ +/** Two captures of the same run must be byte-identical whatever the machine, home + * directory, version or timestamp. */ function normalize(text: string): string { return ( text - // A Windows capture spells its separator "\" and carries a drive letter neither - // placeholder rule expects - fold both into the same drive-less, "/"-only shape a - // POSIX capture already has, so one set of rules covers a run from either platform. - // The lookbehind keeps a URL's scheme colon ("https:") alone - only a colon not - // preceded by a letter is a drive. Only ever called on a raw string (see - // normalizeManifest below for why the manifest is walked before, not after, - // JSON.stringify) so there is no JSON escaping here to corrupt. + // Folds a Windows capture into the drive-less, "/"-only shape a POSIX one already has. + // The lookbehind spares a URL's scheme colon: only a colon not preceded by a letter is a drive. .replace(/(?") .replace(/\/[^\s",'\\]+\/tests\/fixtures\/framework/g, "") .replace(/\/[^\s",'\\]+\/aidd\/cli/g, "") - // Version strings like 4.5.0 or 4.10.2 in manifest / stdout - .replace(/\b\d+\.\d+\.\d+\b/g, "") - // Windows line endings + // The sandbox's `$HOME` is a fresh per-run temp directory that an `unanswerable` + // registration message names, so unnormalized it alone makes two captures differ. + .replace(/\/[^\s",'\\]+\/aidd-e2e-[^/\s",'\\]+\/home\b/g, "") + // `(?` cell, while both still refuse to clip "5.2.2" out of "15.2.2". + .replace(/(?") .replace(/\r\n/g, "\n") ); } -// Walks the parsed manifest and normalizes each string value directly, rather than -// normalizing after JSON.stringify - a Windows path's "\" is a single character in a real -// string but two once JSON-escapes it, and a blanket text replace big enough to fold both -// forms would just as readily mangle every other escape (`\"`, `\n`) the same encoding -// produces, corrupting content `JSON.parse` would then fail on rather than merely miscompare. +// Normalizes each parsed string, never the JSON text: a replace wide enough to fold both a +// real "\" and its escaped pair would mangle every other escape (`\"`, `\n`) the same way. function normalizeManifest(value: unknown): unknown { if (typeof value === "string") return normalize(value); if (Array.isArray(value)) return value.map(normalizeManifest); @@ -94,6 +75,11 @@ function normalizeEntry(entry: CommandEntry): CommandEntry { stderr: normalize(entry.stderr), filesWritten: entry.filesWritten.map(normalize).sort(), manifest: entry.manifest === null ? null : normalizeManifest(entry.manifest), + userConfigFiles: entry.userConfigFiles?.map(normalize).sort(), + userManifest: + entry.userManifest === null || entry.userManifest === undefined + ? entry.userManifest + : normalizeManifest(entry.userManifest), }; } @@ -101,10 +87,6 @@ function normalizeSnapshot(entries: CommandEntry[]): CommandEntry[] { return entries.map(normalizeEntry); } -// --------------------------------------------------------------------------- -// Capture helpers -// --------------------------------------------------------------------------- - async function readManifest(projectDir: string): Promise { const manifestPath = join(projectDir, ".aidd", "manifest.json"); try { @@ -115,13 +97,20 @@ async function readManifest(projectDir: string): Promise { } } -/** - * Recompute manifest file hashes over normalized content so the snapshot is - * machine-independent. The production code hashes raw file bytes (which may - * contain an absolute path like extraKnownMarketplaces). We replace each hash - * with MD5(normalize(fileContent)) so CI and local machines produce the same - * hex digest. - */ +/** Read directly, unlike the project manifest: a `--scope user` install records an empty + * file list per tool, so no per-file hash needs recomputing to stay machine-independent. */ +async function readUserManifest(fakeHome: string): Promise { + const manifestPath = join(fakeHome, ".config", "aidd", "manifest.json"); + try { + const raw = await readFile(manifestPath, "utf-8"); + return JSON.parse(raw); + } catch { + return null; + } +} + +/** Production hashes raw bytes, which can hold an absolute path; rehashing over normalized + * content is what makes CI and a local machine produce the same digest. */ async function normalizeManifestHashes(manifest: unknown, projectDir: string): Promise { if (manifest === null || typeof manifest !== "object") return manifest; @@ -161,23 +150,18 @@ async function recomputeFileHashes(files: unknown, projectDir: string): Promise< ); } -// A .json file's raw bytes escape a real path separator as two literal backslash -// characters, never one - un-escape that pairing before the shared normalize() below, -// which expects a genuine single "\" the same way a parsed string (or raw stdout/stderr) -// already has it. Any other file's bytes are not JSON-encoded, so a literal "\" in them -// already is one; normalize() alone is correct as-is. This only ever un-escapes a real -// path separator correctly if the .json file's own string values carry no other escape -// (`\"`, `\n`, ...) - true of every settings.json this matrix writes today (path, repo, -// and plugin-name values only, per marketplace-entry.ts), not a general JSON un-escaper. +// A .json file's bytes escape a path separator as two backslashes, so un-escape before +// normalize(). Correct only while these files carry no other escape (`\"`, `\n`, ...). function normalizeFileContent(content: string, relativePath: string): string { return normalize(relativePath.endsWith(".json") ? content.replace(/\\\\/g, "\\") : content); } -/** Run a command and return a single CommandEntry (raw, not normalized). */ +/** Returns a raw entry, not a normalized one. */ async function captureCommand( args: string[], projectDir: string, - fakeHome: string + fakeHome: string, + options?: { captureUserConfig?: boolean } ): Promise { const before = await listFiles(projectDir); const { stdout, stderr, exitCode } = await runCli(args, projectDir, fakeHome); @@ -185,6 +169,10 @@ async function captureCommand( const filesWritten = after.filter((f) => !before.includes(f)).sort(); const rawManifest = await readManifest(projectDir); const manifest = await normalizeManifestHashes(rawManifest, projectDir); + const userConfigFiles = options?.captureUserConfig + ? await listFiles(join(fakeHome, ".config", "aidd")) + : undefined; + const userManifest = options?.captureUserConfig ? await readUserManifest(fakeHome) : undefined; return { command: args.join(" "), @@ -193,6 +181,8 @@ async function captureCommand( stderr, filesWritten, manifest, + userConfigFiles, + userManifest, }; } @@ -221,59 +211,172 @@ async function collectFiles( } } -// --------------------------------------------------------------------------- -// Command matrix -// --------------------------------------------------------------------------- +/** Not a command, so it produces no entry: its effect shows in what follows. */ +async function drift(projectDir: string, relativePath: string): Promise { + await writeFile(join(projectDir, relativePath), "{}\n", "utf-8"); +} +/** One project, state accumulating in order: `clean --force` is terminal, so nothing may + * follow it but the post-clean read. */ async function captureMatrix(projectDir: string, fakeHome: string): Promise { const entries: CommandEntry[] = []; + const capture = async ( + args: string[], + options?: { captureUserConfig?: boolean } + ): Promise => { + entries.push(await captureCommand(args, projectDir, fakeHome, options)); + }; + + await capture([ + "setup", + "--source", + "local", + "--path", + FRAMEWORK_FIXTURE, + "--ai", + "claude", + "--plugins", + "none", + "--yes", + ]); + + // `filesWritten` must read `[]` here: a `--scope user` run registers machine-wide and + // writes nothing under the project. `captureUserConfig` shows the other half. + await capture( + [ + "setup", + "--source", + "local", + "--path", + FRAMEWORK_FIXTURE, + "--ai", + "claude", + "--plugins", + "none", + "--yes", + "--scope", + "user", + ], + { captureUserConfig: true } + ); + await capture(["doctor", "--scope", "user"]); + + await capture(["doctor"]); + await capture(["marketplace", "list"]); + await capture(["plugin", "list"]); + + // The fixture serves aidd-test from a local path, so this stays offline. + await capture(["plugin", "install", "aidd-test"]); + await capture(["plugin", "list"]); + + await capture(["framework", "install", "--tool", "cursor", "--force"]); + await capture(["doctor"]); + + await drift(projectDir, join(".claude", "settings.json")); + await capture(["doctor"]); + + await capture(["sync", "--force"]); + await capture(["doctor"]); + + await capture(["plugin", "remove", "aidd-test"]); + + // Only this step lists userConfigDir(), where what a project-scope `clean` leaves behind + // survives on record: the built cache, the marketplaces.json entry, the decremented reference. + await capture(["clean", "--force"], { captureUserConfig: true }); + await capture(["doctor"]); + + return entries; +} + +/** A project of its own: a plain `setup` registers `aidd-framework` at user scope too, so + * purging it inside the main matrix would take the entry every later step depends on. */ +async function captureUserScopeClean( + projectDir: string, + fakeHome: string +): Promise { + const entries: CommandEntry[] = []; + const capture = async ( + args: string[], + options?: { captureUserConfig?: boolean } + ): Promise => { + const entry = await captureCommand(args, projectDir, fakeHome, options); + entries.push({ ...entry, command: `[user-scope-clean] ${entry.command}` }); + }; - // 1. setup — initialize from local fixture, claude only, no plugins - entries.push( - await captureCommand( - [ - "setup", - "--source", - "local", - "--path", - FRAMEWORK_FIXTURE, - "--ai", - "claude", - "--plugins", - "none", - "--yes", - ], - projectDir, - fakeHome - ) + await capture( + [ + "setup", + "--source", + "local", + "--path", + FRAMEWORK_FIXTURE, + "--ai", + "claude", + "--plugins", + "none", + "--yes", + "--scope", + "user", + ], + { captureUserConfig: true } ); + await capture(["clean", "--scope", "user", "--force"], { captureUserConfig: true }); + await capture(["doctor", "--scope", "user"]); - // 2. status — after fresh setup, everything should be in sync - entries.push(await captureCommand(["status"], projectDir, fakeHome)); + return entries; +} + +/** A project of their own because `clean --force` ends the main one. Each entry is prefixed + * so both scenarios share one snapshot file. */ +async function captureErrors(projectDir: string, fakeHome: string): Promise { + const entries: CommandEntry[] = []; + const capture = async (args: string[]): Promise => { + const entry = await captureCommand(args, projectDir, fakeHome); + entries.push({ ...entry, command: `[errors] ${entry.command}` }); + }; - // 3. restore --force — no-op since nothing modified - entries.push(await captureCommand(["restore", "--force"], projectDir, fakeHome)); + // A directory that was never set up. + await capture(["doctor"]); + await capture(["plugin", "list"]); - // 4. clean --force — removes all AIDD files - entries.push(await captureCommand(["clean", "--force"], projectDir, fakeHome)); + await capture(["plugin", "install", "does-not-exist"]); + await capture(["framework", "install", "--tool", "not-a-tool"]); + await capture(["definitely-not-a-command"]); - // 5. status after clean — warns about missing manifest - entries.push(await captureCommand(["status"], projectDir, fakeHome)); + await capture([ + "marketplace", + "add", + "malformed", + join(FRAMEWORK_FIXTURE, "marketplace-malformed"), + ]); return entries; } -// --------------------------------------------------------------------------- -// Test -// --------------------------------------------------------------------------- +async function captureAll(projectDir: string, fakeHome: string): Promise { + const main = await captureMatrix(projectDir, fakeHome); + const errorEnv = await createTestEnv("golden-errors"); + const userScopeCleanEnv = await createTestEnv("golden-user-scope-clean"); + try { + const errors = await captureErrors(errorEnv.projectDir, errorEnv.fakeHome); + const userScopeClean = await captureUserScopeClean( + userScopeCleanEnv.projectDir, + userScopeCleanEnv.fakeHome + ); + return [...main, ...errors, ...userScopeClean]; + } finally { + await errorEnv.cleanup(); + await userScopeCleanEnv.cleanup(); + } +} describe.concurrent("Golden baseline — command matrix", () => { it("snapshot is deterministic (two captures are byte-identical)", async () => { const env1 = await createTestEnv("golden-det-1"); const env2 = await createTestEnv("golden-det-2"); try { - const capture1 = normalizeSnapshot(await captureMatrix(env1.projectDir, env1.fakeHome)); - const capture2 = normalizeSnapshot(await captureMatrix(env2.projectDir, env2.fakeHome)); + const capture1 = normalizeSnapshot(await captureAll(env1.projectDir, env1.fakeHome)); + const capture2 = normalizeSnapshot(await captureAll(env2.projectDir, env2.fakeHome)); expect(JSON.stringify(capture1, null, 2)).toStrictEqual(JSON.stringify(capture2, null, 2)); } finally { await env1.cleanup(); @@ -284,7 +387,7 @@ describe.concurrent("Golden baseline — command matrix", () => { it("snapshot matches stored baseline (behavior-preserving gate)", async () => { const { projectDir, fakeHome, cleanup } = await createTestEnv("golden-baseline"); try { - const captured = normalizeSnapshot(await captureMatrix(projectDir, fakeHome)); + const captured = normalizeSnapshot(await captureAll(projectDir, fakeHome)); if (process.env.UPDATE_GOLDEN === "1") { await mkdir(join(ROOT, "tests/golden/snapshots/phase0"), { recursive: true }); diff --git a/cli/tests/golden/help-surface.e2e.test.ts b/cli/tests/golden/help-surface.e2e.test.ts new file mode 100644 index 000000000..b23079908 --- /dev/null +++ b/cli/tests/golden/help-surface.e2e.test.ts @@ -0,0 +1,91 @@ +// Capture the snapshot by running this file with `UPDATE_HELP_GOLDEN=1`. + +import { mkdir, readFile, writeFile } from "node:fs/promises"; +import { dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { describe, expect, it } from "vitest"; +import { createTestEnv, runCli } from "../e2e/helpers.js"; + +const ROOT = resolve(fileURLToPath(import.meta.url), "../../.."); +const SNAPSHOT_FILE = join(ROOT, "tests/golden/snapshots/help/surface.json"); + +/** One node of the command tree: how it is invoked, and what its help prints. */ +interface HelpEntry { + invocation: string; + exitCode: number; + help: string; +} + +/** Strip what differs between machines and releases: the version in the root help, and the + * absolute paths in default-value hints. */ +function normalize(text: string): string { + return text + .replace(/\b\d+\.\d+\.\d+(-[0-9A-Za-z.-]+)?\b/g, "") + .replace(/\/[^\s"',]+\/aidd-e2e-[^\s"',]*/g, "") + .replace(/\/Users\/[^\s"',/]+/g, "") + .replace(/\r\n/g, "\n") + .trimEnd(); +} + +/** Commander wraps a long description past the name column, so only lines at the first entry's + * exact indentation are commands — reading a wrapped word as one hangs on the interactive menu. */ +function subcommandsOf(help: string): string[] { + const lines = help.split("\n"); + const start = lines.findIndex((line) => line.trim() === "Commands:"); + if (start === -1) return []; + + const body = lines.slice(start + 1); + const first = body.find((line) => line.trim() !== ""); + if (first === undefined) return []; + const indent = first.length - first.trimStart().length; + + const names: string[] = []; + for (const line of body) { + if (line.trim() === "") break; + if (line.length - line.trimStart().length !== indent) continue; + const match = /^\s+([a-z][a-z-]*)(?:\||\s|$)/.exec(line); + if (match && match[1] !== "help") names.push(match[1]); + } + return names; +} + +/** Depth-first walk of the command tree, capturing each node's help. */ +async function captureTree(cwd: string, fakeHome: string): Promise { + const entries: HelpEntry[] = []; + + const visit = async (path: string[]): Promise => { + const args = [...path, "--help"]; + const { stdout, stderr, exitCode } = await runCli(args, cwd, fakeHome); + const help = normalize(stdout || stderr); + entries.push({ invocation: ["aidd", ...path].join(" "), exitCode, help }); + + for (const child of subcommandsOf(help)) await visit([...path, child]); + }; + + await visit([]); + return entries.sort((a, b) => a.invocation.localeCompare(b.invocation)); +} + +describe("help surface", () => { + it("matches the stored command tree", async () => { + const { projectDir, fakeHome, cleanup } = await createTestEnv("help-surface"); + try { + const captured = await captureTree(projectDir, fakeHome); + + if (process.env.UPDATE_HELP_GOLDEN === "1") { + await mkdir(dirname(SNAPSHOT_FILE), { recursive: true }); + await writeFile(SNAPSHOT_FILE, `${JSON.stringify(captured, null, 2)}\n`, "utf-8"); + return; + } + + const stored = JSON.parse(await readFile(SNAPSHOT_FILE, "utf-8")) as HelpEntry[]; + expect(captured.map((e) => e.invocation)).toEqual(stored.map((e) => e.invocation)); + for (const entry of captured) { + const match = stored.find((s) => s.invocation === entry.invocation); + expect(entry, `help changed for \`${entry.invocation}\``).toEqual(match); + } + } finally { + await cleanup(); + } + }, 120000); +}); diff --git a/cli/tests/golden/snapshots/framework-build/golden.json b/cli/tests/golden/snapshots/framework-build/golden.json index c354e85f5..c21e1f80a 100644 --- a/cli/tests/golden/snapshots/framework-build/golden.json +++ b/cli/tests/golden/snapshots/framework-build/golden.json @@ -1748,7 +1748,8 @@ ".opencode/skills/aidd-async-dev/01-setup/actions/skills/03-generate-workflow.md": "11f7ec6c03284d0524179f71337691301a6362cf77bf3aa666fe41686b4df40b", ".opencode/skills/aidd-async-dev/01-setup/actions/skills/04-write-config.md": "eb7ecb812e8bdaaeba2e56c71c77bf8c14fce0a2c44311ff7985444617635dd5", ".opencode/skills/aidd-async-dev/01-setup/actions/skills/05-bootstrap-labels.md": "f53177ce1c58767f1bdfcfa3e72f7d4cc5e3d4fd782c35c3998815317be108b1", - ".opencode/plugin/update_memory.js": "140d7db788452f5f4c32316d522f595a36e06638b19a42d32e42a1a7324b7149", + ".opencode/plugin/aidd-context-hooks.js": "7c38ebdedacd85321f3f475769ef814ba355271254a14e9b44cdcd684467ab71", + ".opencode/hooks/aidd-context/update_memory.js": "140d7db788452f5f4c32316d522f595a36e06638b19a42d32e42a1a7324b7149", ".opencode/agents/aidd-async-dev-async-orchestrator.md": "3eb709fb7da8f6df7d4d76c7c69fce0a00b596d7b9522b5dcf3898c7a94d91cf", ".opencode/agents/aidd-dev-implementer.md": "4b4fe709e0ed56b097b697a49f6adfc200cccaad36a0e978a12e4f062a3a5e38", ".opencode/agents/aidd-dev-planner.md": "c83648c34068b6fa762fdc6b073e31cb3e9dfe5bd5bf2fd7e2cb4ad3e865feee", diff --git a/cli/tests/golden/snapshots/help/surface.json b/cli/tests/golden/snapshots/help/surface.json new file mode 100644 index 000000000..0123934dd --- /dev/null +++ b/cli/tests/golden/snapshots/help/surface.json @@ -0,0 +1,202 @@ +[ + { + "invocation": "aidd", + "exitCode": 0, + "help": "Usage: aidd [options] [command]\n\nGenerate AI coding assistant configurations from the AIDD framework\n\nOptions:\n -V, --version Show version number\n --verbose Show detailed diagnostic output (default: false)\n -h, --help display help for command\n\nCommands:\n setup [options] Set up or update the project to a correct state\n — bootstraps the whole project (marketplace,\n framework, tools, plugins); see `framework\n install`, which acts on the framework alone\n framework Manage the framework's lifecycle on installed\n tools\n translate [options] Convert an arbitrary source into a target-native\n plugin tree — records nothing (see `sync` for\n the manifest-driven, tracked version)\n plugin Manage plugins for AI tools\n marketplace Manage plugin marketplaces\n auth Manage authentication\n sync [options] [files...] Rewrite owned files from what is already there —\n regenerate tracked files, driven by the manifest\n (see `translate`, which converts a source\n without recording anything)\n update|upgrade [options] Update the aidd CLI itself to the latest version\n doctor [options] Detected and equipped tools, plugins, drift, and\n problems — across all tools or one\n clean [options] Remove all AIDD-managed files from the project —\n retires every part of AIDD; see `framework\n remove`, which removes the framework only\n telemetry Control whether AIDD may measure this project\n help [command] display help for command" + }, + { + "invocation": "aidd auth", + "exitCode": 0, + "help": "Usage: aidd auth [options] [command]\n\nManage authentication\n\nOptions:\n -h, --help display help for command\n\nCommands:\n login [options] Authenticate with GitHub\n logout Remove stored authentication\n status Show authentication status" + }, + { + "invocation": "aidd auth login", + "exitCode": 0, + "help": "Usage: aidd auth login [options]\n\nAuthenticate with GitHub\n\nOptions:\n --gh Use GitHub CLI token (default: false)\n --token Personal access token\n --level Storage level (user or project)\n -h, --help display help for command" + }, + { + "invocation": "aidd auth logout", + "exitCode": 0, + "help": "Usage: aidd auth logout [options]\n\nRemove stored authentication\n\nOptions:\n -h, --help display help for command" + }, + { + "invocation": "aidd auth status", + "exitCode": 0, + "help": "Usage: aidd auth status [options]\n\nShow authentication status\n\nOptions:\n -h, --help display help for command" + }, + { + "invocation": "aidd clean", + "exitCode": 0, + "help": "Usage: aidd clean [options]\n\nRemove all AIDD-managed files from the project — retires every part of AIDD; see\n`framework remove`, which removes the framework only\n\nOptions:\n --force Confirm file removal (skip dry-run) (default: false)\n --scope project (default) cleans this project alone; user undoes the\n machine-wide registration setup --scope user wrote and purges\n the shared source itself\n -h, --help display help for command" + }, + { + "invocation": "aidd doctor", + "exitCode": 0, + "help": "Usage: aidd doctor [options]\n\nDetected and equipped tools, plugins, drift, and problems — across all tools or\none\n\nOptions:\n --tool Limit to a specific AI or IDE tool\n --plugin Limit plugin checks to a specific plugin\n --scope project (default) checks this project's own manifest; user\n checks the machine-wide manifest --scope user setup wrote\n -h, --help display help for command" + }, + { + "invocation": "aidd framework", + "exitCode": 0, + "help": "Usage: aidd framework [options] [command]\n\nManage the framework's lifecycle on installed tools\n\nOptions:\n -h, --help display help for command\n\nCommands:\n install [options] Install a tool's runtime configuration from bundled assets\n — acts on the framework alone (see `setup`, which\n bootstraps the whole project)\n remove [options] Remove a tool's generated configuration files — removes the\n framework only (see `clean`, which removes all of AIDD)\n update [options] Re-install tool configs from bundled CLI assets, moving to\n a new version (all installed tools if --tool is omitted;\n see `marketplace refresh`, which re-fetches catalogs\n instead)\n rules [options] List the rules installed in this project, across every AI\n tool\n help [command] display help for command" + }, + { + "invocation": "aidd framework install", + "exitCode": 0, + "help": "Usage: aidd framework install [options]\n\nInstall a tool's runtime configuration from bundled assets — acts on the\nframework alone (see `setup`, which bootstraps the whole project)\n\nOptions:\n --tool AI or IDE tool ID\n -f, --force Overwrite already-installed tool (default: false)\n --no-plugins Skip propagation of already-installed plugins onto the new tool\n -h, --help display help for command" + }, + { + "invocation": "aidd framework remove", + "exitCode": 0, + "help": "Usage: aidd framework remove [options]\n\nRemove a tool's generated configuration files — removes the framework only (see\n`clean`, which removes all of AIDD)\n\nOptions:\n --tool AI or IDE tool ID\n -h, --help display help for command" + }, + { + "invocation": "aidd framework rules", + "exitCode": 0, + "help": "Usage: aidd framework rules [options]\n\nList the rules installed in this project, across every AI tool\n\nOptions:\n --json Print the inventory as JSON\n -h, --help display help for command" + }, + { + "invocation": "aidd framework update", + "exitCode": 0, + "help": "Usage: aidd framework update [options]\n\nRe-install tool configs from bundled CLI assets, moving to a new version (all\ninstalled tools if --tool is omitted; see `marketplace refresh`, which\nre-fetches catalogs instead)\n\nOptions:\n --tool Limit update to a specific AI or IDE tool\n -f, --force Overwrite modified files without prompting (default: false)\n -h, --help display help for command" + }, + { + "invocation": "aidd marketplace", + "exitCode": 0, + "help": "Usage: aidd marketplace [options] [command]\n\nManage plugin marketplaces\n\nOptions:\n -h, --help display help for command\n\nCommands:\n add [options] [name] [source] Register a plugin marketplace\n list [options] List registered plugin marketplaces\n remove [options] Remove a registered plugin marketplace\n refresh [options] [name] Refresh registered marketplaces — re-fetches\n catalogs; see `framework update`, which moves\n installed tools to a new version instead\n check Report stale marketplaces and upstream-removed\n plugins" + }, + { + "invocation": "aidd marketplace add", + "exitCode": 0, + "help": "Usage: aidd marketplace add [options] [name] [source]\n\nRegister a plugin marketplace\n\nOptions:\n --scope Registration scope (default: project) (default:\n \"project\")\n --yes Skip the trust + cleanup prompts\n --overwrite Replace an existing marketplace with the same name\n --token Auth token (host detected from source URL at fetch\n time)\n -h, --help display help for command" + }, + { + "invocation": "aidd marketplace check", + "exitCode": 0, + "help": "Usage: aidd marketplace check [options]\n\nReport stale marketplaces and upstream-removed plugins\n\nOptions:\n -h, --help display help for command" + }, + { + "invocation": "aidd marketplace list", + "exitCode": 0, + "help": "Usage: aidd marketplace list [options]\n\nList registered plugin marketplaces\n\nOptions:\n --plugins Also fetch and print all plugins from each marketplace catalog\n -h, --help display help for command" + }, + { + "invocation": "aidd marketplace refresh", + "exitCode": 0, + "help": "Usage: aidd marketplace refresh [options] [name]\n\nRefresh registered marketplaces — re-fetches catalogs; see `framework update`,\nwhich moves installed tools to a new version instead\n\nOptions:\n --force Clear cache before re-fetching\n -h, --help display help for command" + }, + { + "invocation": "aidd marketplace remove", + "exitCode": 0, + "help": "Usage: aidd marketplace remove [options] \n\nRemove a registered plugin marketplace\n\nOptions:\n --yes Skip the orphan-cleanup prompt\n -h, --help display help for command" + }, + { + "invocation": "aidd plugin", + "exitCode": 0, + "help": "Usage: aidd plugin [options] [command]\n\nManage plugins for AI tools\n\nOptions:\n -h, --help display help for command\n\nCommands:\n remove [options] Remove a plugin from one or all AI tools\n list [options] List installed plugins for one or all AI tools\n install [options] [plugin] Install a plugin (marketplace name, local path, or\n interactive pick)\n search [options] Search registered marketplaces for plugins\n update [options] [name] Update one or all plugins for one or all AI tools" + }, + { + "invocation": "aidd plugin install", + "exitCode": 0, + "help": "Usage: aidd plugin install [options] [plugin]\n\nInstall a plugin (marketplace name, local path, or interactive pick)\n\nOptions:\n --from Marketplace name (when multiple match)\n --tool Target AI tool (default: all installed)\n --token Auth token (host detected from source URL at fetch\n time)\n --scope Install scope; must match the tool's supported scope\n --yes Auto-resolve interactive prompts (CI mode)\n -h, --help display help for command" + }, + { + "invocation": "aidd plugin list", + "exitCode": 0, + "help": "Usage: aidd plugin list [options]\n\nList installed plugins for one or all AI tools\n\nOptions:\n --tool Target AI tool (default: all installed)\n -h, --help display help for command" + }, + { + "invocation": "aidd plugin remove", + "exitCode": 0, + "help": "Usage: aidd plugin remove [options] \n\nRemove a plugin from one or all AI tools\n\nOptions:\n --tool Target AI tool (default: all installed)\n -h, --help display help for command" + }, + { + "invocation": "aidd plugin search", + "exitCode": 0, + "help": "Usage: aidd plugin search [options] \n\nSearch registered marketplaces for plugins\n\nOptions:\n --recommended Show only recommended plugins\n --marketplace Limit to a single marketplace\n -h, --help display help for command" + }, + { + "invocation": "aidd plugin update", + "exitCode": 0, + "help": "Usage: aidd plugin update [options] [name]\n\nUpdate one or all plugins for one or all AI tools\n\nOptions:\n --tool Target AI tool (default: all installed)\n -h, --help display help for command" + }, + { + "invocation": "aidd setup", + "exitCode": 0, + "help": "Usage: aidd setup [options]\n\nSet up or update the project to a correct state — bootstraps the whole project\n(marketplace, framework, tools, plugins); see `framework install`, which acts on\nthe framework alone\n\nOptions:\n --source Framework source: remote or local\n --path Absolute path to local framework (required with\n --source local)\n --release Marketplace release tag to fetch (e.g., v1.2.3)\n --ai Comma-separated AI tool IDs, or 'all' (e.g.,\n claude,cursor or all)\n --ide Comma-separated IDE tool IDs, or 'all' (e.g., vscode\n or all)\n --plugins Plugin install mode: none | all | recommended |\n comma-separated names\n --no-default-marketplace Skip auto-registering aidd-framework (no source\n prompt, no plugin install)\n --yes Accept defaults without prompting\n --scope project (default) installs into this project alone;\n user registers the shared framework source and\n native activation machine-wide, writing nothing\n under this project\n -h, --help display help for command" + }, + { + "invocation": "aidd sync", + "exitCode": 0, + "help": "Usage: aidd sync [options] [files...]\n\nRewrite owned files from what is already there — regenerate tracked files,\ndriven by the manifest (see `translate`, which converts a source without\nrecording anything)\n\nArguments:\n files Limit sync to specific tracked files\n\nOptions:\n -f, --force Sync without prompting (default: false)\n --tool Limit sync to a specific tool\n --plugin Limit sync to a specific plugin\n --scope project (default) resolves this project's own manifest; user\n resolves the machine-wide manifest --scope user setup wrote,\n restoring no project files\n -h, --help display help for command" + }, + { + "invocation": "aidd telemetry", + "exitCode": 0, + "help": "Usage: aidd telemetry [options] [command]\n\nControl whether AIDD may measure this project\n\nOptions:\n -h, --help display help for command\n\nCommands:\n on [options] Turn on the AIDD telemetry switch and git-ignore the run\n journal\n read [options] Read what sessions cost from the files their tools already\n wrote, with no process running\n identity Whether this person's own identifier is attached to records\n read locally\n check Check whether the measurement chain is actually recording\n for this project\n report [options] Report what a period, or one task inside it, cost — tokens,\n models and steps, with how strongly each was attributed\n off Turn off the AIDD telemetry switch, warning if a tool's own\n settings file still exports\n forget [options] Irreversibly remove what this tool measured: this project's\n run journal, this machine's stored records, and this\n machine's identity file\n help [command] display help for command" + }, + { + "invocation": "aidd telemetry check", + "exitCode": 0, + "help": "Usage: aidd telemetry check [options]\n\nCheck whether the measurement chain is actually recording for this project\n\nOptions:\n -h, --help display help for command" + }, + { + "invocation": "aidd telemetry forget", + "exitCode": 0, + "help": "Usage: aidd telemetry forget [options]\n\nIrreversibly remove what this tool measured: this project's run journal, this\nmachine's stored records, and this machine's identity file\n\nOptions:\n --yes Confirm removal after seeing what would go — without it, nothing\n is removed (default: false)\n -h, --help display help for command" + }, + { + "invocation": "aidd telemetry identity", + "exitCode": 0, + "help": "Usage: aidd telemetry identity [options] [command]\n\nWhether this person's own identifier is attached to records read locally\n\nOptions:\n -h, --help display help for command\n\nCommands:\n use [options] [identifier] Mint this person's identifier, or take one minted\n on another machine. --name attaches a display name\n off Opt out: new records carry no person, from now on\n link Add an identifier this person cannot choose onto\n this same person - one row, not two, in a report\n unlink Withdraw an added identifier from this person" + }, + { + "invocation": "aidd telemetry identity link", + "exitCode": 0, + "help": "Usage: aidd telemetry identity link [options] \n\nAdd an identifier this person cannot choose onto this same person - one row, not\ntwo, in a report\n\nOptions:\n -h, --help display help for command" + }, + { + "invocation": "aidd telemetry identity off", + "exitCode": 0, + "help": "Usage: aidd telemetry identity off [options]\n\nOpt out: new records carry no person, from now on\n\nOptions:\n -h, --help display help for command" + }, + { + "invocation": "aidd telemetry identity unlink", + "exitCode": 0, + "help": "Usage: aidd telemetry identity unlink [options] \n\nWithdraw an added identifier from this person\n\nOptions:\n -h, --help display help for command" + }, + { + "invocation": "aidd telemetry identity use", + "exitCode": 0, + "help": "Usage: aidd telemetry identity use [options] [identifier]\n\nMint this person's identifier, or take one minted on another machine. --name\nattaches a display name\n\nOptions:\n --name A display name for whichever identifier this call settles on\n -h, --help display help for command" + }, + { + "invocation": "aidd telemetry off", + "exitCode": 0, + "help": "Usage: aidd telemetry off [options]\n\nTurn off the AIDD telemetry switch, warning if a tool's own settings file still\nexports\n\nOptions:\n -h, --help display help for command" + }, + { + "invocation": "aidd telemetry on", + "exitCode": 0, + "help": "Usage: aidd telemetry on [options]\n\nTurn on the AIDD telemetry switch and git-ignore the run journal\n\nOptions:\n --yes Confirm writing the git-tracked switch — this turns measurement on\n for everyone who clones (default: false)\n -h, --help display help for command" + }, + { + "invocation": "aidd telemetry read", + "exitCode": 0, + "help": "Usage: aidd telemetry read [options]\n\nRead what sessions cost from the files their tools already wrote, with no\nprocess running\n\nOptions:\n --session One session to read. Omitted, every session the run journal\n knows is read\n -h, --help display help for command" + }, + { + "invocation": "aidd telemetry report", + "exitCode": 0, + "help": "Usage: aidd telemetry report [options]\n\nReport what a period, or one task inside it, cost — tokens, models and steps,\nwith how strongly each was attributed\n\nOptions:\n --from First UTC day to report, as YYYY-MM-DD\n --to Last UTC day to report, as YYYY-MM-DD (default today)\n --days How many days back to report, ending at --to (default 7)\n --task Restrict to the sessions that wrote into this task, as\n /\n --project Restrict to this project\n --step Restrict to this step\n --model Restrict to this model\n --tool Restrict to this tool\n --axis Print one axis as a table to paste elsewhere: total | day |\n step | model | agent | prompt | task | backlog | flow |\n tool | project | person\n --json Print one object a program can parse, instead of text for a\n person\n -h, --help display help for command" + }, + { + "invocation": "aidd translate", + "exitCode": 0, + "help": "Usage: aidd translate [options] \n\nConvert an arbitrary source into a target-native plugin tree — records nothing\n(see `sync` for the manifest-driven, tracked version)\n\nArguments:\n source Path to the source framework directory\n\nOptions:\n --to Conversion target (claude, cursor, copilot, codex,\n opencode)\n --out Output directory (marketplace dist or project root)\n --as Output layout (default: \"marketplace\")\n --force Overwrite existing files at canonical paths under\n --out\n -h, --help display help for command" + }, + { + "invocation": "aidd update", + "exitCode": 0, + "help": "Usage: aidd update|upgrade [options]\n\nUpdate the aidd CLI itself to the latest version\n\nOptions:\n --check Check if a newer version is available without installing\n (default: false)\n --dry-run Preview the update without installing (default: false)\n -f, --force Reinstall even if already up to date (default: false)\n -h, --help display help for command" + } +] diff --git a/cli/tests/golden/snapshots/phase0/snapshot.json b/cli/tests/golden/snapshots/phase0/snapshot.json index 320701acd..e28a34f61 100644 --- a/cli/tests/golden/snapshots/phase0/snapshot.json +++ b/cli/tests/golden/snapshots/phase0/snapshot.json @@ -3,24 +3,81 @@ "command": "setup --source local --path --ai claude --plugins none --yes", "exitCode": 0, "stdout": "Fetching marketplace 'aidd-framework'...\nProject initialized.\nInstalled claude (1 files)\n", - "stderr": "Warning: Skipping commands/ in plugin 'aidd-test' (out of scope for MVP1).\nWarning: Skipping rules/ in plugin 'aidd-test' (out of scope for MVP1).\n", - "filesWritten": [ - ".aidd/cache/built/aidd-framework/claude/.build-version", - ".aidd/cache/built/aidd-framework/claude/.claude-plugin/marketplace.json", - ".aidd/cache/built/aidd-framework/claude/plugins/aidd-test/.claude-plugin/plugin.json", - ".aidd/cache/built/aidd-framework/claude/plugins/aidd-test/.mcp.json", - ".aidd/cache/built/aidd-framework/claude/plugins/aidd-test/agents/code-reviewer.md", - ".aidd/cache/built/aidd-framework/claude/plugins/aidd-test/hooks/check.sh", - ".aidd/cache/built/aidd-framework/claude/plugins/aidd-test/hooks/hooks.json", - ".aidd/cache/built/aidd-framework/claude/plugins/aidd-test/skills/commit/SKILL.md", - ".aidd/cache/built/aidd-framework/claude/plugins/aidd-test/skills/hello.md", - ".aidd/manifest.json", - ".aidd/marketplaces.json", - ".claude/settings.json", - ".gitignore" + "stderr": "Warning: claude CLI not found on PATH — skipping native plugin activation.\n", + "filesWritten": [".aidd/manifest.json", ".claude/settings.json", ".gitignore"], + "manifest": { + "version": 8, + "tools": { + "claude": { + "toolId": "claude", + "version": "", + "files": [ + { + "relativePath": ".claude/settings.json", + "hash": "0e55cf920e5a903c80a10fa7034d48c2" + } + ], + "mergeFiles": [] + } + } + } + }, + { + "command": "setup --source local --path --ai claude --plugins none --yes --scope user", + "exitCode": 0, + "stdout": "Fetching marketplace 'aidd-framework'...\nProject initialized.\n", + "stderr": "Warning: claude CLI not found on PATH — skipping native plugin activation.\n", + "filesWritten": [], + "manifest": { + "version": 8, + "tools": { + "claude": { + "toolId": "claude", + "version": "", + "files": [ + { + "relativePath": ".claude/settings.json", + "hash": "0e55cf920e5a903c80a10fa7034d48c2" + } + ], + "mergeFiles": [] + } + } + }, + "userConfigFiles": [ + "cache/built//aidd-framework/claude/.build-version", + "cache/built//aidd-framework/claude/.claude-plugin/marketplace.json", + "cache/built//aidd-framework/claude/plugins/aidd-test/.claude-plugin/plugin.json", + "cache/built//aidd-framework/claude/plugins/aidd-test/.mcp.json", + "cache/built//aidd-framework/claude/plugins/aidd-test/agents/code-reviewer.md", + "cache/built//aidd-framework/claude/plugins/aidd-test/hooks/check.sh", + "cache/built//aidd-framework/claude/plugins/aidd-test/hooks/hooks.json", + "cache/built//aidd-framework/claude/plugins/aidd-test/skills/commit/SKILL.md", + "cache/built//aidd-framework/claude/plugins/aidd-test/skills/hello.md", + "manifest.json", + "marketplaces.json", + "references.json" ], + "userManifest": { + "version": 8, + "tools": { + "claude": { + "toolId": "claude", + "version": "", + "files": [], + "mergeFiles": [] + } + } + } + }, + { + "command": "doctor --scope user", + "exitCode": 0, + "stdout": "User-scope tools:\n claude (v): expects activation in /.claude/settings.json\n\nUser-scope installation is healthy\n", + "stderr": "", + "filesWritten": [], "manifest": { - "version": 6, + "version": 8, "tools": { "claude": { "toolId": "claude", @@ -28,7 +85,7 @@ "files": [ { "relativePath": ".claude/settings.json", - "hash": "b7669b899b9a72e8ef129510a7da6d62" + "hash": "0e55cf920e5a903c80a10fa7034d48c2" } ], "mergeFiles": [] @@ -37,13 +94,13 @@ } }, { - "command": "status", + "command": "doctor", "exitCode": 0, - "stdout": "All files are in sync\n", + "stdout": "\nAI tools:\n claude (v): 1 files, 0 merge files\n\nDrift:\nAI tools:\n claude (v): in sync\nIDE tools:\n (none installed)\nPlugins:\n (all in sync)\n\nInstallation is healthy\n", "stderr": "", "filesWritten": [], "manifest": { - "version": 6, + "version": 8, "tools": { "claude": { "toolId": "claude", @@ -51,7 +108,7 @@ "files": [ { "relativePath": ".claude/settings.json", - "hash": "b7669b899b9a72e8ef129510a7da6d62" + "hash": "0e55cf920e5a903c80a10fa7034d48c2" } ], "mergeFiles": [] @@ -60,13 +117,273 @@ } }, { - "command": "restore --force", + "command": "marketplace list", "exitCode": 0, - "stdout": "Checking claude for files to restore...\nNothing to restore — all files are unmodified.\n", + "stdout": "aidd-framework v [user]\n", "stderr": "", "filesWritten": [], "manifest": { - "version": 6, + "version": 8, + "tools": { + "claude": { + "toolId": "claude", + "version": "", + "files": [ + { + "relativePath": ".claude/settings.json", + "hash": "0e55cf920e5a903c80a10fa7034d48c2" + } + ], + "mergeFiles": [] + } + } + } + }, + { + "command": "plugin list", + "exitCode": 0, + "stdout": "No plugins installed.\n", + "stderr": "", + "filesWritten": [], + "manifest": { + "version": 8, + "tools": { + "claude": { + "toolId": "claude", + "version": "", + "files": [ + { + "relativePath": ".claude/settings.json", + "hash": "0e55cf920e5a903c80a10fa7034d48c2" + } + ], + "mergeFiles": [] + } + } + } + }, + { + "command": "plugin install aidd-test", + "exitCode": 0, + "stdout": "Installed 'aidd-test'.\n", + "stderr": "Warning: claude CLI not found on PATH — skipping native plugin activation.\n", + "filesWritten": [], + "manifest": { + "version": 8, + "tools": { + "claude": { + "toolId": "claude", + "version": "", + "files": [ + { + "relativePath": ".claude/settings.json", + "hash": "267dba9c9c9dbe2190d91ae84213c77b" + } + ], + "mergeFiles": [], + "plugins": [ + { + "name": "aidd-test", + "source": { + "kind": "local", + "path": "/plugins/aidd-test" + }, + "version": "", + "strict": true, + "files": {}, + "scope": "project", + "marketplace": "aidd-framework" + } + ] + } + } + } + }, + { + "command": "plugin list", + "exitCode": 0, + "stdout": "claude:\n aidd-test@\n", + "stderr": "", + "filesWritten": [], + "manifest": { + "version": 8, + "tools": { + "claude": { + "toolId": "claude", + "version": "", + "files": [ + { + "relativePath": ".claude/settings.json", + "hash": "267dba9c9c9dbe2190d91ae84213c77b" + } + ], + "mergeFiles": [], + "plugins": [ + { + "name": "aidd-test", + "source": { + "kind": "local", + "path": "/plugins/aidd-test" + }, + "version": "", + "strict": true, + "files": {}, + "scope": "project", + "marketplace": "aidd-framework" + } + ] + } + } + } + }, + { + "command": "framework install --tool cursor --force", + "exitCode": 0, + "stdout": "Installed cursor (1 files)\n", + "stderr": "Warning: claude CLI not found on PATH — skipping native plugin activation.\n", + "filesWritten": [ + ".cursor/hooks.json", + ".cursor/hooks/aidd-test/check.sh", + ".cursor/settings.json" + ], + "manifest": { + "version": 8, + "tools": { + "claude": { + "toolId": "claude", + "version": "", + "files": [ + { + "relativePath": ".claude/settings.json", + "hash": "267dba9c9c9dbe2190d91ae84213c77b" + } + ], + "mergeFiles": [], + "plugins": [ + { + "name": "aidd-test", + "source": { + "kind": "local", + "path": "/plugins/aidd-test" + }, + "version": "", + "strict": true, + "files": {}, + "scope": "project", + "marketplace": "aidd-framework" + } + ] + }, + "cursor": { + "toolId": "cursor", + "version": "", + "files": [ + { + "relativePath": ".cursor/settings.json", + "hash": "07616ffa7dc41a282ca2179f6a2394d3" + } + ], + "mergeFiles": [], + "plugins": [ + { + "name": "aidd-test", + "source": { + "kind": "local", + "path": "/plugins/aidd-test" + }, + "version": "", + "strict": true, + "files": { + "aidd-test/.cursor-plugin/plugin.json": "af3965dc3bb38289cd5501b7244926fa", + "aidd-test/.mcp.json": "8d5f495dc98074770f3390b2271ddf4a", + "aidd-test/agents/code-reviewer.md": "3085f2108f7b9448d523f1555a802bb2", + "aidd-test/skills/commit/SKILL.md": "01c4b6a281146776eb7304577c56bb79", + "aidd-test/skills/hello.md": "f00ea16a97341b9314df0da073633624" + }, + "scope": "user", + "marketplace": "aidd-framework" + } + ] + } + } + } + }, + { + "command": "doctor", + "exitCode": 0, + "stdout": "\nAI tools:\n claude (v): 1 files, 0 merge files\n cursor (v): 1 files, 0 merge files\n\nDrift:\nAI tools:\n claude (v): in sync\n cursor (v):\n + .cursor/hooks/aidd-test/check.sh\n + .cursor/hooks.json\n 0 modified, 0 deleted, 2 added\nIDE tools:\n (none installed)\nPlugins:\n (all in sync)\n\nAI:\n\nInstallation is healthy\n", + "stderr": "Warning: /.claude/plugins/installed_plugins.json could not be read — ENOENT\n Fix: The plugin does not load until claude's own CLI has run and answered this.\n", + "filesWritten": [], + "manifest": { + "version": 8, + "tools": { + "claude": { + "toolId": "claude", + "version": "", + "files": [ + { + "relativePath": ".claude/settings.json", + "hash": "267dba9c9c9dbe2190d91ae84213c77b" + } + ], + "mergeFiles": [], + "plugins": [ + { + "name": "aidd-test", + "source": { + "kind": "local", + "path": "/plugins/aidd-test" + }, + "version": "", + "strict": true, + "files": {}, + "scope": "project", + "marketplace": "aidd-framework" + } + ] + }, + "cursor": { + "toolId": "cursor", + "version": "", + "files": [ + { + "relativePath": ".cursor/settings.json", + "hash": "07616ffa7dc41a282ca2179f6a2394d3" + } + ], + "mergeFiles": [], + "plugins": [ + { + "name": "aidd-test", + "source": { + "kind": "local", + "path": "/plugins/aidd-test" + }, + "version": "", + "strict": true, + "files": { + "aidd-test/.cursor-plugin/plugin.json": "af3965dc3bb38289cd5501b7244926fa", + "aidd-test/.mcp.json": "8d5f495dc98074770f3390b2271ddf4a", + "aidd-test/agents/code-reviewer.md": "3085f2108f7b9448d523f1555a802bb2", + "aidd-test/skills/commit/SKILL.md": "01c4b6a281146776eb7304577c56bb79", + "aidd-test/skills/hello.md": "f00ea16a97341b9314df0da073633624" + }, + "scope": "user", + "marketplace": "aidd-framework" + } + ] + } + } + } + }, + { + "command": "doctor", + "exitCode": 1, + "stdout": "\nAI tools:\n claude (v): 1 files, 0 merge files\n cursor (v): 1 files, 0 merge files\n\nDrift:\nAI tools:\n claude (v):\n ~ .claude/settings.json\n 1 modified, 0 deleted, 0 added\n cursor (v):\n + .cursor/hooks/aidd-test/check.sh\n + .cursor/hooks.json\n 0 modified, 0 deleted, 2 added\nIDE tools:\n (none installed)\nPlugins:\n (all in sync)\n\nAI:\n", + "stderr": "Warning: /.claude/plugins/installed_plugins.json could not be read — ENOENT\n Fix: The plugin does not load until claude's own CLI has run and answered this.\nWarning: Modified tracked file: .claude/settings.json\n Fix: Run `aidd sync --force` to revert to the framework version.\n", + "filesWritten": [], + "manifest": { + "version": 8, "tools": { "claude": { "toolId": "claude", @@ -74,7 +391,222 @@ "files": [ { "relativePath": ".claude/settings.json", - "hash": "b7669b899b9a72e8ef129510a7da6d62" + "hash": "8a80554c91d9fca8acb82f023de02f11" + } + ], + "mergeFiles": [], + "plugins": [ + { + "name": "aidd-test", + "source": { + "kind": "local", + "path": "/plugins/aidd-test" + }, + "version": "", + "strict": true, + "files": {}, + "scope": "project", + "marketplace": "aidd-framework" + } + ] + }, + "cursor": { + "toolId": "cursor", + "version": "", + "files": [ + { + "relativePath": ".cursor/settings.json", + "hash": "07616ffa7dc41a282ca2179f6a2394d3" + } + ], + "mergeFiles": [], + "plugins": [ + { + "name": "aidd-test", + "source": { + "kind": "local", + "path": "/plugins/aidd-test" + }, + "version": "", + "strict": true, + "files": { + "aidd-test/.cursor-plugin/plugin.json": "af3965dc3bb38289cd5501b7244926fa", + "aidd-test/.mcp.json": "8d5f495dc98074770f3390b2271ddf4a", + "aidd-test/agents/code-reviewer.md": "3085f2108f7b9448d523f1555a802bb2", + "aidd-test/skills/commit/SKILL.md": "01c4b6a281146776eb7304577c56bb79", + "aidd-test/skills/hello.md": "f00ea16a97341b9314df0da073633624" + }, + "scope": "user", + "marketplace": "aidd-framework" + } + ] + } + } + } + }, + { + "command": "sync --force", + "exitCode": 0, + "stdout": "Checking claude for files to restore...\nChecking cursor for files to restore...\nRestored 1 file(s), kept 0 file(s)\n", + "stderr": "Warning: claude CLI not found on PATH — skipping native plugin activation.\nWarning: This project's own pre-migration framework cache kept: a requested tool's CLI was not on PATH this run, so its own registration may still point at it — run `aidd sync` again once every tool's CLI is on PATH.\nWarning: claude: the plugin will not load until the claude CLI has run.\n", + "filesWritten": [], + "manifest": { + "version": 8, + "tools": { + "claude": { + "toolId": "claude", + "version": "", + "files": [ + { + "relativePath": ".claude/settings.json", + "hash": "267dba9c9c9dbe2190d91ae84213c77b" + } + ], + "mergeFiles": [], + "plugins": [ + { + "name": "aidd-test", + "source": { + "kind": "local", + "path": "/plugins/aidd-test" + }, + "version": "", + "strict": false, + "files": {}, + "scope": "project", + "marketplace": "aidd-framework" + } + ] + }, + "cursor": { + "toolId": "cursor", + "version": "", + "files": [ + { + "relativePath": ".cursor/settings.json", + "hash": "07616ffa7dc41a282ca2179f6a2394d3" + } + ], + "mergeFiles": [], + "plugins": [ + { + "name": "aidd-test", + "source": { + "kind": "local", + "path": "/plugins/aidd-test" + }, + "version": "", + "strict": false, + "files": { + "aidd-test/.cursor-plugin/plugin.json": "af3965dc3bb38289cd5501b7244926fa", + "aidd-test/.mcp.json": "8d5f495dc98074770f3390b2271ddf4a", + "aidd-test/agents/code-reviewer.md": "3085f2108f7b9448d523f1555a802bb2", + "aidd-test/skills/commit/SKILL.md": "01c4b6a281146776eb7304577c56bb79", + "aidd-test/skills/hello.md": "f00ea16a97341b9314df0da073633624" + }, + "scope": "user", + "marketplace": "aidd-framework" + } + ] + } + } + } + }, + { + "command": "doctor", + "exitCode": 0, + "stdout": "\nAI tools:\n claude (v): 1 files, 0 merge files\n cursor (v): 1 files, 0 merge files\n\nDrift:\nAI tools:\n claude (v): in sync\n cursor (v):\n + .cursor/hooks/aidd-test/check.sh\n + .cursor/hooks.json\n 0 modified, 0 deleted, 2 added\nIDE tools:\n (none installed)\nPlugins:\n (all in sync)\n\nAI:\n\nInstallation is healthy\n", + "stderr": "Warning: /.claude/plugins/installed_plugins.json could not be read — ENOENT\n Fix: The plugin does not load until claude's own CLI has run and answered this.\n", + "filesWritten": [], + "manifest": { + "version": 8, + "tools": { + "claude": { + "toolId": "claude", + "version": "", + "files": [ + { + "relativePath": ".claude/settings.json", + "hash": "267dba9c9c9dbe2190d91ae84213c77b" + } + ], + "mergeFiles": [], + "plugins": [ + { + "name": "aidd-test", + "source": { + "kind": "local", + "path": "/plugins/aidd-test" + }, + "version": "", + "strict": false, + "files": {}, + "scope": "project", + "marketplace": "aidd-framework" + } + ] + }, + "cursor": { + "toolId": "cursor", + "version": "", + "files": [ + { + "relativePath": ".cursor/settings.json", + "hash": "07616ffa7dc41a282ca2179f6a2394d3" + } + ], + "mergeFiles": [], + "plugins": [ + { + "name": "aidd-test", + "source": { + "kind": "local", + "path": "/plugins/aidd-test" + }, + "version": "", + "strict": false, + "files": { + "aidd-test/.cursor-plugin/plugin.json": "af3965dc3bb38289cd5501b7244926fa", + "aidd-test/.mcp.json": "8d5f495dc98074770f3390b2271ddf4a", + "aidd-test/agents/code-reviewer.md": "3085f2108f7b9448d523f1555a802bb2", + "aidd-test/skills/commit/SKILL.md": "01c4b6a281146776eb7304577c56bb79", + "aidd-test/skills/hello.md": "f00ea16a97341b9314df0da073633624" + }, + "scope": "user", + "marketplace": "aidd-framework" + } + ] + } + } + } + }, + { + "command": "plugin remove aidd-test", + "exitCode": 0, + "stdout": "Plugin 'aidd-test' removed.\n", + "stderr": "Warning: claude CLI not found on PATH — 'aidd-test@aidd-framework' was not uninstalled from claude's own plugin registry and may still be enabled there.\nWarning: claude CLI not found on PATH — skipping native plugin activation.\n", + "filesWritten": [], + "manifest": { + "version": 8, + "tools": { + "claude": { + "toolId": "claude", + "version": "", + "files": [ + { + "relativePath": ".claude/settings.json", + "hash": "267dba9c9c9dbe2190d91ae84213c77b" + } + ], + "mergeFiles": [] + }, + "cursor": { + "toolId": "cursor", + "version": "", + "files": [ + { + "relativePath": ".cursor/settings.json", + "hash": "07616ffa7dc41a282ca2179f6a2394d3" } ], "mergeFiles": [] @@ -85,17 +617,137 @@ { "command": "clean --force", "exitCode": 0, - "stdout": "Removing claude files...\nCleaned all AIDD files (1 files removed)\n", + "stdout": "Removing claude files...\nRemoving cursor files...\nCleaned all AIDD files (2 files removed)\n", "stderr": "", "filesWritten": [], + "manifest": null, + "userConfigFiles": [ + "cache/built//aidd-framework/claude/.build-version", + "cache/built//aidd-framework/claude/.claude-plugin/marketplace.json", + "cache/built//aidd-framework/claude/plugins/aidd-test/.claude-plugin/plugin.json", + "cache/built//aidd-framework/claude/plugins/aidd-test/.mcp.json", + "cache/built//aidd-framework/claude/plugins/aidd-test/agents/code-reviewer.md", + "cache/built//aidd-framework/claude/plugins/aidd-test/hooks/check.sh", + "cache/built//aidd-framework/claude/plugins/aidd-test/hooks/hooks.json", + "cache/built//aidd-framework/claude/plugins/aidd-test/skills/commit/SKILL.md", + "cache/built//aidd-framework/claude/plugins/aidd-test/skills/hello.md", + "cache/built//aidd-framework/cursor/.build-version", + "cache/built//aidd-framework/cursor/.cursor-plugin/marketplace.json", + "cache/built//aidd-framework/cursor/plugins/aidd-test/.cursor-plugin/plugin.json", + "cache/built//aidd-framework/cursor/plugins/aidd-test/.mcp.json", + "cache/built//aidd-framework/cursor/plugins/aidd-test/agents/code-reviewer.md", + "cache/built//aidd-framework/cursor/plugins/aidd-test/hooks/check.sh", + "cache/built//aidd-framework/cursor/plugins/aidd-test/hooks/hooks.json", + "cache/built//aidd-framework/cursor/plugins/aidd-test/skills/commit/SKILL.md", + "cache/built//aidd-framework/cursor/plugins/aidd-test/skills/hello.md", + "manifest.json", + "marketplaces.json", + "references.json" + ], + "userManifest": { + "version": 8, + "tools": { + "claude": { + "toolId": "claude", + "version": "", + "files": [], + "mergeFiles": [] + } + } + } + }, + { + "command": "doctor", + "exitCode": 1, + "stdout": "\nDrift:\nAI tools:\n (none installed)\nIDE tools:\n (none installed)\nPlugins:\n (all in sync)\n", + "stderr": "Warning: [ai] No AIDD manifest found. Run `aidd setup` to initialize your project.\nWarning: [ide] No AIDD manifest found. Run `aidd setup` to initialize your project.\n", + "filesWritten": [], "manifest": null }, { - "command": "status", - "exitCode": 0, - "stdout": "\nAI tools:\n (none installed)\n\nIDE tools:\n (none installed)\n\nPlugins:\n (all in sync)\n\nLegend: ~ modified - deleted + added\n", + "command": "[errors] doctor", + "exitCode": 1, + "stdout": "\nDrift:\nAI tools:\n (none installed)\nIDE tools:\n (none installed)\nPlugins:\n (all in sync)\n", "stderr": "Warning: [ai] No AIDD manifest found. Run `aidd setup` to initialize your project.\nWarning: [ide] No AIDD manifest found. Run `aidd setup` to initialize your project.\n", "filesWritten": [], "manifest": null + }, + { + "command": "[errors] plugin list", + "exitCode": 1, + "stdout": "", + "stderr": "Error: No AIDD manifest found. Run `aidd setup` to initialize your project.\n", + "filesWritten": [], + "manifest": null + }, + { + "command": "[errors] plugin install does-not-exist", + "exitCode": 1, + "stdout": "", + "stderr": "Error: Plugin 'does-not-exist' was not found in any registered marketplace.\n", + "filesWritten": [], + "manifest": null + }, + { + "command": "[errors] framework install --tool not-a-tool", + "exitCode": 1, + "stdout": "", + "stderr": "Error: Unknown tool: not-a-tool. Valid tools: claude, cursor, copilot, opencode, codex, vscode\n", + "filesWritten": [], + "manifest": null + }, + { + "command": "[errors] definitely-not-a-command", + "exitCode": 1, + "stdout": "", + "stderr": "error: unknown command 'definitely-not-a-command'\n", + "filesWritten": [], + "manifest": null + }, + { + "command": "[errors] marketplace add malformed /marketplace-malformed", + "exitCode": 1, + "stdout": "", + "stderr": "Error: Invalid plugin manifest: catalog at \"/marketplace-malformed/.claude-plugin/marketplace.json\" is malformed (not valid JSON). Fix or re-create the marketplace catalog file.\n", + "filesWritten": [], + "manifest": null + }, + { + "command": "[user-scope-clean] setup --source local --path --ai claude --plugins none --yes --scope user", + "exitCode": 0, + "stdout": "Fetching marketplace 'aidd-framework'...\nProject initialized.\n", + "stderr": "Warning: claude CLI not found on PATH — skipping native plugin activation.\n", + "filesWritten": [], + "manifest": null, + "userConfigFiles": ["manifest.json", "marketplaces.json"], + "userManifest": { + "version": 8, + "tools": { + "claude": { + "toolId": "claude", + "version": "", + "files": [], + "mergeFiles": [] + } + } + } + }, + { + "command": "[user-scope-clean] clean --scope user --force", + "exitCode": 0, + "stdout": "Cleaned the shared aidd-framework source for this machine\n", + "stderr": "", + "filesWritten": [], + "manifest": null, + "userConfigFiles": ["marketplaces.json"], + "userManifest": null + }, + { + "command": "[user-scope-clean] doctor --scope user", + "exitCode": 0, + "stdout": "Nothing registered at user scope yet — run `aidd setup --scope user` first.\n", + "stderr": "", + "filesWritten": [], + "manifest": null } ] diff --git a/cli/tests/helpers/auth.ts b/cli/tests/helpers/auth.ts index 4b0d10948..bf6741b08 100644 --- a/cli/tests/helpers/auth.ts +++ b/cli/tests/helpers/auth.ts @@ -1,8 +1,8 @@ import { mkdtemp, rm } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import type { AuthConfig } from "../../src/domain/models/auth.js"; -import { AuthStorage } from "../../src/infrastructure/auth/auth-storage.js"; +import type { AuthConfig } from "../../src/runtime/auth/auth.js"; +import { AuthStorage } from "../../src/runtime/auth/auth-storage.js"; export async function makeTempAuthStorage(prefix: string): Promise<{ tempDir: string; diff --git a/cli/tests/helpers/ports/build-unit-deps.ts b/cli/tests/helpers/ports/build-unit-deps.ts index 6da24d9b1..b6a741d30 100644 --- a/cli/tests/helpers/ports/build-unit-deps.ts +++ b/cli/tests/helpers/ports/build-unit-deps.ts @@ -1,34 +1,36 @@ import { resolve } from "node:path"; // Register all tools so use-cases that call getToolConfig / getIdeToolConfig don't throw -import "../../../src/domain/tools/ai/claude.js"; -import "../../../src/domain/tools/ai/codex.js"; -import "../../../src/domain/tools/ai/copilot.js"; -import "../../../src/domain/tools/ai/cursor.js"; -import "../../../src/domain/tools/ai/opencode.js"; -import "../../../src/domain/tools/ide/vscode.js"; -import { CLIOutput } from "../../../src/application/output.js"; -import { DoctorLayoutUseCase } from "../../../src/application/use-cases/doctor/doctor-layout-use-case.js"; -import { DoctorMergeFilesUseCase } from "../../../src/application/use-cases/doctor/doctor-merge-files-use-case.js"; -import { DoctorPluginUseCase } from "../../../src/application/use-cases/doctor/doctor-plugin-use-case.js"; -import { DoctorReferencesUseCase } from "../../../src/application/use-cases/doctor/doctor-references-use-case.js"; -import { DoctorTrackedFilesUseCase } from "../../../src/application/use-cases/doctor/doctor-tracked-files-use-case.js"; -import { DoctorUseCase } from "../../../src/application/use-cases/doctor/doctor-use-case.js"; -import { InitUseCase } from "../../../src/application/use-cases/init-use-case.js"; -import { InstallIdeConfigUseCase } from "../../../src/application/use-cases/install/install-ide-config-use-case.js"; -import { InstallRuntimeConfigUseCase } from "../../../src/application/use-cases/install/install-runtime-config-use-case.js"; -import { MarketplaceSyncSettingsUseCase } from "../../../src/application/use-cases/marketplace/marketplace-sync-settings-use-case.js"; -import { DetectPluginDriftUseCase } from "../../../src/application/use-cases/shared/detect-plugin-drift-use-case.js"; -import { GitignoreUseCase } from "../../../src/application/use-cases/shared/gitignore-use-case.js"; -import { PostInstallPipelineUseCase } from "../../../src/application/use-cases/shared/post-install-pipeline-use-case.js"; -import { ResolveUpdateDecisionUseCase } from "../../../src/application/use-cases/shared/resolve-update-decision-use-case.js"; -import { UpdateOneToolUseCase } from "../../../src/application/use-cases/shared/update-one-tool-use-case.js"; -import { SyncConflictResolverUseCase } from "../../../src/application/use-cases/sync/sync-conflict-resolver-use-case.js"; -import { Manifest } from "../../../src/domain/models/manifest.js"; -import { isIdeToolId, type ToolId } from "../../../src/domain/tools/registry.js"; -import { PluginCatalogRepositoryAdapter } from "../../../src/infrastructure/adapters/plugin-catalog-repository-adapter.js"; -import { PluginDistributionReaderAdapter } from "../../../src/infrastructure/adapters/plugin-distribution-reader-adapter.js"; -import { SilentPrompterAdapter } from "../../../src/infrastructure/adapters/prompter-adapter.js"; -import { BundledAssetProviderAdapter } from "../../../src/infrastructure/assets/asset-loader.js"; +import "../../../src/contexts/tools/domain/profiles/claude/profile.js"; +import "../../../src/contexts/tools/domain/profiles/codex/profile.js"; +import "../../../src/contexts/tools/domain/profiles/copilot/profile.js"; +import "../../../src/contexts/tools/domain/profiles/cursor/profile.js"; +import "../../../src/contexts/tools/domain/profiles/opencode/profile.js"; +import "../../../src/contexts/tools/domain/profiles/vscode/profile.js"; +import { PluginCatalogRepositoryAdapter } from "../../../src/contexts/distribution/infrastructure/plugin-catalog-repository-adapter.js"; +import { DoctorLayoutUseCase } from "../../../src/contexts/framework/application/doctor/doctor-layout-use-case.js"; +import { DoctorMergeFilesUseCase } from "../../../src/contexts/framework/application/doctor/doctor-merge-files-use-case.js"; +import { DoctorPluginUseCase } from "../../../src/contexts/framework/application/doctor/doctor-plugin-use-case.js"; +import { DoctorReferencesUseCase } from "../../../src/contexts/framework/application/doctor/doctor-references-use-case.js"; +import { DoctorRegistrationUseCase } from "../../../src/contexts/framework/application/doctor/doctor-registration-use-case.js"; +import { DoctorTrackedFilesUseCase } from "../../../src/contexts/framework/application/doctor/doctor-tracked-files-use-case.js"; +import { DoctorUseCase } from "../../../src/contexts/framework/application/doctor/doctor-use-case.js"; +import { MarketplaceSyncSettingsUseCase } from "../../../src/contexts/framework/application/flows/marketplace-sync-settings-use-case.js"; +import { GitignoreUseCase } from "../../../src/contexts/framework/application/gitignore-use-case.js"; +import { ResolveUpdateDecisionUseCase } from "../../../src/contexts/framework/application/global/resolve-update-decision-use-case.js"; +import { UpdateOneToolUseCase } from "../../../src/contexts/framework/application/global/update-one-tool-use-case.js"; +import { InitUseCase } from "../../../src/contexts/framework/application/init-use-case.js"; +import { InstallIdeConfigUseCase } from "../../../src/contexts/framework/application/install/install-ide-config-use-case.js"; +import { InstallRuntimeConfigUseCase } from "../../../src/contexts/framework/application/install/install-runtime-config-use-case.js"; +import { PostInstallPipelineUseCase } from "../../../src/contexts/framework/application/install/post-install-pipeline-use-case.js"; +import { DetectPluginDriftUseCase } from "../../../src/contexts/framework/application/shared/detect-plugin-drift-use-case.js"; +import { Manifest } from "../../../src/contexts/framework/domain/manifest.js"; +import { PluginDistributionReaderAdapter } from "../../../src/contexts/framework/infrastructure/plugin-distribution-reader-adapter.js"; +import { isIdeToolId } from "../../../src/contexts/tools/domain/registry.js"; +import type { ToolId } from "../../../src/kernel/tool.js"; +import { CLIOutput } from "../../../src/presentation/output.js"; +import { SyncConflictResolverUseCase } from "../../../src/presentation/prompts/sync-conflict-resolver-use-case.js"; +import { BundledAssetProviderAdapter } from "../../../src/runtime/assets/asset-loader.js"; +import { SilentPrompterAdapter } from "../../../src/runtime/prompter/prompter-adapter.js"; import { DeterministicHasher } from "./deterministic-hasher.js"; import { FakeCurrentVersion } from "./fake-current-version.js"; import { fakeEnsureBuiltMarketplace } from "./fake-ensure-built-marketplace.js"; @@ -41,10 +43,8 @@ import { seedFromDirectory } from "./seed-from-directory.js"; const FIXTURE_DIR = resolve(process.cwd(), "tests/fixtures/framework"); -/** - * Builds in-memory deps for use-case unit tests. - * The InMemoryFileAdapter is pre-seeded with the framework fixture content (absolute paths). - */ +/** Builds in-memory deps for use-case unit tests, the file adapter pre-seeded with the + * framework fixture content under absolute paths. */ export async function buildUnitDeps(_projectRoot: string) { const hasher = new DeterministicHasher(); const fs = new InMemoryFileAdapter({}, hasher); @@ -53,7 +53,7 @@ export async function buildUnitDeps(_projectRoot: string) { const assetProvider = new BundledAssetProviderAdapter(); const pluginFetcher = new FixturePluginFetcher(); const pluginDistributionReader = new PluginDistributionReaderAdapter(fs); - const pluginCatalogRepository = new PluginCatalogRepositoryAdapter(fs); + const _pluginCatalogRepository = new PluginCatalogRepositoryAdapter(fs); const marketplaceRegistry = new InMemoryMarketplaceRegistry(); const gitignoreUseCase = new GitignoreUseCase(fs); const postInstallPipelineUseCase = new PostInstallPipelineUseCase(manifestRepo, gitignoreUseCase); @@ -80,14 +80,12 @@ export async function buildUnitDeps(_projectRoot: string) { fs, manifestRepo, marketplaceRegistry, - pluginCatalogRepository, hasher, logger, nativePluginActivators, fakeEnsureBuiltMarketplace() ); - // Seed the framework fixture content so the install use-case can read it await seedFromDirectory(fs, FIXTURE_DIR, { useAbsolutePaths: true }); return { @@ -98,7 +96,6 @@ export async function buildUnitDeps(_projectRoot: string) { assetProvider, pluginFetcher, pluginDistributionReader, - pluginCatalogRepository, marketplaceRegistry, marketplaceSyncSettings, nativePluginActivators, @@ -171,7 +168,8 @@ export function buildUpdateOneToolUseCase( export function buildDoctorUseCase( deps: Awaited>, - authReader?: ConstructorParameters[1] + authReader?: ConstructorParameters[1], + hostRegistries?: ConstructorParameters[3] ): DoctorUseCase { return new DoctorUseCase( deps.manifestRepo, @@ -179,7 +177,16 @@ export function buildDoctorUseCase( new DoctorMergeFilesUseCase(deps.fs, deps.hasher), new DoctorPluginUseCase(new DetectPluginDriftUseCase(deps.fs)), new DoctorReferencesUseCase(deps.fs), - new DoctorLayoutUseCase(deps.fs, authReader) + new DoctorLayoutUseCase(deps.fs, authReader), + new DoctorRegistrationUseCase( + deps.fs, + deps.marketplaceRegistry, + deps.nativePluginActivators, + hostRegistries, + new Map(), + () => "/user-cache", + { get: () => "1.0.0" } + ) ); } diff --git a/cli/tests/helpers/ports/capturing-logger.ts b/cli/tests/helpers/ports/capturing-logger.ts index faf82ecb6..bd7ecc4cd 100644 --- a/cli/tests/helpers/ports/capturing-logger.ts +++ b/cli/tests/helpers/ports/capturing-logger.ts @@ -1,9 +1,5 @@ -import type { Logger } from "../../../src/domain/ports/logger.js"; +import type { Logger } from "../../../src/kernel/ports/logger.js"; -/** - * In-memory Logger implementation that captures messages to arrays. - * Useful for asserting on log output in unit tests. - */ export class CapturingLogger implements Logger { readonly debugMessages: string[] = []; readonly infoMessages: string[] = []; @@ -21,7 +17,6 @@ export class CapturingLogger implements Logger { this.warnMessages.push(message); } - /** All messages across all levels, in order of emission. */ get allMessages(): string[] { return [...this.debugMessages, ...this.infoMessages, ...this.warnMessages]; } diff --git a/cli/tests/helpers/ports/capturing-output.ts b/cli/tests/helpers/ports/capturing-output.ts new file mode 100644 index 000000000..f3040f024 --- /dev/null +++ b/cli/tests/helpers/ports/capturing-output.ts @@ -0,0 +1,42 @@ +import { CLIOutput } from "../../../src/presentation/output.js"; + +export type OutputLevel = "debug" | "info" | "warn" | "print" | "success" | "error"; + +export interface CapturedLine { + readonly level: OutputLevel; + readonly message: string; +} + +/** Extends the real output rather than standing in for it, so a widened double cannot stop + * failing the day the class grows a method a display starts calling. */ +export class CapturingOutput extends CLIOutput { + readonly captured: CapturedLine[] = []; + + /** Every message, in order, whatever its level. */ + get lines(): string[] { + return this.captured.map((line) => line.message); + } + + at(level: OutputLevel): string[] { + return this.captured.filter((line) => line.level === level).map((line) => line.message); + } + + override debug(message: string): void { + this.captured.push({ level: "debug", message }); + } + override info(message: string): void { + this.captured.push({ level: "info", message }); + } + override warn(message: string): void { + this.captured.push({ level: "warn", message }); + } + override print(message: string): void { + this.captured.push({ level: "print", message }); + } + override success(message: string): void { + this.captured.push({ level: "success", message }); + } + override error(message: string): void { + this.captured.push({ level: "error", message }); + } +} diff --git a/cli/tests/helpers/ports/deterministic-hasher.ts b/cli/tests/helpers/ports/deterministic-hasher.ts index c0e068bb2..7f5c6c8ba 100644 --- a/cli/tests/helpers/ports/deterministic-hasher.ts +++ b/cli/tests/helpers/ports/deterministic-hasher.ts @@ -1,12 +1,9 @@ import { createHash } from "node:crypto"; -import { FileHash } from "../../../src/domain/models/file.js"; -import type { Hasher } from "../../../src/domain/ports/hasher.js"; +import { FileHash } from "../../../src/kernel/file.js"; +import type { Hasher } from "../../../src/kernel/ports/hasher.js"; -/** - * Deterministic in-memory hasher using real MD5. - * Returns the same hash as HasherAdapter for identical content — keeping expected - * hash values valid across adapter and in-memory implementations. - */ +/** Deterministic in-memory hasher using real MD5: the same hash as `HasherAdapter` for identical + * content, so an expected value stays valid across the adapter and this one. */ export class DeterministicHasher implements Hasher { hash(content: string): FileHash { const hex = createHash("md5").update(content, "utf-8").digest("hex"); diff --git a/cli/tests/helpers/ports/fake-auth-reader.ts b/cli/tests/helpers/ports/fake-auth-reader.ts index 7e3cc8545..8f896b92f 100644 --- a/cli/tests/helpers/ports/fake-auth-reader.ts +++ b/cli/tests/helpers/ports/fake-auth-reader.ts @@ -1,8 +1,6 @@ -import type { TokenProvider } from "../../../src/domain/ports/token-provider.js"; +import type { TokenProvider } from "../../../src/runtime/auth/ports/token-provider.js"; -/** - * Returns a scripted token (or null) — no disk reads. - */ +/** Returns a scripted token (or null) — no disk reads. */ export class FakeAuthReader implements TokenProvider { constructor(private readonly token: string | null = null) {} diff --git a/cli/tests/helpers/ports/fake-current-version.ts b/cli/tests/helpers/ports/fake-current-version.ts index 2435ac257..1dd93020e 100644 --- a/cli/tests/helpers/ports/fake-current-version.ts +++ b/cli/tests/helpers/ports/fake-current-version.ts @@ -1,8 +1,5 @@ -import type { VersionReader } from "../../../src/domain/ports/version-reader.js"; +import type { VersionReader } from "../../../src/kernel/ports/version-reader.js"; -/** - * Returns a constant version string — no disk or package.json I/O. - */ export class FakeCurrentVersion implements VersionReader { constructor(private readonly version: string = "0.0.0-test") {} diff --git a/cli/tests/helpers/ports/fake-ensure-built-marketplace.ts b/cli/tests/helpers/ports/fake-ensure-built-marketplace.ts index c93275fbe..67fc43fd1 100644 --- a/cli/tests/helpers/ports/fake-ensure-built-marketplace.ts +++ b/cli/tests/helpers/ports/fake-ensure-built-marketplace.ts @@ -1,13 +1,10 @@ import type { EnsureBuiltMarketplace, EnsureBuiltMarketplaceOptions, -} from "../../../src/application/use-cases/shared/ensure-built-marketplace-use-case.js"; +} from "../../../src/contexts/framework/application/shared/ensure-built-marketplace-use-case.js"; -/** - * Stand-in for EnsureBuiltMarketplace that returns a deterministic per-target - * built dir without running a real framework build. Lets native/materialize tests - * assert "install consumes the built tree" without disk I/O. - */ +/** Stand-in for EnsureBuiltMarketplace returning a deterministic per-target built dir, so a + * native or materialize test can consume a built tree without disk I/O. */ export function fakeEnsureBuiltMarketplace( builtDirFor: (target: string) => string = (target) => `/built/${target}` ): EnsureBuiltMarketplace { diff --git a/cli/tests/helpers/ports/fake-host-marketplace-registry-reader.ts b/cli/tests/helpers/ports/fake-host-marketplace-registry-reader.ts new file mode 100644 index 000000000..02addbc23 --- /dev/null +++ b/cli/tests/helpers/ports/fake-host-marketplace-registry-reader.ts @@ -0,0 +1,25 @@ +import type { + HostMarketplaceRegistryReader, + HostMarketplaceRegistryReading, +} from "../../../src/contexts/tools/domain/ports/host-marketplace-registry-reader.js"; + +/** Stand-in for a host's own marketplace registry: one fixed reading unless a caller queues + * several, each `read()` consuming the next and the last repeating forever. */ +export class FakeHostMarketplaceRegistryReader implements HostMarketplaceRegistryReader { + private readonly queue: HostMarketplaceRegistryReading[]; + reads = 0; + + constructor(...readings: readonly HostMarketplaceRegistryReading[]) { + if (readings.length === 0) { + throw new Error("FakeHostMarketplaceRegistryReader needs at least one reading"); + } + this.queue = [...readings]; + } + + async read(): Promise { + this.reads += 1; + return this.queue.length > 1 + ? (this.queue.shift() as HostMarketplaceRegistryReading) + : this.queue[0]; + } +} diff --git a/cli/tests/helpers/ports/fake-host-plugin-registry-reader.ts b/cli/tests/helpers/ports/fake-host-plugin-registry-reader.ts new file mode 100644 index 000000000..66d89e74b --- /dev/null +++ b/cli/tests/helpers/ports/fake-host-plugin-registry-reader.ts @@ -0,0 +1,14 @@ +import type { + HostPluginRegistryReader, + HostPluginRegistryReading, +} from "../../../src/contexts/tools/domain/ports/host-plugin-registry-reader.js"; + +/** A registry double whose answer is fixed at construction — never a real file, never a + * real binary. Stands in for whichever host a doctor or telemetry test needs to ask. */ +export class FakeHostPluginRegistryReader implements HostPluginRegistryReader { + constructor(private readonly reading: HostPluginRegistryReading) {} + + async read(): Promise { + return this.reading; + } +} diff --git a/cli/tests/helpers/ports/fake-native-plugin-activator.ts b/cli/tests/helpers/ports/fake-native-plugin-activator.ts index 7d3519bb2..07e6ac70f 100644 --- a/cli/tests/helpers/ports/fake-native-plugin-activator.ts +++ b/cli/tests/helpers/ports/fake-native-plugin-activator.ts @@ -1,29 +1,28 @@ -import { NativePluginCliError } from "../../../src/domain/errors.js"; -import type { NativePluginActivator } from "../../../src/domain/ports/native-plugin-activator.js"; +import type { NativePluginActivator } from "../../../src/contexts/tools/domain/ports/native-plugin-activator.js"; +import { NativePluginCliError } from "../../../src/kernel/errors.js"; +import type { MarketplaceScope } from "../../../src/kernel/scope.js"; -/** - * Records native plugin CLI activation calls instead of shelling out. - * Defaults to unavailable so unit deps skip activation unless a test opts in. - * `failOnPlugins` makes `enablePlugin` throw for the listed refs (simulates a - * plugin missing from the marketplace snapshot). - * `conflictOnAdd` makes `addMarketplace` throw until `removeMarketplace` is called - * once (simulates the CLI rejecting `add` when the name exists from a different source). - * `throwOnRemove` makes `removeMarketplace` throw (simulates removing an absent name, - * i.e. an `add` that failed for a reason other than a different-source conflict). - * `failOnUninstall` makes `uninstallPlugin` throw for the listed refs (simulates the - * plugin already being absent from the tool's own registry). - */ +/** Records native plugin CLI calls instead of shelling out. Two measured shapes: a real + * `claude` refuses a mismatched-scope uninstall, and a plain `Error` no adapter produces. */ export class FakeNativePluginActivator implements NativePluginActivator { available: boolean; readonly addedMarketplaces: string[] = []; readonly removedMarketplaces: string[] = []; + readonly forcedRemovals: boolean[] = []; readonly enabledPlugins: string[] = []; readonly uninstalledPlugins: string[] = []; + /** The scope each call actually carried, in call order, never guessed from the ref. */ + readonly enabledPluginScopes: MarketplaceScope[] = []; + readonly uninstalledPluginScopes: MarketplaceScope[] = []; upgradeCount = 0; private readonly failOnPlugins: ReadonlySet; private readonly conflictOnAdd: boolean; private readonly throwOnRemove: boolean; + private readonly pluginsEnabledHere: boolean; + private readonly state: "live" | "dead" | "unknown"; private readonly failOnUninstall: ReadonlySet; + private readonly crashOnAddMarketplace: boolean; + private readonly installedAtScope: ReadonlyMap; constructor( options: { @@ -31,21 +30,42 @@ export class FakeNativePluginActivator implements NativePluginActivator { failOnPlugins?: readonly string[]; conflictOnAdd?: boolean; throwOnRemove?: boolean; + /** False for a tool whose plugins are enabled by a file this CLI writes. */ + enablesPlugins?: boolean; + /** What the tool answers about a name already registered. */ + registrationState?: "live" | "dead" | "unknown"; failOnUninstall?: readonly string[]; + crashOnAddMarketplace?: boolean; + installedAtScope?: ReadonlyMap; } = {} ) { this.available = options.available ?? false; this.failOnPlugins = new Set(options.failOnPlugins ?? []); this.conflictOnAdd = options.conflictOnAdd ?? false; this.throwOnRemove = options.throwOnRemove ?? false; + this.pluginsEnabledHere = options.enablesPlugins ?? true; + this.state = options.registrationState ?? "unknown"; this.failOnUninstall = new Set(options.failOnUninstall ?? []); + this.crashOnAddMarketplace = options.crashOnAddMarketplace ?? false; + this.installedAtScope = options.installedAtScope ?? new Map(); + } + + registrationState(): "live" | "dead" | "unknown" { + return this.state; + } + + enablesPlugins(): boolean { + return this.pluginsEnabledHere; } isAvailable(): boolean { return this.available; } - addMarketplace(source: string): void { + addMarketplace(source: string, _scope?: unknown): void { + if (this.crashOnAddMarketplace) { + throw new Error("activator crashed adding a marketplace"); + } if (this.conflictOnAdd && this.removedMarketplaces.length === 0) { throw new NativePluginCliError( "marketplace is already added from a different source; remove it before adding this source" @@ -54,7 +74,8 @@ export class FakeNativePluginActivator implements NativePluginActivator { this.addedMarketplaces.push(source); } - removeMarketplace(name: string): void { + removeMarketplace(name: string, _scope?: unknown, options?: { force?: boolean }): void { + this.forcedRemovals.push(options?.force === true); if (this.throwOnRemove) { throw new NativePluginCliError( `marketplace remove ${name} failed: '${name}' is not configured or installed` @@ -67,17 +88,25 @@ export class FakeNativePluginActivator implements NativePluginActivator { this.upgradeCount += 1; } - enablePlugin(pluginRef: string): void { + enablePlugin(pluginRef: string, scope: MarketplaceScope = "project"): void { + this.enabledPluginScopes.push(scope); if (this.failOnPlugins.has(pluginRef)) { throw new NativePluginCliError(`plugin \`${pluginRef}\` was not found in marketplace`); } this.enabledPlugins.push(pluginRef); } - uninstallPlugin(pluginRef: string): void { + uninstallPlugin(pluginRef: string, scope: MarketplaceScope = "project"): void { + this.uninstalledPluginScopes.push(scope); if (this.failOnUninstall.has(pluginRef)) { throw new NativePluginCliError(`plugin \`${pluginRef}\` is not installed`); } + const installedScope = this.installedAtScope.get(pluginRef); + if (installedScope !== undefined && installedScope !== scope) { + throw new NativePluginCliError( + `plugin \`${pluginRef}\` is not installed at scope '${scope}'` + ); + } this.uninstalledPlugins.push(pluginRef); } } diff --git a/cli/tests/helpers/ports/fake-platform.ts b/cli/tests/helpers/ports/fake-platform.ts index 63be6c95c..60ec96d7c 100644 --- a/cli/tests/helpers/ports/fake-platform.ts +++ b/cli/tests/helpers/ports/fake-platform.ts @@ -1,8 +1,5 @@ -import type { Platform } from "../../../src/domain/ports/platform.js"; +import type { Platform } from "../../../src/runtime/platform/platform.js"; -/** - * Static Platform implementation returning a fixed OS string. - */ export class FakePlatform implements Platform { constructor(private readonly platformName: string = "linux") {} diff --git a/cli/tests/helpers/ports/fixture-plugin-fetcher.ts b/cli/tests/helpers/ports/fixture-plugin-fetcher.ts index 836737eb9..412ba90ae 100644 --- a/cli/tests/helpers/ports/fixture-plugin-fetcher.ts +++ b/cli/tests/helpers/ports/fixture-plugin-fetcher.ts @@ -1,14 +1,11 @@ -import type { PluginSource } from "../../../src/domain/models/plugin-source.js"; -import { serializePluginSource } from "../../../src/domain/models/plugin-source.js"; import type { PluginFetcher, PluginFetchOptions, -} from "../../../src/domain/ports/plugin-fetcher.js"; +} from "../../../src/contexts/distribution/domain/ports/plugin-fetcher.js"; +import type { PluginSource } from "../../../src/kernel/source.js"; +import { serializePluginSource } from "../../../src/kernel/source.js"; -/** - * In-memory PluginFetcher that returns pre-staged paths from a local fixture map. - * Keyed by serialized PluginSource. - */ +/** In-memory `PluginFetcher` over a fixture map keyed by serialized `PluginSource`. */ export class FixturePluginFetcher implements PluginFetcher { private readonly fixtures: Map; @@ -25,20 +22,15 @@ export class FixturePluginFetcher implements PluginFetcher { const path = this.fixtures.get(key); if (path !== undefined) return path; - // Also try matching by "local" kind with path as key if (source.kind === "local") { const localPath = this.fixtures.get(source.path); if (localPath !== undefined) return localPath; - // If the source itself is a local path, return it directly (fixture on disk) return source.path; } throw new Error(`FixturePluginFetcher: no fixture registered for source ${key}`); } - /** - * Register a fixture: source key (JSON of serializePluginSource) → local dir path. - */ register(source: PluginSource, localPath: string): void { const key = JSON.stringify(serializePluginSource(source)); this.fixtures.set(key, localPath); diff --git a/cli/tests/helpers/ports/in-memory-environment.ts b/cli/tests/helpers/ports/in-memory-environment.ts new file mode 100644 index 000000000..d82478a5c --- /dev/null +++ b/cli/tests/helpers/ports/in-memory-environment.ts @@ -0,0 +1,17 @@ +import type { Environment } from "../../../src/contexts/framework/domain/ports/environment.js"; + +export class InMemoryEnvironment implements Environment { + private readonly values: Map; + + constructor(initial: Readonly> = {}) { + this.values = new Map(Object.entries(initial)); + } + + get(name: string): string | undefined { + return this.values.get(name); + } + + set(name: string, value: string): void { + this.values.set(name, value); + } +} diff --git a/cli/tests/helpers/ports/in-memory-file-adapter.ts b/cli/tests/helpers/ports/in-memory-file-adapter.ts index af3b63c90..ab2c8d5d2 100644 --- a/cli/tests/helpers/ports/in-memory-file-adapter.ts +++ b/cli/tests/helpers/ports/in-memory-file-adapter.ts @@ -1,28 +1,23 @@ import { createHash } from "node:crypto"; -import { stripJsonComments } from "../../../src/domain/formats/jsonc.js"; -import { FileHash } from "../../../src/domain/models/file.js"; +import type { FileMerger } from "../../../src/contexts/tools/domain/ports/file-merger.js"; +import { FileHash } from "../../../src/kernel/file.js"; import { isPerKeyMergeStrategy, type MergeStrategy, type PerKeyMergeStrategy, -} from "../../../src/domain/models/merge.js"; -import type { FileMerger } from "../../../src/domain/ports/file-merger.js"; -import type { FileReader } from "../../../src/domain/ports/file-reader.js"; -import type { FileWriter } from "../../../src/domain/ports/file-writer.js"; -import type { Hasher } from "../../../src/domain/ports/hasher.js"; - -/** - * Pure in-memory implementation of the FileReader, FileWriter, and FileMerger ports. - * Uses a Map — no real I/O. - */ +} from "../../../src/kernel/merge.js"; +import type { FileReader } from "../../../src/kernel/ports/file-reader.js"; +import type { FileWriter } from "../../../src/kernel/ports/file-writer.js"; +import type { Hasher } from "../../../src/kernel/ports/hasher.js"; +import { stripJsonComments } from "../../../src/kernel/reading/jsonc.js"; + export class InMemoryFileAdapter implements FileReader, FileWriter, FileMerger { /** Executable when `chmodExecutable` was called for it, absent otherwise — the two states * `delegateState` tells apart, without a filesystem. */ private readonly executable = new Set(); - /** Normalized like every other reader in this class. Skipping `norm` made this the one - * method that could answer for a path `fileExists` disagreed with — which is the exact - * contradiction `FileReader.isExecutable` sits behind the port to prevent. */ + /** Normalized like every other reader here: an un-normalized key would answer for a path + * `fileExists` disagrees with. */ async isExecutable(path: string): Promise { const key = norm(path); return this.files.has(key) && this.executable.has(key); @@ -30,6 +25,9 @@ export class InMemoryFileAdapter implements FileReader, FileWriter, FileMerger { private readonly files = new Map(); private readonly hasher: Hasher; + /** Path → what it resolves to, for a test proving `realpath`-based containment + * without a real filesystem — see `setSymlink`. */ + private readonly symlinks = new Map(); constructor(seed: Record = {}, hasher?: Hasher) { this.hasher = hasher ?? new DefaultHasher(); @@ -69,9 +67,7 @@ export class InMemoryFileAdapter implements FileReader, FileWriter, FileMerger { return content; } - /** - * Returns relative paths of all files under dirPath (recursive), same as FileAdapter. - */ + /** Relative paths, recursive, the shape `FileAdapter` returns. */ async listDirectory(dirPath: string): Promise { const normalizedDir = norm(dirPath); const prefix = normalizedDir.endsWith("/") ? normalizedDir : `${normalizedDir}/`; @@ -100,6 +96,28 @@ export class InMemoryFileAdapter implements FileReader, FileWriter, FileMerger { return this.hasher.hash(content); } + /** Resolves through the longest matching registered symlink, like a real `fs.realpath` + * walking a symlinked ancestor; identity when none was declared for the path. */ + async realpath(path: string): Promise { + const key = norm(path); + let bestMatch: { link: string; target: string } | undefined; + for (const [link, target] of this.symlinks) { + if (key === link || key.startsWith(`${link}/`)) { + if (bestMatch === undefined || link.length > bestMatch.link.length) { + bestMatch = { link, target }; + } + } + } + if (bestMatch === undefined) return key; + return bestMatch.target + key.slice(bestMatch.link.length); + } + + /** Test-only: declares that `path` is a symlink resolving to `target`, so a test can + * prove `realpath`-based containment without touching a real filesystem. */ + setSymlink(path: string, target: string): void { + this.symlinks.set(norm(path), norm(target)); + } + async mergeJsonFile(path: string, content: string, strategy: MergeStrategy): Promise { const normalizedPath = norm(path); let existing: Record = {}; @@ -128,6 +146,10 @@ export class InMemoryFileAdapter implements FileReader, FileWriter, FileMerger { this.files.set(normalizedPath, JSON.stringify(merged, null, 2)); } + async chmodExecutable(_path: string): Promise { + // No-op: no permission bits in memory + } + async deleteDirectory(dirPath: string): Promise { const normalizedDir = norm(dirPath); const prefix = normalizedDir.endsWith("/") ? normalizedDir : `${normalizedDir}/`; @@ -138,27 +160,6 @@ export class InMemoryFileAdapter implements FileReader, FileWriter, FileMerger { } } - async chmodExecutable(path: string): Promise { - this.executable.add(norm(path)); - } - - async backup(absolutePath: string): Promise { - const content = await this.readFile(absolutePath); - const timestamp = new Date() - .toISOString() - .slice(0, 19) - .replace(/[^0-9T]/g, ""); - const backupPath = `${norm(absolutePath)}.bak.${timestamp}`; - this.files.set(backupPath, content); - return backupPath; - } - - async hasLocalChanges(path: string, knownHash: FileHash): Promise { - if (!(await this.fileExists(path))) return false; - const diskHash = await this.readFileHash(path); - return diskHash.value !== knownHash.value; - } - async listFilesRecursive(dirPath: string): Promise { const normalizedDir = norm(dirPath); const prefix = normalizedDir.endsWith("/") ? normalizedDir : `${normalizedDir}/`; @@ -171,8 +172,6 @@ export class InMemoryFileAdapter implements FileReader, FileWriter, FileMerger { return result; } - // ── Inspection helpers for test assertions ────────────────────────────────── - setFile(path: string, content: string): void { this.files.set(norm(path), content); } @@ -202,8 +201,6 @@ function norm(path: string): string { return path.replaceAll("\\", "/"); } -// ── Private helpers ─────────────────────────────────────────────────────────── - class DefaultHasher implements Hasher { hash(content: string): FileHash { const hex = createHash("md5").update(content, "utf-8").digest("hex"); diff --git a/cli/tests/helpers/ports/in-memory-manifest-repository.ts b/cli/tests/helpers/ports/in-memory-manifest-repository.ts index 8de67a879..a88009176 100644 --- a/cli/tests/helpers/ports/in-memory-manifest-repository.ts +++ b/cli/tests/helpers/ports/in-memory-manifest-repository.ts @@ -1,14 +1,9 @@ -import type { Manifest } from "../../../src/domain/models/manifest.js"; -import type { ManifestRepository } from "../../../src/domain/ports/manifest-repository.js"; +import type { Manifest } from "../../../src/contexts/framework/domain/manifest.js"; +import type { ManifestRepository } from "../../../src/contexts/framework/domain/ports/manifest-repository.js"; -/** - * Pure in-memory implementation of the ManifestRepository port. - * Holds a single Manifest | null — no disk I/O. - */ export class InMemoryManifestRepository implements ManifestRepository { - /** Derived from the same root the test drives the use case with, never a fixed literal: - * this exists so a diagnostic can name the real file, and a double naming a fictional one - * would let that go wrong with every test still green. */ + /** Derived from the root the test drives the use case with, never a fixed literal: a + * double naming a fictional file lets a diagnostic go wrong with every test still green. */ readonly path: string; private manifest: Manifest | null; @@ -29,8 +24,6 @@ export class InMemoryManifestRepository implements ManifestRepository { this.manifest = null; } - // ── Inspection helpers ────────────────────────────────────────────────────── - getCurrent(): Manifest | null { return this.manifest; } diff --git a/cli/tests/helpers/ports/in-memory-marketplace-cache.ts b/cli/tests/helpers/ports/in-memory-marketplace-cache.ts index 0acb3adae..a7764ed77 100644 --- a/cli/tests/helpers/ports/in-memory-marketplace-cache.ts +++ b/cli/tests/helpers/ports/in-memory-marketplace-cache.ts @@ -1,36 +1,25 @@ -import type { MarketplaceCacheEntry } from "../../../src/domain/models/marketplace-cache-entry.js"; -import type { MarketplaceCachePort } from "../../../src/domain/ports/marketplace-cache.js"; +import type { MarketplaceCachePort } from "../../../src/contexts/distribution/domain/ports/marketplace-cache.js"; -/** - * Pure in-memory MarketplaceCachePort. - */ +/** Pure in-memory MarketplaceCachePort: names only, since clearing is the whole port. */ export class InMemoryMarketplaceCache implements MarketplaceCachePort { - private readonly entries = new Map(); + private readonly names = new Set(); /** Every clear() argument in call order, so callers can assert whether and how it was cleared. */ readonly clearCalls: (string | undefined)[] = []; - constructor(seed: MarketplaceCacheEntry[] = []) { - for (const entry of seed) { - this.entries.set(entry.name, entry); - } - } - - async list(): Promise { - return [...this.entries.values()]; + constructor(seed: readonly string[] = []) { + for (const name of seed) this.names.add(name); } async clear(name?: string): Promise { this.clearCalls.push(name); if (name !== undefined) { - this.entries.delete(name); + this.names.delete(name); } else { - this.entries.clear(); + this.names.clear(); } } - // ── Inspection helpers ────────────────────────────────────────────────────── - has(name: string): boolean { - return this.entries.has(name); + return this.names.has(name); } } diff --git a/cli/tests/helpers/ports/in-memory-marketplace-registry.ts b/cli/tests/helpers/ports/in-memory-marketplace-registry.ts index d2232cad3..b58846688 100644 --- a/cli/tests/helpers/ports/in-memory-marketplace-registry.ts +++ b/cli/tests/helpers/ports/in-memory-marketplace-registry.ts @@ -1,10 +1,7 @@ -import type { Marketplace, MarketplaceScope } from "../../../src/domain/models/marketplace.js"; -import type { MarketplaceRegistry } from "../../../src/domain/ports/marketplace-registry.js"; +import type { Marketplace } from "../../../src/contexts/distribution/domain/marketplace.js"; +import type { MarketplaceRegistry } from "../../../src/contexts/distribution/domain/ports/marketplace-registry.js"; +import type { MarketplaceScope } from "../../../src/kernel/scope.js"; -/** - * Pure in-memory MarketplaceRegistry. - * Stores entries per (projectRoot, name) — no disk I/O. - */ export class InMemoryMarketplaceRegistry implements MarketplaceRegistry { private readonly project = new Map(); private readonly user = new Map(); @@ -54,8 +51,6 @@ export class InMemoryMarketplaceRegistry implements MarketplaceRegistry { } } - // ── Inspection helpers ────────────────────────────────────────────────────── - getAll(projectRoot: string): readonly Marketplace[] { return [...this.getProjectEntries(projectRoot), ...this.user.values()]; } diff --git a/cli/tests/helpers/ports/in-memory-marketplace-trust-store.ts b/cli/tests/helpers/ports/in-memory-marketplace-trust-store.ts index 60c038453..15ca74b52 100644 --- a/cli/tests/helpers/ports/in-memory-marketplace-trust-store.ts +++ b/cli/tests/helpers/ports/in-memory-marketplace-trust-store.ts @@ -1,12 +1,9 @@ import { createHash } from "node:crypto"; -import type { PluginSource } from "../../../src/domain/models/plugin-source.js"; -import { serializePluginSource } from "../../../src/domain/models/plugin-source.js"; -import type { MarketplaceTrustStore } from "../../../src/domain/ports/marketplace-trust-store.js"; +import type { MarketplaceTrustStore } from "../../../src/contexts/distribution/domain/ports/marketplace-trust-store.js"; +import type { PluginSource } from "../../../src/kernel/source.js"; +import { serializePluginSource } from "../../../src/kernel/source.js"; -/** - * Pure in-memory MarketplaceTrustStore — no disk I/O. - * Keyed by MD5 of the canonical serialized source (same algorithm as the real adapter). - */ +/** Pure in-memory MarketplaceTrustStore, keyed by MD5 of the canonical serialized source. */ export class InMemoryMarketplaceTrustStore implements MarketplaceTrustStore { private readonly trusted = new Set(); @@ -18,8 +15,6 @@ export class InMemoryMarketplaceTrustStore implements MarketplaceTrustStore { this.trusted.add(this.key(source)); } - // ── Inspection helpers ────────────────────────────────────────────────────── - isTrustedSync(source: PluginSource): boolean { return this.trusted.has(this.key(source)); } diff --git a/cli/tests/helpers/ports/in-memory-person-identity-reader.ts b/cli/tests/helpers/ports/in-memory-person-identity-reader.ts index 26140dfe4..c6695558f 100644 --- a/cli/tests/helpers/ports/in-memory-person-identity-reader.ts +++ b/cli/tests/helpers/ports/in-memory-person-identity-reader.ts @@ -1,7 +1,7 @@ import type { PersonIdentity, PersonIdentityReader, -} from "../../../src/domain/ports/person-identity-reader.js"; +} from "../../../src/contexts/telemetry/domain/ports/person-identity-reader.js"; /** In-memory double for `PersonIdentityReader` — one identity, set once, or `null`. */ export class InMemoryPersonIdentityReader implements PersonIdentityReader { diff --git a/cli/tests/helpers/ports/in-memory-person-identity-store.ts b/cli/tests/helpers/ports/in-memory-person-identity-store.ts index 7a9b906ba..c7e5366d2 100644 --- a/cli/tests/helpers/ports/in-memory-person-identity-store.ts +++ b/cli/tests/helpers/ports/in-memory-person-identity-store.ts @@ -2,27 +2,21 @@ import { withAlsoMeAdded, withAlsoMeRemoved, withPersonIdAdopted, -} from "../../../src/domain/models/person-resolution.js"; -import type { PersonIdentity } from "../../../src/domain/ports/person-identity-reader.js"; -import type { PersonIdentityStore } from "../../../src/domain/ports/person-identity-store.js"; +} from "../../../src/contexts/telemetry/domain/person-resolution.js"; +import type { PersonIdentity } from "../../../src/contexts/telemetry/domain/ports/person-identity-reader.js"; +import type { PersonIdentityStore } from "../../../src/contexts/telemetry/domain/ports/person-identity-store.js"; -/** In-memory double for `PersonIdentityStore` — one identity, or `null`, mutated the way - * the real adapter's file would be by `mint`/`adopt`/`addAlsoMe`/`removeAlsoMe`/ - * `setDisplayName`/`forget`. `throwOnRead`, when set, is what `readStrict()` throws instead - * of answering — standing in for a damaged or unreadable identity file without touching a - * real one. `throwOnForget`, when set, is what `forget()` throws instead of removing — - * standing in for a file that refuses deletion. */ +/** In-memory double for `PersonIdentityStore`. `throwOnRead` and `throwOnForget`, when set, are + * what `readStrict()` and `forget()` throw — a damaged, or an undeletable, identity file. */ export class InMemoryPersonIdentityStore implements PersonIdentityStore { mintCount = 0; forgetCount = 0; throwOnForget: Error | null = null; - /** The `path` argument `forget()` actually received, last call wins — what a mutation - * test checks to prove a caller passed the preview's own path, never this double's fixed - * `filePath`. */ + /** The `path` `forget()` actually received: what proves a caller passed the preview's own + * path, never this double's fixed `filePath`. */ forgetCalledWithPath: string | null = null; - /** Whether a file would be on disk. Distinct from `identity` on purpose: a real file - * holding an empty `person_id` parses to `null` while still existing, and that is the - * case `off` has to keep working for. Seeded from the identity, settable directly. */ + /** Whether a file would be on disk. Distinct from `identity`: a real file holding an empty + * `person_id` parses to `null` while still existing. */ filePresent: boolean; throwOnRead: Error | null = null; diff --git a/cli/tests/helpers/ports/in-memory-run-journal-reader.ts b/cli/tests/helpers/ports/in-memory-run-journal-reader.ts index e6ecda01f..f0478fbb8 100644 --- a/cli/tests/helpers/ports/in-memory-run-journal-reader.ts +++ b/cli/tests/helpers/ports/in-memory-run-journal-reader.ts @@ -1,13 +1,11 @@ -import type { RunJournal, RunJournalStore } from "../../../src/domain/ports/run-journal-reader.js"; - -/** In-memory double for `RunJournalStore` — a journal per session id, or `null` for a - * session the map holds nothing for, mirroring the port's own contract of never throwing. - * `runFileNames` is settable directly rather than derived from `journals`: a name-only - * listing must be able to name a file `list()` could never parse, which is exactly the - * damaged-journal case a caller of `listRunFiles()` needs. `undeletable`, mirroring - * `InMemoryTelemetrySink`, stands in for a run file that refuses removal. `deletedFromDirs` - * records every `dir` argument `deleteRunFile` actually received — what a mutation test - * checks to prove a caller passed the preview's own path, never this double's `runsDir`. */ +import type { + RunJournal, + RunJournalStore, +} from "../../../src/contexts/telemetry/domain/ports/run-journal-reader.js"; + +/** In-memory double for `RunJournalStore`. `runFileNames` is settable rather than derived, so + * a listing can name a file `list()` could never parse; `undeletable` stands in for a run file + * that refuses removal; `deletedFromDirs` and `listCalls` record what a caller actually did. */ export class InMemoryRunJournalReader implements RunJournalStore { readonly runsDir = "/fake/project/aidd_docs/runs"; runFileNames: string[] = []; @@ -17,6 +15,7 @@ export class InMemoryRunJournalReader implements RunJournalStore { readonly deletedFiles: string[] = []; readonly deletedFromDirs: string[] = []; readonly undeletable = new Set(); + listCalls = 0; private readonly journals = new Map(); set(sessionId: string, journal: RunJournal): void { @@ -28,6 +27,7 @@ export class InMemoryRunJournalReader implements RunJournalStore { } async list(): Promise { + this.listCalls += 1; return [...this.journals.values()]; } diff --git a/cli/tests/helpers/ports/in-memory-task-backlog-reader.ts b/cli/tests/helpers/ports/in-memory-task-backlog-reader.ts index b47b4a6ed..2dc7e1e25 100644 --- a/cli/tests/helpers/ports/in-memory-task-backlog-reader.ts +++ b/cli/tests/helpers/ports/in-memory-task-backlog-reader.ts @@ -1,10 +1,8 @@ -import type { TaskBacklogDeclaration } from "../../../src/domain/models/task-backlog-link.js"; -import type { TaskBacklogReader } from "../../../src/domain/ports/task-backlog-reader.js"; +import type { TaskBacklogReader } from "../../../src/contexts/telemetry/domain/ports/task-backlog-reader.js"; +import type { TaskBacklogDeclaration } from "../../../src/contexts/telemetry/domain/task-backlog-link.js"; -/** In-memory double for `TaskBacklogReader` — one declaration per task folder path, or - * `{ kind: "none" }` for a path the map holds nothing for, mirroring the port's own - * contract of never throwing. Lets the report's own tests exercise every axis with no - * filesystem. */ +/** In-memory double for `TaskBacklogReader`: `{ kind: "none" }` for a path it holds nothing + * for, mirroring the port's own contract of never throwing. */ export class InMemoryTaskBacklogReader implements TaskBacklogReader { private readonly declarations = new Map(); diff --git a/cli/tests/helpers/ports/in-memory-telemetry-sink.ts b/cli/tests/helpers/ports/in-memory-telemetry-sink.ts index a2e1a231f..a4c2b2f1c 100644 --- a/cli/tests/helpers/ports/in-memory-telemetry-sink.ts +++ b/cli/tests/helpers/ports/in-memory-telemetry-sink.ts @@ -1,12 +1,12 @@ -import { - type TelemetrySinkRecord, - telemetrySinkRecordDayKey, -} from "../../../src/domain/models/telemetry-sink-record.js"; import type { TelemetrySink, TelemetrySinkAppendResult, TelemetrySinkPeriodRead, -} from "../../../src/domain/ports/telemetry-sink.js"; +} from "../../../src/contexts/telemetry/domain/ports/telemetry-sink.js"; +import { + type TelemetrySinkRecord, + telemetrySinkRecordDayKey, +} from "../../../src/contexts/telemetry/domain/telemetry-sink-record.js"; function dayKey(at: Date): string { return at.toISOString().slice(0, 10); @@ -16,10 +16,8 @@ function dayFileName(at: Date): string { return `${dayKey(at)}.jsonl`; } -/** In-memory double for `TelemetrySink` — day files keyed by name, in append order. - * `deletedFromDirs` records every `dir` argument `deleteDayFile` actually received — what a - * mutation test checks to prove a caller passed the preview's own path, never this double's - * `rootDir`. */ +/** `deletedFromDirs` records every `dir` argument `deleteDayFile` received, which is what + * proves a caller passed the preview's own path and never this double's `rootDir`. */ export class InMemoryTelemetrySink implements TelemetrySink { /** Settable, so a test can stand in for a machine that located its figures either way. */ locatedBy: TelemetrySink["locatedBy"] = "default"; @@ -58,12 +56,8 @@ export class InMemoryTelemetrySink implements TelemetrySink { return [...this.files.values()].flat().filter((record) => record.vendor_id === vendorId); } - /** Selects on each record's own moment through the same domain derivation the real - * adapter uses, so the two cannot disagree on a non-UTC offset or a malformed moment — - * the day file a record landed in is when it was stored, not when the work ran. Holds - * nothing - * unparseable, so a period read from this double always reports zero skipped; the - * counting itself is the real adapter's, exercised against real files there. */ + /** Selects through the same domain derivation the real adapter uses, so the two cannot + * disagree on a non-UTC offset. Nothing unparseable is held, so skipped is always zero. */ async readRecordsInPeriod(fromDay: Date, toDay: Date): Promise { const [fromKey, toKey] = [dayKey(fromDay), dayKey(toDay)].sort(); const records: TelemetrySinkRecord[] = []; diff --git a/cli/tests/helpers/ports/index.ts b/cli/tests/helpers/ports/index.ts index cea9de262..ef48c38e8 100644 --- a/cli/tests/helpers/ports/index.ts +++ b/cli/tests/helpers/ports/index.ts @@ -5,6 +5,7 @@ export { FakeCurrentVersion } from "./fake-current-version.js"; export { FakeNativePluginActivator } from "./fake-native-plugin-activator.js"; export { darwinPlatform, FakePlatform, linuxPlatform, win32Platform } from "./fake-platform.js"; export { FixturePluginFetcher } from "./fixture-plugin-fetcher.js"; +export { InMemoryEnvironment } from "./in-memory-environment.js"; export { InMemoryFileAdapter } from "./in-memory-file-adapter.js"; export { InMemoryManifestRepository } from "./in-memory-manifest-repository.js"; export { InMemoryMarketplaceCache } from "./in-memory-marketplace-cache.js"; diff --git a/cli/tests/helpers/ports/scripted-prompter.ts b/cli/tests/helpers/ports/scripted-prompter.ts index 9b23f1e3a..3d477e439 100644 --- a/cli/tests/helpers/ports/scripted-prompter.ts +++ b/cli/tests/helpers/ports/scripted-prompter.ts @@ -1,4 +1,4 @@ -import type { Prompter } from "../../../src/domain/ports/prompter.js"; +import type { Prompter } from "../../../src/kernel/ports/prompter.js"; type PromptAnswer = | { type: "conflict"; value: "keep" | "overwrite" } @@ -8,10 +8,7 @@ type PromptAnswer = | { type: "select"; value: string } | { type: "checkbox"; value: string[] }; -/** - * Scripted prompter that returns pre-defined answers in order. - * Throws on unexpected prompt calls (queue exhausted). - */ +/** Returns pre-defined answers in order, throwing once the queue is exhausted. */ export class ScriptedPrompter implements Prompter { private readonly queue: PromptAnswer[]; private index = 0; @@ -94,8 +91,6 @@ export class ScriptedPrompter implements Prompter { return answer; } - // ── Builder helpers ──────────────────────────────────────────────────────── - static answer = { conflict(value: "keep" | "overwrite"): PromptAnswer { return { type: "conflict", value }; @@ -118,9 +113,7 @@ export class ScriptedPrompter implements Prompter { }; } -/** - * Always-overwrite variant — convenience for tests that don't care about conflicts. - */ +/** Always-overwrite variant, for tests that do not care about conflicts. */ export class OverwritePrompter implements Prompter { async resolveConflict( _relativePath: string, @@ -161,9 +154,7 @@ export class OverwritePrompter implements Prompter { } } -/** - * Always-keep variant — convenience for tests that preserve user files. - */ +/** Always-keep variant, for tests that preserve user files. */ export class KeepPrompter implements Prompter { async resolveConflict( _relativePath: string, diff --git a/cli/tests/helpers/ports/seed-from-directory.ts b/cli/tests/helpers/ports/seed-from-directory.ts index 6f781551c..664f02958 100644 --- a/cli/tests/helpers/ports/seed-from-directory.ts +++ b/cli/tests/helpers/ports/seed-from-directory.ts @@ -3,19 +3,14 @@ import { join, relative } from "node:path"; import type { InMemoryFileAdapter } from "./in-memory-file-adapter.js"; interface SeedOptions { - /** - * If true, use the absolute path as the key instead of the path relative to dirPath. - * Required when the use-case constructs paths via join(frameworkPath, relativePart). - */ + /** Key by absolute path rather than one relative to dirPath — required when the use case + * constructs paths via join(frameworkPath, relativePart). */ useAbsolutePaths?: boolean; /** Path prefix to prepend when useAbsolutePaths is false (default: ""). */ prefix?: string; } -/** - * Seeds an InMemoryFileAdapter from a real directory on disk. - * Used once at test setup to replicate fixture content — the use-case run itself stays I/O-free. - */ +/** Runs once at setup, so the use-case run it seeds for stays I/O-free. */ export async function seedFromDirectory( fs: InMemoryFileAdapter, dirPath: string, diff --git a/cli/tests/helpers/ports/stub-telemetry-evidence-reader.ts b/cli/tests/helpers/ports/stub-telemetry-evidence-reader.ts index 70f5e538b..b12bc3eb7 100644 --- a/cli/tests/helpers/ports/stub-telemetry-evidence-reader.ts +++ b/cli/tests/helpers/ports/stub-telemetry-evidence-reader.ts @@ -1,17 +1,13 @@ -import type { TelemetryExportLeftover } from "../../../src/domain/models/telemetry-export-leftover.js"; -import type { TelemetryRecorderDeclarationSetup } from "../../../src/domain/models/telemetry-setup.js"; import type { TelemetryEvidenceReader, TelemetrySwitchSetupRead, TelemetryUnrecognisedPayload, -} from "../../../src/domain/ports/telemetry-evidence-reader.js"; +} from "../../../src/contexts/telemetry/domain/ports/telemetry-evidence-reader.js"; +import type { TelemetryExportLeftover } from "../../../src/contexts/telemetry/domain/telemetry-export-leftover.js"; +import type { TelemetryRecorderDeclarationSetup } from "../../../src/contexts/telemetry/domain/telemetry-setup.js"; -/** Shared by every test that needs `TelemetryEvidenceReader` without a real config file on - * disk — `enabled` defaults to `true` since that is the ordinary case a report or a check - * runs against; a test that cares about the off state sets it explicitly. `leftoverExport` - * defaults to empty — a machine with nothing left over — for the same reason. - * `switchSetup`/`recorderDeclaration` default to a readable, absent-everywhere state — a - * clean machine that has never touched either fact. */ +/** `TelemetryEvidenceReader` without a real config file on disk. Every field defaults to the + * ordinary clean-machine state, so a test sets only the one it cares about. */ export class StubTelemetryEvidenceReader implements TelemetryEvidenceReader { enabled = true; unrecognisedPayload: TelemetryUnrecognisedPayload | null = null; diff --git a/cli/tests/helpers/repository-root.ts b/cli/tests/helpers/repository-root.ts new file mode 100644 index 000000000..ccaf16574 --- /dev/null +++ b/cli/tests/helpers/repository-root.ts @@ -0,0 +1,16 @@ +import { existsSync } from "node:fs"; +import { dirname, join, resolve } from "node:path"; + +/** Walks up to the marketplace manifest, so a sandboxed copy of `cli/` (a mutation run) + * still reads the real repository's plugins and product documents. */ +export function repositoryRoot(from: string = import.meta.dirname): string { + let dir = resolve(from); + while (!existsSync(join(dir, ".claude-plugin", "marketplace.json"))) { + const parent = dirname(dir); + if (parent === dir) throw new Error(`no repository root above ${from}`); + dir = parent; + } + return dir; +} + +export const REPOSITORY_ROOT = repositoryRoot(); diff --git a/cli/tests/helpers/sweep-stale-temp-dirs.ts b/cli/tests/helpers/sweep-stale-temp-dirs.ts index c6c9bd515..264e61c46 100644 --- a/cli/tests/helpers/sweep-stale-temp-dirs.ts +++ b/cli/tests/helpers/sweep-stale-temp-dirs.ts @@ -1,12 +1,10 @@ import { createRequire } from "node:module"; +import { join } from "node:path"; +import { REPOSITORY_ROOT } from "./repository-root.js"; -/** - * Vitest's entry point into the repository's single sweep. - * - * The implementation is `scripts/sweep-stale-test-dirs.cjs`, shared with the plugin's - * `node:test` suites rather than reimplemented here — two copies of a housekeeping rule - * would be exactly the duplication the read path just spent three phases removing. - */ +/** Vitest's entry point into the repository's single sweep. The implementation is + * `scripts/sweep-stale-test-dirs.cjs`, shared with the plugin's `node:test` suites so one + * housekeeping rule is never written twice. */ const require_ = createRequire(import.meta.url); interface Sweep { @@ -14,10 +12,12 @@ interface Sweep { } export function sweepStaleTempDirs(now?: number): number { - const { sweepStaleTestDirs } = require_("../../../scripts/sweep-stale-test-dirs.cjs") as Sweep; + const { sweepStaleTestDirs } = require_( + join(REPOSITORY_ROOT, "scripts", "sweep-stale-test-dirs.cjs") + ) as Sweep; return sweepStaleTestDirs(now); } -export default function setup(): void { +export function setup(): void { sweepStaleTempDirs(); } diff --git a/cli/tests/helpers/telemetry-journal-hook.ts b/cli/tests/helpers/telemetry-journal-hook.ts index 680e61f08..2c3c75d3c 100644 --- a/cli/tests/helpers/telemetry-journal-hook.ts +++ b/cli/tests/helpers/telemetry-journal-hook.ts @@ -1,12 +1,13 @@ import { createRequire } from "node:module"; +import { join } from "node:path"; +import { REPOSITORY_ROOT } from "./repository-root.js"; -/** - * The journal hook is zero-dependency CommonJS that `aidd framework build` copies verbatim - * into user projects, so it ships no types and production code cannot import it — esbuild - * leaves no `require` in the CLI's ESM output. Tests reach it here instead, declaring only - * the surface they exercise; a name the hook stops exporting becomes a call on `undefined`, - * which fails loudly rather than silently. - */ +const hookLib = (name: string): string => + join(REPOSITORY_ROOT, "plugins", "aidd-telemetry", "hooks", "lib", name); + +/** The journal hook is zero-dependency CommonJS copied verbatim into user projects, so it ships + * no types and production code cannot import it. Tests reach it here, declaring only the + * surface they exercise, so a name the hook stops exporting fails loudly. */ interface JournalRepoModule { getRepoRoot(cwd: string): string | null; getRemoteUrl(repoRoot: string): string | null; @@ -18,25 +19,20 @@ interface JournalRepoModule { personRefusesTelemetry(): boolean; } -export const journalRepo: JournalRepoModule = createRequire(import.meta.url)( - "../../../plugins/aidd-telemetry/hooks/lib/repo.cjs" -); +export const journalRepo: JournalRepoModule = createRequire(import.meta.url)(hookLib("repo.cjs")); -/** - * The same reach into `record.cjs`, for the one derivation the reader side must agree with: - * a Codex session's identity, taken from the rollout the hook is told the session writes. - */ +/** The same reach into `record.cjs`, for the one derivation the reader side must agree with: a + * Codex session's identity, taken from the rollout the hook is told the session writes. */ interface JournalRecordModule { codexSessionIdFromTranscriptPath(transcriptPath: unknown): string | undefined; readSessionId(host: string, payload: Record): string | undefined; - /** The schema the hook stamps on every `session_start` it writes. Reached rather than - * copied so the reader's own notion of which schema it can read is pinned against the - * writer's, not against a second constant that can drift from it silently. */ + /** The schema the hook stamps on every `session_start`. Reached rather than copied, so the + * reader's notion of what it can read is pinned against the writer's, not a second constant. */ SCHEMA_VERSION: number; } export const journalRecord: JournalRecordModule = createRequire(import.meta.url)( - "../../../plugins/aidd-telemetry/hooks/lib/record.cjs" + hookLib("record.cjs") ); /** The hook's own list of the hosts it writes for, so a conformance test can compare it @@ -45,13 +41,10 @@ interface JournalHostModule { DECLARED_HOSTS: ReadonlySet; } -export const journalHost: JournalHostModule = createRequire(import.meta.url)( - "../../../plugins/aidd-telemetry/hooks/lib/host.cjs" -); +export const journalHost: JournalHostModule = createRequire(import.meta.url)(hookLib("host.cjs")); -/** The hook's file-writes module, for the one line phase 2's task derivation rests on. - * `WRITTEN_PATH_EXTRACTOR_BY_HOST` is exposed so a test can assert which hosts are covered - * rather than assume all of them are. */ +/** The hook's file-writes module. `WRITTEN_PATH_EXTRACTOR_BY_HOST` is exposed so a test can + * assert which hosts are covered rather than assume all of them are. */ interface JournalFileWritesModule { WRITTEN_PATH_EXTRACTOR_BY_HOST: Readonly>; taskFolderRelativePath(repoRoot: string, rawPath: string): string | null; @@ -63,12 +56,11 @@ interface JournalFileWritesModule { } export const journalFileWrites: JournalFileWritesModule = createRequire(import.meta.url)( - "../../../plugins/aidd-telemetry/hooks/lib/file-writes.cjs" + hookLib("file-writes.cjs") ); -/** The hook's declaration module, for the same reason `journalFileWrites` is exposed: a - * task can now be declared on any host `journal.cjs`'s `tool-used` dispatch reaches, and this - * is the one place that reads a tool call's own arguments for it. */ +/** The hook's declaration module: a task can be declared on any host `journal.cjs`'s + * `tool-used` dispatch reaches, and this is the one place that reads a call's own arguments. */ interface JournalTaskDeclaredModule { declaredTaskPath(payload: Record): string | null; handleTaskDeclared( @@ -79,12 +71,11 @@ interface JournalTaskDeclaredModule { } export const journalTaskDeclared: JournalTaskDeclaredModule = createRequire(import.meta.url)( - "../../../plugins/aidd-telemetry/hooks/lib/task-declared.cjs" + hookLib("task-declared.cjs") ); -/** The hook's own trailer repair. Exposed for the one thing a test on this side must prove - * and the hook's own suite cannot: that the line this writes is character for character the - * line the CLI writes, when neither can import the other. */ +/** The hook's own trailer repair, exposed for the one thing the hook's own suite cannot prove: + * that the line it writes is character for character the line the CLI writes. */ interface JournalTrailerRepairModule { repairCommitTrailerHook(hooksDir: string | undefined, gitDir?: string): string; hookLine(delegatePath: string): string; @@ -94,5 +85,5 @@ interface JournalTrailerRepairModule { } export const journalTrailerRepair: JournalTrailerRepairModule = createRequire(import.meta.url)( - "../../../plugins/aidd-telemetry/hooks/lib/trailer-repair.cjs" + hookLib("trailer-repair.cjs") ); diff --git a/cli/tests/infrastructure/adapters/ajv-schema-validator-adapter.unit.test.ts b/cli/tests/infrastructure/adapters/ajv-schema-validator-adapter.unit.test.ts deleted file mode 100644 index c0b08e8cf..000000000 --- a/cli/tests/infrastructure/adapters/ajv-schema-validator-adapter.unit.test.ts +++ /dev/null @@ -1,70 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { JsonSchemaValidationError } from "../../../src/domain/errors.js"; -import { AjvSchemaValidatorAdapter } from "../../../src/infrastructure/adapters/ajv-schema-validator-adapter.js"; - -const STRING_SCHEMA = { type: "string" }; -const OBJECT_SCHEMA = { - type: "object", - properties: { - name: { type: "string" }, - age: { type: "number" }, - }, - required: ["name"], -}; - -describe("AjvSchemaValidatorAdapter", () => { - describe("validate", () => { - it("does not throw for valid data against string schema", () => { - const validator = new AjvSchemaValidatorAdapter(); - expect(() => validator.validate(STRING_SCHEMA, "hello")).not.toThrow(); - }); - - it("throws JsonSchemaValidationError for invalid type", () => { - const validator = new AjvSchemaValidatorAdapter(); - expect(() => validator.validate(STRING_SCHEMA, 42)).toThrow(JsonSchemaValidationError); - }); - - it("does not throw for valid object", () => { - const validator = new AjvSchemaValidatorAdapter(); - expect(() => validator.validate(OBJECT_SCHEMA, { name: "Alice", age: 30 })).not.toThrow(); - }); - - it("throws when required property is missing", () => { - const validator = new AjvSchemaValidatorAdapter(); - expect(() => validator.validate(OBJECT_SCHEMA, { age: 30 })).toThrow( - JsonSchemaValidationError - ); - }); - - it("error message includes field path", () => { - const validator = new AjvSchemaValidatorAdapter(); - try { - validator.validate(OBJECT_SCHEMA, { name: 123 }); - expect.fail("should have thrown"); - } catch (e) { - expect(e).toBeInstanceOf(JsonSchemaValidationError); - expect((e as Error).message).toContain("/name"); - } - }); - - it("collects all errors when allErrors is true", () => { - const schema = { - type: "object", - properties: { - a: { type: "string" }, - b: { type: "number" }, - }, - required: ["a", "b"], - }; - const validator = new AjvSchemaValidatorAdapter(); - try { - validator.validate(schema, {}); - expect.fail("should have thrown"); - } catch (e) { - expect(e).toBeInstanceOf(JsonSchemaValidationError); - expect((e as Error).message).toContain("a"); - expect((e as Error).message).toContain("b"); - } - }); - }); -}); diff --git a/cli/tests/infrastructure/adapters/claude-cli-adapter.integration.test.ts b/cli/tests/infrastructure/adapters/claude-cli-adapter.integration.test.ts deleted file mode 100644 index 08fb41054..000000000 --- a/cli/tests/infrastructure/adapters/claude-cli-adapter.integration.test.ts +++ /dev/null @@ -1,148 +0,0 @@ -import { spawnSync } from "node:child_process"; -import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; -import { afterEach, describe, expect, it, vi } from "vitest"; -import { NativePluginCliError } from "../../../src/domain/errors.js"; -import { ClaudeCliAdapter } from "../../../src/infrastructure/adapters/claude-cli-adapter.js"; - -function pathWithExecutable(name: string): { dir: string; restore: () => void } { - const dir = mkdtempSync(join(tmpdir(), "aidd-bin-")); - writeFileSync(join(dir, name), "#!/bin/sh\n", { mode: 0o755 }); - const prev = process.env.PATH; - process.env.PATH = dir; - return { - dir, - restore: () => { - process.env.PATH = prev; - rmSync(dir, { recursive: true, force: true }); - }, - }; -} - -vi.mock("node:child_process", () => ({ - spawnSync: vi.fn(), -})); - -const mockSpawnSync = vi.mocked(spawnSync); - -function makeResult(overrides: Partial>) { - return { - pid: 1, - output: [], - stdout: "", - stderr: "", - status: 0, - signal: null, - error: undefined, - ...overrides, - } as ReturnType; -} - -// Measured on #703: a project's `.claude/settings.json` can declare -// `extraKnownMarketplaces`/`enabledPlugins` correctly and `claude -p` will still drop -// it as "orphaned" — the runtime only loads what's in its own user-global registry -// (`~/.claude/plugins/known_marketplaces.json`, `installed_plugins.json`), which only -// `claude plugin marketplace add` / `claude plugin install` populate. These commands -// are the ones proven, in a throwaway project, to make that registry match and a -// headless session resolve the plugin's skill. -describe("ClaudeCliAdapter", () => { - let restorePath: (() => void) | undefined; - afterEach(() => { - restorePath?.(); - restorePath = undefined; - }); - - it("reports available when the claude binary is on PATH (no spawn)", () => { - const env = pathWithExecutable("claude"); - restorePath = env.restore; - - expect(new ClaudeCliAdapter().isAvailable()).toBe(true); - expect(mockSpawnSync).not.toHaveBeenCalled(); - }); - - it("reports unavailable when the claude binary is not on PATH", () => { - const emptyDir = mkdtempSync(join(tmpdir(), "aidd-empty-")); - const prev = process.env.PATH; - process.env.PATH = emptyDir; - restorePath = () => { - process.env.PATH = prev; - rmSync(emptyDir, { recursive: true, force: true }); - }; - - expect(new ClaudeCliAdapter().isAvailable()).toBe(false); - }); - - it("registers a project-scoped marketplace via `claude plugin marketplace add`", () => { - mockSpawnSync.mockReturnValue(makeResult({})); - - new ClaudeCliAdapter().addMarketplace("/abs/mkt"); - - expect(mockSpawnSync).toHaveBeenCalledWith( - "claude", - ["plugin", "marketplace", "add", "--scope", "project", "/abs/mkt"], - expect.anything() - ); - }); - - it("upgrades marketplaces via `claude plugin marketplace update`", () => { - mockSpawnSync.mockReturnValue(makeResult({})); - - new ClaudeCliAdapter().upgradeMarketplaces(); - - expect(mockSpawnSync).toHaveBeenCalledWith( - "claude", - ["plugin", "marketplace", "update"], - expect.anything() - ); - }); - - it("enables a plugin via `claude plugin install --scope project --yes`", () => { - mockSpawnSync.mockReturnValue(makeResult({})); - - new ClaudeCliAdapter().enablePlugin("aidd-context@aidd-framework"); - - expect(mockSpawnSync).toHaveBeenCalledWith( - "claude", - ["plugin", "install", "aidd-context@aidd-framework", "--scope", "project", "--yes"], - expect.anything() - ); - }); - - it("throws NativePluginCliError with stderr detail on non-zero exit", () => { - mockSpawnSync.mockReturnValue( - makeResult({ status: 1, stderr: "plugin `ghost` was not found in marketplace `m1`" }) - ); - - expect(() => new ClaudeCliAdapter().enablePlugin("ghost@m1")).toThrow(NativePluginCliError); - expect(() => new ClaudeCliAdapter().enablePlugin("ghost@m1")).toThrow( - "plugin `ghost` was not found" - ); - }); - - it("uninstalls a plugin via `claude plugin uninstall --scope project --yes`", () => { - mockSpawnSync.mockReturnValue(makeResult({})); - - new ClaudeCliAdapter().uninstallPlugin("aidd-telemetry@aidd-framework"); - - expect(mockSpawnSync).toHaveBeenCalledWith( - "claude", - ["plugin", "uninstall", "aidd-telemetry@aidd-framework", "--scope", "project", "--yes"], - expect.anything() - ); - }); - - it("throws NativePluginCliError when uninstalling an already-absent plugin", () => { - mockSpawnSync.mockReturnValue( - makeResult({ status: 1, stderr: "plugin `ghost` is not installed" }) - ); - - expect(() => new ClaudeCliAdapter().uninstallPlugin("ghost@m1")).toThrow(NativePluginCliError); - }); - - it("throws NativePluginCliError when the process fails to spawn", () => { - mockSpawnSync.mockReturnValue(makeResult({ error: new Error("spawn EACCES"), status: null })); - - expect(() => new ClaudeCliAdapter().addMarketplace("/abs/mkt")).toThrow(NativePluginCliError); - }); -}); diff --git a/cli/tests/infrastructure/adapters/codex-cli-adapter.integration.test.ts b/cli/tests/infrastructure/adapters/codex-cli-adapter.integration.test.ts deleted file mode 100644 index d694bacbd..000000000 --- a/cli/tests/infrastructure/adapters/codex-cli-adapter.integration.test.ts +++ /dev/null @@ -1,139 +0,0 @@ -import { spawnSync } from "node:child_process"; -import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; -import { afterEach, describe, expect, it, vi } from "vitest"; -import { NativePluginCliError } from "../../../src/domain/errors.js"; -import { CodexCliAdapter } from "../../../src/infrastructure/adapters/codex-cli-adapter.js"; - -function pathWithExecutable(name: string): { dir: string; restore: () => void } { - const dir = mkdtempSync(join(tmpdir(), "aidd-bin-")); - writeFileSync(join(dir, name), "#!/bin/sh\n", { mode: 0o755 }); - const prev = process.env.PATH; - process.env.PATH = dir; - return { - dir, - restore: () => { - process.env.PATH = prev; - rmSync(dir, { recursive: true, force: true }); - }, - }; -} - -vi.mock("node:child_process", () => ({ - spawnSync: vi.fn(), -})); - -const mockSpawnSync = vi.mocked(spawnSync); - -function makeResult(overrides: Partial>) { - return { - pid: 1, - output: [], - stdout: "", - stderr: "", - status: 0, - signal: null, - error: undefined, - ...overrides, - } as ReturnType; -} - -describe("CodexCliAdapter", () => { - let restorePath: (() => void) | undefined; - afterEach(() => { - restorePath?.(); - restorePath = undefined; - }); - - it("reports available when the codex binary is on PATH (no spawn)", () => { - const env = pathWithExecutable("codex"); - restorePath = env.restore; - - expect(new CodexCliAdapter().isAvailable()).toBe(true); - expect(mockSpawnSync).not.toHaveBeenCalled(); - }); - - it("reports unavailable when the codex binary is not on PATH", () => { - const emptyDir = mkdtempSync(join(tmpdir(), "aidd-empty-")); - const prev = process.env.PATH; - process.env.PATH = emptyDir; - restorePath = () => { - process.env.PATH = prev; - rmSync(emptyDir, { recursive: true, force: true }); - }; - - expect(new CodexCliAdapter().isAvailable()).toBe(false); - }); - - it("registers a marketplace via `codex plugin marketplace add `", () => { - mockSpawnSync.mockReturnValue(makeResult({})); - - new CodexCliAdapter().addMarketplace("/abs/mkt"); - - expect(mockSpawnSync).toHaveBeenCalledWith( - "codex", - ["plugin", "marketplace", "add", "/abs/mkt"], - expect.anything() - ); - }); - - it("upgrades marketplaces via `codex plugin marketplace upgrade`", () => { - mockSpawnSync.mockReturnValue(makeResult({})); - - new CodexCliAdapter().upgradeMarketplaces(); - - expect(mockSpawnSync).toHaveBeenCalledWith( - "codex", - ["plugin", "marketplace", "upgrade"], - expect.anything() - ); - }); - - it("enables a plugin via `codex plugin add `", () => { - mockSpawnSync.mockReturnValue(makeResult({})); - - new CodexCliAdapter().enablePlugin("aidd-context@aidd-framework"); - - expect(mockSpawnSync).toHaveBeenCalledWith( - "codex", - ["plugin", "add", "aidd-context@aidd-framework"], - expect.anything() - ); - }); - - it("throws NativePluginCliError with stderr detail on non-zero exit", () => { - mockSpawnSync.mockReturnValue( - makeResult({ status: 1, stderr: "plugin `ghost` was not found in marketplace `m1`" }) - ); - - expect(() => new CodexCliAdapter().enablePlugin("ghost@m1")).toThrow(NativePluginCliError); - expect(() => new CodexCliAdapter().enablePlugin("ghost@m1")).toThrow( - "plugin `ghost` was not found" - ); - }); - - it("uninstalls a plugin via `codex plugin remove `", () => { - mockSpawnSync.mockReturnValue(makeResult({})); - - new CodexCliAdapter().uninstallPlugin("aidd-telemetry@aidd-framework"); - - expect(mockSpawnSync).toHaveBeenCalledWith( - "codex", - ["plugin", "remove", "aidd-telemetry@aidd-framework"], - expect.anything() - ); - }); - - it("throws NativePluginCliError when uninstalling an already-absent plugin", () => { - mockSpawnSync.mockReturnValue(makeResult({ status: 1, stderr: "plugin `ghost` not found" })); - - expect(() => new CodexCliAdapter().uninstallPlugin("ghost@m1")).toThrow(NativePluginCliError); - }); - - it("throws NativePluginCliError when the process fails to spawn", () => { - mockSpawnSync.mockReturnValue(makeResult({ error: new Error("spawn EACCES"), status: null })); - - expect(() => new CodexCliAdapter().addMarketplace("/abs/mkt")).toThrow(NativePluginCliError); - }); -}); diff --git a/cli/tests/infrastructure/adapters/copilot-cli-adapter.integration.test.ts b/cli/tests/infrastructure/adapters/copilot-cli-adapter.integration.test.ts deleted file mode 100644 index 74186de2b..000000000 --- a/cli/tests/infrastructure/adapters/copilot-cli-adapter.integration.test.ts +++ /dev/null @@ -1,127 +0,0 @@ -import { spawnSync } from "node:child_process"; -import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; -import { afterEach, describe, expect, it, vi } from "vitest"; -import { NativePluginCliError } from "../../../src/domain/errors.js"; -import { CopilotCliAdapter } from "../../../src/infrastructure/adapters/copilot-cli-adapter.js"; - -vi.mock("node:child_process", () => ({ - spawnSync: vi.fn(), -})); - -const mockSpawnSync = vi.mocked(spawnSync); - -function makeResult(overrides: Partial>) { - return { - pid: 1, - output: [], - stdout: "", - stderr: "", - status: 0, - signal: null, - error: undefined, - ...overrides, - } as ReturnType; -} - -describe("CopilotCliAdapter", () => { - let restorePath: (() => void) | undefined; - afterEach(() => { - restorePath?.(); - restorePath = undefined; - }); - - it("reports available when the copilot binary is on PATH (no spawn)", () => { - const dir = mkdtempSync(join(tmpdir(), "aidd-bin-")); - writeFileSync(join(dir, "copilot"), "#!/bin/sh\n", { mode: 0o755 }); - const prev = process.env.PATH; - process.env.PATH = dir; - restorePath = () => { - process.env.PATH = prev; - rmSync(dir, { recursive: true, force: true }); - }; - - expect(new CopilotCliAdapter().isAvailable()).toBe(true); - expect(mockSpawnSync).not.toHaveBeenCalled(); - }); - - it("reports unavailable when the copilot binary is not on PATH", () => { - const emptyDir = mkdtempSync(join(tmpdir(), "aidd-empty-")); - const prev = process.env.PATH; - process.env.PATH = emptyDir; - restorePath = () => { - process.env.PATH = prev; - rmSync(emptyDir, { recursive: true, force: true }); - }; - - expect(new CopilotCliAdapter().isAvailable()).toBe(false); - }); - - it("registers a marketplace via `copilot plugin marketplace add `", () => { - mockSpawnSync.mockReturnValue(makeResult({})); - - new CopilotCliAdapter().addMarketplace("/abs/mkt"); - - expect(mockSpawnSync).toHaveBeenCalledWith( - "copilot", - ["plugin", "marketplace", "add", "/abs/mkt"], - expect.anything() - ); - }); - - it("refreshes marketplaces via `copilot plugin marketplace update`", () => { - mockSpawnSync.mockReturnValue(makeResult({})); - - new CopilotCliAdapter().upgradeMarketplaces(); - - expect(mockSpawnSync).toHaveBeenCalledWith( - "copilot", - ["plugin", "marketplace", "update"], - expect.anything() - ); - }); - - it("installs a plugin via `copilot plugin install `", () => { - mockSpawnSync.mockReturnValue(makeResult({})); - - new CopilotCliAdapter().enablePlugin("aidd-context@aidd-framework"); - - expect(mockSpawnSync).toHaveBeenCalledWith( - "copilot", - ["plugin", "install", "aidd-context@aidd-framework"], - expect.anything() - ); - }); - - it("throws NativePluginCliError with stderr detail on non-zero exit", () => { - mockSpawnSync.mockReturnValue(makeResult({ status: 1, stderr: 'Marketplace "m1" not found' })); - - expect(() => new CopilotCliAdapter().enablePlugin("ghost@m1")).toThrow(NativePluginCliError); - expect(() => new CopilotCliAdapter().enablePlugin("ghost@m1")).toThrow("Marketplace"); - }); - - it("uninstalls a plugin via `copilot plugin uninstall `", () => { - mockSpawnSync.mockReturnValue(makeResult({})); - - new CopilotCliAdapter().uninstallPlugin("aidd-telemetry@aidd-framework"); - - expect(mockSpawnSync).toHaveBeenCalledWith( - "copilot", - ["plugin", "uninstall", "aidd-telemetry@aidd-framework"], - expect.anything() - ); - }); - - it("throws NativePluginCliError when uninstalling an already-absent plugin", () => { - mockSpawnSync.mockReturnValue(makeResult({ status: 1, stderr: "Plugin not found" })); - - expect(() => new CopilotCliAdapter().uninstallPlugin("ghost@m1")).toThrow(NativePluginCliError); - }); - - it("throws NativePluginCliError when the process fails to spawn", () => { - mockSpawnSync.mockReturnValue(makeResult({ error: new Error("spawn EACCES"), status: null })); - - expect(() => new CopilotCliAdapter().addMarketplace("/abs/mkt")).toThrow(NativePluginCliError); - }); -}); diff --git a/cli/tests/infrastructure/adapters/current-version-adapter.integration.test.ts b/cli/tests/infrastructure/adapters/current-version-adapter.integration.test.ts deleted file mode 100644 index 28f01501e..000000000 --- a/cli/tests/infrastructure/adapters/current-version-adapter.integration.test.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { CurrentVersionAdapter } from "../../../src/infrastructure/adapters/current-version-adapter.js"; - -describe("CurrentVersionAdapter", () => { - it("returns the bundled package version", () => { - const adapter = new CurrentVersionAdapter(); - expect(adapter.get()).toMatch(/^\d+\.\d+\.\d+/); - }); -}); diff --git a/cli/tests/infrastructure/adapters/git-adapter-commit-trailer.integration.test.ts b/cli/tests/infrastructure/adapters/git-adapter-commit-trailer.integration.test.ts deleted file mode 100644 index 347059bdf..000000000 --- a/cli/tests/infrastructure/adapters/git-adapter-commit-trailer.integration.test.ts +++ /dev/null @@ -1,231 +0,0 @@ -import { execFileSync } from "node:child_process"; -import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; -import { fileURLToPath } from "node:url"; -import { afterEach, describe, expect, it } from "vitest"; -import { - SESSION_TRAILER_DELEGATE_FILE, - sessionTrailerDelegateScript, - sessionTrailerHookLine, -} from "../../../src/domain/formats/commit-session-trailer.js"; -import { FileAdapter } from "../../../src/infrastructure/adapters/file-adapter.js"; -import { GitAdapter } from "../../../src/infrastructure/adapters/git-adapter.js"; -import { HasherAdapter } from "../../../src/infrastructure/adapters/hasher-adapter.js"; -import { CapturingLogger } from "../../helpers/ports/capturing-logger.js"; - -/** - * The real adapter against real repositories, because the whole question is where git looks - * for a hook — which is not knowable from the shape of `.git` alone. A `core.hooksPath` and - * a linked worktree each move it somewhere the obvious answer does not point, and in both - * cases a hook installed at the obvious place is silently never run. - */ -const created: string[] = []; - -function gitEnv(): NodeJS.ProcessEnv { - return Object.fromEntries(Object.entries(process.env).filter(([key]) => !key.startsWith("GIT_"))); -} - -function git(cwd: string, ...args: string[]): string { - return execFileSync("git", args, { cwd, encoding: "utf8", env: gitEnv() }); -} - -function makeRepo(prefix: string): string { - const dir = mkdtempSync(join(tmpdir(), `aidd-trailer-${prefix}-`)); - created.push(dir); - git(dir, "init", "-q", "."); - return dir; -} - -function adapter(): GitAdapter { - return new GitAdapter(new FileAdapter(new HasherAdapter(), new CapturingLogger())); -} - -afterEach(() => { - for (const dir of created) rmSync(dir, { recursive: true, force: true }); - created.length = 0; -}); - -describe("installing the trailer hook where git will actually run it", () => { - it("installs into a plain repository, and says it did", async () => { - const repo = makeRepo("plain"); - - const installed = await adapter().installCommitMessageDelegate( - repo, - SESSION_TRAILER_DELEGATE_FILE, - sessionTrailerDelegateScript() - ); - - const hooks = join(repo, ".git", "hooks"); - expect(installed).toBe(true); - expect(readFileSync(join(hooks, "prepare-commit-msg"), "utf8")).toContain( - sessionTrailerHookLine(join(hooks, SESSION_TRAILER_DELEGATE_FILE)) - ); - expect(existsSync(join(hooks, SESSION_TRAILER_DELEGATE_FILE))).toBe(true); - }); - - // The defect this test exists for: `.git/hooks` is not where git looks when a - // `core.hooksPath` is set, and a hook written there runs on nothing while reporting - // success. This machine's own checkout is configured exactly this way. - it("installs where core.hooksPath points, never into .git/hooks it would ignore", async () => { - const repo = makeRepo("hookspath"); - const elsewhere = join(repo, "team-hooks"); - mkdirSync(elsewhere, { recursive: true }); - git(repo, "config", "core.hooksPath", elsewhere); - - await adapter().installCommitMessageDelegate( - repo, - SESSION_TRAILER_DELEGATE_FILE, - sessionTrailerDelegateScript() - ); - - expect(existsSync(join(elsewhere, "prepare-commit-msg"))).toBe(true); - expect(existsSync(join(repo, ".git", "hooks", "prepare-commit-msg"))).toBe(false); - }); - - it("installs into the common repository's hooks from inside a linked worktree", async () => { - const repo = makeRepo("worktree"); - writeFileSync(join(repo, "seed.txt"), "seed\n"); - git(repo, "add", "seed.txt"); - git(repo, "-c", "user.email=t@example.com", "-c", "user.name=T", "commit", "-q", "-m", "seed"); - const linked = join(repo, "linked"); - git(repo, "worktree", "add", "-q", linked); - - await adapter().installCommitMessageDelegate( - linked, - SESSION_TRAILER_DELEGATE_FILE, - sessionTrailerDelegateScript() - ); - - expect(existsSync(join(repo, ".git", "hooks", "prepare-commit-msg"))).toBe(true); - }); - - it("keeps a hook the repository already had, and adds one line to it", async () => { - const repo = makeRepo("existing"); - const hooks = join(repo, ".git", "hooks"); - mkdirSync(hooks, { recursive: true }); - writeFileSync(join(hooks, "prepare-commit-msg"), "#!/bin/sh\necho theirs\n"); - - await adapter().installCommitMessageDelegate( - repo, - SESSION_TRAILER_DELEGATE_FILE, - sessionTrailerDelegateScript() - ); - - const content = readFileSync(join(hooks, "prepare-commit-msg"), "utf8"); - expect(content).toContain("echo theirs"); - expect(content).toContain(sessionTrailerHookLine(join(hooks, SESSION_TRAILER_DELEGATE_FILE))); - }); - - it("adds its line once however often it is installed", async () => { - const repo = makeRepo("twice"); - - const first = await adapter().installCommitMessageDelegate( - repo, - SESSION_TRAILER_DELEGATE_FILE, - sessionTrailerDelegateScript() - ); - const second = await adapter().installCommitMessageDelegate( - repo, - SESSION_TRAILER_DELEGATE_FILE, - sessionTrailerDelegateScript() - ); - - const content = readFileSync(join(repo, ".git", "hooks", "prepare-commit-msg"), "utf8"); - expect(first).toBe(true); - expect(second).toBe(false); - expect(content.split("aidd-session-trailer.sh").length - 1).toBe(1); - }); - - it("reports nothing installed outside a repository, rather than failing", async () => { - const notARepo = mkdtempSync(join(tmpdir(), "aidd-trailer-none-")); - created.push(notARepo); - - await expect( - adapter().installCommitMessageDelegate( - notARepo, - SESSION_TRAILER_DELEGATE_FILE, - sessionTrailerDelegateScript() - ) - ).resolves.toBe(false); - }); -}); - -describe("removing it again", () => { - it("takes back its own line and its own file, and says it did", async () => { - const repo = makeRepo("remove"); - await adapter().installCommitMessageDelegate( - repo, - SESSION_TRAILER_DELEGATE_FILE, - sessionTrailerDelegateScript() - ); - - const removed = await adapter().removeCommitMessageDelegate( - repo, - SESSION_TRAILER_DELEGATE_FILE - ); - - const hooks = join(repo, ".git", "hooks"); - expect(removed).toBe(true); - expect(readFileSync(join(hooks, "prepare-commit-msg"), "utf8")).not.toContain( - SESSION_TRAILER_DELEGATE_FILE - ); - expect(existsSync(join(hooks, SESSION_TRAILER_DELEGATE_FILE))).toBe(false); - }); - - it("leaves every other line of somebody else's hook exactly as it found it", async () => { - const repo = makeRepo("shared"); - const hooks = join(repo, ".git", "hooks"); - mkdirSync(hooks, { recursive: true }); - writeFileSync(join(hooks, "prepare-commit-msg"), "#!/bin/sh\necho theirs\nexit 0\n"); - await adapter().installCommitMessageDelegate( - repo, - SESSION_TRAILER_DELEGATE_FILE, - sessionTrailerDelegateScript() - ); - - await adapter().removeCommitMessageDelegate(repo, SESSION_TRAILER_DELEGATE_FILE); - - expect(readFileSync(join(hooks, "prepare-commit-msg"), "utf8")).toBe( - "#!/bin/sh\necho theirs\nexit 0\n" - ); - }); - - it("reports nothing to remove when it was never installed", async () => { - const repo = makeRepo("never"); - - await expect( - adapter().removeCommitMessageDelegate(repo, SESSION_TRAILER_DELEGATE_FILE) - ).resolves.toBe(false); - }); -}); - -/** - * The evidence behind a stated limit, pinned so the limit cannot quietly stop being true. - * - * `commit-session-trailer.ts` and the metrics contract both say the commit-to-session join - * is measured on Claude Code and unconfirmed on Codex, and give a concrete reason: a resumed - * Codex rollout carries a `session_meta.id` — which is what becomes a record's `vendor_id` — - * different from its own `session_meta.session_id`, so "the thread" and "the rollout" are - * demonstrably two things there. If the captured rollout were replaced by one where they - * agree, that reason would evaporate while both documents went on giving it. - */ -describe("the reason the Codex half of this join is only a candidate", () => { - it("still holds a resumed rollout whose own id differs from the session it continues", () => { - const rollout = fileURLToPath( - new URL( - "../../fixtures/local-cost/.codex/sessions/2026/07/29/" + - "rollout-2026-07-29T17-12-26-019fae6f-2009-7cd3-86b2-b8f83481b160.jsonl", - import.meta.url - ) - ); - const first = readFileSync(rollout, "utf8").split("\n")[0] ?? ""; - const meta = JSON.parse(first) as { - payload?: { id?: string; session_id?: string }; - }; - - expect(meta.payload?.id).toBe("019fae6f-2009-7cd3-86b2-b8f83481b160"); - expect(meta.payload?.session_id).toBeDefined(); - expect(meta.payload?.session_id).not.toBe(meta.payload?.id); - }); -}); diff --git a/cli/tests/infrastructure/adapters/git-adapter-telemetry-project-id.integration.test.ts b/cli/tests/infrastructure/adapters/git-adapter-telemetry-project-id.integration.test.ts deleted file mode 100644 index 9199897f6..000000000 --- a/cli/tests/infrastructure/adapters/git-adapter-telemetry-project-id.integration.test.ts +++ /dev/null @@ -1,45 +0,0 @@ -import { execFileSync } from "node:child_process"; -import { mkdtempSync, rmSync } from "node:fs"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; -import { afterEach, describe, expect, it } from "vitest"; -import { GitAdapter } from "../../../src/infrastructure/adapters/git-adapter.js"; -import { InMemoryFileAdapter } from "../../helpers/ports/in-memory-file-adapter.js"; -import { journalRepo } from "../../helpers/telemetry-journal-hook.js"; - -// git exports GIT_DIR into every process it spawns, so both sides of the join must read -// the repository at `cwd` rather than the one the environment points at. Without the -// strip, a CLI run from a git hook or a CI step tags records with the wrong project. -describe("neither side follows a leaked GIT_DIR", () => { - const created: string[] = []; - const savedGitDir = process.env.GIT_DIR; - - afterEach(() => { - if (savedGitDir === undefined) delete process.env.GIT_DIR; - else process.env.GIT_DIR = savedGitDir; - for (const dir of created) rmSync(dir, { recursive: true, force: true }); - created.length = 0; - }); - - function makeRepo(remoteUrl: string): string { - const dir = mkdtempSync(join(tmpdir(), "aidd-gitdir-")); - created.push(dir); - const env = Object.fromEntries( - Object.entries(process.env).filter(([key]) => !key.startsWith("GIT_")) - ); - execFileSync("git", ["init", "-q", "."], { cwd: dir, env }); - execFileSync("git", ["remote", "add", "origin", remoteUrl], { cwd: dir, env }); - return dir; - } - - it("reads the remote of the repository at cwd, not the one GIT_DIR names", async () => { - const elsewhere = makeRepo("git@github.com:leaked/elsewhere.git"); - const here = makeRepo("git@github.com:expected/here.git"); - - process.env.GIT_DIR = join(elsewhere, ".git"); - - const git = new GitAdapter(new InMemoryFileAdapter()); - expect(await git.getRemoteUrl(here)).toBe("git@github.com:expected/here.git"); - expect(journalRepo.deriveProjectId(here)).toBe("expected/here"); - }); -}); diff --git a/cli/tests/infrastructure/adapters/git-commit-trailer-setup.integration.test.ts b/cli/tests/infrastructure/adapters/git-commit-trailer-setup.integration.test.ts deleted file mode 100644 index f042fc535..000000000 --- a/cli/tests/infrastructure/adapters/git-commit-trailer-setup.integration.test.ts +++ /dev/null @@ -1,214 +0,0 @@ -import { execFileSync } from "node:child_process"; -import { chmod, mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; -import { afterEach, beforeEach, describe, expect, it } from "vitest"; -import { - SESSION_TRAILER_DELEGATE_FILE, - SESSION_TRAILER_TOKEN, - sessionTrailerHookLine, -} from "../../../src/domain/formats/commit-session-trailer.js"; -import { FileAdapter } from "../../../src/infrastructure/adapters/file-adapter.js"; -import { GitAdapter } from "../../../src/infrastructure/adapters/git-adapter.js"; -import { environmentWithoutGitVariables } from "../../../src/infrastructure/git-environment.js"; -import { CapturingLogger } from "../../helpers/ports/capturing-logger.js"; -import { DeterministicHasher } from "../../helpers/ports/deterministic-hasher.js"; - -/** - * The five facts `check` states about the trailer, read from a real repository. - * - * Against a real git rather than a fake one because every one of them is a git question, and - * the two that matter most — where hooks are run from, and what the last commits actually - * carry — are answers only git has. `%(trailers:key=…)` in particular is git's own reader: - * asserting against a regex of ours would prove the regex agrees with itself. - */ -let dir: string; -let git: GitAdapter; - -/** Windows records no execute bit: `chmod` cannot take one away, every readable file reports - * `0o666`, and git runs whatever hook it finds through `sh`. "Present but unrunnable" is - * therefore a state that exists only where the bit does, and asserting it elsewhere measures - * the platform rather than the reader. The states that exist everywhere — absent, and - * runnable — are asserted on both. */ -const REMOVING_THE_BIT_MEANS_SOMETHING = process.platform !== "win32"; - -/** Git exports `GIT_DIR` and friends into everything it spawns, this suite included when it - * runs from a commit hook. Stripped, or these read the repository being committed. */ -function run(args: readonly string[], env: NodeJS.ProcessEnv = {}): void { - execFileSync("git", [...args], { - cwd: dir, - env: { ...environmentWithoutGitVariables(process.env), ...env }, - }); -} - -beforeEach(async () => { - dir = await mkdtemp(join(tmpdir(), "aidd-trailer-setup-")); - execFileSync("git", ["init", "-q", dir], { env: environmentWithoutGitVariables(process.env) }); - run(["config", "user.email", "t@example.com"]); - run(["config", "user.name", "T"]); - git = new GitAdapter(new FileAdapter(new DeterministicHasher(), new CapturingLogger())); -}); - -afterEach(async () => { - await rm(dir, { recursive: true, force: true }); -}); - -function read() { - return git.readCommitTrailerSetup(dir, SESSION_TRAILER_DELEGATE_FILE, SESSION_TRAILER_TOKEN, 20); -} - -async function hooksDir(): Promise { - const at = join(dir, ".git", "hooks"); - await mkdir(at, { recursive: true }); - return at; -} - -async function installDelegate(mode = 0o755): Promise { - const at = join(await hooksDir(), SESSION_TRAILER_DELEGATE_FILE); - await writeFile(at, "#!/bin/sh\nexit 0\n"); - await chmod(at, mode); - return at; -} - -function commit(message: string): void { - run(["commit", "-q", "--allow-empty", "-m", message]); -} - -function commitCarrying(message: string, session: string): void { - run([ - "commit", - "-q", - "--allow-empty", - "-m", - `${message}\n\n${SESSION_TRAILER_TOKEN}: ${session}`, - ]); -} - -describe("what check reads about the commit trailer", () => { - it("names the directory git runs hooks from, not one assumed", async () => { - expect((await read()).hooksDir).toContain(join(".git", "hooks")); - }); - - it("tells a delegate that is not executable from one that is absent", async () => { - expect((await read()).delegate).toBe("absent"); - - if (REMOVING_THE_BIT_MEANS_SOMETHING) { - await installDelegate(0o644); - expect((await read()).delegate).toBe("not-executable"); - } - - await installDelegate(0o755); - expect((await read()).delegate).toBe("executable"); - }); - - it("says whether prepare-commit-msg calls the delegate", async () => { - const delegatePath = await installDelegate(); - expect((await read()).callSite).toBe("no-hook-file"); - - const hookPath = join(await hooksDir(), "prepare-commit-msg"); - await writeFile(hookPath, "#!/bin/sh\n# generated\nexit 0\n"); - expect((await read()).callSite).toBe("missing"); - - await writeFile(hookPath, `#!/bin/sh\n${sessionTrailerHookLine(delegatePath)}\n`); - expect((await read()).callSite).toBe("present"); - }); - - // Said, never named: which tool owns the file changes nothing a person does. - it("says the hook is somebody else's, and does not say whose", async () => { - const delegatePath = await installDelegate(); - const hookPath = join(await hooksDir(), "prepare-commit-msg"); - - await writeFile(hookPath, `#!/bin/sh\n${sessionTrailerHookLine(delegatePath)}\n`); - expect((await read()).hookHasOtherContent).toBe(false); - - await writeFile( - hookPath, - `#!/bin/sh\nlefthook run x\n${sessionTrailerHookLine(delegatePath)}\n` - ); - expect((await read()).hookHasOtherContent).toBe(true); - }); - - /** - * The only claim about the chain rather than its parts, and the reason it is a count. - * "Some of your commits carry it" is not something a person can check; "0 of the last 3" - * in a repository that has been measuring all week is the entire finding. - */ - it("counts how many recent commits actually carry it", async () => { - commit("one"); - commitCarrying("two", "s-1"); - commitCarrying("three", "s-2"); - - expect((await read()).recentlyCarrying).toEqual({ carrying: 2, examined: 3 }); - }); - - /** - * A merge carries no trailer by the delegate's own design — one session's id on a merge - * would attribute every commit it brings in to that session. Counting them puts commits in - * the denominator that can never be in the numerator, which is arithmetic that reads as - * breakage on a healthy install. - */ - it("does not count merges, which can never carry it", async () => { - commitCarrying("one", "s-1"); - run(["checkout", "-q", "-b", "side"]); - commitCarrying("side", "s-2"); - run(["checkout", "-q", "-"]); - commitCarrying("main", "s-3"); - run(["merge", "-q", "--no-ff", "-m", "merge", "side"]); - - const counted = (await read()).recentlyCarrying; - - expect(counted).toEqual({ carrying: 3, examined: 3 }); - }); - - // Never `0`: a repository with no commits and one whose commits are all unstamped are - // different facts, and only the second is something to act on. - it("reports no history rather than zero when there are no commits", async () => { - expect((await read()).recentlyCarrying).toBeUndefined(); - }); - - /** - * Outside a repository, and said as such. The distinction existed in the type and in two - * display fixtures and was produced by nothing — so `check` printed "git could not say - * where it runs hooks from" beside its own "not a git repository" line, one of them false. - * Asserted through the real adapter, because that is where it was missing. - */ - it("says there is no repository, rather than that git could not answer", async () => { - const outside = await mkdtemp(join(tmpdir(), "aidd-trailer-nogit-")); - try { - const setup = await git.readCommitTrailerSetup( - outside, - SESSION_TRAILER_DELEGATE_FILE, - SESSION_TRAILER_TOKEN, - 20 - ); - - expect(setup.hooksDirMissing).toBe("no-repository"); - expect(setup.recentlyCarrying).toBeUndefined(); - expect(setup.delegate).toBe("absent"); - } finally { - await rm(outside, { recursive: true, force: true }); - } - }); - - // Git refuses to run a hook without the bit and prints a hint on every commit, so an - // install that looks perfect can write nothing. The hook's own mode, not the delegate's. - it("says whether the hook itself is executable", async () => { - const delegatePath = await installDelegate(); - const hookPath = join(await hooksDir(), "prepare-commit-msg"); - await writeFile(hookPath, `#!/bin/sh\n${sessionTrailerHookLine(delegatePath)}\n`); - - if (REMOVING_THE_BIT_MEANS_SOMETHING) { - await chmod(hookPath, 0o644); - expect((await read()).hookExecutable).toBe(false); - } - - await chmod(hookPath, 0o755); - expect((await read()).hookExecutable).toBe(true); - }); - - it("has no opinion on a hook's mode when there is no hook", async () => { - await installDelegate(); - - expect((await read()).hookExecutable).toBeUndefined(); - }); -}); diff --git a/cli/tests/infrastructure/adapters/host-plugin-registry-reader-adapter.integration.test.ts b/cli/tests/infrastructure/adapters/host-plugin-registry-reader-adapter.integration.test.ts deleted file mode 100644 index 9f0763b12..000000000 --- a/cli/tests/infrastructure/adapters/host-plugin-registry-reader-adapter.integration.test.ts +++ /dev/null @@ -1,343 +0,0 @@ -import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; -import { afterEach, beforeEach, describe, expect, it } from "vitest"; -import type { AiToolId } from "../../../src/domain/models/tool-ids.js"; -import { hostPluginRegistryReaders } from "../../../src/infrastructure/adapters/host-plugin-registry-reader-adapter.js"; - -/** - * Every fixture below is written from the shape recorded in this task's own spec, never - * copied from a real file: the machine that was measured carries hashed experiment keys, - * absolute project paths and a list of somebody's marketplaces, none of which belongs in a - * public repository. Only the shape was ever needed. - */ -const PROJECT = "/repo/mine"; - -let home: string; - -beforeEach(async () => { - home = await mkdtemp(join(tmpdir(), "aidd-host-registry-")); -}); - -afterEach(async () => { - await rm(home, { recursive: true, force: true }); -}); - -async function write(relative: string, content: string): Promise { - const path = join(home, relative); - await mkdir(join(path, ".."), { recursive: true }); - await writeFile(path, content, "utf8"); -} - -function readerFor(tool: AiToolId) { - const reader = hostPluginRegistryReaders(home).get(tool); - if (reader === undefined) throw new Error(`no reader declared for ${tool}`); - return reader; -} - -describe("Claude Code's own installed_plugins.json", () => { - const PATH = ".claude/plugins/installed_plugins.json"; - - it("counts a ref installed for this project, and one installed for the machine", async () => { - await write( - PATH, - JSON.stringify({ - version: 1, - plugins: { - "aidd-telemetry@aidd-framework": [{ scope: "project", projectPath: PROJECT }], - "aidd-dev@aidd-framework": [{ scope: "user" }], - }, - }) - ); - - const reading = await readerFor("claude").read(PROJECT); - - expect(reading.refs?.get("aidd-telemetry@aidd-framework")).toBe(true); - expect(reading.refs?.get("aidd-dev@aidd-framework")).toBe(true); - }); - - /** - * The blocker an independent check found, and the reason the first version of this reader - * was wrong: it mapped every key to `true`, on a doc claim that the entries "record scope, - * install path and version, none of which decides whether the plugin loads". Read across - * all 115 entries rather than the first one, they also carry `projectPath`, on 100 of - * them. And `aidd` registers every plugin at project scope - * (`claude-cli-adapter.ts`'s `PROJECT_SCOPE_ARGS`), so this is the ordinary case, not an - * exotic one: without this, running `check` in one project reports a plugin installed for - * a different project as one this host will load here. - */ - it("does not count a ref installed only for another project", async () => { - await write( - PATH, - JSON.stringify({ - version: 1, - plugins: { - "aidd-telemetry@aidd-framework": [{ scope: "project", projectPath: "/repo/theirs" }], - }, - }) - ); - - const reading = await readerFor("claude").read(PROJECT); - - expect(reading.refs?.has("aidd-telemetry@aidd-framework")).toBe(false); - // Read, and answering — the ref is absent from a map that exists, which is - // `not-registered`, never the `unanswerable` an unread registry produces. - expect(reading.unreadable).toBeUndefined(); - }); - - it("counts a ref carrying one entry for this project among entries for others", async () => { - await write( - PATH, - JSON.stringify({ - version: 1, - plugins: { - "aidd-telemetry@aidd-framework": [ - { scope: "project", projectPath: "/repo/theirs" }, - { scope: "project", projectPath: PROJECT }, - ], - }, - }) - ); - - expect((await readerFor("claude").read(PROJECT)).refs?.size).toBe(1); - }); - - // An entry naming neither a scope nor a project is evidence of nothing, and guessing from - // it is the habit this whole file exists to break. - it("ignores an entry that names neither a scope nor a project", async () => { - await write( - PATH, - JSON.stringify({ - version: 1, - plugins: { "aidd-telemetry@aidd-framework": [{ version: "1" }] }, - }) - ); - - expect((await readerFor("claude").read(PROJECT)).refs?.size).toBe(0); - }); - - // An empty map is a real answer — the file opened and carries nothing — and it must stay - // reachable only from a file that actually opened. - it("reads an empty registry as an empty answer, not as unreadable", async () => { - await write(PATH, JSON.stringify({ version: 1, plugins: {} })); - - const reading = await readerFor("claude").read(PROJECT); - - expect(reading.refs?.size).toBe(0); - expect(reading.unreadable).toBeUndefined(); - }); - - it("says it could not read an absent registry, and carries no refs at all", async () => { - const reading = await readerFor("claude").read(PROJECT); - - expect(reading.refs).toBeUndefined(); - expect(reading.unreadable).toBe("ENOENT"); - }); - - /** - * Not hypothetical, and the reason this distinction exists at all: Copilot's own - * `~/.copilot/config.json` opens with two `//` lines, so a registry that looks like JSON - * and turns out to be JSONC is a file a reader really does meet. It must say it could not - * read the file — reporting "no plugins registered" here would invent the exact fact this - * feature exists to stop inventing. - */ - it("reads a JSONC registry as unreadable, never as carrying no plugins", async () => { - await write(PATH, '// managed automatically\n{ "version": 1, "plugins": {} }\n'); - - const reading = await readerFor("claude").read(PROJECT); - - expect(reading.refs).toBeUndefined(); - expect(reading.unreadable).toBeDefined(); - }); - - it("reads a registry with no plugins object as unreadable rather than empty", async () => { - await write(PATH, JSON.stringify({ version: 1 })); - - const reading = await readerFor("claude").read(PROJECT); - - expect(reading.refs).toBeUndefined(); - }); -}); - -describe("Codex's own config.toml", () => { - const PATH = ".codex/config.toml"; - - it("finds its plugin tables among the arbitrary ones around them", async () => { - await write( - PATH, - [ - '[projects."/somewhere/else"]', - 'trust_level = "trusted"', - "", - '[plugins."aidd-telemetry@aidd-framework"]', - "enabled = true", - "", - '[plugins."aidd-dev@aidd-framework"]', - "enabled = false", - "", - '[hooks.state."aidd-telemetry@aidd-framework:hooks/hooks.json:session_start:0:0"]', - 'trusted_hash = "abc"', - "", - ].join("\n") - ); - - const reading = await readerFor("codex").read(PROJECT); - - expect(reading.refs?.get("aidd-telemetry@aidd-framework")).toBe(true); - expect(reading.refs?.get("aidd-dev@aidd-framework")).toBe(false); - expect(reading.refs?.size).toBe(2); - }); - - // A table with no `enabled` is a shape Codex does not produce — every plugin table on the - // machine measured carried one — and between "the host listed this plugin" and "the host - // listed it and said nothing", the listing is the fact. Asserted against the next table - // rather than end-of-file, so it cannot pass by conflating "no key" with "no more input". - it("treats a table with no enabled line as enabled", async () => { - await write( - PATH, - '[plugins."aidd-telemetry@aidd-framework"]\n[plugins."other@elsewhere"]\nenabled = true\n' - ); - - expect( - (await readerFor("codex").read(PROJECT)).refs?.get("aidd-telemetry@aidd-framework") - ).toBe(true); - }); - - /** - * The four shapes that read `enabled = false` as an enabled plugin when this scanned one - * line past the header instead of the table's body. Each one is a file Codex may write or - * a person may edit, and each turned the answer into its exact opposite — a host that will - * not load the plugin reported as one that will. - */ - it.each([ - ["a blank line before it", "\nenabled = false\n"], - ["a comment line before it", "# why\nenabled = false\n"], - ["another key before it", 'version = "1"\nenabled = false\n'], - ["a trailing comment on it", "enabled = false # turned off\n"], - ])("finds enabled = false with %s", async (_shape, body) => { - await write(PATH, `[plugins."aidd-telemetry@aidd-framework"]\n${body}`); - - expect( - (await readerFor("codex").read(PROJECT)).refs?.get("aidd-telemetry@aidd-framework") - ).toBe(false); - }); - - // The mirror failure: a header carrying its own trailing comment matched nothing, so a - // plugin that is registered reported as absent — a false alarm rather than a false calm. - it("finds a plugin whose header carries a trailing comment", async () => { - await write( - PATH, - '[plugins."aidd-telemetry@aidd-framework"] # installed by hand\nenabled = true\n' - ); - - expect( - (await readerFor("codex").read(PROJECT)).refs?.get("aidd-telemetry@aidd-framework") - ).toBe(true); - }); - - /** - * A header spelled inside a multi-line string is not a table, and TOML forbids the real - * one being defined twice — so exactly one of the two occurrences is real, and which comes - * first decides nothing. Both orders are asserted because each was, at one point, the one - * the scanner got wrong: last-write-wins let the fake override the real, and the - * first-wins that replaced it let the fake win when it came first. - */ - it.each([ - [ - "before the real table", - '[projects."/p"]\nnotes = """\n[plugins."aidd-telemetry@aidd-framework"]\nenabled = true\n"""\n\n[plugins."aidd-telemetry@aidd-framework"]\nenabled = false\n', - ], - [ - "after the real table", - '[plugins."aidd-telemetry@aidd-framework"]\nenabled = false\n\n[projects."/p"]\nnotes = """\n[plugins."aidd-telemetry@aidd-framework"]\nenabled = true\n"""\n', - ], - ])("ignores a header inside a multi-line string, %s", async (_where, content) => { - await write(PATH, content); - - expect( - (await readerFor("codex").read(PROJECT)).refs?.get("aidd-telemetry@aidd-framework") - ).toBe(false); - }); - - // A string that opens and closes on one line leaves the scanner outside it, so the tables - // after it are still read. - it("stays outside a multi-line string that opens and closes on one line", async () => { - await write( - PATH, - '[projects."/p"]\nnotes = """one line"""\n\n[plugins."aidd-telemetry@aidd-framework"]\nenabled = false\n' - ); - - expect( - (await readerFor("codex").read(PROJECT)).refs?.get("aidd-telemetry@aidd-framework") - ).toBe(false); - }); - - // `enabled` belongs to the table it sits under, never to the one before it. - it("does not read the next table's enabled as this table's", async () => { - await write(PATH, '[plugins."a@m"]\n[plugins."b@m"]\nenabled = false\n'); - - const refs = (await readerFor("codex").read(PROJECT)).refs; - - expect(refs?.get("a@m")).toBe(true); - expect(refs?.get("b@m")).toBe(false); - }); - - it("says it could not read an absent config, and carries no refs", async () => { - const reading = await readerFor("codex").read(PROJECT); - - expect(reading.refs).toBeUndefined(); - expect(reading.unreadable).toBe("ENOENT"); - }); -}); - -/** - * Every shape below was driven live under a sandboxed home on 2026-09-03, against - * `GitHub Copilot CLI 1.0.82`: `copilot plugin marketplace add ` then - * `copilot plugin install @` then `copilot plugin uninstall `. - * The fixtures are that file's shape, never its contents. - */ -describe("Copilot's own settings.json", () => { - const PATH = ".copilot/settings.json"; - - it("reads the refs its enabledPlugins carries", async () => { - await write( - PATH, - JSON.stringify({ - extraKnownMarketplaces: { "aidd-framework": { source: { source: "directory" } } }, - enabledPlugins: { "aidd-telemetry@aidd-framework": true }, - }) - ); - - expect( - (await readerFor("copilot").read(PROJECT)).refs?.get("aidd-telemetry@aidd-framework") - ).toBe(true); - }); - - // `copilot plugin uninstall` writes `false` and keeps the key — measured, and it makes - // registered-but-off an ordinary state on this host rather than a Codex peculiarity. - it("reads an uninstalled plugin as registered and disabled, not as absent", async () => { - await write( - PATH, - JSON.stringify({ enabledPlugins: { "aidd-telemetry@aidd-framework": false } }) - ); - - expect( - (await readerFor("copilot").read(PROJECT)).refs?.get("aidd-telemetry@aidd-framework") - ).toBe(false); - }); - - // A settings file exists from the first `copilot` run and gains `enabledPlugins` only on - // the first install, so its absence is "carries none" — a real answer, not a failed read. - it("reads a settings file with no enabledPlugins as carrying none", async () => { - await write(PATH, JSON.stringify({ extraKnownMarketplaces: {} })); - - const reading = await readerFor("copilot").read(PROJECT); - - expect(reading.refs?.size).toBe(0); - expect(reading.unreadable).toBeUndefined(); - }); - - it("says it could not read an absent settings file", async () => { - expect((await readerFor("copilot").read(PROJECT)).unreadable).toBe("ENOENT"); - }); -}); diff --git a/cli/tests/infrastructure/adapters/manifest-repository-adapter.integration.test.ts b/cli/tests/infrastructure/adapters/manifest-repository-adapter.integration.test.ts deleted file mode 100644 index df592002d..000000000 --- a/cli/tests/infrastructure/adapters/manifest-repository-adapter.integration.test.ts +++ /dev/null @@ -1,87 +0,0 @@ -import { mkdir, rm } from "node:fs/promises"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; -import { afterEach, beforeEach, describe, expect, it } from "vitest"; -import { Manifest } from "../../../src/domain/models/manifest.js"; -import { ManifestRepositoryAdapter } from "../../../src/infrastructure/adapters/manifest-repository-adapter.js"; - -describe("ManifestRepositoryAdapter", () => { - let tempDir: string; - let adapter: ManifestRepositoryAdapter; - - beforeEach(async () => { - tempDir = join(tmpdir(), `manifest-repo-test-${Date.now()}`); - await mkdir(tempDir, { recursive: true }); - adapter = new ManifestRepositoryAdapter(tempDir); - }); - - afterEach(async () => { - await rm(tempDir, { recursive: true, force: true }); - }); - - describe("load()", () => { - it("returns null when manifest file does not exist", async () => { - const result = await adapter.load(); - expect(result).toBeNull(); - }); - }); - - describe("save() + load() roundtrip", () => { - it("persists and restores manifest without data loss", async () => { - const manifest = Manifest.create(); - await adapter.save(manifest); - - const loaded = await adapter.load(); - expect(loaded).not.toBeNull(); - expect(loaded?.getInstalledToolIds()).toHaveLength(0); - }); - - it("manifest version is 6 after roundtrip", async () => { - const manifest = Manifest.create(); - await adapter.save(manifest); - - const loaded = await adapter.load(); - const json = loaded?.toJSON(); - expect(json?.version).toBe(6); - expect("marketplaces" in (json ?? {})).toBe(false); - expect("docsDir" in (json ?? {})).toBe(false); - }); - }); - - describe("delete()", () => { - it("deletes manifest file from disk", async () => { - const manifest = Manifest.create(); - await adapter.save(manifest); - - await adapter.delete(); - - const result = await adapter.load(); - expect(result).toBeNull(); - }); - - it("prunes empty .aidd/ directory after manifest deletion", async () => { - const manifest = Manifest.create(); - await adapter.save(manifest); - - await adapter.delete(); - - const { existsSync } = await import("node:fs"); - const aiddDir = join(tempDir, ".aidd"); - expect(existsSync(aiddDir)).toBe(false); - }); - - it("silently succeeds when no manifest to delete", async () => { - await expect(adapter.delete()).resolves.toBeUndefined(); - }); - }); - - describe("manifest persistence", () => { - it("creates .aidd/ directory if it does not exist", async () => { - const manifest = Manifest.create(); - await adapter.save(manifest); - - const { existsSync } = await import("node:fs"); - expect(existsSync(join(tempDir, ".aidd", "manifest.json"))).toBe(true); - }); - }); -}); diff --git a/cli/tests/infrastructure/adapters/marketplace-cache-adapter.integration.test.ts b/cli/tests/infrastructure/adapters/marketplace-cache-adapter.integration.test.ts deleted file mode 100644 index 0d97405e9..000000000 --- a/cli/tests/infrastructure/adapters/marketplace-cache-adapter.integration.test.ts +++ /dev/null @@ -1,208 +0,0 @@ -import { mkdir, readdir, rm, writeFile } from "node:fs/promises"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; -import { afterEach, beforeEach, describe, expect, it } from "vitest"; -import { MarketplaceCacheEntry } from "../../../src/domain/models/marketplace-cache-entry.js"; -import { MARKETPLACE_CACHE_SUBDIR } from "../../../src/domain/models/paths.js"; -import { MarketplaceCacheAdapter } from "../../../src/infrastructure/adapters/marketplace-cache-adapter.js"; - -describe("MarketplaceCacheAdapter", () => { - let projectRoot: string; - let cacheRoot: string; - let adapter: MarketplaceCacheAdapter; - - beforeEach(async () => { - projectRoot = join(tmpdir(), `marketplace-cache-test-${Date.now()}`); - cacheRoot = join(projectRoot, MARKETPLACE_CACHE_SUBDIR); - await mkdir(cacheRoot, { recursive: true }); - adapter = new MarketplaceCacheAdapter(projectRoot); - }); - - afterEach(async () => { - await rm(projectRoot, { recursive: true, force: true }); - }); - - async function createEntry( - name: string, - files: Record = {}, - lastFetchedAt?: string - ): Promise { - const entryDir = join(cacheRoot, name); - await mkdir(entryDir, { recursive: true }); - for (const [filename, content] of Object.entries(files)) { - await writeFile(join(entryDir, filename), content, "utf-8"); - } - if (lastFetchedAt !== undefined) { - await writeFile( - join(entryDir, ".fetch-meta.json"), - JSON.stringify({ lastFetchedAt }), - "utf-8" - ); - } - return entryDir; - } - - describe("list()", () => { - it("returns empty array when cache directory does not exist", async () => { - await rm(cacheRoot, { recursive: true, force: true }); - const entries = await adapter.list(); - expect(entries).toEqual([]); - }); - - it("returns empty array when cache directory is empty", async () => { - const entries = await adapter.list(); - expect(entries).toEqual([]); - }); - - it("returns an entry with correct name, path, and sizeBytes", async () => { - const content = "file content"; - await createEntry("my-marketplace", { "plugins.json": content }); - - const entries = await adapter.list(); - - expect(entries).toHaveLength(1); - expect(entries[0].name).toBe("my-marketplace"); - expect(entries[0].path).toBe(join(cacheRoot, "my-marketplace")); - expect(entries[0].sizeBytes).toBe(Buffer.byteLength(content, "utf-8")); - }); - - it("returns lastFetchedAt from .fetch-meta.json when present", async () => { - const timestamp = "2026-01-15T10:00:00.000Z"; - await createEntry("with-meta", {}, timestamp); - - const entries = await adapter.list(); - - expect(entries).toHaveLength(1); - expect(entries[0].lastFetchedAt).toBeInstanceOf(Date); - expect(entries[0].lastFetchedAt?.toISOString()).toBe(timestamp); - }); - - it("returns null lastFetchedAt when .fetch-meta.json is absent", async () => { - await createEntry("without-meta", { "data.json": "{}" }); - - const entries = await adapter.list(); - - expect(entries).toHaveLength(1); - expect(entries[0].lastFetchedAt).toBeNull(); - }); - - it("returns null lastFetchedAt when .fetch-meta.json is malformed", async () => { - const entryDir = join(cacheRoot, "malformed"); - await mkdir(entryDir, { recursive: true }); - await writeFile(join(entryDir, ".fetch-meta.json"), "not valid json", "utf-8"); - - const entries = await adapter.list(); - - expect(entries).toHaveLength(1); - expect(entries[0].lastFetchedAt).toBeNull(); - }); - - it("returns multiple entries sorted by directory listing", async () => { - await createEntry("alpha"); - await createEntry("beta"); - await createEntry("gamma"); - - const entries = await adapter.list(); - - expect(entries).toHaveLength(3); - const names = entries.map((e) => e.name).sort(); - expect(names).toEqual(["alpha", "beta", "gamma"]); - }); - - it("sums sizes of all files recursively", async () => { - const content1 = "abc"; - const content2 = "defgh"; - const entryDir = join(cacheRoot, "multi-file"); - await mkdir(join(entryDir, "subdir"), { recursive: true }); - await writeFile(join(entryDir, "root.txt"), content1, "utf-8"); - await writeFile(join(entryDir, "subdir", "nested.txt"), content2, "utf-8"); - - const entries = await adapter.list(); - - expect(entries).toHaveLength(1); - expect(entries[0].sizeBytes).toBe( - Buffer.byteLength(content1, "utf-8") + Buffer.byteLength(content2, "utf-8") - ); - }); - }); - - describe("clear(name)", () => { - it("removes a single named entry directory", async () => { - await createEntry("target", { "data.json": "{}" }); - await createEntry("keep", { "data.json": "{}" }); - - await adapter.clear("target"); - - const remaining = await readdir(cacheRoot); - expect(remaining).not.toContain("target"); - expect(remaining).toContain("keep"); - }); - - it("does not throw when named entry does not exist", async () => { - await expect(adapter.clear("nonexistent")).resolves.not.toThrow(); - }); - }); - - describe("clear() — no argument", () => { - it("removes all entries in the cache", async () => { - await createEntry("one", { "a.json": "{}" }); - await createEntry("two", { "b.json": "{}" }); - await createEntry("three", { "c.json": "{}" }); - - await adapter.clear(); - - const remaining = await readdir(cacheRoot); - expect(remaining).toHaveLength(0); - }); - - it("does not throw when cache directory is empty", async () => { - await expect(adapter.clear()).resolves.not.toThrow(); - }); - - it("does not throw when cache directory does not exist", async () => { - await rm(cacheRoot, { recursive: true, force: true }); - await expect(adapter.clear()).resolves.not.toThrow(); - }); - }); -}); - -describe("MarketplaceCacheEntry", () => { - function makeEntry(name: string, path: string): MarketplaceCacheEntry { - return new MarketplaceCacheEntry({ name, path, sizeBytes: 0, lastFetchedAt: null }); - } - - describe("constructor", () => { - it("throws EmptyMarketplaceCacheNameError when name is empty", () => { - expect(() => makeEntry("", "/some/path")).toThrow(); - }); - - it("throws EmptyMarketplaceCacheNameError when name is only whitespace", () => { - expect(() => makeEntry(" ", "/some/path")).toThrow(); - }); - - it("accepts a valid name", () => { - const entry = makeEntry("valid", "/path"); - expect(entry.name).toBe("valid"); - }); - }); - - describe("equals()", () => { - it("returns true when name and path match", () => { - const a = makeEntry("alpha", "/cache/alpha"); - const b = makeEntry("alpha", "/cache/alpha"); - expect(a.equals(b)).toBe(true); - }); - - it("returns false when names differ", () => { - const a = makeEntry("alpha", "/cache/alpha"); - const b = makeEntry("beta", "/cache/alpha"); - expect(a.equals(b)).toBe(false); - }); - - it("returns false when paths differ", () => { - const a = makeEntry("alpha", "/cache/alpha"); - const b = makeEntry("alpha", "/other/path"); - expect(a.equals(b)).toBe(false); - }); - }); -}); diff --git a/cli/tests/infrastructure/adapters/plugin-catalog-repository-adapter.integration.test.ts b/cli/tests/infrastructure/adapters/plugin-catalog-repository-adapter.integration.test.ts deleted file mode 100644 index 60f79fe56..000000000 --- a/cli/tests/infrastructure/adapters/plugin-catalog-repository-adapter.integration.test.ts +++ /dev/null @@ -1,425 +0,0 @@ -import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; -import { describe, expect, it } from "vitest"; -import { - ForeignSchemaValidationError, - InvalidPluginManifestError, - MalformedMarketplaceCatalogError, -} from "../../../src/domain/errors.js"; -import { FileAdapter } from "../../../src/infrastructure/adapters/file-adapter.js"; -import { HasherAdapter } from "../../../src/infrastructure/adapters/hasher-adapter.js"; -import { PluginCatalogRepositoryAdapter } from "../../../src/infrastructure/adapters/plugin-catalog-repository-adapter.js"; - -const FIXTURE_DIR = join(process.cwd(), "tests/fixtures/framework"); -const CURSOR_FIXTURE_DIR = join(process.cwd(), "tests/fixtures/plugins/cursor-format"); -const CODEX_FIXTURE_DIR = join(process.cwd(), "tests/fixtures/plugins/codex-format"); -const COPILOT_FIXTURE_DIR = join(process.cwd(), "tests/fixtures/plugins/copilot-format"); -const OPENCODE_FIXTURE_DIR = join(process.cwd(), "tests/fixtures/plugins/opencode-format"); - -function makeAdapter(): PluginCatalogRepositoryAdapter { - return new PluginCatalogRepositoryAdapter(new FileAdapter(new HasherAdapter())); -} - -describe("PluginCatalogRepositoryAdapter", () => { - describe("marketplace-sample fixture", () => { - it("returns a catalog with two entries", async () => { - const adapter = makeAdapter(); - const catalog = await adapter.load(join(FIXTURE_DIR, "marketplace-sample")); - expect(catalog).not.toBeNull(); - expect(catalog?.plugins).toHaveLength(2); - }); - - it("first entry has recommended true", async () => { - const adapter = makeAdapter(); - const catalog = await adapter.load(join(FIXTURE_DIR, "marketplace-sample")); - expect(catalog?.plugins[0].recommended).toBe(true); - }); - - it("second entry has recommended false", async () => { - const adapter = makeAdapter(); - const catalog = await adapter.load(join(FIXTURE_DIR, "marketplace-sample")); - expect(catalog?.plugins[1].recommended).toBe(false); - }); - - it("resolves relative local source path against framework directory", async () => { - const adapter = makeAdapter(); - const frameworkDir = join(FIXTURE_DIR, "marketplace-sample"); - const catalog = await adapter.load(frameworkDir); - expect(catalog?.plugins[0].source).toEqual({ - kind: "local", - path: join(frameworkDir, "plugins/dev"), - }); - }); - - it("parses github source for second entry", async () => { - const adapter = makeAdapter(); - const catalog = await adapter.load(join(FIXTURE_DIR, "marketplace-sample")); - expect(catalog?.plugins[1].source).toEqual({ - kind: "github", - repo: "ai-driven-dev/aidd-pm", - }); - }); - }); - - describe("marketplace-missing fixture", () => { - it("returns null when marketplace.json is absent", async () => { - const adapter = makeAdapter(); - const catalog = await adapter.load(join(FIXTURE_DIR, "marketplace-missing")); - expect(catalog).toBeNull(); - }); - }); - - describe("marketplace-malformed fixture", () => { - it("throws InvalidPluginManifestError for invalid JSON", async () => { - const adapter = makeAdapter(); - await expect(adapter.load(join(FIXTURE_DIR, "marketplace-malformed"))).rejects.toThrow( - InvalidPluginManifestError - ); - }); - }); -}); - -describe("PluginCatalogRepositoryAdapter.load (Copilot-native path)", () => { - describe("copilot marketplace-multi-sample fixture", () => { - it("returns a catalog with two entries from .plugin/marketplace.json", async () => { - const adapter = makeAdapter(); - const catalog = await adapter.load(join(COPILOT_FIXTURE_DIR, "marketplace-multi-sample")); - expect(catalog).not.toBeNull(); - expect(catalog?.plugins).toHaveLength(2); - }); - - it("carries the catalog name", async () => { - const adapter = makeAdapter(); - const catalog = await adapter.load(join(COPILOT_FIXTURE_DIR, "marketplace-multi-sample")); - expect(catalog?.name).toBe("aidd-framework"); - }); - - it("resolves relative local source path against framework directory", async () => { - const adapter = makeAdapter(); - const frameworkDir = join(COPILOT_FIXTURE_DIR, "marketplace-multi-sample"); - const catalog = await adapter.load(frameworkDir); - expect(catalog?.plugins[0].source).toEqual({ - kind: "local", - path: join(frameworkDir, "plugins/aidd-dev"), - }); - }); - - it("sets recommended and strict to false", async () => { - const adapter = makeAdapter(); - const catalog = await adapter.load(join(COPILOT_FIXTURE_DIR, "marketplace-multi-sample")); - expect(catalog?.plugins[0].recommended).toBe(false); - expect(catalog?.plugins[0].strict).toBe(false); - }); - }); - - describe("copilot marketplace-multi-missing fixture", () => { - it("returns null when neither .plugin/marketplace.json nor .claude-plugin/marketplace.json exists", async () => { - const adapter = makeAdapter(); - const catalog = await adapter.load(join(COPILOT_FIXTURE_DIR, "marketplace-multi-missing")); - expect(catalog).toBeNull(); - }); - }); - - describe("copilot marketplace-multi-malformed fixture", () => { - it("throws InvalidPluginManifestError for invalid JSON in .plugin/marketplace.json", async () => { - const adapter = makeAdapter(); - await expect( - adapter.load(join(COPILOT_FIXTURE_DIR, "marketplace-multi-malformed")) - ).rejects.toThrow(InvalidPluginManifestError); - }); - }); -}); - -describe("PluginCatalogRepositoryAdapter.loadForeign", () => { - describe("cursor marketplace-sample fixture", () => { - it("returns three normalized plugins", async () => { - const adapter = makeAdapter(); - const plugins = await adapter.loadForeign(join(CURSOR_FIXTURE_DIR, "marketplace-sample")); - expect(plugins).toHaveLength(3); - }); - - it("first plugin has name, version and description", async () => { - const adapter = makeAdapter(); - const plugins = await adapter.loadForeign(join(CURSOR_FIXTURE_DIR, "marketplace-sample")); - expect(plugins[0]).toEqual({ - name: "cursor-dev-tools", - version: "1.2.0", - description: "Developer tools for Cursor", - source: "cursor", - }); - }); - - it("plugin without version has name and description only", async () => { - const adapter = makeAdapter(); - const plugins = await adapter.loadForeign(join(CURSOR_FIXTURE_DIR, "marketplace-sample")); - expect(plugins[1]).toEqual({ - name: "cursor-testing", - description: "Testing utilities", - source: "cursor", - }); - }); - - it("minimal plugin has name and source only", async () => { - const adapter = makeAdapter(); - const plugins = await adapter.loadForeign(join(CURSOR_FIXTURE_DIR, "marketplace-sample")); - expect(plugins[2]).toEqual({ name: "cursor-minimal", source: "cursor" }); - }); - }); - - describe("cursor marketplace-empty fixture", () => { - it("returns empty array when plugins list is empty", async () => { - const adapter = makeAdapter(); - const plugins = await adapter.loadForeign(join(CURSOR_FIXTURE_DIR, "marketplace-empty")); - expect(plugins).toEqual([]); - }); - }); - - describe("cursor marketplace-malformed fixture", () => { - it("throws ForeignSchemaValidationError for invalid JSON", async () => { - const adapter = makeAdapter(); - await expect( - adapter.loadForeign(join(CURSOR_FIXTURE_DIR, "marketplace-malformed")) - ).rejects.toThrow(ForeignSchemaValidationError); - }); - }); - - describe("no marketplace.json present", () => { - it("returns empty array when no cursor marketplace exists", async () => { - const adapter = makeAdapter(); - const plugins = await adapter.loadForeign(join(FIXTURE_DIR, "marketplace-missing")); - expect(plugins).toEqual([]); - }); - }); -}); - -describe("PluginCatalogRepositoryAdapter.loadForeign (Codex)", () => { - describe("codex marketplace-sample fixture", () => { - it("returns three normalized plugins", async () => { - const adapter = makeAdapter(); - const plugins = await adapter.loadForeign(join(CODEX_FIXTURE_DIR, "marketplace-sample")); - expect(plugins).toHaveLength(3); - }); - - it("first plugin has name, version and description", async () => { - const adapter = makeAdapter(); - const plugins = await adapter.loadForeign(join(CODEX_FIXTURE_DIR, "marketplace-sample")); - expect(plugins[0]).toEqual({ - name: "codex-dev-tools", - version: "1.2.0", - description: "Developer tools for Codex", - source: "codex", - }); - }); - - it("plugin without version has name and description only", async () => { - const adapter = makeAdapter(); - const plugins = await adapter.loadForeign(join(CODEX_FIXTURE_DIR, "marketplace-sample")); - expect(plugins[1]).toEqual({ - name: "codex-testing", - description: "Testing utilities", - source: "codex", - }); - }); - - it("minimal plugin has name and source only", async () => { - const adapter = makeAdapter(); - const plugins = await adapter.loadForeign(join(CODEX_FIXTURE_DIR, "marketplace-sample")); - expect(plugins[2]).toEqual({ name: "codex-minimal", source: "codex" }); - }); - }); - - describe("codex marketplace-empty fixture", () => { - it("returns empty array when plugins list is empty", async () => { - const adapter = makeAdapter(); - const plugins = await adapter.loadForeign(join(CODEX_FIXTURE_DIR, "marketplace-empty")); - expect(plugins).toEqual([]); - }); - }); - - describe("codex marketplace-malformed fixture", () => { - it("throws ForeignSchemaValidationError for invalid JSON", async () => { - const adapter = makeAdapter(); - await expect( - adapter.loadForeign(join(CODEX_FIXTURE_DIR, "marketplace-malformed")) - ).rejects.toThrow(ForeignSchemaValidationError); - }); - }); - - describe("no codex marketplace.json present", () => { - it("returns empty array when no codex marketplace exists", async () => { - const adapter = makeAdapter(); - const plugins = await adapter.loadForeign(join(FIXTURE_DIR, "marketplace-missing")); - expect(plugins).toEqual([]); - }); - }); -}); - -describe("PluginCatalogRepositoryAdapter.loadForeign (Copilot)", () => { - describe("copilot marketplace-sample fixture", () => { - it("returns one normalized plugin", async () => { - const adapter = makeAdapter(); - const plugins = await adapter.loadForeign(join(COPILOT_FIXTURE_DIR, "marketplace-sample")); - expect(plugins).toHaveLength(1); - }); - - it("plugin has name, version and description", async () => { - const adapter = makeAdapter(); - const plugins = await adapter.loadForeign(join(COPILOT_FIXTURE_DIR, "marketplace-sample")); - expect(plugins[0]).toEqual({ - name: "copilot-dev-tools", - version: "2.0.0", - description: "Developer tools for Copilot", - source: "copilot", - }); - }); - }); - - describe("copilot marketplace-minimal fixture", () => { - it("returns plugin with name and source only", async () => { - const adapter = makeAdapter(); - const plugins = await adapter.loadForeign(join(COPILOT_FIXTURE_DIR, "marketplace-minimal")); - expect(plugins[0]).toEqual({ name: "copilot-minimal", source: "copilot" }); - }); - }); - - describe("copilot marketplace-malformed fixture", () => { - it("throws ForeignSchemaValidationError for invalid JSON", async () => { - const adapter = makeAdapter(); - await expect( - adapter.loadForeign(join(COPILOT_FIXTURE_DIR, "marketplace-malformed")) - ).rejects.toThrow(ForeignSchemaValidationError); - }); - }); - - describe("no copilot plugin.json present", () => { - it("returns empty array when no copilot plugin.json exists", async () => { - const adapter = makeAdapter(); - const plugins = await adapter.loadForeign(join(FIXTURE_DIR, "marketplace-missing")); - expect(plugins).toEqual([]); - }); - }); -}); - -describe("PluginCatalogRepositoryAdapter.loadForeign (OpenCode)", () => { - describe("opencode marketplace-sample fixture", () => { - it("returns three normalized plugins", async () => { - const adapter = makeAdapter(); - const plugins = await adapter.loadForeign(join(OPENCODE_FIXTURE_DIR, "marketplace-sample")); - expect(plugins).toHaveLength(3); - }); - - it("first plugin is bare string specifier", async () => { - const adapter = makeAdapter(); - const plugins = await adapter.loadForeign(join(OPENCODE_FIXTURE_DIR, "marketplace-sample")); - expect(plugins[0]).toEqual({ name: "opencode-dev-tools", source: "opencode" }); - }); - - it("second plugin is scoped npm package specifier", async () => { - const adapter = makeAdapter(); - const plugins = await adapter.loadForeign(join(OPENCODE_FIXTURE_DIR, "marketplace-sample")); - expect(plugins[1]).toEqual({ name: "@my-org/opencode-testing", source: "opencode" }); - }); - - it("third plugin comes from tuple, name is first element", async () => { - const adapter = makeAdapter(); - const plugins = await adapter.loadForeign(join(OPENCODE_FIXTURE_DIR, "marketplace-sample")); - expect(plugins[2]).toEqual({ name: "opencode-minimal", source: "opencode" }); - }); - - it("no plugin has version or description", async () => { - const adapter = makeAdapter(); - const plugins = await adapter.loadForeign(join(OPENCODE_FIXTURE_DIR, "marketplace-sample")); - for (const p of plugins) { - expect(p.version).toBeUndefined(); - expect(p.description).toBeUndefined(); - } - }); - }); - - describe("opencode marketplace-empty fixture", () => { - it("returns empty array when plugin list is empty", async () => { - const adapter = makeAdapter(); - const plugins = await adapter.loadForeign(join(OPENCODE_FIXTURE_DIR, "marketplace-empty")); - expect(plugins).toEqual([]); - }); - }); - - describe("opencode marketplace-no-plugin-key fixture", () => { - it("returns empty array when plugin field is absent", async () => { - const adapter = makeAdapter(); - const plugins = await adapter.loadForeign( - join(OPENCODE_FIXTURE_DIR, "marketplace-no-plugin-key") - ); - expect(plugins).toEqual([]); - }); - }); - - describe("opencode marketplace-malformed fixture", () => { - it("throws ForeignSchemaValidationError for invalid JSON", async () => { - const adapter = makeAdapter(); - await expect( - adapter.loadForeign(join(OPENCODE_FIXTURE_DIR, "marketplace-malformed")) - ).rejects.toThrow(ForeignSchemaValidationError); - }); - }); - - describe("no opencode.json present", () => { - it("returns empty array when no opencode.json exists", async () => { - const adapter = makeAdapter(); - const plugins = await adapter.loadForeign(join(FIXTURE_DIR, "marketplace-missing")); - expect(plugins).toEqual([]); - }); - }); -}); - -// Regression: a user (framework 4.4.1, claude) hit a cryptic -// `Invalid plugin manifest: "plugins" must be an array` crash when a cached -// marketplace.json held a non-array object (stale / interrupted fetch). -// The catalog reader must surface an actionable, recovery-bearing error -// instead, and the hint must differ for cache vs user-provided sources. -describe("PluginCatalogRepositoryAdapter.load — malformed catalog recovery", () => { - async function writeCatalog(frameworkDir: string, content: string): Promise { - await mkdir(join(frameworkDir, ".claude-plugin"), { recursive: true }); - await writeFile(join(frameworkDir, ".claude-plugin/marketplace.json"), content, "utf-8"); - } - - it("non-array object under a cache path → MalformedMarketplaceCatalogError with refresh hint", async () => { - const tmp = await mkdtemp(join(tmpdir(), "aidd-catalog-cache-")); - const cacheDir = join(tmp, ".aidd/cache/marketplaces/aidd-framework/github-x"); - await writeCatalog(cacheDir, '{"message":"API rate limit exceeded"}'); - const adapter = makeAdapter(); - try { - await expect(adapter.load(cacheDir)).rejects.toThrow(MalformedMarketplaceCatalogError); - await expect(adapter.load(cacheDir)).rejects.toThrow(/marketplace refresh --force/); - // Backward-compat: still an InvalidPluginManifestError for existing catchers. - await expect(adapter.load(cacheDir)).rejects.toThrow(InvalidPluginManifestError); - } finally { - await rm(tmp, { recursive: true, force: true }); - } - }); - - it("malformed JSON under a cache path → recovery hint, never a raw JSON.parse crash", async () => { - const tmp = await mkdtemp(join(tmpdir(), "aidd-catalog-cache-")); - const cacheDir = join(tmp, ".aidd/cache/marketplaces/aidd-framework/github-x"); - await writeCatalog(cacheDir, "{ not valid json"); - const adapter = makeAdapter(); - try { - await expect(adapter.load(cacheDir)).rejects.toThrow(/marketplace refresh --force/); - } finally { - await rm(tmp, { recursive: true, force: true }); - } - }); - - it("malformed catalog from a user-provided (non-cache) source → fix-the-file hint", async () => { - const tmp = await mkdtemp(join(tmpdir(), "aidd-catalog-local-")); - await writeCatalog(tmp, '{"plugins":{}}'); - const adapter = makeAdapter(); - try { - await expect(adapter.load(tmp)).rejects.toThrow(MalformedMarketplaceCatalogError); - await expect(adapter.load(tmp)).rejects.toThrow(/Fix or re-create/); - } finally { - await rm(tmp, { recursive: true, force: true }); - } - }); -}); diff --git a/cli/tests/infrastructure/adapters/plugin-distribution-reader-adapter.integration.test.ts b/cli/tests/infrastructure/adapters/plugin-distribution-reader-adapter.integration.test.ts deleted file mode 100644 index 558af4725..000000000 --- a/cli/tests/infrastructure/adapters/plugin-distribution-reader-adapter.integration.test.ts +++ /dev/null @@ -1,120 +0,0 @@ -import { join } from "node:path"; -import { describe, expect, it } from "vitest"; -import { InvalidPluginManifestError, InvalidPluginNameError } from "../../../src/domain/errors.js"; -import { FileAdapter } from "../../../src/infrastructure/adapters/file-adapter.js"; -import { HasherAdapter } from "../../../src/infrastructure/adapters/hasher-adapter.js"; -import { PluginDistributionReaderAdapter } from "../../../src/infrastructure/adapters/plugin-distribution-reader-adapter.js"; - -const FIXTURE_DIR = join(process.cwd(), "tests/fixtures/plugins"); - -function makeAdapter(): PluginDistributionReaderAdapter { - return new PluginDistributionReaderAdapter(new FileAdapter(new HasherAdapter())); -} - -describe("PluginDistributionReaderAdapter", () => { - describe("claude-format fixture", () => { - it("detects claude format", async () => { - const adapter = makeAdapter(); - const dist = await adapter.read(join(FIXTURE_DIR, "claude-format/sample-plugin")); - expect(dist.format).toBe("claude"); - }); - - it("includes all hooks/ files including companion scripts", async () => { - const adapter = makeAdapter(); - const dist = await adapter.read(join(FIXTURE_DIR, "claude-format/sample-plugin")); - const paths = dist.files.map((f) => f.relativePath); - expect(paths).toContain("hooks/hooks.json"); - expect(paths).toContain("hooks/update_memory.js"); - }); - - it("parses manifest fields", async () => { - const adapter = makeAdapter(); - const dist = await adapter.read(join(FIXTURE_DIR, "claude-format/sample-plugin")); - expect(dist.manifest.name).toBe("sample-plugin"); - expect(dist.manifest.version).toBe("1.0.0"); - }); - - it("collects component files", async () => { - const adapter = makeAdapter(); - const dist = await adapter.read(join(FIXTURE_DIR, "claude-format/sample-plugin")); - expect(dist.files.length).toBeGreaterThan(0); - }); - - it("categorizes skills correctly", async () => { - const adapter = makeAdapter(); - const dist = await adapter.read(join(FIXTURE_DIR, "claude-format/sample-plugin")); - expect(dist.components.skills.length).toBe(1); - expect(dist.components.skills[0].relativePath).toBe("skills/hello/SKILL.md"); - }); - - it("categorizes commands correctly", async () => { - const adapter = makeAdapter(); - const dist = await adapter.read(join(FIXTURE_DIR, "claude-format/sample-plugin")); - expect(dist.components.commands.length).toBe(1); - expect(dist.components.commands[0].relativePath).toBe("commands/greet.md"); - }); - - it("categorizes agents correctly", async () => { - const adapter = makeAdapter(); - const dist = await adapter.read(join(FIXTURE_DIR, "claude-format/sample-plugin")); - expect(dist.components.agents.length).toBe(1); - expect(dist.components.agents[0].relativePath).toBe("agents/reviewer.md"); - }); - - it("reads file content", async () => { - const adapter = makeAdapter(); - const dist = await adapter.read(join(FIXTURE_DIR, "claude-format/sample-plugin")); - const skill = dist.components.skills[0]; - expect(skill.content).toContain("Hello from sample-plugin skill."); - }); - - it("includes the plugin manifest in files for native installation", async () => { - const adapter = makeAdapter(); - const dist = await adapter.read(join(FIXTURE_DIR, "claude-format/sample-plugin")); - const paths = dist.files.map((f) => f.relativePath); - expect(paths).toContain(".claude-plugin/plugin.json"); - }); - }); - - describe("cursor-format fixture", () => { - it("detects cursor format", async () => { - const adapter = makeAdapter(); - const dist = await adapter.read(join(FIXTURE_DIR, "cursor-format/sample-plugin")); - expect(dist.format).toBe("cursor"); - }); - }); - - describe("codex-format fixture", () => { - it("detects codex format", async () => { - const adapter = makeAdapter(); - const dist = await adapter.read(join(FIXTURE_DIR, "codex-format/sample-plugin")); - expect(dist.format).toBe("codex"); - }); - }); - - describe("copilot-format fixture", () => { - it("detects copilot format", async () => { - const adapter = makeAdapter(); - const dist = await adapter.read(join(FIXTURE_DIR, "copilot-format/sample-plugin")); - expect(dist.format).toBe("copilot"); - }); - }); - - describe("broken-plugin fixture", () => { - it("throws InvalidPluginNameError for invalid plugin name", async () => { - const adapter = makeAdapter(); - await expect(adapter.read(join(FIXTURE_DIR, "broken-plugin"))).rejects.toThrow( - InvalidPluginNameError - ); - }); - }); - - describe("non-existent directory", () => { - it("throws InvalidPluginManifestError when directory has no plugin.json", async () => { - const adapter = makeAdapter(); - await expect(adapter.read(join(FIXTURE_DIR, "nonexistent-plugin"))).rejects.toThrow( - InvalidPluginManifestError - ); - }); - }); -}); diff --git a/cli/tests/infrastructure/adapters/plugin-manifest-schema.integration.test.ts b/cli/tests/infrastructure/adapters/plugin-manifest-schema.integration.test.ts deleted file mode 100644 index 1057db1b2..000000000 --- a/cli/tests/infrastructure/adapters/plugin-manifest-schema.integration.test.ts +++ /dev/null @@ -1,34 +0,0 @@ -/** - * Integration test: real BundledAssetProviderAdapter + real AjvSchemaValidatorAdapter - * validates that every kind of scaffold produces a plugin.json that passes the - * bundled claude-code-plugin-manifest.json schema. - */ - -import { describe, expect, it } from "vitest"; -import type { PluginComponentKind } from "../../../src/domain/models/plugin-component-kind.js"; -import { buildScaffold } from "../../../src/domain/models/plugin-scaffold.js"; -import { AjvSchemaValidatorAdapter } from "../../../src/infrastructure/adapters/ajv-schema-validator-adapter.js"; -import { BundledAssetProviderAdapter } from "../../../src/infrastructure/assets/asset-loader.js"; - -const ALL_KINDS: PluginComponentKind[] = ["full", "skills", "agents", "hooks", "mcp"]; - -describe("plugin manifest schema validation (real adapters)", () => { - const assetProvider = new BundledAssetProviderAdapter(); - const validator = new AjvSchemaValidatorAdapter(); - const schema = assetProvider.loadSchema("plugin-manifest"); - - for (const kind of ALL_KINDS) { - it(`scaffold kind '${kind}' generates a valid plugin.json`, () => { - const scaffold = buildScaffold({ - name: "test-plugin", - version: "0.1.0", - description: "Test", - kind, - }); - const manifestContent = scaffold.get(".claude-plugin/plugin.json"); - expect(manifestContent).toBeDefined(); - const manifest = JSON.parse(manifestContent as string); - expect(() => validator.validate(schema, manifest)).not.toThrow(); - }); - } -}); diff --git a/cli/tests/infrastructure/adapters/task-backlog-skill-shape.integration.test.ts b/cli/tests/infrastructure/adapters/task-backlog-skill-shape.integration.test.ts deleted file mode 100644 index c1ab42503..000000000 --- a/cli/tests/infrastructure/adapters/task-backlog-skill-shape.integration.test.ts +++ /dev/null @@ -1,103 +0,0 @@ -import { readFileSync } from "node:fs"; -import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; -import { tmpdir } from "node:os"; -import { join, resolve } from "node:path"; -import { afterEach, describe, expect, it } from "vitest"; -import { TaskBacklogAdapter } from "../../../src/infrastructure/adapters/task-backlog-adapter.js"; - -/** - * What `aidd-pm:04-spec` and `aidd-dev:01-plan` say they write into `backlog-link.json`, - * held to what `TaskBacklogAdapter` actually accepts — the same family - * `telemetry-check-skill-commands.e2e.test.ts` and its siblings run for a skill's account - * of a CLI command, extended here to a file's shape instead of a command line. Each - * skill's own fenced example is fed through the real adapter, over a real temp folder, - * never a stand-in parser: a doc renaming a field, or the adapter expecting a different - * one, fails this the same way a skill naming a command the CLI does not accept fails - * those. - */ -const REPO_ROOT = resolve(process.cwd(), ".."); -const SPEC_SKILL_MD = join( - REPO_ROOT, - "plugins", - "aidd-pm", - "skills", - "04-spec", - "actions", - "01-build.md" -); -const PLAN_SKILL_MD = join( - REPO_ROOT, - "plugins", - "aidd-dev", - "skills", - "01-plan", - "actions", - "04-plan.md" -); - -/** The first fenced ```json block in a skill's own markdown - the literal example it tells - * an agent to write. Tolerant of the block sitting inside a numbered-list item's own - * indentation, which is where both skills place theirs. `null` when none is found, which - * the tests below refuse to pass on silently (a closure test over an empty extraction - * passes vacuously). */ -function fencedJsonExample(markdown: string): string | null { - const match = /^[ \t]*```json\r?\n([\s\S]*?)\r?\n[ \t]*```/mu.exec(markdown); - return match?.[1] ?? null; -} - -const tempDirs: string[] = []; - -afterEach(async () => { - for (const dir of tempDirs.splice(0)) await rm(dir, { recursive: true, force: true }); -}); - -async function projectWithLink(json: string): Promise<{ root: string; taskFolder: string }> { - const root = await mkdtemp(join(tmpdir(), "aidd-backlog-skill-shape-")); - tempDirs.push(root); - const taskFolder = "aidd_docs/tasks/2026_08/2026_08_21_example/"; - await mkdir(join(root, taskFolder), { recursive: true }); - await writeFile(join(root, taskFolder, "backlog-link.json"), json, "utf8"); - return { root, taskFolder }; -} - -describe.each([ - ["aidd-pm:04-spec", SPEC_SKILL_MD], - ["aidd-dev:01-plan", PLAN_SKILL_MD], -])("%s's own backlog-link.json example matches what the reader accepts", (_skill, path) => { - it("names a fenced JSON example at all (guards against a no-op extraction)", () => { - const markdown = readFileSync(path, "utf8"); - expect(fencedJsonExample(markdown)).not.toBeNull(); - }); - - it("parses through the real TaskBacklogAdapter as a declared item", async () => { - const markdown = readFileSync(path, "utf8"); - const example = fencedJsonExample(markdown); - if (example === null) throw new Error("no fenced json example to test"); - - const { root, taskFolder } = await projectWithLink(`${example}\n`); - const adapter = new TaskBacklogAdapter(root); - - const declaration = await adapter.read(taskFolder); - - expect(declaration.kind).toBe("declared"); - if (declaration.kind === "declared") { - expect(declaration.link.backlog).toBe("owner/repo#123"); - expect(declaration.link.writtenAt.length).toBeGreaterThan(0); - expect(declaration.link.writtenBy.length).toBeGreaterThan(0); - } - }); -}); - -describe("both skills agree with each other, not only with the reader", () => { - it("write the identical field names, so neither can drift from the other unnoticed", () => { - const specExample = fencedJsonExample(readFileSync(SPEC_SKILL_MD, "utf8")); - const planExample = fencedJsonExample(readFileSync(PLAN_SKILL_MD, "utf8")); - expect(specExample).not.toBeNull(); - expect(planExample).not.toBeNull(); - - const fieldNames = (json: string): readonly string[] => - Object.keys(JSON.parse(json) as Record).sort(); - - expect(fieldNames(specExample as string)).toEqual(fieldNames(planExample as string)); - }); -}); diff --git a/cli/tests/infrastructure/adapters/telemetry-sink-location.unit.test.ts b/cli/tests/infrastructure/adapters/telemetry-sink-location.unit.test.ts deleted file mode 100644 index 6f2c5cf44..000000000 --- a/cli/tests/infrastructure/adapters/telemetry-sink-location.unit.test.ts +++ /dev/null @@ -1,282 +0,0 @@ -import { chmodSync, mkdirSync, mkdtempSync, readFileSync, rmSync, statSync } from "node:fs"; -import { tmpdir } from "node:os"; -import { join, resolve } from "node:path"; -import { afterEach, describe, expect, it } from "vitest"; -import { - defaultConfigDir, - TelemetrySinkAdapter, -} from "../../../src/infrastructure/adapters/telemetry-sink-adapter.js"; -import { AuthStorage } from "../../../src/infrastructure/auth/auth-storage.js"; -import { sandboxedEnv, sinkDirIn } from "../../e2e/helpers.js"; - -/** - * Where a person's figures land, pinned on any platform rather than only on a Windows runner. - * - * `defaultConfigDir` reads `process.platform` on every call, so stating it here is enough. - * This pin lived in the plugin's own `sink.cjs` suite until the read path moved into the CLI; - * that suite is gone, and a rule only `cli / Windows` can check is a rule that regresses in - * silence for everyone else. The Windows half is a measurement, not a preference: `%APPDATA%` - * is where a Windows application keeps this, and `.config` is not. - */ -const REPO_ROOT = resolve(process.cwd(), ".."); -const PLUGIN_README = join(REPO_ROOT, "plugins", "aidd-telemetry", "README.md"); - -function withPlatform(platform: NodeJS.Platform, run: () => T): T { - const original = Object.getOwnPropertyDescriptor(process, "platform"); - Object.defineProperty(process, "platform", { value: platform, configurable: true }); - try { - return run(); - } finally { - if (original) Object.defineProperty(process, "platform", original); - } -} - -const previousAppData = process.env.APPDATA; -const previousHome = process.env.HOME; -const temporaryHomes: string[] = []; - -/** A home with no `.config/aidd/telemetry` in it, so the legacy-data fallback below does not - * fire. On the machine writing this it does fire, which is the documented behaviour: a - * machine that already journalled under `.config` keeps landing there rather than losing - * access to what it wrote. Only a fresh machine gets `%APPDATA%`, and that is what these - * assertions are about. */ -function freshHome(): string { - const home = mkdtempSync(join(tmpdir(), "aidd-sink-location-")); - temporaryHomes.push(home); - process.env.HOME = home; - return home; -} - -afterEach(() => { - if (previousAppData === undefined) delete process.env.APPDATA; - else process.env.APPDATA = previousAppData; - if (previousHome === undefined) delete process.env.HOME; - else process.env.HOME = previousHome; - for (const home of temporaryHomes.splice(0)) rmSync(home, { recursive: true, force: true }); -}); - -describe("where the figures land by default", () => { - it("a POSIX machine keeps them under the OS user's own .config", () => { - const home = freshHome(); - - expect(withPlatform("linux", defaultConfigDir)).toBe(join(home, ".config", "aidd")); - }); - - it("a fresh Windows machine keeps them under %APPDATA%, never under .config", () => { - freshHome(); - process.env.APPDATA = join("C:", "Users", "someone", "AppData", "Roaming"); - - expect(withPlatform("win32", defaultConfigDir)).toBe(join(process.env.APPDATA, "aidd")); - }); - - it("Windows without APPDATA falls back rather than inventing a path", () => { - const home = freshHome(); - delete process.env.APPDATA; - - expect(withPlatform("win32", defaultConfigDir)).toBe(join(home, ".config", "aidd")); - }); - - it("the plugin README states the exact default the code writes", () => { - // Written with forward slashes rather than `join`, which yields `~\\.config\\aidd` on - // Windows and fails against prose that is the same on every platform. Documentation - // spells a path one way; only the code has a separator that follows the host. - const documented = "~/.config/aidd/telemetry"; - const text = readFileSync(PLUGIN_README, "utf8"); - - expect(text).toContain(documented); - // The variable the *code* reads. Pinning the older one instead passed on the strength of - // a mention that only tells a reader not to use it — which could be deleted without this - // noticing, leaving the override the adapter honours documented nowhere. - expect(text).toContain("AIDD_TELEMETRY_DIR"); - }); -}); - -/** - * The rule an e2e test needs and cannot see: where a *sandboxed* run's figures land. - * - * `sandboxedEnv` points `APPDATA` inside the fake home, so a Windows run writes under - * `AppData\\Roaming\\aidd` while a POSIX run writes under `.config`. A test that hardcoded - * the POSIX path read as "nothing was stored" on Windows instead of as a wrong lookup, and - * `cli / Windows` was the only job that could ever say so — it caught exactly this, twice. - * - * Pinning the helper against the adapter on both platforms is what stops the next test from - * hardcoding it again: the two can no longer disagree without failing here, on any machine. - */ -describe("a sandboxed run's sink, agreed between the helper and the adapter", () => { - for (const platform of ["linux", "win32"] as const) { - it(`agrees on ${platform}, whichever platform this suite runs on`, () => { - const home = freshHome(); - const env = sandboxedEnv(home); - const previousPlatformAppData = process.env.APPDATA; - process.env.APPDATA = env.APPDATA; - try { - const fromAdapter = join(withPlatform(platform, defaultConfigDir), "telemetry"); - const fromHelper = withPlatform(platform, () => sinkDirIn(home)); - - expect(fromHelper).toBe(fromAdapter); - } finally { - if (previousPlatformAppData === undefined) delete process.env.APPDATA; - else process.env.APPDATA = previousPlatformAppData; - } - }); - } -}); - -/** - * The measurement has its own name, and nothing else follows it. - * - * The figures are the one thing here meant to leave a machine, so a team shares the - * directory they land in. Until this, that directory was named by `AIDD_USER_CONFIG_DIR`, - * which also names where `auth.json` — a GitHub token — is written. Sharing the figures - * shared the token. - */ -describe("where the figures land, and what does not follow them there", () => { - const previousTelemetryDir = process.env.AIDD_TELEMETRY_DIR; - const previousUserConfigDir = process.env.AIDD_USER_CONFIG_DIR; - - afterEach(() => { - for (const [key, value] of [ - ["AIDD_TELEMETRY_DIR", previousTelemetryDir], - ["AIDD_USER_CONFIG_DIR", previousUserConfigDir], - ] as const) { - if (value === undefined) delete process.env[key]; - else process.env[key] = value; - } - }); - - it("puts the figures exactly where AIDD_TELEMETRY_DIR names, not in a subdirectory of it", () => { - // The two variables mean different things on purpose: this one names the directory the - // day files sit in, `AIDD_USER_CONFIG_DIR` names the directory above it. Appending - // "telemetry" to both would make a person who set this one wonder where their figures - // went. - const shared = mkdtempSync(join(tmpdir(), "aidd-shared-figures-")); - try { - process.env.AIDD_TELEMETRY_DIR = shared; - delete process.env.AIDD_USER_CONFIG_DIR; - - expect(new TelemetrySinkAdapter().rootDir).toBe(shared); - } finally { - rmSync(shared, { recursive: true, force: true }); - } - }); - - it("leaves the token where it was when the figures are shared", () => { - // The whole point of the split, asserted as the property rather than as the wiring: a - // person following the documented way to share their figures must not move their - // credential with them. - const shared = mkdtempSync(join(tmpdir(), "aidd-shared-figures-")); - const home = mkdtempSync(join(tmpdir(), "aidd-home-")); - try { - delete process.env.AIDD_USER_CONFIG_DIR; - const tokenBefore = new AuthStorage().userConfigPath(); - - process.env.AIDD_TELEMETRY_DIR = shared; - - expect(new TelemetrySinkAdapter().rootDir).toBe(shared); - expect(new AuthStorage().userConfigPath()).toBe(tokenBefore); - } finally { - rmSync(shared, { recursive: true, force: true }); - rmSync(home, { recursive: true, force: true }); - } - }); - - it("still honours the older variable, so a setup that predates the split keeps working", () => { - const older = mkdtempSync(join(tmpdir(), "aidd-legacy-config-")); - try { - delete process.env.AIDD_TELEMETRY_DIR; - process.env.AIDD_USER_CONFIG_DIR = older; - - expect(new TelemetrySinkAdapter().rootDir).toBe(join(older, "telemetry")); - } finally { - rmSync(older, { recursive: true, force: true }); - } - }); - - it("prefers the name given to the figures when both are set", () => { - const shared = mkdtempSync(join(tmpdir(), "aidd-shared-figures-")); - const older = mkdtempSync(join(tmpdir(), "aidd-legacy-config-")); - try { - process.env.AIDD_TELEMETRY_DIR = shared; - process.env.AIDD_USER_CONFIG_DIR = older; - - expect(new TelemetrySinkAdapter().rootDir).toBe(shared); - } finally { - rmSync(shared, { recursive: true, force: true }); - rmSync(older, { recursive: true, force: true }); - } - }); -}); - -/** - * Who may list a person's working days. - * - * A day file's content was always 0600. What the directory's own mode decides is the - * *listing* — which days this person worked, and how many. A default location is theirs - * alone and is tightened; a location they named themselves is left as they made it, because - * a shared directory is what naming one is for and locking it to one account would break it. - * - * Both halves were uncovered until now, on a boolean this change rewrote. - */ -describe("who may list the days a person worked", () => { - const previousTelemetryDir = process.env.AIDD_TELEMETRY_DIR; - const previousUserConfigDir = process.env.AIDD_USER_CONFIG_DIR; - const previousHome = process.env.HOME; - - afterEach(() => { - for (const [key, value] of [ - ["AIDD_TELEMETRY_DIR", previousTelemetryDir], - ["AIDD_USER_CONFIG_DIR", previousUserConfigDir], - ["HOME", previousHome], - ] as const) { - if (value === undefined) delete process.env[key]; - else process.env[key] = value; - } - }); - - function modeOf(dir: string): string { - return (statSync(dir).mode & 0o777).toString(8); - } - - it.skipIf(process.platform === "win32")( - "tightens a default location to this person alone", - async () => { - const home = mkdtempSync(join(tmpdir(), "aidd-tighten-home-")); - try { - delete process.env.AIDD_TELEMETRY_DIR; - delete process.env.AIDD_USER_CONFIG_DIR; - process.env.HOME = home; - - const sink = new TelemetrySinkAdapter(); - await sink.ensureWritable(); - - expect(modeOf(sink.rootDir)).toBe("700"); - } finally { - rmSync(home, { recursive: true, force: true }); - } - } - ); - - it.skipIf(process.platform === "win32")( - "leaves a location a person named themselves exactly as they made it", - async () => { - const home = mkdtempSync(join(tmpdir(), "aidd-tighten-home-")); - const shared = join(mkdtempSync(join(tmpdir(), "aidd-tighten-shared-")), "figures"); - try { - process.env.HOME = home; - delete process.env.AIDD_USER_CONFIG_DIR; - process.env.AIDD_TELEMETRY_DIR = shared; - mkdirSync(shared, { recursive: true }); - chmodSync(shared, 0o755); - - const sink = new TelemetrySinkAdapter(); - await sink.ensureWritable(); - - // Untouched: a directory a team shares must stay listable by the team. - expect(modeOf(shared)).toBe("755"); - } finally { - rmSync(home, { recursive: true, force: true }); - rmSync(shared, { recursive: true, force: true }); - } - } - ); -}); diff --git a/cli/tests/infrastructure/auth/auth-storage.integration.test.ts b/cli/tests/infrastructure/auth/auth-storage.integration.test.ts deleted file mode 100644 index e0e163b6d..000000000 --- a/cli/tests/infrastructure/auth/auth-storage.integration.test.ts +++ /dev/null @@ -1,226 +0,0 @@ -import { mkdtemp, readFile, rm, stat, writeFile } from "node:fs/promises"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; -import { afterEach, beforeEach, describe, expect, it } from "vitest"; -import { AuthStorage } from "../../../src/infrastructure/auth/auth-storage.js"; -import { makeAuthConfig } from "../../helpers/auth.js"; - -describe("AuthStorage", () => { - let tempDir: string; - let storage: AuthStorage; - - beforeEach(async () => { - tempDir = await mkdtemp(join(tmpdir(), "auth-storage-test-")); - storage = new AuthStorage(); - }); - - afterEach(async () => { - await rm(tempDir, { recursive: true, force: true }); - }); - - describe("read", () => { - it("returns null when file does not exist", async () => { - const result = await storage.read(join(tempDir, "nonexistent.json")); - expect(result).toBeNull(); - }); - - it("returns null when file contains invalid JSON", async () => { - const path = join(tempDir, "bad.json"); - await writeFile(path, "not json"); - const result = await storage.read(path); - expect(result).toBeNull(); - }); - - it("returns null when file contains JSON missing required fields", async () => { - const path = join(tempDir, "incomplete.json"); - await writeFile(path, JSON.stringify({ version: 1 })); - const result = await storage.read(path); - expect(result).toBeNull(); - }); - - it("returns AuthConfig when file is valid", async () => { - const config = makeAuthConfig({ token: "ghp_abc123" }); - const path = join(tempDir, "auth.json"); - await writeFile(path, JSON.stringify(config)); - const result = await storage.read(path); - expect(result).toEqual(config); - }); - }); - - describe("write", () => { - it("creates parent directories and writes the file", async () => { - const path = join(tempDir, "nested", "dir", "auth.json"); - const config = makeAuthConfig({ method: "external", level: "project", token: undefined }); - await storage.write(path, config); - const content = await readFile(path, "utf-8"); - expect(JSON.parse(content)).toEqual(config); - }); - - it("sets restrictive file permissions on non-Windows", async () => { - if (process.platform === "win32") return; - const path = join(tempDir, "auth.json"); - await storage.write(path, makeAuthConfig({ token: "ghp_secret" })); - const stats = await stat(path); - expect(stats.mode & 0o777).toBe(0o600); - }); - - it("written file can be read back", async () => { - const path = join(tempDir, "auth.json"); - const config = makeAuthConfig({ token: "ghp_roundtrip" }); - await storage.write(path, config); - const result = await storage.read(path); - expect(result).toEqual(config); - }); - }); - - describe("delete", () => { - it("removes an existing file", async () => { - const path = join(tempDir, "auth.json"); - await writeFile(path, "{}"); - await storage.delete(path); - await expect(readFile(path)).rejects.toThrow(); - }); - - it("does not throw when file does not exist", async () => { - await expect(storage.delete(join(tempDir, "missing.json"))).resolves.not.toThrow(); - }); - }); - - describe("paths", () => { - it("projectConfigPath returns .aidd/auth.json under projectRoot", () => { - const path = storage.projectConfigPath("/my/project"); - expect(path).toBe(join("/my/project", ".aidd", "auth.json")); - }); - - it("userConfigPath respects AIDD_USER_CONFIG_DIR env override", () => { - const original = process.env.AIDD_USER_CONFIG_DIR; - try { - process.env.AIDD_USER_CONFIG_DIR = "/custom/config/dir"; - const path = storage.userConfigPath(); - expect(path).toBe(join("/custom/config/dir", "auth.json")); - } finally { - if (original === undefined) { - delete process.env.AIDD_USER_CONFIG_DIR; - } else { - process.env.AIDD_USER_CONFIG_DIR = original; - } - } - }); - }); - - describe("readActive", () => { - it("returns AIDD_TOKEN env config when env var is set", async () => { - const original = process.env.AIDD_TOKEN; - try { - process.env.AIDD_TOKEN = "env-token-123"; - const result = await storage.readActive(tempDir); - expect(result).not.toBeNull(); - expect(result?.token).toBe("env-token-123"); - expect(result?.method).toBe("stored"); - } finally { - if (original === undefined) { - delete process.env.AIDD_TOKEN; - } else { - process.env.AIDD_TOKEN = original; - } - } - }); - - it("returns project config when no AIDD_TOKEN env var but project auth.json exists", async () => { - const original = process.env.AIDD_TOKEN; - delete process.env.AIDD_TOKEN; - try { - const config = makeAuthConfig({ token: "project-tok", level: "project" }); - const projectPath = storage.projectConfigPath(tempDir); - await storage.write(projectPath, config); - - const result = await storage.readActive(tempDir); - - expect(result?.token).toBe("project-tok"); - expect(result?.level).toBe("project"); - } finally { - if (original !== undefined) process.env.AIDD_TOKEN = original; - } - }); - - it("returns user config when no AIDD_TOKEN and no project auth.json", async () => { - const original = process.env.AIDD_TOKEN; - const userConfigDirOriginal = process.env.AIDD_USER_CONFIG_DIR; - delete process.env.AIDD_TOKEN; - try { - process.env.AIDD_USER_CONFIG_DIR = tempDir; - const config = makeAuthConfig({ token: "user-tok", level: "user" }); - const userPath = storage.userConfigPath(); - await storage.write(userPath, config); - - const result = await storage.readActive("/some/other/project"); - - expect(result?.token).toBe("user-tok"); - } finally { - if (original !== undefined) process.env.AIDD_TOKEN = original; - if (userConfigDirOriginal === undefined) { - delete process.env.AIDD_USER_CONFIG_DIR; - } else { - process.env.AIDD_USER_CONFIG_DIR = userConfigDirOriginal; - } - } - }); - - it("returns null when no token source is available", async () => { - const tokenOriginal = process.env.AIDD_TOKEN; - const userConfigDirOriginal = process.env.AIDD_USER_CONFIG_DIR; - delete process.env.AIDD_TOKEN; - process.env.AIDD_USER_CONFIG_DIR = join(tempDir, "no-such-dir"); - try { - const result = await storage.readActive(join(tempDir, "no-project")); - expect(result).toBeNull(); - } finally { - if (tokenOriginal !== undefined) process.env.AIDD_TOKEN = tokenOriginal; - if (userConfigDirOriginal === undefined) { - delete process.env.AIDD_USER_CONFIG_DIR; - } else { - process.env.AIDD_USER_CONFIG_DIR = userConfigDirOriginal; - } - } - }); - }); - - describe("save", () => { - it("saves project-level credential to .aidd/auth.json", async () => { - const credential = { method: "stored" as const, token: "ghp_save_project" }; - await storage.save({ credential, level: "project", projectRoot: tempDir }); - - const saved = await storage.read(storage.projectConfigPath(tempDir)); - expect(saved?.token).toBe("ghp_save_project"); - expect(saved?.level).toBe("project"); - }); - - it("saves user-level credential to user config path", async () => { - const userConfigDirOriginal = process.env.AIDD_USER_CONFIG_DIR; - process.env.AIDD_USER_CONFIG_DIR = tempDir; - try { - const credential = { method: "stored" as const, token: "ghp_save_user" }; - await storage.save({ credential, level: "user", projectRoot: tempDir }); - - const saved = await storage.read(storage.userConfigPath()); - expect(saved?.token).toBe("ghp_save_user"); - expect(saved?.level).toBe("user"); - } finally { - if (userConfigDirOriginal === undefined) { - delete process.env.AIDD_USER_CONFIG_DIR; - } else { - process.env.AIDD_USER_CONFIG_DIR = userConfigDirOriginal; - } - } - }); - - it("saves external credential without token field", async () => { - const credential = { method: "external" as const, provider: "gh" }; - await storage.save({ credential, level: "project", projectRoot: tempDir }); - - const saved = await storage.read(storage.projectConfigPath(tempDir)); - expect(saved?.method).toBe("external"); - expect("token" in (saved ?? {})).toBe(false); - }); - }); -}); diff --git a/cli/tests/infrastructure/errors.unit.test.ts b/cli/tests/infrastructure/errors.unit.test.ts deleted file mode 100644 index a65cd7c7f..000000000 --- a/cli/tests/infrastructure/errors.unit.test.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { - AuthStorageError, - HttpRedirectError, - JsonParseError, -} from "../../src/infrastructure/errors.js"; - -describe("HttpRedirectError", () => { - it("includes the URL in the message and sets error name", () => { - const error = new HttpRedirectError("https://example.com/redirect"); - expect(error.name).toBe("HttpRedirectError"); - expect(error.message).toContain("https://example.com/redirect"); - expect(error.url).toBe("https://example.com/redirect"); - }); -}); - -describe("JsonParseError", () => { - it("includes the path and cause in the message", () => { - const error = new JsonParseError("/some/file.json", "Unexpected token"); - expect(error.name).toBe("JsonParseError"); - expect(error.message).toContain("/some/file.json"); - expect(error.message).toContain("Unexpected token"); - }); -}); - -describe("AuthStorageError", () => { - it("carries the provided message", () => { - const error = new AuthStorageError("Failed to write auth file"); - expect(error.name).toBe("AuthStorageError"); - expect(error.message).toBe("Failed to write auth file"); - }); -}); diff --git a/cli/tests/infrastructure/framework-build-registry.unit.test.ts b/cli/tests/infrastructure/framework-build-registry.unit.test.ts deleted file mode 100644 index 3a19d3731..000000000 --- a/cli/tests/infrastructure/framework-build-registry.unit.test.ts +++ /dev/null @@ -1,50 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { - FRAMEWORK_BUILD_TARGET_MODES, - type FrameworkBuildMode, - type FrameworkBuildTarget, -} from "../../src/domain/models/framework-build.js"; -import { BundledAssetProviderAdapter } from "../../src/infrastructure/assets/asset-loader.js"; -import { createFrameworkBuildUseCase } from "../../src/infrastructure/deps.js"; -import { CapturingLogger } from "../helpers/ports/capturing-logger.js"; -import { InMemoryFileAdapter } from "../helpers/ports/in-memory-file-adapter.js"; - -const ALL_TARGETS: readonly FrameworkBuildTarget[] = [ - "claude", - "cursor", - "copilot", - "codex", - "opencode", -]; -const ALL_MODES: readonly FrameworkBuildMode[] = ["marketplace", "flat"]; - -function makeDeps() { - return { - fs: new InMemoryFileAdapter(), - assetProvider: new BundledAssetProviderAdapter(), - logger: new CapturingLogger(), - }; -} - -function isSupported(target: FrameworkBuildTarget, mode: FrameworkBuildMode): boolean { - return FRAMEWORK_BUILD_TARGET_MODES.some((e) => e.target === target && e.mode === mode); -} - -describe("deps.ts's build registry matches domain's FRAMEWORK_BUILD_TARGET_MODES exactly", () => { - for (const target of ALL_TARGETS) { - for (const mode of ALL_MODES) { - const label = `${target}:${mode}`; - const expected = isSupported(target, mode); - - it(`${label} is ${expected ? "" : "NOT "}wired in the registry, matching the domain list`, () => { - const useCase = createFrameworkBuildUseCase(makeDeps(), { - target, - mode, - outDir: "/out", - force: false, - }); - expect(useCase !== undefined).toBe(expected); - }); - } - } -}); diff --git a/cli/tests/infrastructure/git/inject-token.unit.test.ts b/cli/tests/infrastructure/git/inject-token.unit.test.ts deleted file mode 100644 index 9b55c02fd..000000000 --- a/cli/tests/infrastructure/git/inject-token.unit.test.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { injectTokenIntoUrl } from "../../../src/infrastructure/git/inject-token.js"; - -describe("injectTokenIntoUrl", () => { - it("returns the URL unchanged when token is undefined", () => { - const url = "https://github.com/owner/repo.git"; - expect(injectTokenIntoUrl(url, undefined)).toBe(url); - }); - - it("does not modify ssh URLs", () => { - const ssh = "git@github.com:owner/repo.git"; - expect(injectTokenIntoUrl(ssh, "tk")).toBe(ssh); - }); - - it("uses x-access-token for github", () => { - expect(injectTokenIntoUrl("https://github.com/owner/repo.git", "tk")).toBe( - "https://x-access-token:tk@github.com/owner/repo.git" - ); - }); - - it("uses oauth2 for gitlab", () => { - expect(injectTokenIntoUrl("https://gitlab.com/owner/repo.git", "tk")).toBe( - "https://oauth2:tk@gitlab.com/owner/repo.git" - ); - }); - - it("uses x-token-auth for bitbucket", () => { - expect(injectTokenIntoUrl("https://bitbucket.org/owner/repo.git", "tk")).toBe( - "https://x-token-auth:tk@bitbucket.org/owner/repo.git" - ); - }); - - it("falls back to bare-token form for unknown hosts", () => { - expect(injectTokenIntoUrl("https://example.com/owner/repo.git", "tk")).toBe( - "https://tk@example.com/owner/repo.git" - ); - }); -}); diff --git a/cli/tests/infrastructure/home-dir.unit.test.ts b/cli/tests/infrastructure/home-dir.unit.test.ts deleted file mode 100644 index f1d9700e1..000000000 --- a/cli/tests/infrastructure/home-dir.unit.test.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { resolveHomeDir } from "../../src/infrastructure/home-dir.js"; - -describe("resolveHomeDir", () => { - // `os.homedir()` never reads `HOME` on Windows — it reads `USERPROFILE` instead - // (https://nodejs.org/api/os.html#oshomedir) — so a bare `homedir()` call silently drops - // a sandboxed `HOME` there. Spelled with backslashes and no `process.platform` branch: - // this must fail on every platform if `resolveHomeDir` regresses to a bare `homedir()` - // call, not just on a real Windows machine. - it("prefers HOME over the OS-reported home directory", () => { - const env = { HOME: "C:\\sandbox\\home" } as NodeJS.ProcessEnv; - const osHomedir = () => "C:\\Users\\runneradmin"; - - expect(resolveHomeDir(env, osHomedir)).toBe("C:\\sandbox\\home"); - }); - - it("falls back to the OS-reported home directory when HOME is unset", () => { - const env = {} as NodeJS.ProcessEnv; - const osHomedir = () => "C:\\Users\\runneradmin"; - - expect(resolveHomeDir(env, osHomedir)).toBe("C:\\Users\\runneradmin"); - }); - - it("falls back when HOME is set but empty", () => { - const env = { HOME: "" } as NodeJS.ProcessEnv; - const osHomedir = () => "C:\\Users\\runneradmin"; - - expect(resolveHomeDir(env, osHomedir)).toBe("C:\\Users\\runneradmin"); - }); -}); diff --git a/cli/tests/infrastructure/smoke-harness-isolation.unit.test.ts b/cli/tests/infrastructure/smoke-harness-isolation.unit.test.ts deleted file mode 100644 index 05de7ea8b..000000000 --- a/cli/tests/infrastructure/smoke-harness-isolation.unit.test.ts +++ /dev/null @@ -1,72 +0,0 @@ -import { readFileSync } from "node:fs"; -import { fileURLToPath } from "node:url"; -import { describe, expect, it } from "vitest"; - -const harness = readFileSync( - fileURLToPath(new URL("../../scripts/smoke-tools.sh", import.meta.url)), - "utf8" -); - -/** The smoke harness drives the real binary across every command and every tool, and three - * of those tools activate plugins through their own CLI - which writes into the *user's* - * home, never the project directory. A fresh `/tmp` project isolates nothing there. - * - * `testing.md` has stated the rule since the work that discovered it ("this work polluted - * the repo + `~/.copilot` twice before the env-sandbox was right"), and nothing enforced it: - * the harness sandboxed `AIDD_USER_CONFIG_DIR` for every case and `HOME` for exactly one, - * so `plugin install --tool codex|copilot|claude` ran against the real home of whoever ran - * `pnpm smoke`. */ -describe("the smoke harness never runs against the real user home", () => { - it("gives every case a home under its own temporary root", () => { - expect(harness).toMatch(/export HOME="\$TMPROOT\/[^"]+"/u); - }); - - // `HOME` does not isolate Codex: it reads `CODEX_HOME`, and falls back to the real - // `~/.codex` when that is unset. - it("gives Codex its own home too, which HOME alone does not move", () => { - expect(harness).toMatch(/export CODEX_HOME="\$TMPROOT\/[^"]+"/u); - }); - - // A case that damages a file it picked at random, then asserts only an exit code, proves - // nothing twice over: it does not know which file it broke, and it never looks at whether - // the command repaired it. `find` returns directory order, which is neither sorted nor - // stable across filesystems, so `find … | head -1` on a tree of several files runs a - // different case on every machine. - it("picks the file a case damages in a fixed order, never whatever find returns first", () => { - const unsorted = [...harness.matchAll(/find [^\n|]*\|[ \t]*head\b/gu)].map((m) => m[0]); - - expect(unsorted).toEqual([]); - }); - - // Ordering is the whole guard: the token is resolved through `gh`, which reads the real - // home. Exporting the sandbox before that line makes every authenticated case silently - // unauthenticated, so the sandbox must come after it and before the first case that runs. - it("resolves the token before moving home, and moves it before the first case", () => { - const token = harness.indexOf("gh auth token"); - const home = harness.search(/export HOME="\$TMPROOT/u); - const firstCase = harness.indexOf("section "); - - expect(token).toBeGreaterThan(-1); - expect(home).toBeGreaterThan(token); - expect(home).toBeLessThan(firstCase); - }); -}); - -/** A `restore --force` that returns 0 having restored nothing is exactly the failure #762 - * fixed in the command itself, and the smoke case that was supposed to cover it asserted the - * exit code alone. An exit code is not a repair. */ -describe("a smoke case that damages a file checks the damage was undone", () => { - it("marks the drift it writes, so the check can name what it is looking for", () => { - expect(harness).toContain("SMOKE_DRIFT"); - }); - - it("looks for that mark again after every restore it runs", () => { - const restores = [...harness.matchAll(/run "((?:ai |ide )?restore --force)"/gu)].map( - (match) => match[1] - ); - const checks = [...harness.matchAll(/repaired "([^"]+)"/gu)].map((match) => match[1]); - - expect(restores.length).toBeGreaterThan(0); - expect(checks.sort()).toEqual(restores.sort()); - }); -}); diff --git a/cli/tests/integration/telemetry-trailer-line-agrees.integration.test.ts b/cli/tests/integration/telemetry-trailer-line-agrees.integration.test.ts index be97675b4..fa5d959eb 100644 --- a/cli/tests/integration/telemetry-trailer-line-agrees.integration.test.ts +++ b/cli/tests/integration/telemetry-trailer-line-agrees.integration.test.ts @@ -6,26 +6,12 @@ import { SESSION_TRAILER_DELEGATE_FILE, SESSION_TRAILER_HOOK_HEADER, sessionTrailerHookLine, -} from "../../src/domain/formats/commit-session-trailer.js"; +} from "../../src/contexts/telemetry/domain/formats/commit-session-trailer.js"; import { journalTrailerRepair } from "../helpers/telemetry-journal-hook.js"; -/** - * The one literal this feature spells twice, held to itself across the language boundary. - * - * `aidd telemetry on` writes the call site from TypeScript; the hook restores it from - * zero-dependency CommonJS shipped into a person's repository, and cannot import the CLI's - * own function — the CLI may not be installed when the hook runs, which is the whole point - * of the hook needing nothing. So the line exists in two places. - * - * This is what stops them drifting, and it is the shape this plugin's other cross-language - * literal already uses: the real hook module is loaded and asked, and its answer is compared - * against the real CLI function's — never against a third copy typed into a fixture, which - * would only prove that the fixture agrees with whoever wrote it last. - * - * If they ever diverge, `aidd telemetry on` installs one line and the hook restores a - * different one, so a repaired repository silently stops trailering while both sides pass - * their own tests. - */ +/** The one literal this feature spells twice, held to itself across the language boundary: the + * CLI writes the call site, the hook restores it from CommonJS that cannot import the CLI. + * Diverged, a repaired repository stops trailering while both sides pass their own tests. */ describe("the hook and the CLI spell the call site identically", () => { it.each([ ["a POSIX path", "/home/dev/repo/.git/hooks"], @@ -48,26 +34,17 @@ describe("the hook and the CLI spell the call site identically", () => { expect(journalTrailerRepair.HOOK_FILE).toBe("prepare-commit-msg"); }); - /** - * The header a hook written from scratch starts with, and — read back — the one line that - * does not count as somebody else's content. If the two sides ever disagreed, a hook the - * hook created would report through `check` as "somebody else's too", about a file this - * project wrote itself. - */ + /** The header a hook written from scratch starts with, and the one line that does not count as + * somebody else's content when read back. Disagreeing, `check` calls this project's own hook + * somebody else's. */ it("agrees on the header a hook written from scratch starts with", () => { expect(journalTrailerRepair.HOOK_HEADER).toBe(SESSION_TRAILER_HOOK_HEADER); }); }); -/** - * The words the repair answers with, exercised through the module rather than a spawned - * hook: this side is where the distinction matters, since a caller has to tell a directory - * the repair declined from one that simply had nothing to do. - * - * The delegate is really written, because without it every call returns `"no-delegate"` from - * the existence check and never reaches the guard the case is named for — which is how an - * earlier version of this block passed with that guard deleted. - */ +/** The words the repair answers with, exercised through the module: a caller has to tell a + * directory the repair declined from one that had nothing to do. The delegate is really + * written, since without it every call returns `"no-delegate"` and never reaches that guard. */ describe("what the repair reports about a directory it will not write to", () => { let root: string; diff --git a/cli/tests/kernel/errors.unit.test.ts b/cli/tests/kernel/errors.unit.test.ts new file mode 100644 index 000000000..4f07f5756 --- /dev/null +++ b/cli/tests/kernel/errors.unit.test.ts @@ -0,0 +1,143 @@ +import { describe, expect, it } from "vitest"; +import { + AiddFilesDetectedError, + AlreadyInitializedError, + AuthStorageError, + FlatTargetExistsError, + HttpRedirectError, + InputRequiredError, + JsonParseError, + NoManifestError, + OutDirNotDirectoryError, + ToolNotInstalledError, +} from "../../src/kernel/errors.js"; + +describe("NoManifestError", () => { + it("includes aidd setup hint in message", () => { + const error = new NoManifestError(); + expect(error.message).toContain("aidd setup"); + expect(error.name).toBe("NoManifestError"); + }); +}); + +describe("AiddFilesDetectedError", () => { + it("includes setup hint in message", () => { + const error = new AiddFilesDetectedError(); + expect(error.message).toContain("AIDD files detected but no manifest found"); + expect(error.message).toContain("aidd setup"); + expect(error.name).toBe("AiddFilesDetectedError"); + }); +}); + +describe("FlatTargetExistsError", () => { + it("has correct error name", () => { + const error = new FlatTargetExistsError( + "/out/.github/agents/my-plugin/foo.agent.md", + "my-plugin" + ); + expect(error.name).toBe("FlatTargetExistsError"); + }); + + it("includes the conflicting path in the message", () => { + const error = new FlatTargetExistsError( + "/out/.github/agents/my-plugin/foo.agent.md", + "my-plugin" + ); + expect(error.message).toContain("/out/.github/agents/my-plugin/foo.agent.md"); + }); + + it("includes the plugin name in the message", () => { + const error = new FlatTargetExistsError( + "/out/.github/agents/my-plugin/foo.agent.md", + "my-plugin" + ); + expect(error.message).toContain("my-plugin"); + }); + + it("mentions --force hint in message", () => { + const error = new FlatTargetExistsError( + "/out/.github/agents/my-plugin/foo.agent.md", + "my-plugin" + ); + expect(error.message).toContain("--force"); + }); +}); + +describe("OutDirNotDirectoryError", () => { + it("has correct error name", () => { + const error = new OutDirNotDirectoryError("/tmp/some-out"); + expect(error.name).toBe("OutDirNotDirectoryError"); + }); + + it("includes the outDir path in the message", () => { + const error = new OutDirNotDirectoryError("/tmp/some-out"); + expect(error.message).toContain("/tmp/some-out"); + }); + + it("does not mention source directory in the message", () => { + const error = new OutDirNotDirectoryError("/tmp/some-out"); + expect(error.message).not.toContain("--source"); + expect(error.message).toContain("not a directory"); + }); +}); + +describe("AlreadyInitializedError", () => { + it("has default message when no argument provided", () => { + const error = new AlreadyInitializedError(); + expect(error.name).toBe("AlreadyInitializedError"); + expect(error.message).toContain("Already initialized"); + }); + + it("uses provided message when given", () => { + const error = new AlreadyInitializedError("Custom message here."); + expect(error.message).toBe("Custom message here."); + }); +}); + +describe("InputRequiredError", () => { + it("carries the provided message", () => { + const error = new InputRequiredError("Prompt answer is required."); + expect(error.name).toBe("InputRequiredError"); + expect(error.message).toBe("Prompt answer is required."); + }); +}); + +describe("ToolNotInstalledError", () => { + it("includes tool ID in message without context", () => { + const error = new ToolNotInstalledError("claude"); + expect(error.name).toBe("ToolNotInstalledError"); + expect(error.message).toContain("claude"); + }); + + it("includes context and tool ID when context is provided", () => { + const error = new ToolNotInstalledError("cursor", "The target tool"); + expect(error.message).toContain("cursor"); + expect(error.message).toContain("The target tool"); + }); +}); + +describe("HttpRedirectError", () => { + it("includes the URL in the message and sets error name", () => { + const error = new HttpRedirectError("https://example.com/redirect"); + expect(error.name).toBe("HttpRedirectError"); + expect(error.message).toContain("https://example.com/redirect"); + expect(error.url).toBe("https://example.com/redirect"); + }); +}); + +describe("JsonParseError", () => { + it("includes the path and cause in the message", () => { + const error = new JsonParseError("/some/file.json", "Unexpected token"); + expect(error.name).toBe("JsonParseError"); + expect(error.message).toContain("/some/file.json"); + expect(error.message).toContain("Unexpected token"); + }); +}); + +describe("AuthStorageError", () => { + it("carries the provided message", () => { + const error = new AuthStorageError("Failed to write auth file"); + expect(error.name).toBe("AuthStorageError"); + expect(error.message).toBe("Failed to write auth file"); + }); +}); diff --git a/cli/tests/domain/models/file-hash.unit.test.ts b/cli/tests/kernel/file-hash.unit.test.ts similarity index 94% rename from cli/tests/domain/models/file-hash.unit.test.ts rename to cli/tests/kernel/file-hash.unit.test.ts index f97261abb..526d53c89 100644 --- a/cli/tests/domain/models/file-hash.unit.test.ts +++ b/cli/tests/kernel/file-hash.unit.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { FileHash } from "../../../src/domain/models/file.js"; +import { FileHash } from "../../src/kernel/file.js"; describe("FileHash", () => { const validHash = "d41d8cd98f00b204e9800998ecf8427e"; diff --git a/cli/tests/kernel/markdown.unit.test.ts b/cli/tests/kernel/markdown.unit.test.ts new file mode 100644 index 000000000..884f3b4ee --- /dev/null +++ b/cli/tests/kernel/markdown.unit.test.ts @@ -0,0 +1,194 @@ +import { describe, expect, it } from "vitest"; +import { parseFrontmatter, serializeFrontmatter } from "../../src/kernel/markdown.js"; + +describe("parseFrontmatter()", () => { + it("parses frontmatter and body from a well-formed file", () => { + const content = "---\nname: my-agent\ndescription: A test agent\n---\nBody text here."; + const { frontmatter, body } = parseFrontmatter(content); + expect(frontmatter).toEqual({ name: "my-agent", description: "A test agent" }); + expect(body).toBe("Body text here."); + }); + + it("returns empty frontmatter and full content when no delimiter", () => { + const content = "Just a plain body with no frontmatter."; + const { frontmatter, body } = parseFrontmatter(content); + expect(frontmatter).toEqual({}); + expect(body).toBe(content); + }); + + it("returns empty frontmatter when closing delimiter is missing", () => { + const content = "---\nname: broken\nno closing delimiter"; + const { frontmatter, body } = parseFrontmatter(content); + expect(frontmatter).toEqual({}); + expect(body).toBe(content); + }); + + it("parses boolean values correctly", () => { + const content = "---\nalwaysApply: false\nenabled: true\n---\nbody"; + const { frontmatter } = parseFrontmatter(content); + expect(frontmatter.alwaysApply).toBe(false); + expect(frontmatter.enabled).toBe(true); + }); + + it("parses array values correctly", () => { + const content = "---\npaths:\n - src/**/*.ts\n - tests/**/*.ts\n---\nbody"; + const { frontmatter } = parseFrontmatter(content); + expect(frontmatter.paths).toEqual(["src/**/*.ts", "tests/**/*.ts"]); + }); + + it("parses quoted string values", () => { + const content = "---\nname: 'my agent'\n---\nbody"; + const { frontmatter } = parseFrontmatter(content); + expect(frontmatter.name).toBe("my agent"); + }); +}); + +describe("serializeFrontmatter()", () => { + it("serializes frontmatter and body into delimited format", () => { + const result = serializeFrontmatter({ name: "my-agent", description: "A test" }, "Body text."); + expect(result).toContain("---"); + expect(result).toContain("name: 'my-agent'"); + expect(result).toContain("description: 'A test'"); + expect(result).toContain("Body text."); + }); + + it("returns body only (without leading newline) when frontmatter is empty", () => { + const result = serializeFrontmatter({}, "\nBody only."); + expect(result).toBe("Body only."); + }); + + it("serializes array values as YAML lists", () => { + const result = serializeFrontmatter({ paths: ["src/**/*.ts"] }, "body"); + expect(result).toContain("paths:"); + expect(result).toContain(' - "src/**/*.ts"'); + }); + + it("serializes boolean values without quotes", () => { + const result = serializeFrontmatter({ alwaysApply: false }, "body"); + expect(result).toContain("alwaysApply: false"); + }); + + it("round-trips: parse then serialize preserves content", () => { + const original = "---\nname: 'my-agent'\ndescription: 'A test'\n---\nBody text."; + const { frontmatter, body } = parseFrontmatter(original); + const result = serializeFrontmatter(frontmatter, body); + const reparsed = parseFrontmatter(result); + expect(reparsed.frontmatter).toEqual(frontmatter); + expect(reparsed.body).toBe(body); + }); +}); + +describe("parseFrontmatter() — block scalars", () => { + it("parses literal block scalar (|) preserving newlines", () => { + const content = "---\ndescription: |\n line one\n line two\n---\nbody"; + const { frontmatter } = parseFrontmatter(content); + expect(typeof frontmatter.description).toBe("string"); + expect(frontmatter.description as string).toContain("line one"); + expect(frontmatter.description as string).toContain("line two"); + }); + + it("parses folded block scalar (>) joining lines with space", () => { + const content = "---\ndescription: >\n folded line one\n folded line two\n---\nbody"; + const { frontmatter } = parseFrontmatter(content); + expect(typeof frontmatter.description).toBe("string"); + expect((frontmatter.description as string).trim()).toContain("folded line one"); + }); + + it("parses null scalar value", () => { + const content = "---\nvalue: null\n---\nbody"; + const { frontmatter } = parseFrontmatter(content); + expect(frontmatter.value).toBeNull(); + }); + + it("parses inline JSON array string as array", () => { + const content = '---\ntools: ["read","write"]\n---\nbody'; + const { frontmatter } = parseFrontmatter(content); + expect(frontmatter.tools).toEqual(["read", "write"]); + }); + + it("falls back to string for malformed inline JSON array", () => { + const content = "---\ntools: [invalid json}\n---\nbody"; + const { frontmatter } = parseFrontmatter(content); + expect(frontmatter.tools).toBe("[invalid json}"); + }); + // A Windows checkout hands the parser the same document with CRLF, and the transform is + // pure string work, so this fails on any platform when the fix is reverted. + it("parses the same document whichever way its lines end", () => { + const lf = "---\nname: hi\nallowed_tools:\n - Read\n - Bash\n---\nbody\n"; + const crlf = lf.replace(/\n/g, "\r\n"); + expect(parseFrontmatter(crlf).frontmatter).toEqual(parseFrontmatter(lf).frontmatter); + expect(parseFrontmatter(crlf).frontmatter).toEqual({ + name: "hi", + allowed_tools: ["Read", "Bash"], + }); + }); + + it("keeps a carriage return that is content rather than a line ending", () => { + const content = "---\nname: a\rb\n---\nbody"; + const { frontmatter } = parseFrontmatter(content); + expect(frontmatter.name).toBe("a\rb"); + }); +}); + +/** + * Frontmatter is where every tool profile meets the content it rewrites, so a change here is + * a change everywhere: the quoting decisions, the delimiter checks, an empty document. + */ +describe("frontmatter, at the edges", () => { + it("keeps a glob quoted so YAML cannot read it as a pattern", () => { + const out = serializeFrontmatter({ globs: ["*.ts", "a?b", "{x,y}"] }, "body"); + expect(out).toContain(' - "*.ts"'); + expect(out).toContain(' - "a?b"'); + expect(out).toContain(' - "{x,y}"'); + }); + + it("leaves an ordinary list item unquoted", () => { + expect(serializeFrontmatter({ tags: ["plain"] }, "body")).toContain(" - plain"); + }); + + it("emits a JSON-array string raw, so it stays an inline YAML array", () => { + expect(serializeFrontmatter({ globs: '["a","b"]' }, "body")).toContain('globs: ["a","b"]'); + }); + + it("doubles an apostrophe rather than ending the quoted string early", () => { + expect(serializeFrontmatter({ name: "it's" }, "body")).toContain("name: 'it''s'"); + }); + + it("writes a boolean bare, not quoted", () => { + expect(serializeFrontmatter({ enabled: true }, "body")).toContain("enabled: true"); + expect(serializeFrontmatter({ enabled: false }, "body")).toContain("enabled: false"); + }); + + it("returns the body untouched when there is no frontmatter to write", () => { + expect(serializeFrontmatter({}, "just a body")).toBe("just a body"); + }); + + it("drops one leading newline, and only one, when there is no frontmatter", () => { + expect(serializeFrontmatter({}, "\n\nbody")).toBe("\nbody"); + }); + + it("treats a document whose first line is not the delimiter as all body", () => { + const { frontmatter, body } = parseFrontmatter("no delimiter\n---\nlate"); + expect(frontmatter).toEqual({}); + expect(body).toBe("no delimiter\n---\nlate"); + }); + + it("treats an unterminated frontmatter block as all body", () => { + const content = "---\nname: x\nstill open"; + const { frontmatter, body } = parseFrontmatter(content); + expect(frontmatter).toEqual({}); + expect(body).toBe(content); + }); + + it("accepts a delimiter carrying trailing spaces", () => { + const { frontmatter, body } = parseFrontmatter("--- \nname: x\n--- \nbody"); + expect(frontmatter.name).toBe("x"); + expect(body).toBe("body"); + }); + + it("reads an empty frontmatter block and keeps the body", () => { + const { frontmatter, body } = parseFrontmatter("---\n---\nbody"); + expect(frontmatter).toEqual({}); + expect(body).toBe("body"); + }); +}); diff --git a/cli/tests/domain/formats/claude-root-path-rewrite.unit.test.ts b/cli/tests/kernel/materialization/claude-root-path-rewrite.unit.test.ts similarity index 97% rename from cli/tests/domain/formats/claude-root-path-rewrite.unit.test.ts rename to cli/tests/kernel/materialization/claude-root-path-rewrite.unit.test.ts index ca64226a9..3f196b7ac 100644 --- a/cli/tests/domain/formats/claude-root-path-rewrite.unit.test.ts +++ b/cli/tests/kernel/materialization/claude-root-path-rewrite.unit.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { rewriteClaudeRootInJson } from "../../../src/domain/formats/claude-root-path-rewrite.js"; +import { rewriteClaudeRootInJson } from "../../../src/kernel/materialization/claude-root-path-rewrite.js"; // Written as split literals to avoid biome's noTemplateCurlyInString warning. const CLAUDE_ROOT = "$" + "{CLAUDE_PLUGIN_ROOT}"; diff --git a/cli/tests/kernel/materialization/flat-paths.unit.test.ts b/cli/tests/kernel/materialization/flat-paths.unit.test.ts new file mode 100644 index 000000000..51cf170e3 --- /dev/null +++ b/cli/tests/kernel/materialization/flat-paths.unit.test.ts @@ -0,0 +1,174 @@ +import { describe, expect, it } from "vitest"; +import { + flatHooksPathWithLoaderEntry, + flatMcpKeyPrefix, + genericFlatAgentPath, + genericFlatHooksFile, + genericFlatHooksScriptPath, + genericFlatSkillPath, + genericFlatSkillTreePath, +} from "../../../src/kernel/materialization/flat-paths.js"; + +describe("genericFlatAgentPath", () => { + it("strips .md suffix, adds outputExt, and prepends plugin prefix", () => { + expect(genericFlatAgentPath(".github/agents/", "aidd-dev", "implementer.md", ".agent.md")).toBe( + ".github/agents/aidd-dev-implementer.agent.md" + ); + }); + + it("does not double-strip when name has no .md", () => { + expect(genericFlatAgentPath(".github/agents/", "aidd-dev", "reviewer", ".agent.md")).toBe( + ".github/agents/aidd-dev-reviewer.agent.md" + ); + }); + + it("preserves .md output ext for tools that keep .md", () => { + expect(genericFlatAgentPath(".claude/agents/", "my-plugin", "agent.md", ".md")).toBe( + ".claude/agents/my-plugin-agent.md" + ); + }); + + it("plugin param is used as name prefix", () => { + expect(genericFlatAgentPath(".cursor/agents/", "aidd-context", "agent.md", ".md")).toBe( + ".cursor/agents/aidd-context-agent.md" + ); + }); +}); + +describe("genericFlatSkillPath", () => { + it("sits directly under skills root with plugin prefix on folder name", () => { + expect(genericFlatSkillPath(".github/skills/", "aidd-dev", "commit/SKILL.md")).toBe( + ".github/skills/aidd-dev-commit/SKILL.md" + ); + }); + + it("prepends plugin prefix to single-level rel path", () => { + expect(genericFlatSkillPath(".github/skills/", "aidd-dev", "hello.md")).toBe( + ".github/skills/aidd-dev-hello.md" + ); + }); + + it("works with different prefixes", () => { + expect(genericFlatSkillPath(".claude/skills/", "aidd-context", "00-onboard/SKILL.md")).toBe( + ".claude/skills/aidd-context-00-onboard/SKILL.md" + ); + }); +}); + +describe("genericFlatSkillTreePath", () => { + it("nests the whole plugin skills subtree under one plugin/ segment", () => { + expect(genericFlatSkillTreePath(".opencode/skills/", "aidd-dev", "commit/SKILL.md")).toBe( + ".opencode/skills/aidd-dev/commit/SKILL.md" + ); + }); + + it("keeps a non-skill top-level child's own name intact", () => { + expect( + genericFlatSkillTreePath(".opencode/skills/", "aidd-telemetry", "shared/attribution.cjs") + ).toBe(".opencode/skills/aidd-telemetry/shared/attribution.cjs"); + expect(genericFlatSkillTreePath(".opencode/skills/", "aidd-telemetry", "package.json")).toBe( + ".opencode/skills/aidd-telemetry/package.json" + ); + }); + + it("works with different prefixes", () => { + expect(genericFlatSkillTreePath(".claude/skills/", "aidd-context", "00-onboard/SKILL.md")).toBe( + ".claude/skills/aidd-context/00-onboard/SKILL.md" + ); + }); +}); + +describe("genericFlatHooksFile", () => { + it("returns per-plugin hooks file path", () => { + expect(genericFlatHooksFile(".github/hooks/", "aidd-dev")).toBe( + ".github/hooks/aidd-dev.hooks.json" + ); + }); + + it("uses the full plugin name", () => { + expect(genericFlatHooksFile(".github/hooks/", "my-awesome-plugin")).toBe( + ".github/hooks/my-awesome-plugin.hooks.json" + ); + }); + + it("works with different prefixes", () => { + expect(genericFlatHooksFile(".claude/hooks/", "aidd-dev")).toBe( + ".claude/hooks/aidd-dev.hooks.json" + ); + }); +}); + +describe("genericFlatHooksScriptPath", () => { + it("returns per-plugin script path under hooks/plugin/", () => { + expect(genericFlatHooksScriptPath(".github/hooks/", "aidd-dev", "check.sh")).toBe( + ".github/hooks/aidd-dev/check.sh" + ); + }); + + it("works with different prefixes", () => { + expect(genericFlatHooksScriptPath(".cursor/hooks/", "aidd-dev", "check.sh")).toBe( + ".cursor/hooks/aidd-dev/check.sh" + ); + }); +}); + +describe("flatHooksPathWithLoaderEntry", () => { + it("namespaces a plain hook script under //", () => { + expect( + flatHooksPathWithLoaderEntry( + ".opencode/hooks/", + null, + "aidd-context", + "hooks/update_memory.js" + ) + ).toBe(".opencode/hooks/aidd-context/update_memory.js"); + }); + + it("routes a script matching the loader entry's name flat, renamed to the plugin", () => { + const loaderEntry = { dir: ".opencode/plugin/", baseName: "opencode-plugin.js" }; + expect( + flatHooksPathWithLoaderEntry( + ".opencode/hooks/", + loaderEntry, + "aidd-telemetry", + "hooks/opencode-plugin.js" + ) + ).toBe(".opencode/plugin/aidd-telemetry.js"); + }); + + it("does not match the loader entry's name for a nested script of the same basename", () => { + // "hooks/lib/opencode-plugin.js" is not "hooks/opencode-plugin.js": the exception + // matches the top-level script only, not anything sharing its leaf name deeper down. + const loaderEntry = { dir: ".opencode/plugin/", baseName: "opencode-plugin.js" }; + expect( + flatHooksPathWithLoaderEntry( + ".opencode/hooks/", + loaderEntry, + "aidd-telemetry", + "hooks/lib/opencode-plugin.js" + ) + ).toBe(".opencode/hooks/aidd-telemetry/lib/opencode-plugin.js"); + }); + + it("falls back to full namespacing when no loader entry is declared", () => { + expect(flatHooksPathWithLoaderEntry(".codex/hooks/", null, "aidd-dev", "hooks/check.sh")).toBe( + ".codex/hooks/aidd-dev/check.sh" + ); + }); + + it("keeps two plugins' same-named hook script from colliding", () => { + const a = flatHooksPathWithLoaderEntry(".opencode/hooks/", null, "plugin-a", "hooks/x.js"); + const b = flatHooksPathWithLoaderEntry(".opencode/hooks/", null, "plugin-b", "hooks/x.js"); + expect(a).not.toBe(b); + }); +}); + +describe("flatMcpKeyPrefix", () => { + it("returns plugin name with trailing dash", () => { + expect(flatMcpKeyPrefix("aidd-dev")).toBe("aidd-dev-"); + }); + + it("uses the full plugin name", () => { + expect(flatMcpKeyPrefix("my-awesome-plugin")).toBe("my-awesome-plugin-"); + }); +}); diff --git a/cli/tests/domain/formats/relative-link-rewrite.unit.test.ts b/cli/tests/kernel/materialization/relative-link-rewrite.unit.test.ts similarity index 92% rename from cli/tests/domain/formats/relative-link-rewrite.unit.test.ts rename to cli/tests/kernel/materialization/relative-link-rewrite.unit.test.ts index d2fc10d11..d9db38c0e 100644 --- a/cli/tests/domain/formats/relative-link-rewrite.unit.test.ts +++ b/cli/tests/kernel/materialization/relative-link-rewrite.unit.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from "vitest"; -import { rewriteRelativeLinks } from "../../../src/domain/formats/relative-link-rewrite.js"; +import { rewriteRelativeLinks } from "../../../src/kernel/materialization/relative-link-rewrite.js"; -// Stable test option used for all existing tests (the third branch is not triggered by @./ and @../). +// One options object serves them all: the third branch is not reached by @./ or @../. const STABLE_OPTS = { currentFilePluginRelative: "skills/foo/SKILL.md" }; // Written as split literals to avoid biome's noTemplateCurlyInString warning. @@ -88,7 +88,6 @@ describe("rewriteRelativeLinks", () => { }); it("handles a deep file referencing a skill in a peer skills subdirectory", () => { - // File at skills/aidd-test/commit/SKILL.md references root-level skills/aidd-test/SKILL.md const opts = { currentFilePluginRelative: "skills/aidd-test/commit/SKILL.md" }; const input = `@${CLAUDE_ROOT}/skills/aidd-test/SKILL.md`; const output = rewriteRelativeLinks(input, opts); @@ -116,9 +115,8 @@ describe("rewriteRelativeLinks", () => { describe("resolveTargetPath override", () => { it("uses the override to compute the link path instead of the default relative computation", () => { - // currentFile at "agents/reviewer.md" → dirname = "agents" - // resolveTargetPath returns ".github/agents/my-plugin/agents/reviewer.md" - // posix.relative("agents", ".github/agents/my-plugin/agents/reviewer.md") = "../.github/agents/my-plugin/agents/reviewer.md" + // posix.relative("agents", ".github/agents/my-plugin/agents/reviewer.md") = + // "../.github/agents/my-plugin/agents/reviewer.md" const opts = { currentFilePluginRelative: "agents/reviewer.md", resolveTargetPath: (rel: string) => `.github/agents/my-plugin/${rel}`, diff --git a/cli/tests/kernel/merge-entry.unit.test.ts b/cli/tests/kernel/merge-entry.unit.test.ts new file mode 100644 index 000000000..c2842324f --- /dev/null +++ b/cli/tests/kernel/merge-entry.unit.test.ts @@ -0,0 +1,160 @@ +import { describe, expect, it } from "vitest"; +import { + extractMergeEntries, + hashJsonEntries, + removeEntriesFromJson, +} from "../../src/kernel/merge.js"; +import type { Hasher } from "../../src/kernel/ports/hasher.js"; +import { HasherAdapter } from "../../src/runtime/filesystem/hasher-adapter.js"; + +const hasher: Hasher = new HasherAdapter(); + +describe("extractMergeEntries", () => { + describe("with section key", () => { + it("extracts per-entry hashes from a nested section", () => { + const json = JSON.stringify({ + mcpServers: { + playwright: { command: "npx", args: ["-y", "playwright-mcp"] }, + github: { command: "gh", args: ["mcp"] }, + }, + }); + const entries = extractMergeEntries(json, "mcpServers", hasher); + expect(Object.keys(entries)).toEqual(["playwright", "github"]); + expect(entries.playwright.value).toBe( + hasher.hash(JSON.stringify({ command: "npx", args: ["-y", "playwright-mcp"] })).value + ); + expect(entries.github.value).toBe( + hasher.hash(JSON.stringify({ command: "gh", args: ["mcp"] })).value + ); + }); + + it("returns empty map when section key is missing", () => { + const json = JSON.stringify({ other: {} }); + const entries = extractMergeEntries(json, "mcpServers", hasher); + expect(entries).toEqual({}); + }); + + it("returns empty map when section is not an object", () => { + const json = JSON.stringify({ mcpServers: "not an object" }); + const entries = extractMergeEntries(json, "mcpServers", hasher); + expect(entries).toEqual({}); + }); + }); + + describe("without section key (top-level)", () => { + it("extracts per-entry hashes from top-level keys", () => { + const json = JSON.stringify({ + "editor.formatOnSave": true, + "editor.tabSize": 2, + }); + const entries = extractMergeEntries(json, null, hasher); + expect(Object.keys(entries)).toEqual(["editor.formatOnSave", "editor.tabSize"]); + expect(entries["editor.formatOnSave"].value).toBe(hasher.hash(JSON.stringify(true)).value); + }); + }); + + describe("edge cases", () => { + it("returns empty map for empty JSON object", () => { + const entries = extractMergeEntries("{}", "mcpServers", hasher); + expect(entries).toEqual({}); + }); + + it("returns empty map for empty section", () => { + const json = JSON.stringify({ mcpServers: {} }); + const entries = extractMergeEntries(json, "mcpServers", hasher); + expect(entries).toEqual({}); + }); + + it("returns empty map for empty top-level object without section key", () => { + const entries = extractMergeEntries("{}", null, hasher); + expect(entries).toEqual({}); + }); + + it("returns empty map when section value is an array", () => { + const json = JSON.stringify({ mcpServers: [1, 2, 3] }); + const entries = extractMergeEntries(json, "mcpServers", hasher); + expect(entries).toEqual({}); + }); + + it("returns empty map for malformed JSON", () => { + const entries = extractMergeEntries("not valid json {{{", "mcpServers", hasher); + expect(entries).toEqual({}); + }); + + it("handles JSONC content with comments and trailing commas", () => { + const jsonc = `{ + // line comment + "mcpServers": { + /** block comment **/ + "playwright": { "command": "npx", "args": ["-y", "pkg"] }, + } + }`; + const entries = extractMergeEntries(jsonc, "mcpServers", hasher); + expect(Object.keys(entries)).toEqual(["playwright"]); + }); + + it("produces deterministic hashes for identical values", () => { + const json = JSON.stringify({ + mcpServers: { + a: { command: "npx", args: ["-y", "pkg"] }, + b: { command: "npx", args: ["-y", "pkg"] }, + }, + }); + const entries = extractMergeEntries(json, "mcpServers", hasher); + expect(entries.a.value).toBe(entries.b.value); + }); + }); +}); + +describe("removeEntriesFromJson", () => { + it("removes keys from a nested section", () => { + const json = JSON.stringify({ + mcpServers: { playwright: { cmd: "npx" }, github: { cmd: "gh" } }, + }); + const result = JSON.parse(removeEntriesFromJson(json, "mcpServers", ["playwright"])); + expect(result.mcpServers).toEqual({ github: { cmd: "gh" } }); + }); + + it("removes keys from root when sectionKey is null", () => { + const json = JSON.stringify({ playwright: { cmd: "npx" }, github: { cmd: "gh" } }); + const result = JSON.parse(removeEntriesFromJson(json, null, ["playwright"])); + expect(result).toEqual({ github: { cmd: "gh" } }); + }); + + it("drops a section entirely once emptied, even alongside unrelated top-level keys", () => { + const json = JSON.stringify({ + permissions: { allow: ["Bash(ls:*)"] }, + env: { CLAUDE_CODE_ENABLE_TELEMETRY: "1" }, + }); + const result = JSON.parse(removeEntriesFromJson(json, "env", ["CLAUDE_CODE_ENABLE_TELEMETRY"])); + expect(result).toEqual({ permissions: { allow: ["Bash(ls:*)"] } }); + expect("env" in result).toBe(false); + }); + + it("keeps a section that still has entries after removal, alongside unrelated keys", () => { + const json = JSON.stringify({ + permissions: { allow: ["Bash(ls:*)"] }, + env: { CLAUDE_CODE_ENABLE_TELEMETRY: "1", MY_OWN_VAR: "keep-me" }, + }); + const result = JSON.parse(removeEntriesFromJson(json, "env", ["CLAUDE_CODE_ENABLE_TELEMETRY"])); + expect(result).toEqual({ + permissions: { allow: ["Bash(ls:*)"] }, + env: { MY_OWN_VAR: "keep-me" }, + }); + }); +}); + +describe("hashJsonEntries", () => { + it("hashes each top-level value, one entry per key", () => { + const entries = hashJsonEntries({ a: 1, b: "two" }, hasher); + expect(entries.a.value).toBe(hasher.hash(JSON.stringify(1)).value); + expect(entries.b.value).toBe(hasher.hash(JSON.stringify("two")).value); + }); + + it("backs extractMergeEntries with the same hashing logic", () => { + const json = JSON.stringify({ env: { FOO: "bar" } }); + const viaExtract = extractMergeEntries(json, "env", hasher); + const viaHash = hashJsonEntries({ FOO: "bar" }, hasher); + expect(viaExtract.FOO.value).toBe(viaHash.FOO.value); + }); +}); diff --git a/cli/tests/domain/models/merge-strategy.unit.test.ts b/cli/tests/kernel/merge-strategy.unit.test.ts similarity index 86% rename from cli/tests/domain/models/merge-strategy.unit.test.ts rename to cli/tests/kernel/merge-strategy.unit.test.ts index 39913fd3a..f62f0d86a 100644 --- a/cli/tests/domain/models/merge-strategy.unit.test.ts +++ b/cli/tests/kernel/merge-strategy.unit.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { isPerKeyMergeStrategy } from "../../../src/domain/models/merge.js"; +import { isPerKeyMergeStrategy } from "../../src/kernel/merge.js"; describe("isPerKeyMergeStrategy", () => { it("returns true for PerKeyMergeStrategy objects", () => { diff --git a/cli/tests/domain/formats/plain-object.unit.test.ts b/cli/tests/kernel/reading/plain-object.unit.test.ts similarity index 91% rename from cli/tests/domain/formats/plain-object.unit.test.ts rename to cli/tests/kernel/reading/plain-object.unit.test.ts index 04a15bf78..66ecc6c90 100644 --- a/cli/tests/domain/formats/plain-object.unit.test.ts +++ b/cli/tests/kernel/reading/plain-object.unit.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { asPlainObject } from "../../../src/domain/formats/plain-object.js"; +import { asPlainObject } from "../../../src/kernel/reading/plain-object.js"; describe("asPlainObject", () => { it("passes a plain object through unchanged", () => { diff --git a/cli/tests/kernel/semver.unit.test.ts b/cli/tests/kernel/semver.unit.test.ts new file mode 100644 index 000000000..790da855c --- /dev/null +++ b/cli/tests/kernel/semver.unit.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, it } from "vitest"; +import { compareSemver, isSemver } from "../../src/kernel/semver.js"; + +describe("isSemver()", () => { + it("accepts a plain release version", () => { + expect(isSemver("5.3.0")).toBe(true); + expect(isSemver("v5.3.0")).toBe(true); + }); + + it("accepts a pre-release version", () => { + expect(isSemver("1.0.0-rc.1")).toBe(true); + }); + + it("accepts optional build metadata", () => { + expect(isSemver("1.0.0+build.5")).toBe(true); + expect(isSemver("1.0.0-rc.1+build.5")).toBe(true); + }); + + // Unanchored, `isSemver` matches trailing garbage after the three components, so a + // hand-edited or corrupted version field reads as valid semver. + it("rejects trailing garbage after the three numeric components", () => { + expect(isSemver("1.2.3abc")).toBe(false); + expect(isSemver("1.2.3.4")).toBe(false); + }); + + it("rejects a string with no version at all", () => { + expect(isSemver("not-a-version")).toBe(false); + expect(isSemver("")).toBe(false); + }); +}); + +describe("compareSemver()", () => { + it("compares numerically, not lexically", () => { + expect(compareSemver("5.10.0", "5.9.0")).toBe(1); + expect(compareSemver("5.9.0", "5.10.0")).toBe(-1); + }); + + it("is 0 for two identical release versions", () => { + expect(compareSemver("5.3.0", "5.3.0")).toBe(0); + }); + + // Compared equal, a host on the final release and a run on its own release candidate are + // told "no drift", and the rollback refusal's equality gate lets a rollback through. + it("orders a pre-release version below its own release", () => { + expect(compareSemver("5.3.0-rc.1", "5.3.0")).toBe(-1); + expect(compareSemver("5.3.0", "5.3.0-rc.1")).toBe(1); + }); + + it("orders two pre-release identifiers numerically when both are numeric", () => { + expect(compareSemver("5.3.0-rc.2", "5.3.0-rc.10")).toBe(-1); + }); + + it("orders two pre-release identifiers lexically when not numeric", () => { + expect(compareSemver("5.3.0-alpha", "5.3.0-beta")).toBe(-1); + }); + + it("treats two unparseable strings as equal rather than throwing", () => { + expect(compareSemver("garbage", "also-garbage")).toBe(0); + }); +}); diff --git a/cli/tests/kernel/source.unit.test.ts b/cli/tests/kernel/source.unit.test.ts new file mode 100644 index 000000000..f8e7b016a --- /dev/null +++ b/cli/tests/kernel/source.unit.test.ts @@ -0,0 +1,435 @@ +import { describe, expect, it } from "vitest"; +import { InvalidPluginSourceError } from "../../src/kernel/errors.js"; +import { + describePluginSource, + parsePluginSource, + parsePluginSourceShorthand, + serializePluginSource, +} from "../../src/kernel/source.js"; + +describe("parsePluginSource", () => { + describe("github kind", () => { + it("round-trips a minimal github source", () => { + const raw = { kind: "github", repo: "owner/repo" }; + const src = parsePluginSource(raw); + expect(serializePluginSource(src)).toEqual(raw); + }); + + it("round-trips a github source with ref and sha", () => { + const raw = { kind: "github", repo: "owner/repo", ref: "main", sha: "a".repeat(40) }; + const src = parsePluginSource(raw); + expect(serializePluginSource(src)).toEqual(raw); + }); + + it("throws when repo is missing", () => { + expect(() => parsePluginSource({ kind: "github" })).toThrow(InvalidPluginSourceError); + }); + + it("throws when repo format is invalid", () => { + expect(() => parsePluginSource({ kind: "github", repo: "not-valid" })).toThrow( + InvalidPluginSourceError + ); + }); + }); + + describe("url kind", () => { + it("round-trips a url source", () => { + const raw = { kind: "url", url: "https://example.com/plugin.zip" }; + const src = parsePluginSource(raw); + expect(serializePluginSource(src)).toEqual(raw); + }); + + it("round-trips a url source with optional fields", () => { + const raw = { + kind: "url", + url: "https://example.com/plugin.zip", + ref: "v1", + sha: "b".repeat(40), + }; + const src = parsePluginSource(raw); + expect(serializePluginSource(src)).toEqual(raw); + }); + + it("throws when url is missing", () => { + expect(() => parsePluginSource({ kind: "url" })).toThrow(InvalidPluginSourceError); + }); + }); + + describe("git-subdir kind", () => { + it("round-trips a git-subdir source", () => { + const raw = { + kind: "git-subdir", + url: "https://github.com/org/repo.git", + path: "plugins/my-plugin", + }; + const src = parsePluginSource(raw); + expect(serializePluginSource(src)).toEqual(raw); + }); + + it("keeps the ref and the sha through a round trip", () => { + // `InstalledPlugin.create` serializes then re-parses this in memory, never through + // JSON, and dropping `ref` unpins the plugin: the default branch, not the version asked for. + const raw = { + kind: "git-subdir", + url: "https://github.com/org/repo.git", + path: "plugins/my-plugin", + ref: "v1.2.0", + sha: "b".repeat(40), + }; + expect(serializePluginSource(parsePluginSource(raw))).toStrictEqual(raw); + }); + + it("throws when url is missing", () => { + expect(() => parsePluginSource({ kind: "git-subdir", path: "sub" })).toThrow( + InvalidPluginSourceError + ); + }); + + it("throws when path is missing", () => { + expect(() => parsePluginSource({ kind: "git-subdir", url: "https://example.com" })).toThrow( + InvalidPluginSourceError + ); + }); + }); + + describe("npm kind", () => { + it("round-trips a minimal npm source", () => { + const raw = { kind: "npm", package: "@my-org/my-plugin" }; + const src = parsePluginSource(raw); + expect(serializePluginSource(src)).toEqual(raw); + }); + + it("round-trips an npm source with version and registry", () => { + const raw = { + kind: "npm", + package: "@my-org/my-plugin", + version: "1.2.3", + registry: "https://registry.npmjs.org", + }; + const src = parsePluginSource(raw); + expect(serializePluginSource(src)).toEqual(raw); + }); + + it("throws when package is missing", () => { + expect(() => parsePluginSource({ kind: "npm" })).toThrow(InvalidPluginSourceError); + }); + + describe("npm name security validation", () => { + it("accepts a valid unscoped package name", () => { + expect(() => parsePluginSource({ kind: "npm", package: "my-plugin" })).not.toThrow(); + }); + + it("accepts a valid scoped package name", () => { + expect(() => + parsePluginSource({ kind: "npm", package: "@my-org/my-plugin" }) + ).not.toThrow(); + }); + + it("rejects a package name starting with a dash (injection vector)", () => { + expect(() => parsePluginSource({ kind: "npm", package: "-x" })).toThrow( + InvalidPluginSourceError + ); + }); + + it("rejects a package name starting with double-dash (option injection)", () => { + expect(() => + parsePluginSource({ kind: "npm", package: "--registry=https://evil.com" }) + ).toThrow(InvalidPluginSourceError); + }); + + it("rejects a package name starting with a dot", () => { + expect(() => parsePluginSource({ kind: "npm", package: ".my-plugin" })).toThrow( + InvalidPluginSourceError + ); + }); + + it("rejects a package name with uppercase letters", () => { + expect(() => parsePluginSource({ kind: "npm", package: "My-Plugin" })).toThrow( + InvalidPluginSourceError + ); + }); + }); + }); + + describe("local kind", () => { + it("round-trips a local source", () => { + const raw = { kind: "local", path: "./plugins/my-plugin" }; + const src = parsePluginSource(raw); + expect(serializePluginSource(src)).toEqual(raw); + }); + + it("throws when path is missing", () => { + expect(() => parsePluginSource({ kind: "local" })).toThrow(InvalidPluginSourceError); + }); + }); + + describe("a source recorded as a plain string", () => { + // A manifest may record a source as a string rather than an object. Read wrong, the + // plugin is fetched from the wrong place — or a valid record is refused on load. + it("reads a bare owner/repo as a github source", () => { + expect(parsePluginSource("ai-driven-dev/framework")).toEqual({ + kind: "github", + repo: "ai-driven-dev/framework", + }); + }); + + it("reads a relative path as a local source", () => { + expect(parsePluginSource("./plugins/mine")).toEqual({ + kind: "local", + path: "./plugins/mine", + }); + }); + + it("reads an absolute path as a local source", () => { + expect(parsePluginSource("/opt/plugins/mine")).toEqual({ + kind: "local", + path: "/opt/plugins/mine", + }); + }); + }); + + describe("a field that is present but empty", () => { + it("refuses an empty path rather than recording a source pointing nowhere", () => { + expect(() => parsePluginSource({ kind: "local", path: "" })).toThrow( + /"path" must be a non-empty string/ + ); + }); + }); + + describe("invalid inputs", () => { + it("throws for unknown kind", () => { + expect(() => parsePluginSource({ kind: "svn", url: "svn://example.com" })).toThrow( + InvalidPluginSourceError + ); + }); + + it("throws for null", () => { + expect(() => parsePluginSource(null)).toThrow(InvalidPluginSourceError); + }); + + it("throws for array", () => { + expect(() => parsePluginSource([])).toThrow(InvalidPluginSourceError); + }); + + it("throws for primitive string", () => { + expect(() => parsePluginSource("github:owner/repo")).toThrow(InvalidPluginSourceError); + }); + + it("throws when kind is missing", () => { + expect(() => parsePluginSource({ repo: "owner/repo" })).toThrow(InvalidPluginSourceError); + }); + }); +}); + +/** + * Grouped by what a user types, not by the parsing function: the wrong kind sends the plugin + * to the wrong fetch adapter, and a dropped ref installs the default branch — both silently. + */ +describe("the source spellings a user types", () => { + describe("a bare owner/repo", () => { + it("resolves to a github source with no ref", () => { + expect(parsePluginSourceShorthand("ai-driven-dev/framework")).toEqual({ + kind: "github", + repo: "ai-driven-dev/framework", + }); + }); + + it("is not mistaken for a path when it contains dots or dashes", () => { + expect(parsePluginSourceShorthand("my-org/my.plugin_v2")).toEqual({ + kind: "github", + repo: "my-org/my.plugin_v2", + }); + }); + }); + + describe("a pinned version, owner/repo@ref", () => { + it("keeps the ref and strips it from the repo", () => { + expect(parsePluginSourceShorthand("ai-driven-dev/framework@v1.2.0")).toEqual({ + kind: "github", + repo: "ai-driven-dev/framework", + ref: "v1.2.0", + }); + }); + + it("splits on the last @, so a ref containing one is refused rather than mangled", () => { + // The repo half would be "owner/repo@release", not owner/repo, so the spelling falls + // through to the JSON branch and is rejected rather than installing a mangled name. + expect(() => parsePluginSourceShorthand("owner/repo@release@2")).toThrow( + InvalidPluginSourceError + ); + }); + + it("refuses a spelling whose repo half is not owner/repo", () => { + expect(() => parsePluginSourceShorthand("not-a-repo@v1")).toThrow(InvalidPluginSourceError); + }); + + it("treats a leading @ as part of an unrecognized spelling, not a separator", () => { + expect(() => parsePluginSourceShorthand("@v1.2.0")).toThrow(InvalidPluginSourceError); + }); + }); + + describe("a gitlab: shorthand", () => { + it("resolves gitlab:owner/repo to a gitlab.com git URL", () => { + expect(parsePluginSourceShorthand("gitlab:my-org/my-plugin")).toEqual({ + kind: "url", + url: "https://gitlab.com/my-org/my-plugin.git", + }); + }); + + it("carries a ref through when one is given", () => { + expect(parsePluginSourceShorthand("gitlab:my-org/my-plugin@v2")).toEqual({ + kind: "url", + url: "https://gitlab.com/my-org/my-plugin.git", + ref: "v2", + }); + }); + + it("says what the spelling should have looked like when it is malformed", () => { + expect(() => parsePluginSourceShorthand("gitlab:nope")).toThrow( + /gitlab:owner\/repo or gitlab:owner\/repo@ref/ + ); + }); + }); + + describe("a URL", () => { + it("keeps an https URL as a url source", () => { + expect(parsePluginSourceShorthand("https://example.com/p.git")).toEqual({ + kind: "url", + url: "https://example.com/p.git", + }); + }); + + it("keeps an http URL as a url source", () => { + expect(parsePluginSourceShorthand("http://example.com/p.git")).toEqual({ + kind: "url", + url: "http://example.com/p.git", + }); + }); + + it("keeps an SSH URL as a url source", () => { + expect(parsePluginSourceShorthand("git@github.com:owner/repo.git")).toEqual({ + kind: "url", + url: "git@github.com:owner/repo.git", + }); + }); + }); + + describe("a path on this machine", () => { + it("resolves a relative path to a local source", () => { + expect(parsePluginSourceShorthand("./plugins/mine")).toEqual({ + kind: "local", + path: "./plugins/mine", + }); + }); + + it("resolves an absolute path to a local source", () => { + expect(parsePluginSourceShorthand("/opt/plugins/mine")).toEqual({ + kind: "local", + path: "/opt/plugins/mine", + }); + }); + }); + + describe("raw JSON, for the sources no shorthand covers", () => { + it("parses a JSON object into the source it describes", () => { + expect( + parsePluginSourceShorthand('{"kind":"npm","package":"@scope/pkg","version":"1.0.0"}') + ).toEqual({ kind: "npm", package: "@scope/pkg", version: "1.0.0" }); + }); + + it("reports the JSON's own complaint when the object is a bad source", () => { + // Both branches throw InvalidPluginSourceError, so asserting the class alone would + // pass even with the parser's own message swallowed by the generic one. + expect(() => parsePluginSourceShorthand('{"kind":"github"}')).toThrow( + /"repo" must be a non-empty string/ + ); + }); + + it("names the string it was given when nothing recognizes it", () => { + expect(() => parsePluginSourceShorthand("just some words")).toThrow( + /unrecognized source format: "just some words"/ + ); + }); + }); +}); + +/** A wrong line here tells the user their project points somewhere it does not. */ +describe("the source shown back to a user", () => { + it("shows a github source as its full URL", () => { + expect(describePluginSource({ kind: "github", repo: "owner/repo" })).toBe( + "https://github.com/owner/repo" + ); + }); + + it("appends the ref when the source is pinned", () => { + expect(describePluginSource({ kind: "github", repo: "owner/repo", ref: "v1" })).toBe( + "https://github.com/owner/repo@v1" + ); + }); + + it("shows a url source as the URL itself", () => { + expect(describePluginSource({ kind: "url", url: "https://example.com/p.git" })).toBe( + "https://example.com/p.git" + ); + }); + + it("shows a git-subdir source as the URL and the path it points into", () => { + expect( + describePluginSource({ kind: "git-subdir", url: "https://example.com/r.git", path: "pkg/a" }) + ).toBe("https://example.com/r.git#pkg/a"); + }); + + it("shows an npm source with its registry prefix", () => { + expect(describePluginSource({ kind: "npm", package: "@scope/pkg" })).toBe("npm:@scope/pkg"); + }); + + it("appends the version when the npm source has one", () => { + expect(describePluginSource({ kind: "npm", package: "@scope/pkg", version: "2.1.0" })).toBe( + "npm:@scope/pkg@2.1.0" + ); + }); + + it("shows a local source as the path itself", () => { + expect(describePluginSource({ kind: "local", path: "./plugins/mine" })).toBe("./plugins/mine"); + }); +}); + +/** + * A manifest field of the wrong type must be refused, not coerced: a source silently + * accepted here is a fetch that fails much later, with an error naming the wrong thing. + */ +describe("a manifest field of the wrong type", () => { + it("refuses a non-string optional field", () => { + expect(() => parsePluginSource({ kind: "github", repo: "owner/repo", ref: 3 })).toThrow( + /"ref" must be a string/ + ); + }); + + it("accepts the field being absent", () => { + expect(parsePluginSource({ kind: "github", repo: "owner/repo" })).toEqual({ + kind: "github", + repo: "owner/repo", + }); + }); + + it("refuses a sha that is not 40 lowercase hex characters", () => { + expect(() => parsePluginSource({ kind: "github", repo: "owner/repo", sha: "ABC123" })).toThrow( + /40-character lowercase hex/ + ); + }); + + it("accepts a well-formed sha", () => { + const sha = "a".repeat(40); + expect(parsePluginSource({ kind: "github", repo: "owner/repo", sha })).toEqual({ + kind: "github", + repo: "owner/repo", + sha, + }); + }); + + it("lists the kinds it knows when given one it does not", () => { + expect(() => parsePluginSource({ kind: "svn" })).toThrow( + /Expected: github, url, git-subdir, npm, local/ + ); + }); +}); diff --git a/cli/tests/kernel/tool.unit.test.ts b/cli/tests/kernel/tool.unit.test.ts new file mode 100644 index 000000000..1efbfd0bf --- /dev/null +++ b/cli/tests/kernel/tool.unit.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, it } from "vitest"; +import { UnknownAiToolIdError } from "../../src/kernel/errors.js"; +import { assertValidAiToolId, isAiToolId, parseToolOption } from "../../src/kernel/tool.js"; + +describe("isAiToolId", () => { + it("returns true for known AI tool IDs", () => { + expect(isAiToolId("claude")).toBe(true); + expect(isAiToolId("cursor")).toBe(true); + expect(isAiToolId("copilot")).toBe(true); + expect(isAiToolId("opencode")).toBe(true); + expect(isAiToolId("codex")).toBe(true); + }); + + it("returns false for unknown strings", () => { + expect(isAiToolId("unknown")).toBe(false); + expect(isAiToolId("vscode")).toBe(false); + expect(isAiToolId("")).toBe(false); + }); +}); + +describe("parseToolOption", () => { + it("returns 'all' when argument is undefined", () => { + expect(parseToolOption(undefined)).toBe("all"); + }); + + it("returns 'all' when argument is the string 'all'", () => { + expect(parseToolOption("all")).toBe("all"); + }); + + it("returns a single-element array for a named tool", () => { + expect(parseToolOption("claude")).toEqual(["claude"]); + expect(parseToolOption("cursor")).toEqual(["cursor"]); + }); +}); + +describe("assertValidAiToolId", () => { + it("does not throw when id is undefined", () => { + expect(() => assertValidAiToolId(undefined)).not.toThrow(); + }); + + it("does not throw when id is 'all'", () => { + expect(() => assertValidAiToolId("all")).not.toThrow(); + }); + + it("does not throw for valid AI tool IDs", () => { + expect(() => assertValidAiToolId("claude")).not.toThrow(); + expect(() => assertValidAiToolId("cursor")).not.toThrow(); + }); + + it("throws UnknownAiToolIdError for invalid IDs", () => { + expect(() => assertValidAiToolId("invalid-tool")).toThrow(UnknownAiToolIdError); + expect(() => assertValidAiToolId("vscode")).toThrow(UnknownAiToolIdError); + }); +}); diff --git a/cli/tests/presentation/commands/auth-wiring.integration.test.ts b/cli/tests/presentation/commands/auth-wiring.integration.test.ts new file mode 100644 index 000000000..c406ccbdb --- /dev/null +++ b/cli/tests/presentation/commands/auth-wiring.integration.test.ts @@ -0,0 +1,290 @@ +import { Command } from "commander"; +import { afterEach, beforeEach, describe, expect, it, type MockInstance, vi } from "vitest"; +import { AIDD_DIR } from "../../../src/kernel/paths.js"; + +const login = vi.fn(); +const status = vi.fn(); +const logout = vi.fn(); +const promptSelect = vi.fn(); +const promptConfirm = vi.fn(); +const promptInput = vi.fn(); + +vi.mock("../../../src/runtime/wiring/framework.js", () => ({ + createDeps: vi.fn(async () => ({ + credentialStore: { login, status, logout }, + prompter: { select: promptSelect, confirm: promptConfirm, input: promptInput }, + })), + createMenuDeps: vi.fn(), +})); + +const { createDeps } = await import("../../../src/runtime/wiring/framework.js"); +const { registerAuthCommand } = await import("../../../src/presentation/commands/auth.js"); + +const PROJECT_ROOT = process.cwd(); + +let written: string[] = []; +let errors: string[] = []; + +function pretendTerminal(isTTY: boolean): void { + Object.defineProperty(process.stdout, "isTTY", { value: isTTY, configurable: true }); +} + +beforeEach(() => { + vi.clearAllMocks(); + written = []; + errors = []; + pretendTerminal(false); + vi.spyOn(process.stdout, "write").mockImplementation((chunk) => { + written.push(String(chunk)); + return true; + }); + vi.spyOn(process.stderr, "write").mockImplementation((chunk) => { + errors.push(String(chunk)); + return true; + }); + login.mockResolvedValue({ login: "octocat", level: "user" }); + status.mockResolvedValue({ authenticated: false }); + logout.mockResolvedValue({ found: false }); + promptSelect.mockResolvedValue("project"); + promptConfirm.mockResolvedValue(true); + promptInput.mockResolvedValue("ghp_asked"); +}); + +afterEach(() => { + vi.restoreAllMocks(); + process.exitCode = undefined; +}); + +async function run(...args: string[]): Promise { + const program = new Command(); + program.exitOverride(); + program.option("--verbose"); + registerAuthCommand(program); + await program.parseAsync(["node", "aidd", "auth", ...args]); + return written.join("").split("\n").slice(0, -1); +} + +function refusing(): MockInstance { + return vi.spyOn(process, "exit").mockImplementation(() => { + throw new Error("exited"); + }); +} + +describe("aidd auth login — the credential it stores", () => { + it("stores the token it was handed, at the level it was told", async () => { + login.mockResolvedValue({ login: "octocat", level: "user" }); + + expect(await run("login", "--token", "ghp_x", "--level", "user")).toEqual([ + "Authenticated as octocat (user)", + ]); + expect(login).toHaveBeenCalledWith({ method: "stored", token: "ghp_x" }, "user"); + }); + + it("records a gh-resolved credential as external, storing no token of its own", async () => { + login.mockResolvedValue({ login: "octocat", level: "project" }); + + expect(await run("login", "--gh", "--level", "project")).toEqual([ + "Authenticated as octocat (project)", + ]); + expect(login).toHaveBeenCalledWith({ method: "external", provider: "gh" }, "project"); + }); +}); + +describe("aidd auth login — what it asks a person at a terminal", () => { + it("offers both storage levels, naming where each one lands", async () => { + pretendTerminal(true); + + await run("login", "--token", "ghp_x"); + + expect(promptSelect).toHaveBeenCalledWith("Storage level:", [ + { name: "User (~/.config/aidd/auth.json)", value: "user" }, + { name: `Project (${AIDD_DIR}/auth.json)`, value: "project" }, + ]); + expect(login).toHaveBeenCalledWith({ method: "stored", token: "ghp_x" }, "project"); + }); + + it("falls back to gh when the person has no token of their own", async () => { + pretendTerminal(true); + promptConfirm.mockResolvedValue(false); + + await run("login", "--level", "user"); + + expect(promptConfirm).toHaveBeenCalledWith("Do you have a Personal Access Token?"); + expect(promptInput).not.toHaveBeenCalled(); + expect(login).toHaveBeenCalledWith({ method: "external", provider: "gh" }, "user"); + }); + + it("stores the token the person pasted", async () => { + pretendTerminal(true); + + await run("login", "--level", "user"); + + expect(promptInput).toHaveBeenCalledWith("Paste your GitHub Personal Access Token:"); + expect(login).toHaveBeenCalledWith({ method: "stored", token: "ghp_asked" }, "user"); + }); + + it("refuses an empty paste rather than storing a credential of nothing", async () => { + pretendTerminal(true); + promptInput.mockResolvedValue(""); + const exit = refusing(); + + await expect(run("login", "--level", "user")).rejects.toThrow("exited"); + + expect(errors[0]).toBe("Error: Token cannot be empty.\n"); + expect(exit).toHaveBeenCalledWith(1); + expect(login).not.toHaveBeenCalled(); + }); +}); + +describe("aidd auth login — what it refuses before building anything", () => { + it("refuses a run that names two ways of authenticating at once", async () => { + const exit = refusing(); + + await expect(run("login", "--gh", "--token", "ghp_x")).rejects.toThrow("exited"); + + expect(errors[0]).toBe("Error: --gh and --token are mutually exclusive.\n"); + expect(exit).toHaveBeenCalledWith(1); + expect(vi.mocked(createDeps)).not.toHaveBeenCalled(); + }); + + it("refuses to prompt for a credential off a terminal", async () => { + refusing(); + + await expect(run("login", "--level", "user")).rejects.toThrow("exited"); + + expect(errors[0]).toBe("Error: Use --gh or --token in non-interactive mode.\n"); + expect(vi.mocked(createDeps)).not.toHaveBeenCalled(); + }); + + it("refuses to prompt for a storage level off a terminal", async () => { + refusing(); + + await expect(run("login", "--token", "ghp_x")).rejects.toThrow("exited"); + + expect(errors[0]).toBe("Error: Use --level in non-interactive mode.\n"); + expect(vi.mocked(createDeps)).not.toHaveBeenCalled(); + }); + + it("refuses a level that is neither user nor project", async () => { + pretendTerminal(true); + refusing(); + + await expect(run("login", "--token", "ghp_x", "--level", "machine")).rejects.toThrow("exited"); + + expect(errors[0]).toBe("Error: --level must be 'user' or 'project'.\n"); + expect(vi.mocked(createDeps)).not.toHaveBeenCalled(); + }); +}); + +describe("aidd auth logout", () => { + it("says nothing was stored when there was nothing to remove", async () => { + expect(await run("logout")).toEqual(["Not authenticated."]); + expect(logout).toHaveBeenCalledWith(); + }); + + it("names the level it cleared, and the external command still to run", async () => { + logout.mockResolvedValue({ + found: true, + level: "user", + hint: "external-provider-cleanup", + }); + + expect(await run("logout")).toEqual([ + "To fully logout, run the external provider's logout command (e.g. gh auth logout).", + "Logged out (user)", + ]); + }); +}); + +describe("aidd auth logout — a store that refuses", () => { + it("names the failure on stderr and fails the process", async () => { + logout.mockRejectedValue(new Error("auth.json is read-only")); + const exit = refusing(); + + await expect(run("logout")).rejects.toThrow("exited"); + + expect(errors.join("")).toBe("Error: auth.json is read-only\n"); + expect(exit).toHaveBeenCalledWith(1); + }); +}); + +describe("aidd auth status", () => { + it("says so when nothing authenticates this machine", async () => { + expect(await run("status")).toEqual(["Not authenticated."]); + expect(status).toHaveBeenCalledWith(); + }); + + it("names who is authenticated and at which level", async () => { + status.mockResolvedValue({ authenticated: true, login: "octocat", level: "project" }); + + expect(await run("status")).toEqual(["Authenticated as octocat (project)"]); + }); +}); + +describe("aidd auth — how every subcommand builds its graph and reports a failure", () => { + it.each([["login", "--token", "ghp_x", "--level", "user"], ["logout"], ["status"]])( + "hands %j this run's verbosity, never an empty option set", + async (...args) => { + await run(...args); + + expect(vi.mocked(createDeps)).toHaveBeenCalledWith( + PROJECT_ROOT, + { verbose: false }, + expect.anything() + ); + } + ); + + it("names a failed status read on stderr and fails the process", async () => { + status.mockRejectedValue(new Error("auth.json is unreadable")); + const exit = refusing(); + + await expect(run("status")).rejects.toThrow("exited"); + + expect(errors.join("")).toBe("Error: auth.json is unreadable\n"); + expect(exit).toHaveBeenCalledWith(1); + }); +}); + +describe("aidd auth — the help surface", () => { + function authCommand(): Command { + const program = new Command(); + registerAuthCommand(program); + const auth = program.commands.find((command) => command.name() === "auth"); + if (auth === undefined) throw new Error("auth command was not registered"); + return auth; + } + + it("describes the group and every subcommand, in the order they are registered", () => { + expect(authCommand().description()).toBe("Manage authentication"); + expect( + authCommand().commands.map((command) => [command.name(), command.description()]) + ).toEqual([ + ["login", "Authenticate with GitHub"], + ["logout", "Remove stored authentication"], + ["status", "Show authentication status"], + ]); + }); + + it("offers login three ways of being told, and asks nothing of logout or status", () => { + const optionsOf = (name: string): [string, string | undefined][] => { + const child = authCommand().commands.find((candidate) => candidate.name() === name); + if (child === undefined) throw new Error(`no subcommand ${name}`); + return child.options.map((option) => [option.flags, option.description]); + }; + + expect(optionsOf("login")).toEqual([ + ["--gh", "Use GitHub CLI token"], + ["--token ", "Personal access token"], + ["--level ", "Storage level (user or project)"], + ]); + expect(optionsOf("logout")).toEqual([]); + expect(optionsOf("status")).toEqual([]); + }); + + it("prints its own help when the group is run with no subcommand", async () => { + await expect(run()).rejects.toThrow("(outputHelp)"); + + expect(written.join("").split("\n")[0]).toBe("Usage: aidd auth [options] [command]"); + }); +}); diff --git a/cli/tests/presentation/commands/clean-wiring.integration.test.ts b/cli/tests/presentation/commands/clean-wiring.integration.test.ts new file mode 100644 index 000000000..5fbae0276 --- /dev/null +++ b/cli/tests/presentation/commands/clean-wiring.integration.test.ts @@ -0,0 +1,221 @@ +import { Command } from "commander"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const cleanProject = vi.fn(); +const cleanUserScope = vi.fn(); + +vi.mock("../../../src/runtime/wiring/framework.js", () => ({ + createDeps: vi.fn(async () => ({ + cleanUseCase: { execute: cleanProject }, + cleanUserScopeUseCase: { execute: cleanUserScope }, + })), + createMenuDeps: vi.fn(), +})); + +const { createDeps } = await import("../../../src/runtime/wiring/framework.js"); +const { registerCleanCommand } = await import("../../../src/presentation/commands/clean.js"); + +const PROJECT_ROOT = process.cwd(); + +const EMPTY_PREVIEW = { + tools: [{ toolId: "claude", fileCount: 3 }], + nativeRegistrations: [], + totalFileCount: 3, +}; + +let written: string[] = []; +let errors: string[] = []; + +function pretendTerminal(isTTY: boolean): void { + Object.defineProperty(process.stdout, "isTTY", { value: isTTY, configurable: true }); +} + +beforeEach(() => { + vi.clearAllMocks(); + written = []; + errors = []; + pretendTerminal(false); + vi.spyOn(process.stdout, "write").mockImplementation((chunk) => { + written.push(String(chunk)); + return true; + }); + vi.spyOn(process.stderr, "write").mockImplementation((chunk) => { + errors.push(String(chunk)); + return true; + }); + cleanProject.mockResolvedValue({ + manifestFound: true, + dryRun: true, + fileCount: 0, + preview: EMPTY_PREVIEW, + }); + cleanUserScope.mockResolvedValue({ + dryRun: false, + manifestFound: true, + preview: { toolIds: [], builtVersions: [], referencingProjects: [] }, + }); +}); + +afterEach(() => { + vi.restoreAllMocks(); + process.exitCode = undefined; +}); + +async function run(...args: string[]): Promise { + const program = new Command(); + program.exitOverride(); + program.option("--verbose"); + registerCleanCommand(program); + await program.parseAsync(["node", "aidd", ...args]); + return written.join("").split("\n").slice(0, -1); +} + +describe("aidd clean — the project this command was run in", () => { + it("previews rather than removes when nothing confirmed it", async () => { + expect(await run("clean")).toEqual([ + "The following will be removed:", + " claude: 3 files", + " manifest: .aidd/ (config.json, if present, is kept)", + "Would remove 3 files across 1 tool. Use --force to confirm.", + ]); + expect(cleanProject).toHaveBeenCalledWith({ + projectRoot: PROJECT_ROOT, + force: false, + interactive: false, + }); + expect(cleanUserScope).not.toHaveBeenCalled(); + }); + + it("carries the confirmation through, and names what was removed", async () => { + cleanProject.mockResolvedValue({ + manifestFound: true, + dryRun: false, + fileCount: 7, + preview: EMPTY_PREVIEW, + }); + + expect(await run("clean", "--force")).toEqual(["Cleaned all AIDD files (7 files removed)"]); + expect(cleanProject).toHaveBeenCalledWith({ + projectRoot: PROJECT_ROOT, + force: true, + interactive: false, + }); + }); + + it("tells the use case a terminal is watching, and ends the preview differently", async () => { + pretendTerminal(true); + + const lines = await run("clean"); + + expect(cleanProject).toHaveBeenCalledWith({ + projectRoot: PROJECT_ROOT, + force: false, + interactive: true, + }); + expect(lines[lines.length - 1]).toBe("No files removed."); + }); +}); + +describe("aidd clean --scope", () => { + it("sends a user scope to the machine-wide clean alone", async () => { + expect(await run("clean", "--scope", "user")).toEqual([ + "Cleaned the shared aidd-framework source for this machine", + ]); + expect(cleanUserScope).toHaveBeenCalledWith({ + projectRoot: PROJECT_ROOT, + force: false, + interactive: false, + }); + expect(cleanProject).not.toHaveBeenCalled(); + }); + + it("treats a spelled-out project scope as the default one", async () => { + await run("clean", "--scope", "project"); + + expect(cleanProject).toHaveBeenCalledTimes(1); + expect(cleanUserScope).not.toHaveBeenCalled(); + }); + + it("tells the machine-wide clean a terminal is watching", async () => { + pretendTerminal(true); + cleanUserScope.mockResolvedValue({ + dryRun: true, + manifestFound: true, + preview: { toolIds: [], builtVersions: [], referencingProjects: [] }, + }); + + const lines = await run("clean", "--scope", "user"); + + expect(cleanUserScope).toHaveBeenCalledWith({ + projectRoot: PROJECT_ROOT, + force: false, + interactive: true, + }); + expect(lines[lines.length - 1]).toBe("No files removed."); + }); + + it("refuses a scope it does not know before building anything", async () => { + const exit = vi.spyOn(process, "exit").mockImplementation(() => { + throw new Error("exited"); + }); + + await expect(run("clean", "--scope", "machine")).rejects.toThrow("exited"); + + expect(errors.join("")).toBe( + 'Error: Invalid --scope "machine" — expected "project" or "user".\n' + ); + expect(exit).toHaveBeenCalledWith(1); + expect(vi.mocked(createDeps)).not.toHaveBeenCalled(); + }); +}); + +describe("aidd clean — how it builds its graph and reports a failure", () => { + it("builds the graph for this project at this run's verbosity", async () => { + await run("clean"); + + expect(vi.mocked(createDeps)).toHaveBeenCalledWith( + PROJECT_ROOT, + { verbose: false }, + expect.anything() + ); + }); + + it("names the failure on stderr and fails the process", async () => { + cleanProject.mockRejectedValue(new Error("registry locked")); + const exit = vi.spyOn(process, "exit").mockImplementation(() => { + throw new Error("exited"); + }); + + await expect(run("clean")).rejects.toThrow("exited"); + + expect(errors.join("")).toBe("Error: registry locked\n"); + expect(exit).toHaveBeenCalledWith(1); + }); +}); + +describe("aidd clean — the help surface", () => { + function cleanCommand(): Command { + const program = new Command(); + registerCleanCommand(program); + const clean = program.commands.find((command) => command.name() === "clean"); + if (clean === undefined) throw new Error("clean command was not registered"); + return clean; + } + + it("describes itself against the command it is confused with", () => { + expect(cleanCommand().description()).toBe( + "Remove all AIDD-managed files from the project — retires every part of AIDD; see `framework remove`, which removes the framework only" + ); + }); + + it("offers a confirmation and a scope, and asks nothing else", () => { + expect(cleanCommand().options.map((option) => [option.flags, option.description])).toEqual([ + ["--force", "Confirm file removal (skip dry-run)"], + [ + "--scope ", + "project (default) cleans this project alone; user undoes the machine-wide " + + "registration setup --scope user wrote and purges the shared source itself", + ], + ]); + }); +}); diff --git a/cli/tests/presentation/commands/doctor-wiring.integration.test.ts b/cli/tests/presentation/commands/doctor-wiring.integration.test.ts new file mode 100644 index 000000000..d6000ff04 --- /dev/null +++ b/cli/tests/presentation/commands/doctor-wiring.integration.test.ts @@ -0,0 +1,542 @@ +import { Command } from "commander"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import "../../../src/contexts/tools/domain/profiles/claude/profile.js"; +import "../../../src/contexts/tools/domain/profiles/cursor/profile.js"; +import "../../../src/contexts/tools/domain/profiles/vscode/profile.js"; +import { userMachineLocalFilesOf } from "../../../src/contexts/tools/domain/registry.js"; + +const doctorAll = vi.fn(); +const statusAll = vi.fn(); +const doctorScoped = vi.fn(); +const statusScoped = vi.fn(); +const doctorRegistration = vi.fn(); +const loadUserManifest = vi.fn(); +const homedir = vi.fn(() => "/home/dev"); + +vi.mock("../../../src/runtime/wiring/framework.js", () => ({ + createDeps: vi.fn(async () => ({ + doctorAllUseCase: { execute: doctorAll }, + statusAllUseCase: { execute: statusAll }, + doctorUseCase: { execute: doctorScoped }, + statusUseCase: { execute: statusScoped }, + doctorRegistrationUseCase: { execute: doctorRegistration }, + userManifestRepo: { load: loadUserManifest }, + homedir, + environment: { get: () => undefined, set: () => undefined }, + })), + createMenuDeps: vi.fn(), +})); + +const { createDeps } = await import("../../../src/runtime/wiring/framework.js"); +const { registerDoctorCommand } = await import("../../../src/presentation/commands/doctor.js"); + +const PROJECT_ROOT = process.cwd(); + +const HEALTHY_SCOPE = { toolHealth: [], issues: [] }; + +let written: string[] = []; +let errors: string[] = []; + +beforeEach(() => { + vi.clearAllMocks(); + written = []; + errors = []; + vi.spyOn(process.stdout, "write").mockImplementation((chunk) => { + written.push(String(chunk)); + return true; + }); + vi.spyOn(process.stderr, "write").mockImplementation((chunk) => { + errors.push(String(chunk)); + return true; + }); + homedir.mockReturnValue("/home/dev"); + doctorAll.mockResolvedValue({ + errors: [], + ai: HEALTHY_SCOPE, + ide: HEALTHY_SCOPE, + pluginIssues: [], + healthy: true, + }); + statusAll.mockResolvedValue({ + aiTools: { tools: [] }, + ideTools: { tools: [] }, + pluginDrift: [], + }); + doctorScoped.mockResolvedValue({ + toolHealth: [], + issues: [], + pluginIssues: [], + healthy: true, + }); + statusScoped.mockResolvedValue({ tools: [], pluginDrift: [] }); + doctorRegistration.mockResolvedValue([]); + loadUserManifest.mockResolvedValue({ + getInstalledToolIds: () => ["claude"], + getToolVersion: () => "5.2.2", + }); +}); + +afterEach(() => { + vi.restoreAllMocks(); + process.exitCode = undefined; +}); + +async function run(...args: string[]): Promise { + const program = new Command(); + program.exitOverride(); + program.option("--verbose"); + registerDoctorCommand(program); + await program.parseAsync(["node", "aidd", "doctor", ...args]); + return written.join("").split("\n").slice(0, -1); +} + +describe("aidd doctor — every tool at once", () => { + it("reports drift for both categories, then calls a clean install healthy", async () => { + expect(await run()).toEqual([ + "", + "Drift:", + "AI tools:", + " (none installed)", + "IDE tools:", + " (none installed)", + "Plugins:", + " (all in sync)", + "", + "Installation is healthy", + ]); + expect(doctorAll).toHaveBeenCalledWith(PROJECT_ROOT, undefined); + expect(statusAll).toHaveBeenCalledWith(PROJECT_ROOT); + }); + + it("names each equipped tool with its version and what it carries", async () => { + doctorAll.mockResolvedValue({ + errors: [], + ai: { toolHealth: [{ toolId: "claude", fileCount: 12, mergeFileCount: 1 }], issues: [] }, + ide: HEALTHY_SCOPE, + pluginIssues: [], + healthy: true, + }); + statusAll.mockResolvedValue({ + aiTools: { tools: [{ toolId: "claude", version: "5.2.2", drifted: [] }] }, + ideTools: { tools: [] }, + pluginDrift: [], + }); + + expect(await run()).toEqual([ + "", + "AI tools:", + " claude (v5.2.2): 12 files, 1 merge files", + "", + "Drift:", + "AI tools:", + " claude (v5.2.2): in sync", + "IDE tools:", + " (none installed)", + "Plugins:", + " (all in sync)", + "", + "Installation is healthy", + ]); + }); + + it("surfaces the report's own errors before anything it could measure", async () => { + doctorAll.mockResolvedValue({ + errors: [{ scope: "claude", message: "settings.json is unreadable" }], + ai: HEALTHY_SCOPE, + ide: HEALTHY_SCOPE, + pluginIssues: [], + healthy: true, + }); + + await run(); + + expect(errors.join("")).toBe("Warning: [claude] settings.json is unreadable\n"); + }); + + it("fails the process on an unhealthy install rather than calling it healthy", async () => { + doctorAll.mockResolvedValue({ + errors: [], + ai: { + toolHealth: [], + issues: [{ severity: "error", message: "claude is not registered", fix: "aidd sync" }], + }, + ide: HEALTHY_SCOPE, + pluginIssues: [], + healthy: false, + }); + const exit = vi.spyOn(process, "exit").mockImplementation(() => { + throw new Error("exited"); + }); + + await expect(run()).rejects.toThrow("exited"); + + expect(errors[0]).toBe("Error: claude is not registered\n Fix: aidd sync\n"); + expect(written.join("")).not.toContain("healthy"); + expect(exit).toHaveBeenCalledWith(1); + }); + + it("narrows the health gate to the named plugin, and holds its scope issues back", async () => { + doctorAll.mockResolvedValue({ + errors: [], + ai: { + toolHealth: [], + issues: [{ severity: "error", message: "claude is not registered", fix: "aidd sync" }], + }, + ide: HEALTHY_SCOPE, + pluginIssues: [], + healthy: false, + }); + + const lines = await run("--plugin", "aidd-dev"); + + expect(doctorAll).toHaveBeenCalledWith(PROJECT_ROOT, "aidd-dev"); + expect(errors.join("")).toBe(""); + expect(lines[lines.length - 1]).toBe("Installation is healthy"); + }); + + it("fails when the named plugin is the thing that is broken", async () => { + doctorAll.mockResolvedValue({ + errors: [], + ai: HEALTHY_SCOPE, + ide: HEALTHY_SCOPE, + pluginIssues: [ + { + pluginName: "aidd-dev", + toolId: "claude", + issue: "drifted", + filePath: ".claude/a.md", + }, + ], + healthy: true, + }); + const exit = vi.spyOn(process, "exit").mockImplementation(() => { + throw new Error("exited"); + }); + + await expect(run("--plugin", "aidd-dev")).rejects.toThrow("exited"); + + expect(errors[0]).toBe( + "Error: Plugin aidd-dev (claude): drifted — .claude/a.md\n Fix: Run `aidd sync`\n" + ); + expect(exit).toHaveBeenCalledWith(1); + }); +}); + +describe("aidd doctor — the two categories, told apart", () => { + it("labels each category's inventory and its issues by that category's own name", async () => { + doctorAll.mockResolvedValue({ + errors: [], + ai: { + toolHealth: [{ toolId: "claude", fileCount: 1, mergeFileCount: 0 }], + issues: [{ severity: "info", message: "claude has never run", fix: "run claude" }], + }, + ide: { + toolHealth: [{ toolId: "vscode", fileCount: 2, mergeFileCount: 0 }], + issues: [{ severity: "info", message: "vscode has never run", fix: "run vscode" }], + }, + pluginIssues: [], + healthy: true, + }); + + expect(await run()).toEqual([ + "", + "AI tools:", + " claude (vunknown): 1 files, 0 merge files", + "", + "IDE tools:", + " vscode (vunknown): 2 files, 0 merge files", + "", + "Drift:", + "AI tools:", + " (none installed)", + "IDE tools:", + " (none installed)", + "Plugins:", + " (all in sync)", + "", + "AI:", + "", + "IDE:", + "", + "Installation is healthy", + ]); + expect(errors.join("")).toBe( + "Warning: claude has never run\n Fix: run claude\n" + + "Warning: vscode has never run\n Fix: run vscode\n" + ); + }); +}); + +describe("aidd doctor --tool", () => { + it("asks the category the tool belongs to, then narrows the inventory to that tool", async () => { + doctorScoped.mockResolvedValue({ + toolHealth: [ + { toolId: "claude", fileCount: 12, mergeFileCount: 1 }, + { toolId: "codex", fileCount: 9, mergeFileCount: 0 }, + ], + issues: [], + pluginIssues: [], + healthy: true, + }); + statusScoped.mockResolvedValue({ + tools: [{ toolId: "claude", version: "5.2.2", drifted: [] }], + pluginDrift: [], + }); + + expect(await run("--tool", "claude")).toEqual([ + "", + "claude tools:", + " claude (v5.2.2): 12 files, 1 merge files", + "", + "Drift:", + " claude (v5.2.2): in sync", + "Plugins:", + " (all in sync)", + "", + "Installation is healthy", + ]); + expect(doctorScoped).toHaveBeenCalledWith({ + projectRoot: PROJECT_ROOT, + category: "ai", + pluginName: undefined, + }); + expect(statusScoped).toHaveBeenCalledWith({ + projectRoot: PROJECT_ROOT, + filterToolId: "claude", + pluginName: undefined, + }); + expect(doctorAll).not.toHaveBeenCalled(); + }); + + it("calls an IDE tool an IDE tool when it asks its category", async () => { + await run("--tool", "vscode"); + + expect(doctorScoped).toHaveBeenCalledWith(expect.objectContaining({ category: "ide" })); + }); + + it("carries the named plugin into both reads", async () => { + await run("--tool", "claude", "--plugin", "aidd-dev"); + + expect(doctorScoped).toHaveBeenCalledWith(expect.objectContaining({ pluginName: "aidd-dev" })); + expect(statusScoped).toHaveBeenCalledWith(expect.objectContaining({ pluginName: "aidd-dev" })); + }); + + it("names that tool's own scope issues when no plugin narrows the run", async () => { + doctorScoped.mockResolvedValue({ + toolHealth: [], + issues: [{ severity: "error", message: "claude is not registered", fix: "aidd sync" }], + pluginIssues: [], + healthy: false, + }); + vi.spyOn(process, "exit").mockImplementation(() => { + throw new Error("exited"); + }); + + await expect(run("--tool", "claude")).rejects.toThrow("exited"); + + expect(errors[0]).toBe("Error: claude is not registered\n Fix: aidd sync\n"); + }); + + it("fails the process when that one tool's category is unhealthy", async () => { + doctorScoped.mockResolvedValue({ + toolHealth: [], + issues: [], + pluginIssues: [], + healthy: false, + }); + const exit = vi.spyOn(process, "exit").mockImplementation(() => { + throw new Error("exited"); + }); + + await expect(run("--tool", "claude")).rejects.toThrow("exited"); + + expect(exit).toHaveBeenCalledWith(1); + }); +}); + +describe("aidd doctor --tool with a plugin named", () => { + it("holds that tool's scope issues back, and gates on the plugin alone", async () => { + doctorScoped.mockResolvedValue({ + toolHealth: [], + issues: [{ severity: "error", message: "claude is not registered", fix: "aidd sync" }], + pluginIssues: [], + healthy: false, + }); + + const lines = await run("--tool", "claude", "--plugin", "aidd-dev"); + + expect(errors.join("")).toBe(""); + expect(lines[lines.length - 1]).toBe("Installation is healthy"); + }); + + it("fails when that one plugin is the thing that is broken", async () => { + doctorScoped.mockResolvedValue({ + toolHealth: [], + issues: [], + pluginIssues: [ + { pluginName: "aidd-dev", toolId: "claude", issue: "not-installed-on-machine" }, + ], + healthy: true, + }); + vi.spyOn(process, "exit").mockImplementation(() => { + throw new Error("exited"); + }); + + await expect(run("--tool", "claude", "--plugin", "aidd-dev")).rejects.toThrow("exited"); + + expect(errors[0]).toBe( + "Error: claude: plugins not installed on this machine, run `aidd sync`\n" + ); + }); +}); + +describe("aidd doctor --scope user", () => { + it("reads the machine-wide manifest and checks registrations alone", async () => { + expect(await run("--scope", "user")).toEqual([ + "User-scope tools:", + ` claude (v5.2.2): expects activation in ${userMachineLocalFilesOf("claude", "/home/dev", () => undefined)[0]}`, + "", + "User-scope installation is healthy", + ]); + expect(doctorRegistration).toHaveBeenCalledWith({ + manifest: expect.anything(), + projectRoot: PROJECT_ROOT, + allowedIds: null, + }); + expect(doctorAll).not.toHaveBeenCalled(); + }); + + it("narrows the registration check to the one tool named", async () => { + await run("--scope", "user", "--tool", "claude"); + + expect(doctorRegistration.mock.calls[0][0].allowedIds).toEqual(new Set(["claude"])); + }); + + it("points at setup when nothing was ever registered machine-wide", async () => { + loadUserManifest.mockResolvedValue(null); + + expect(await run("--scope", "user")).toEqual([ + "Nothing registered at user scope yet — run `aidd setup --scope user` first.", + ]); + expect(doctorRegistration).not.toHaveBeenCalled(); + }); + + it("refuses a plugin filter user scope tracks nothing to narrow", async () => { + vi.spyOn(process, "exit").mockImplementation(() => { + throw new Error("exited"); + }); + + await expect(run("--scope", "user", "--plugin", "aidd-dev")).rejects.toThrow("exited"); + + expect(errors.join("")).toBe( + "Error: --scope user tracks nothing --plugin can narrow — it names every requested tool, " + + "not one plugin or one file. Drop --plugin, or run `aidd doctor --plugin ` at project scope.\n" + ); + expect(loadUserManifest).not.toHaveBeenCalled(); + }); + + it("fails the process on a registration error, and stays quiet about health", async () => { + doctorRegistration.mockResolvedValue([ + { severity: "error", message: "claude is not registered", fix: "aidd sync" }, + ]); + const exit = vi.spyOn(process, "exit").mockImplementation(() => { + throw new Error("exited"); + }); + + await expect(run("--scope", "user")).rejects.toThrow("exited"); + + expect(errors[0]).toBe("Error: claude is not registered\n Fix: aidd sync\n"); + expect(exit).toHaveBeenCalledWith(1); + }); + + it("refuses a scope it does not know before building anything", async () => { + vi.spyOn(process, "exit").mockImplementation(() => { + throw new Error("exited"); + }); + + await expect(run("--scope", "machine")).rejects.toThrow("exited"); + + expect(errors[0]).toBe('Error: Invalid --scope "machine" — expected "project" or "user".\n'); + expect(doctorAll).not.toHaveBeenCalled(); + }); +}); + +describe("aidd doctor --scope user — what a tool without user-scope settings reports", () => { + it("names the tool asked for, and says so when it has no user-scope settings file", async () => { + loadUserManifest.mockResolvedValue({ + getInstalledToolIds: () => ["claude"], + getToolVersion: () => undefined, + }); + + expect(await run("--scope", "user", "--tool", "cursor")).toEqual([ + "User-scope tools:", + " cursor (vunknown): expects activation in no user-scope settings file", + "", + "User-scope installation is healthy", + ]); + }); + + it("names a warning under its own heading, and still calls the machine healthy", async () => { + doctorRegistration.mockResolvedValue([ + { severity: "warning", message: "claude is ahead of this aidd", fix: "aidd update" }, + ]); + + const lines = await run("--scope", "user"); + + expect(lines).toContain("User scope:"); + expect(errors.join("")).toBe("Warning: claude is ahead of this aidd\n Fix: aidd update\n"); + expect(lines[lines.length - 1]).toBe("User-scope installation is healthy"); + }); +}); + +describe("aidd doctor — how it builds its graph and reports a failure", () => { + it("builds the graph for this project at this run's verbosity", async () => { + await run("--verbose"); + + expect(vi.mocked(createDeps)).toHaveBeenCalledWith( + PROJECT_ROOT, + { verbose: true }, + expect.anything() + ); + }); + + it("names a failed read on stderr and fails the process", async () => { + doctorAll.mockRejectedValue(new Error("manifest unreadable")); + const exit = vi.spyOn(process, "exit").mockImplementation(() => { + throw new Error("exited"); + }); + + await expect(run()).rejects.toThrow("exited"); + + expect(errors.join("")).toBe("Error: manifest unreadable\n"); + expect(exit).toHaveBeenCalledWith(1); + }); +}); + +describe("aidd doctor — the help surface", () => { + function doctorCommand(): Command { + const program = new Command(); + registerDoctorCommand(program); + const doctor = program.commands.find((command) => command.name() === "doctor"); + if (doctor === undefined) throw new Error("doctor command was not registered"); + return doctor; + } + + it("describes itself by what it reports on", () => { + expect(doctorCommand().description()).toBe( + "Detected and equipped tools, plugins, drift, and problems — across all tools or one" + ); + }); + + it("offers a tool, a plugin and a scope narrowing, and asks nothing else", () => { + expect(doctorCommand().options.map((option) => [option.flags, option.description])).toEqual([ + ["--tool ", "Limit to a specific AI or IDE tool"], + ["--plugin ", "Limit plugin checks to a specific plugin"], + [ + "--scope ", + "project (default) checks this project's own manifest; user checks the " + + "machine-wide manifest --scope user setup wrote", + ], + ]); + }); +}); diff --git a/cli/tests/presentation/commands/framework-options.unit.test.ts b/cli/tests/presentation/commands/framework-options.unit.test.ts new file mode 100644 index 000000000..2814ddbd1 --- /dev/null +++ b/cli/tests/presentation/commands/framework-options.unit.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, it } from "vitest"; +import { VALID_TOOL_IDS } from "../../../src/kernel/tool.js"; +import { assertKnownToolId } from "../../../src/presentation/commands/framework.js"; + +describe("assertKnownToolId", () => { + it("accepts an AI tool id", () => { + expect(() => assertKnownToolId("claude")).not.toThrow(); + }); + + it("accepts an IDE tool id", () => { + expect(() => assertKnownToolId("vscode")).not.toThrow(); + }); + + it("refuses an unknown id and lists every one it would have taken", () => { + expect(() => assertKnownToolId("emacs")).toThrow( + `Unknown tool: emacs. Valid tools: ${VALID_TOOL_IDS.join(", ")}` + ); + }); + + it("refuses an empty id rather than treating it as no filter", () => { + expect(() => assertKnownToolId("")).toThrow("Unknown tool: ."); + }); +}); diff --git a/cli/tests/presentation/commands/framework-wiring.integration.test.ts b/cli/tests/presentation/commands/framework-wiring.integration.test.ts new file mode 100644 index 000000000..277bb2f70 --- /dev/null +++ b/cli/tests/presentation/commands/framework-wiring.integration.test.ts @@ -0,0 +1,435 @@ +import { Command } from "commander"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { VALID_TOOL_IDS } from "../../../src/kernel/tool.js"; + +const installAiTool = vi.fn(); +const installIdeTool = vi.fn(); +const uninstallAiTools = vi.fn(); +const uninstallIdeTool = vi.fn(); +const updateAiTools = vi.fn(); +const updateIdeTools = vi.fn(); +const listInstalledRules = vi.fn(); +const loadManifest = vi.fn(); +const currentVersion = vi.fn(() => "5.2.2"); + +vi.mock("../../../src/runtime/wiring/framework.js", () => ({ + createDeps: vi.fn(async () => ({ + installAiToolUseCase: { execute: installAiTool }, + installIdeToolUseCase: { execute: installIdeTool }, + uninstallUseCase: { execute: uninstallAiTools }, + uninstallIdeUseCase: { execute: uninstallIdeTool }, + updateAiToolsUseCase: { execute: updateAiTools }, + updateIdeToolsUseCase: { execute: updateIdeTools }, + listInstalledRulesUseCase: { execute: listInstalledRules }, + manifestRepo: { load: loadManifest }, + currentVersionProvider: { get: currentVersion }, + })), + createMenuDeps: vi.fn(), +})); + +const { createDeps } = await import("../../../src/runtime/wiring/framework.js"); +const { registerFrameworkCommand } = await import( + "../../../src/presentation/commands/framework.js" +); + +const PROJECT_ROOT = process.cwd(); + +let written: string[] = []; +let errors: string[] = []; + +function pretendTerminal(isTTY: boolean): void { + Object.defineProperty(process.stdout, "isTTY", { value: isTTY, configurable: true }); +} + +beforeEach(() => { + vi.clearAllMocks(); + written = []; + errors = []; + pretendTerminal(false); + vi.spyOn(process.stdout, "write").mockImplementation((chunk) => { + written.push(String(chunk)); + return true; + }); + vi.spyOn(process.stderr, "write").mockImplementation((chunk) => { + errors.push(String(chunk)); + return true; + }); + currentVersion.mockReturnValue("5.2.2"); + installAiTool.mockResolvedValue({ + runtimeResult: { skipped: false, fileCount: 5, warnings: [] }, + propagationWarnings: [], + activation: undefined, + }); + installIdeTool.mockResolvedValue({ + skipped: false, + toolId: "vscode", + fileCount: 2, + warnings: [], + }); + uninstallAiTools.mockResolvedValue([{ toolId: "claude", fileCount: 4 }]); + uninstallIdeTool.mockResolvedValue({ toolId: "vscode", fileCount: 1 }); + updateAiTools.mockResolvedValue({ updatedTools: [], errors: [] }); + updateIdeTools.mockResolvedValue({ updatedTools: [], errors: [] }); + listInstalledRules.mockResolvedValue({ rules: [] }); + loadManifest.mockResolvedValue(null); +}); + +afterEach(() => { + vi.restoreAllMocks(); + process.exitCode = undefined; +}); + +async function run(...args: string[]): Promise { + const program = new Command(); + program.exitOverride(); + program.option("--verbose"); + registerFrameworkCommand(program); + await program.parseAsync(["node", "aidd", "framework", ...args]); + return written.join("").split("\n").slice(0, -1); +} + +describe("aidd framework install", () => { + it("installs an AI tool at the version the graph reports, plugins carried along", async () => { + expect(await run("install", "--tool", "claude")).toEqual(["Installed claude (5 files)"]); + expect(installAiTool).toHaveBeenCalledWith({ + toolId: "claude", + projectRoot: PROJECT_ROOT, + force: false, + version: "5.2.2", + propagatePlugins: true, + }); + expect(installIdeTool).not.toHaveBeenCalled(); + }); + + it("leaves the already-installed alone, and names the flag that would redo it", async () => { + installAiTool.mockResolvedValue({ + runtimeResult: { skipped: true, fileCount: 0, warnings: [] }, + propagationWarnings: [], + }); + + expect(await run("install", "--tool", "claude")).toEqual([]); + expect(errors.join("")).toBe( + "Warning: claude is already installed. Use `--force` to reinstall.\n" + ); + }); + + it("prints the runtime's own warnings before the propagation's, then the count", async () => { + installAiTool.mockResolvedValue({ + runtimeResult: { skipped: false, fileCount: 5, warnings: ["merged settings.json"] }, + propagationWarnings: ["aidd-dev was not propagated"], + }); + + expect(await run("install", "--tool", "claude")).toEqual(["Installed claude (5 files)"]); + expect(errors.join("")).toBe( + "Warning: merged settings.json\nWarning: aidd-dev was not propagated\n" + ); + }); + + it("carries an overwrite and a refused propagation through", async () => { + await run("install", "--tool", "claude", "--force", "--no-plugins"); + + expect(installAiTool).toHaveBeenCalledWith( + expect.objectContaining({ force: true, propagatePlugins: false }) + ); + }); + + it("reports the activation an install already drove, and fails on a refusal", async () => { + installAiTool.mockResolvedValue({ + runtimeResult: { skipped: false, fileCount: 5, warnings: [] }, + propagationWarnings: [], + activation: { errors: [{ scope: "claude", message: "claude CLI refused" }] }, + }); + const exit = vi.spyOn(process, "exit").mockImplementation(() => { + throw new Error("exited"); + }); + + await expect(run("install", "--tool", "claude")).rejects.toThrow("exited"); + + expect(errors.join("")).toBe( + "Warning: [claude] claude CLI refused\nError: Sync failed for: claude. See the warnings above.\n" + ); + expect(exit).toHaveBeenCalledWith(1); + }); + + it("sends an IDE tool down its own install, with the manifest it loaded", async () => { + expect(await run("install", "--tool", "vscode")).toEqual(["Installed vscode (2 files)"]); + expect(installIdeTool).toHaveBeenCalledWith({ + toolId: "vscode", + projectRoot: PROJECT_ROOT, + manifest: expect.anything(), + force: false, + version: "5.2.2", + }); + expect(installAiTool).not.toHaveBeenCalled(); + }); + + it("leaves an already-installed IDE tool alone too", async () => { + installIdeTool.mockResolvedValue({ + skipped: true, + toolId: "vscode", + fileCount: 0, + warnings: [], + }); + + expect(await run("install", "--tool", "vscode")).toEqual([]); + expect(errors.join("")).toBe( + "Warning: vscode is already installed. Use `--force` to reinstall.\n" + ); + }); + + it("refuses a tool neither category declares, and lists every one it would have taken", async () => { + vi.spyOn(process, "exit").mockImplementation(() => { + throw new Error("exited"); + }); + + await expect(run("install", "--tool", "emacs")).rejects.toThrow("exited"); + + expect(errors.join("")).toBe( + `Error: Unknown tool: emacs. Valid tools: ${VALID_TOOL_IDS.join(", ")}\n` + ); + expect(installAiTool).not.toHaveBeenCalled(); + }); +}); + +describe("aidd framework remove", () => { + it("removes one AI tool with no MCP narrowing, and counts every file that went", async () => { + uninstallAiTools.mockResolvedValue([ + { toolId: "claude", fileCount: 4 }, + { toolId: "claude", fileCount: 3 }, + ]); + + expect(await run("remove", "--tool", "claude")).toEqual(["Removed claude (7 files removed)"]); + expect(uninstallAiTools).toHaveBeenCalledWith({ + toolIds: ["claude"], + projectRoot: PROJECT_ROOT, + mcpFilter: [], + }); + expect(uninstallIdeTool).not.toHaveBeenCalled(); + }); + + it("names a failed removal on stderr and fails the process", async () => { + uninstallAiTools.mockRejectedValue(new Error("manifest is locked")); + const exit = vi.spyOn(process, "exit").mockImplementation(() => { + throw new Error("exited"); + }); + + await expect(run("remove", "--tool", "claude")).rejects.toThrow("exited"); + + expect(errors.join("")).toBe("Error: manifest is locked\n"); + expect(exit).toHaveBeenCalledWith(1); + }); + + it("sends an IDE tool down its own removal", async () => { + expect(await run("remove", "--tool", "vscode")).toEqual(["Removed vscode (1 files removed)"]); + expect(uninstallIdeTool).toHaveBeenCalledWith({ + toolId: "vscode", + projectRoot: PROJECT_ROOT, + }); + expect(uninstallAiTools).not.toHaveBeenCalled(); + }); +}); + +describe("aidd framework update", () => { + it("fans out across both categories when no tool was named", async () => { + updateAiTools.mockResolvedValue({ + updatedTools: [{ toolId: "claude", fileCount: 5 }], + errors: [], + }); + updateIdeTools.mockResolvedValue({ + updatedTools: [{ toolId: "vscode", fileCount: 2 }], + errors: [{ scope: "vscode", message: "one file was kept" }], + }); + + expect(await run("update")).toEqual(["Updated claude (5 files)", "Updated vscode (2 files)"]); + expect(updateAiTools).toHaveBeenCalledWith({ + projectRoot: PROJECT_ROOT, + userForce: false, + interactive: false, + }); + expect(updateIdeTools).toHaveBeenCalledWith({ + projectRoot: PROJECT_ROOT, + userForce: false, + interactive: false, + }); + expect(errors.join("")).toBe("Warning: [vscode] one file was kept\n"); + }); + + it("says nothing is installed when neither category moved and neither failed", async () => { + expect(await run("update")).toEqual(["No tools installed."]); + }); + + it("narrows to the one AI tool named, leaving the IDE sweep unrun", async () => { + await run("update", "--tool", "claude", "--force"); + + expect(updateAiTools).toHaveBeenCalledWith({ + toolArg: "claude", + projectRoot: PROJECT_ROOT, + userForce: true, + interactive: false, + }); + expect(updateIdeTools).not.toHaveBeenCalled(); + }); + + it("narrows to the one IDE tool named, leaving the AI sweep unrun", async () => { + await run("update", "--tool", "vscode"); + + expect(updateIdeTools).toHaveBeenCalledWith({ + toolArg: "vscode", + projectRoot: PROJECT_ROOT, + userForce: false, + interactive: false, + }); + expect(updateAiTools).not.toHaveBeenCalled(); + }); + + it("names a failed update on stderr and fails the process", async () => { + updateAiTools.mockRejectedValue(new Error("bundled assets are missing")); + const exit = vi.spyOn(process, "exit").mockImplementation(() => { + throw new Error("exited"); + }); + + await expect(run("update")).rejects.toThrow("exited"); + + expect(errors.join("")).toBe("Error: bundled assets are missing\n"); + expect(exit).toHaveBeenCalledWith(1); + }); + + it("tells the update a terminal is watching", async () => { + pretendTerminal(true); + + await run("update"); + + expect(updateAiTools).toHaveBeenCalledWith(expect.objectContaining({ interactive: true })); + }); + + it("treats a stream that says nothing about being a terminal as none", async () => { + Object.defineProperty(process.stdout, "isTTY", { value: undefined, configurable: true }); + + await run("update"); + + expect(updateAiTools).toHaveBeenCalledWith(expect.objectContaining({ interactive: false })); + }); + + it("refuses a tool it does not know before asking either updater", async () => { + const exit = vi.spyOn(process, "exit").mockImplementation(() => { + throw new Error("exited"); + }); + + await expect(run("update", "--tool", "nope")).rejects.toThrow("exited"); + + expect(errors[0]).toBe( + `Error: Unknown tool: nope. Valid tools: ${VALID_TOOL_IDS.join(", ")}\n` + ); + expect(updateAiTools).not.toHaveBeenCalled(); + expect(updateIdeTools).not.toHaveBeenCalled(); + expect(exit).toHaveBeenCalledWith(1); + }); +}); + +describe("aidd framework rules", () => { + it("reads this project's rules and says so when there are none", async () => { + expect(await run("rules")).toEqual(["No rules installed for any AI tool."]); + expect(listInstalledRules).toHaveBeenCalledWith({ projectRoot: PROJECT_ROOT }); + }); + + it("prints the inventory as text a person reads", async () => { + listInstalledRules.mockResolvedValue({ + rules: [{ tool: "claude", path: ".claude/rules/a.md", description: "", paths: undefined }], + }); + + expect(await run("rules")).toEqual([ + "claude .claude/rules/a.md", + " (no description)", + " applies to: every file", + ]); + }); + + it("prints the same inventory as the JSON a program reads", async () => { + const rules = [ + { tool: "claude", path: ".claude/rules/a.md", description: "a rule", paths: ["src/**"] }, + ]; + listInstalledRules.mockResolvedValue({ rules }); + + expect((await run("rules", "--json")).join("\n")).toBe(JSON.stringify(rules, null, 2)); + }); +}); + +describe("aidd framework — how every subcommand builds its graph and reports a failure", () => { + it.each([["install", "--tool", "claude"], ["remove", "--tool", "claude"], ["update"], ["rules"]])( + "hands %j this run's verbosity, never an empty option set", + async (...args) => { + await run(...args); + + expect(vi.mocked(createDeps)).toHaveBeenCalledWith( + PROJECT_ROOT, + { verbose: false }, + expect.anything() + ); + } + ); + + it("names a failed rules read on stderr and fails the process", async () => { + listInstalledRules.mockRejectedValue(new Error("manifest unreadable")); + const exit = vi.spyOn(process, "exit").mockImplementation(() => { + throw new Error("exited"); + }); + + await expect(run("rules")).rejects.toThrow("exited"); + + expect(errors.join("")).toBe("Error: manifest unreadable\n"); + expect(exit).toHaveBeenCalledWith(1); + }); +}); + +describe("aidd framework — the help surface", () => { + function frameworkCommand(): Command { + const program = new Command(); + registerFrameworkCommand(program); + const framework = program.commands.find((command) => command.name() === "framework"); + if (framework === undefined) throw new Error("framework command was not registered"); + return framework; + } + + function optionsOf(name: string): [string, string | undefined, boolean][] { + const child = frameworkCommand().commands.find((candidate) => candidate.name() === name); + if (child === undefined) throw new Error(`no subcommand ${name}`); + return child.options.map((option) => [option.flags, option.description, option.mandatory]); + } + + it("describes the group and every subcommand against the command it is confused with", () => { + expect(frameworkCommand().description()).toBe( + "Manage the framework's lifecycle on installed tools" + ); + expect( + frameworkCommand().commands.map((command) => [command.name(), command.description()]) + ).toEqual([ + [ + "install", + "Install a tool's runtime configuration from bundled assets — acts on the framework alone (see `setup`, which bootstraps the whole project)", + ], + [ + "remove", + "Remove a tool's generated configuration files — removes the framework only (see `clean`, which removes all of AIDD)", + ], + [ + "update", + "Re-install tool configs from bundled CLI assets, moving to a new version (all installed tools if --tool is omitted; see `marketplace refresh`, which re-fetches catalogs instead)", + ], + ["rules", "List the rules installed in this project, across every AI tool"], + ]); + }); + + it("requires a tool of install and remove, and leaves it optional on update", () => { + expect(optionsOf("install")).toEqual([ + ["--tool ", "AI or IDE tool ID", true], + ["-f, --force", "Overwrite already-installed tool", false], + ["--no-plugins", "Skip propagation of already-installed plugins onto the new tool", false], + ]); + expect(optionsOf("remove")).toEqual([["--tool ", "AI or IDE tool ID", true]]); + expect(optionsOf("update")).toEqual([ + ["--tool ", "Limit update to a specific AI or IDE tool", false], + ["-f, --force", "Overwrite modified files without prompting", false], + ]); + expect(optionsOf("rules")).toEqual([["--json", "Print the inventory as JSON", false]]); + }); +}); diff --git a/cli/tests/presentation/commands/global-options.unit.test.ts b/cli/tests/presentation/commands/global-options.unit.test.ts new file mode 100644 index 000000000..a9e9e1a54 --- /dev/null +++ b/cli/tests/presentation/commands/global-options.unit.test.ts @@ -0,0 +1,56 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { parseScopeFlag } from "../../../src/presentation/commands/global-options.js"; +import type { CLIOutput } from "../../../src/presentation/output.js"; + +function createMockOutput(): CLIOutput { + return { + verbose: false, + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + print: vi.fn(), + success: vi.fn(), + error: vi.fn(), + }; +} + +/** `process.exit` returns `never`, so a double that returns normally lets a test walk through + * code the real process would never reach: throwing is what "does not return" looks like. */ +class ProcessExited extends Error { + constructor(readonly code: number | string | null | undefined) { + super(`process.exit(${String(code)})`); + } +} + +describe("parseScopeFlag()", () => { + let output: CLIOutput; + + beforeEach(() => { + output = createMockOutput(); + vi.spyOn(process, "exit").mockImplementation((code) => { + throw new ProcessExited(code); + }); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("returns undefined when no flag was given", () => { + expect(parseScopeFlag(undefined, output)).toBeUndefined(); + expect(process.exit).not.toHaveBeenCalled(); + }); + + it("passes project through", () => { + expect(parseScopeFlag("project", output)).toBe("project"); + }); + + it("passes user through", () => { + expect(parseScopeFlag("user", output)).toBe("user"); + }); + + it("exits 1 with an instructive message on anything else", () => { + expect(() => parseScopeFlag("machine", output)).toThrow(ProcessExited); + expect(output.error).toHaveBeenCalledWith(expect.stringContaining('"machine"')); + }); +}); diff --git a/cli/tests/presentation/commands/marketplace-add-narrows.integration.test.ts b/cli/tests/presentation/commands/marketplace-add-narrows.integration.test.ts new file mode 100644 index 000000000..8c135dcdd --- /dev/null +++ b/cli/tests/presentation/commands/marketplace-add-narrows.integration.test.ts @@ -0,0 +1,36 @@ +// `marketplace add ` re-drives native activation narrowed to the marketplace it just +// registered; `marketplace remove | refresh` deliberately keep the bare, unnarrowed call. +import { Command } from "commander"; +import { describe, expect, it, vi } from "vitest"; + +const marketplaceSyncExecute = vi + .fn() + .mockResolvedValue({ activated: [], binaryMissing: [], warnings: [], errors: [] }); +const marketplaceAddExecute = vi.fn().mockResolvedValue({ marketplace: { name: "market-b" } }); + +vi.mock("../../../src/runtime/wiring/framework.js", () => ({ + createDeps: vi.fn(async () => ({ + marketplaceAddUseCase: { execute: marketplaceAddExecute }, + marketplaceSyncSettingsUseCase: { execute: marketplaceSyncExecute }, + prompter: { input: vi.fn() }, + })), + createMenuDeps: vi.fn(() => ({ prompter: { input: vi.fn(), select: vi.fn() } })), +})); + +const { registerMarketplaceCommand } = await import( + "../../../src/presentation/commands/marketplace.js" +); + +describe("marketplace add narrows native activation to the marketplace it just registered", () => { + it("passes marketplaceNames: [name] to MarketplaceSyncSettingsUseCase.execute", async () => { + const program = new Command(); + program.exitOverride(); + registerMarketplaceCommand(program); + + await program.parseAsync(["node", "aidd", "marketplace", "add", "market-b", "/some/source"]); + + expect(marketplaceSyncExecute).toHaveBeenCalledWith( + expect.objectContaining({ marketplaceNames: ["market-b"] }) + ); + }); +}); diff --git a/cli/tests/presentation/commands/marketplace-wiring.integration.test.ts b/cli/tests/presentation/commands/marketplace-wiring.integration.test.ts new file mode 100644 index 000000000..4966292e1 --- /dev/null +++ b/cli/tests/presentation/commands/marketplace-wiring.integration.test.ts @@ -0,0 +1,415 @@ +import { Command } from "commander"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { parsePluginSourceShorthand } from "../../../src/kernel/source.js"; + +const marketplaceAdd = vi.fn(); +const marketplaceList = vi.fn(); +const marketplaceRemove = vi.fn(); +const marketplaceRefresh = vi.fn(); +const marketplaceCheck = vi.fn(); +const activation = vi.fn(); +const promptInput = vi.fn(); +const menuSelect = vi.fn(); +const spawn = vi.fn(); + +vi.mock("../../../src/runtime/wiring/framework.js", () => ({ + createDeps: vi.fn(async () => ({ + marketplaceAddUseCase: { execute: marketplaceAdd }, + marketplaceListUseCase: { execute: marketplaceList }, + marketplaceRemoveUseCase: { execute: marketplaceRemove }, + marketplaceRefreshUseCase: { execute: marketplaceRefresh }, + marketplaceCheckUseCase: { execute: marketplaceCheck }, + marketplaceSyncSettingsUseCase: { execute: activation }, + prompter: { input: promptInput }, + })), + createMenuDeps: vi.fn(() => ({ prompter: { select: menuSelect } })), +})); + +vi.mock("../../../src/presentation/commands/spawn-cli-command.js", () => ({ + spawnCliCommand: spawn, +})); + +const { createDeps } = await import("../../../src/runtime/wiring/framework.js"); +const { registerMarketplaceCommand } = await import( + "../../../src/presentation/commands/marketplace.js" +); + +const PROJECT_ROOT = process.cwd(); + +let written: string[] = []; +let errors: string[] = []; +let tokenBefore: string | undefined; + +function pretendTerminal(isTTY: boolean): void { + Object.defineProperty(process.stdout, "isTTY", { value: isTTY, configurable: true }); +} + +beforeEach(() => { + vi.clearAllMocks(); + written = []; + errors = []; + tokenBefore = process.env.AIDD_TOKEN; + pretendTerminal(false); + vi.spyOn(process.stdout, "write").mockImplementation((chunk) => { + written.push(String(chunk)); + return true; + }); + vi.spyOn(process.stderr, "write").mockImplementation((chunk) => { + errors.push(String(chunk)); + return true; + }); + marketplaceAdd.mockResolvedValue({ marketplace: { name: "market-b" } }); + marketplaceList.mockResolvedValue({ marketplaces: [], catalogs: undefined }); + marketplaceRemove.mockResolvedValue({ + marketplace: { name: "market-b" }, + removedPluginCount: 2, + }); + marketplaceRefresh.mockResolvedValue({ + results: [{ name: "market-b", status: "refreshed" }], + failedCount: 0, + }); + marketplaceCheck.mockResolvedValue({ stale: [], upstreamRemoved: [], skipped: [] }); + activation.mockResolvedValue({ binaryMissing: [], errors: [] }); + promptInput.mockResolvedValueOnce("asked-name").mockResolvedValueOnce("asked/source"); + menuSelect.mockResolvedValue("list"); + spawn.mockResolvedValue(0); +}); + +afterEach(() => { + vi.restoreAllMocks(); + if (tokenBefore === undefined) delete process.env.AIDD_TOKEN; + else process.env.AIDD_TOKEN = tokenBefore; + process.exitCode = undefined; +}); + +async function run(...args: string[]): Promise { + const program = new Command(); + program.exitOverride(); + program.option("--verbose"); + registerMarketplaceCommand(program); + await program.parseAsync(["node", "aidd", "marketplace", ...args]); + return written.join("").split("\n").slice(0, -1); +} + +describe("aidd marketplace — the group with no subcommand", () => { + it("offers the five things a person can do, then re-runs itself with the pick", async () => { + pretendTerminal(true); + + await run(); + + expect(menuSelect).toHaveBeenCalledWith("marketplace: what do you want to do?", [ + { name: "List marketplaces", value: "list" }, + { name: "Add marketplace", value: "add" }, + { name: "Refresh marketplaces", value: "refresh" }, + { name: "Remove marketplace", value: "remove", description: "requires name arg" }, + { name: "Check marketplaces", value: "check" }, + ]); + expect(spawn).toHaveBeenCalledWith(["marketplace", "list"]); + }); + + it("prints its own help off a terminal rather than asking a question nobody can answer", async () => { + await expect(run()).rejects.toThrow("(outputHelp)"); + + expect(written.join("").split("\n")[0]).toBe("Usage: aidd marketplace [options] [command]"); + expect(menuSelect).not.toHaveBeenCalled(); + }); +}); + +describe("aidd marketplace add", () => { + it("registers at project scope by default, then activates that marketplace alone", async () => { + expect(await run("add", "market-b", "/some/source")).toEqual([ + "Marketplace 'market-b' registered.", + ]); + expect(marketplaceAdd).toHaveBeenCalledWith({ + source: parsePluginSourceShorthand("/some/source"), + name: "market-b", + scope: "project", + projectRoot: PROJECT_ROOT, + autoTrust: false, + overwrite: false, + }); + expect(activation).toHaveBeenCalledWith({ + projectRoot: PROJECT_ROOT, + marketplaceNames: ["market-b"], + }); + }); + + it("registers machine-wide when the scope says user", async () => { + await run("add", "market-b", "/some/source", "--scope", "user"); + + expect(marketplaceAdd).toHaveBeenCalledWith(expect.objectContaining({ scope: "user" })); + }); + + it("carries a skipped prompt and an allowed replacement through", async () => { + await run("add", "market-b", "/some/source", "--yes", "--overwrite"); + + expect(marketplaceAdd).toHaveBeenCalledWith( + expect.objectContaining({ autoTrust: true, overwrite: true }) + ); + }); + + it("puts the given token where the fetch will read it", async () => { + await run("add", "market-b", "/some/source", "--token", "ghp_x"); + + expect(process.env.AIDD_TOKEN).toBe("ghp_x"); + }); + + it("asks for the name and the source it was not given, on a terminal", async () => { + pretendTerminal(true); + + await run("add"); + + expect(promptInput.mock.calls).toEqual([ + ["Marketplace name:"], + ["Source (path or user/repo):"], + ]); + expect(marketplaceAdd).toHaveBeenCalledWith(expect.objectContaining({ name: "asked-name" })); + }); + + it("refuses to guess a name or a source off a terminal", async () => { + const exit = vi.spyOn(process, "exit").mockImplementation(() => { + throw new Error("exited"); + }); + + await expect(run("add", "market-b")).rejects.toThrow("exited"); + + expect(errors[0]).toBe("Error: name and source are required in non-interactive mode.\n"); + expect(exit).toHaveBeenCalledWith(1); + expect(vi.mocked(createDeps)).not.toHaveBeenCalled(); + }); + + it("refuses a scope that is neither project nor user", async () => { + vi.spyOn(process, "exit").mockImplementation(() => { + throw new Error("exited"); + }); + + await expect(run("add", "market-b", "/some/source", "--scope", "machine")).rejects.toThrow( + "exited" + ); + + expect(errors[0]).toBe("Error: Invalid --scope 'machine'. Expected 'project' or 'user'.\n"); + expect(vi.mocked(createDeps)).not.toHaveBeenCalled(); + }); +}); + +it("leaves the token environment alone when none was given", async () => { + delete process.env.AIDD_TOKEN; + + await run("add", "market-b", "/some/source"); + + expect(process.env.AIDD_TOKEN).toBeUndefined(); +}); + +it("names a failed registration on stderr and fails the process", async () => { + marketplaceAdd.mockRejectedValue(new Error("source is untrusted")); + const exit = vi.spyOn(process, "exit").mockImplementation(() => { + throw new Error("exited"); + }); + + await expect(run("add", "market-b", "/some/source")).rejects.toThrow("exited"); + + expect(errors.join("")).toBe("Error: source is untrusted\n"); + expect(exit).toHaveBeenCalledWith(1); +}); +describe("aidd marketplace list", () => { + it("leaves the catalogs unfetched unless they were asked for", async () => { + expect(await run("list")).toEqual(["No marketplaces registered."]); + expect(marketplaceList).toHaveBeenCalledWith({ + projectRoot: PROJECT_ROOT, + withCatalogs: false, + }); + }); + + it("fetches every catalog when the plugins were asked for", async () => { + marketplaceList.mockResolvedValue({ + marketplaces: [{ name: "market-b", version: "1.2.3", scope: "project" }], + catalogs: new Map(), + }); + + expect(await run("list", "--plugins")).toEqual(["market-b v1.2.3 [project]"]); + expect(marketplaceList).toHaveBeenCalledWith({ + projectRoot: PROJECT_ROOT, + withCatalogs: true, + }); + expect(errors.join("")).toBe("Warning: (could not fetch catalog for 'market-b')\n"); + }); +}); + +describe("aidd marketplace — a failed read or removal", () => { + it("names a failed listing on stderr and fails the process", async () => { + marketplaceList.mockRejectedValue(new Error("registry is unreadable")); + const exit = vi.spyOn(process, "exit").mockImplementation(() => { + throw new Error("exited"); + }); + + await expect(run("list")).rejects.toThrow("exited"); + + expect(errors.join("")).toBe("Error: registry is unreadable\n"); + expect(exit).toHaveBeenCalledWith(1); + }); + + it("names a failed removal on stderr and fails the process", async () => { + marketplaceRemove.mockRejectedValue(new Error("marketplace is unknown")); + const exit = vi.spyOn(process, "exit").mockImplementation(() => { + throw new Error("exited"); + }); + + await expect(run("remove", "market-b")).rejects.toThrow("exited"); + + expect(errors.join("")).toBe("Error: marketplace is unknown\n"); + expect(exit).toHaveBeenCalledWith(1); + }); +}); + +describe("aidd marketplace remove", () => { + it("removes the named one, re-drives every activation, and counts what went with it", async () => { + expect(await run("remove", "market-b")).toEqual([ + "Marketplace 'market-b' removed (2 plugin(s) cleaned up).", + ]); + expect(marketplaceRemove).toHaveBeenCalledWith({ + name: "market-b", + projectRoot: PROJECT_ROOT, + autoConfirm: false, + }); + expect(activation).toHaveBeenCalledWith({ + projectRoot: PROJECT_ROOT, + marketplaceNames: undefined, + }); + }); + + it("carries a skipped orphan prompt through", async () => { + await run("remove", "market-b", "--yes"); + + expect(marketplaceRemove).toHaveBeenCalledWith(expect.objectContaining({ autoConfirm: true })); + }); +}); + +describe("aidd marketplace refresh", () => { + it("refreshes every registered marketplace when none was named", async () => { + expect(await run("refresh")).toEqual(["market-b: refreshed"]); + expect(marketplaceRefresh).toHaveBeenCalledWith({ + projectRoot: PROJECT_ROOT, + name: undefined, + force: undefined, + }); + }); + + it("narrows to one name and clears the cache when forced", async () => { + await run("refresh", "market-b", "--force"); + + expect(marketplaceRefresh).toHaveBeenCalledWith({ + projectRoot: PROJECT_ROOT, + name: "market-b", + force: true, + }); + }); + + it("reports what each one did and fails the process when any failed", async () => { + marketplaceRefresh.mockResolvedValue({ + results: [{ name: "market-b", status: "failed", error: "404" }], + failedCount: 1, + }); + const exit = vi.spyOn(process, "exit").mockImplementation(() => { + throw new Error("exited"); + }); + + await expect(run("refresh")).rejects.toThrow("exited"); + + expect(written.join("")).toBe("market-b: failed (404)\n"); + expect(exit).toHaveBeenCalledWith(1); + }); +}); + +describe("aidd marketplace check", () => { + it("asks about this project alone, and says so when nothing is stale", async () => { + expect(await run("check")).toEqual(["All marketplaces fresh."]); + expect(marketplaceCheck).toHaveBeenCalledWith({ projectRoot: PROJECT_ROOT }); + }); +}); + +describe("aidd marketplace — how every subcommand builds its graph and reports a failure", () => { + it.each([ + ["add", "market-b", "/some/source"], + ["list"], + ["remove", "market-b"], + ["refresh"], + ["check"], + ])("hands %j this run's verbosity, never an empty option set", async (...args) => { + await run(...args); + + expect(vi.mocked(createDeps)).toHaveBeenCalledWith( + PROJECT_ROOT, + { verbose: false }, + expect.anything() + ); + }); + + it("names a failed check on stderr and fails the process", async () => { + marketplaceCheck.mockRejectedValue(new Error("catalog unreachable")); + const exit = vi.spyOn(process, "exit").mockImplementation(() => { + throw new Error("exited"); + }); + + await expect(run("check")).rejects.toThrow("exited"); + + expect(errors.join("")).toBe("Error: catalog unreachable\n"); + expect(exit).toHaveBeenCalledWith(1); + }); +}); + +describe("aidd marketplace — the help surface", () => { + function marketplaceCommand(): Command { + const program = new Command(); + registerMarketplaceCommand(program); + const marketplace = program.commands.find((command) => command.name() === "marketplace"); + if (marketplace === undefined) throw new Error("marketplace command was not registered"); + return marketplace; + } + + function optionsOf(name: string): [string, string | undefined, unknown][] { + const child = marketplaceCommand().commands.find((candidate) => candidate.name() === name); + if (child === undefined) throw new Error(`no subcommand ${name}`); + return child.options.map((option) => [option.flags, option.description, option.defaultValue]); + } + + it("describes the group and every subcommand, in the order they are registered", () => { + expect(marketplaceCommand().description()).toBe("Manage plugin marketplaces"); + expect( + marketplaceCommand().commands.map((command) => [ + command.name(), + command.usage(), + command.description(), + ]) + ).toEqual([ + ["add", "[options] [name] [source]", "Register a plugin marketplace"], + ["list", "[options]", "List registered plugin marketplaces"], + ["remove", "[options] ", "Remove a registered plugin marketplace"], + [ + "refresh", + "[options] [name]", + "Refresh registered marketplaces — re-fetches catalogs; see `framework update`, which moves installed tools to a new version instead", + ], + ["check", "[options]", "Report stale marketplaces and upstream-removed plugins"], + ]); + }); + + it("says what add may be told, and which of it has a default", () => { + expect(optionsOf("add")).toEqual([ + ["--scope ", "Registration scope (default: project)", "project"], + ["--yes", "Skip the trust + cleanup prompts", undefined], + ["--overwrite", "Replace an existing marketplace with the same name", undefined], + ["--token ", "Auth token (host detected from source URL at fetch time)", undefined], + ]); + }); + + it("says what list, remove and refresh each offer, and that check offers nothing", () => { + expect(optionsOf("list")).toEqual([ + ["--plugins", "Also fetch and print all plugins from each marketplace catalog", undefined], + ]); + expect(optionsOf("remove")).toEqual([["--yes", "Skip the orphan-cleanup prompt", undefined]]); + expect(optionsOf("refresh")).toEqual([ + ["--force", "Clear cache before re-fetching", undefined], + ]); + expect(optionsOf("check")).toEqual([]); + }); +}); diff --git a/cli/tests/application/commands/menu-error-routing.unit.test.ts b/cli/tests/presentation/commands/menu-error-routing.unit.test.ts similarity index 93% rename from cli/tests/application/commands/menu-error-routing.unit.test.ts rename to cli/tests/presentation/commands/menu-error-routing.unit.test.ts index 377109867..7b2ddb50f 100644 --- a/cli/tests/application/commands/menu-error-routing.unit.test.ts +++ b/cli/tests/presentation/commands/menu-error-routing.unit.test.ts @@ -1,6 +1,6 @@ import { afterEach, describe, expect, it, vi } from "vitest"; -import { routeMenuError } from "../../../src/application/commands/menu.js"; -import type { ErrorHandler } from "../../../src/application/error-handler.js"; +import { routeMenuError } from "../../../src/presentation/commands/menu.js"; +import type { ErrorHandler } from "../../../src/presentation/error-handler.js"; /** Makes the `never` return observable — both routing branches call process.exit. */ class ProcessExited extends Error { diff --git a/cli/tests/presentation/commands/menu-options.unit.test.ts b/cli/tests/presentation/commands/menu-options.unit.test.ts new file mode 100644 index 000000000..4d5d2d743 --- /dev/null +++ b/cli/tests/presentation/commands/menu-options.unit.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from "vitest"; +import { isUserAbort } from "../../../src/presentation/commands/menu.js"; + +describe("isUserAbort", () => { + it("recognises the error inquirer throws on Ctrl-C", () => { + expect(isUserAbort(Object.assign(new Error("aborted"), { name: "ExitPromptError" }))).toBe( + true + ); + }); + + it("reads any other Error as a genuine failure", () => { + expect(isUserAbort(new Error("manifest is corrupt"))).toBe(false); + }); + + it("reads the name, not the message, so a lookalike message is no abort", () => { + expect(isUserAbort(new Error("ExitPromptError"))).toBe(false); + }); + + it("reads a non-Error throw as a failure rather than an abort", () => { + expect(isUserAbort({ name: "ExitPromptError" })).toBe(false); + }); +}); diff --git a/cli/tests/presentation/commands/menu-wiring.integration.test.ts b/cli/tests/presentation/commands/menu-wiring.integration.test.ts new file mode 100644 index 000000000..60f756e83 --- /dev/null +++ b/cli/tests/presentation/commands/menu-wiring.integration.test.ts @@ -0,0 +1,159 @@ +import { afterEach, beforeEach, describe, expect, it, type MockInstance, vi } from "vitest"; + +const loadManifest = vi.fn(); +const promptSelect = vi.fn(); +const promptConfirm = vi.fn(); +const promptInput = vi.fn(); +const spawn = vi.fn(); +const question = vi.fn(); +const close = vi.fn(); + +vi.mock("../../../src/runtime/wiring/framework.js", () => ({ + createDeps: vi.fn(), + createMenuDeps: vi.fn(() => ({ + manifestRepo: { load: loadManifest }, + prompter: { select: promptSelect, confirm: promptConfirm, input: promptInput }, + })), +})); + +vi.mock("../../../src/presentation/commands/spawn-cli-command.js", () => ({ + spawnCliCommand: spawn, +})); + +vi.mock("node:readline", () => ({ + default: { createInterface: vi.fn(() => ({ question, close })) }, +})); + +const { createMenuDeps } = await import("../../../src/runtime/wiring/framework.js"); +const { runMenuLoop } = await import("../../../src/presentation/commands/menu.js"); +const { resolveProjectRoot } = await import("../../../src/runtime/project-root/project-root.js"); + +let written: string[] = []; +let errors: string[] = []; + +beforeEach(() => { + vi.clearAllMocks(); + written = []; + errors = []; + vi.spyOn(process.stdout, "write").mockImplementation((chunk) => { + written.push(String(chunk)); + return true; + }); + vi.spyOn(process.stderr, "write").mockImplementation((chunk) => { + errors.push(String(chunk)); + return true; + }); + question.mockImplementation((_prompt: string, callback: () => void) => { + callback(); + }); + loadManifest.mockResolvedValue(null); + promptConfirm.mockResolvedValue(false); + spawn.mockResolvedValue(0); +}); + +afterEach(() => { + vi.restoreAllMocks(); + process.exitCode = undefined; +}); + +/** The loop only ever ends by exiting the process, so the spy has to unwind it. */ +function exiting(): MockInstance { + return vi.spyOn(process, "exit").mockImplementation((code) => { + throw new Error(`exit ${String(code)}`); + }); +} + +async function loopUntilExit(): Promise { + await expect(runMenuLoop()).rejects.toThrow(/^exit /); +} + +describe("aidd with no argument — the menu loop", () => { + it("greets with the banner, built off the project the process was started in", async () => { + exiting(); + + await loopUntilExit(); + + expect(written[0].split("\n").at(-2)).toBe(" AI-Driven Development CLI"); + expect(vi.mocked(createMenuDeps)).toHaveBeenCalledWith(resolveProjectRoot()); + }); + + it("leaves without running anything when the menu answers exit", async () => { + const exit = exiting(); + + await loopUntilExit(); + + expect(exit.mock.calls[0]).toEqual([0]); + expect(spawn).not.toHaveBeenCalled(); + expect(question).not.toHaveBeenCalled(); + }); + + it("waits for a keypress after a command, so its output can be read", async () => { + promptConfirm.mockResolvedValueOnce(true).mockResolvedValue(false); + exiting(); + + await loopUntilExit(); + + expect(spawn).toHaveBeenCalledWith(["setup"]); + expect(question.mock.calls[0][0]).toBe("\nPress ENTER to continue..."); + expect(close).toHaveBeenCalledTimes(1); + }); + + it("carries a failed setup's own exit code out of the loop", async () => { + promptConfirm.mockResolvedValue(true); + spawn.mockResolvedValue(3); + const exit = exiting(); + + await loopUntilExit(); + + expect(exit.mock.calls[0]).toEqual([3]); + }); + + it("keeps offering the menu after a setup that succeeded", async () => { + promptConfirm.mockResolvedValueOnce(true).mockResolvedValue(false); + spawn.mockResolvedValue(0); + const exit = exiting(); + + await loopUntilExit(); + + expect(spawn).toHaveBeenCalledTimes(1); + expect(promptConfirm).toHaveBeenCalledTimes(2); + expect(exit.mock.calls[0]).toEqual([0]); + }); + + it("keeps offering the menu after any other command failed", async () => { + loadManifest.mockResolvedValue({}); + promptSelect + .mockResolvedValueOnce("maintain") + .mockResolvedValueOnce("sync-all") + .mockResolvedValue("exit"); + spawn.mockResolvedValue(1); + const exit = exiting(); + + await loopUntilExit(); + + expect(spawn).toHaveBeenCalledWith(["sync"]); + expect(exit.mock.calls[0]).toEqual([0]); + }); + + it("leaves quietly when the person aborts a prompt with Ctrl-C", async () => { + promptConfirm.mockRejectedValue( + Object.assign(new Error("prompt aborted"), { name: "ExitPromptError" }) + ); + const exit = exiting(); + + await loopUntilExit(); + + expect(exit.mock.calls[0]).toEqual([0]); + expect(errors).toEqual([]); + }); + + it("reports any other failure through the error handler, and fails the process", async () => { + promptConfirm.mockRejectedValue(new Error("prompter is broken")); + const exit = exiting(); + + await loopUntilExit(); + + expect(errors).toEqual(["Error: prompter is broken\n"]); + expect(exit.mock.calls[0]).toEqual([1]); + }); +}); diff --git a/cli/tests/presentation/commands/plugin-wiring.integration.test.ts b/cli/tests/presentation/commands/plugin-wiring.integration.test.ts new file mode 100644 index 000000000..41c51e528 --- /dev/null +++ b/cli/tests/presentation/commands/plugin-wiring.integration.test.ts @@ -0,0 +1,374 @@ +import { Command } from "commander"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { AI_TOOL_IDS } from "../../../src/kernel/tool.js"; + +const pluginRemove = vi.fn(); +const pluginList = vi.fn(); +const pluginInstall = vi.fn(); +const pluginSearch = vi.fn(); +const pluginUpdate = vi.fn(); +const activation = vi.fn(); +const menuSelect = vi.fn(); +const spawn = vi.fn(); + +vi.mock("../../../src/runtime/wiring/framework.js", () => ({ + createDeps: vi.fn(async () => ({ + pluginRemoveUseCase: { execute: pluginRemove }, + pluginListUseCase: { execute: pluginList }, + pluginInstallUseCase: { execute: pluginInstall }, + pluginSearchUseCase: { execute: pluginSearch }, + pluginUpdateUseCase: { execute: pluginUpdate }, + marketplaceSyncSettingsUseCase: { execute: activation }, + })), + createMenuDeps: vi.fn(() => ({ prompter: { select: menuSelect } })), +})); + +vi.mock("../../../src/presentation/commands/spawn-cli-command.js", () => ({ + spawnCliCommand: spawn, +})); + +const { createDeps } = await import("../../../src/runtime/wiring/framework.js"); +const { registerPluginCommand } = await import("../../../src/presentation/commands/plugin.js"); + +const PROJECT_ROOT = process.cwd(); + +let written: string[] = []; +let errors: string[] = []; + +function pretendTerminal(isTTY: boolean): void { + Object.defineProperty(process.stdout, "isTTY", { value: isTTY, configurable: true }); +} + +beforeEach(() => { + vi.clearAllMocks(); + written = []; + errors = []; + pretendTerminal(false); + vi.spyOn(process.stdout, "write").mockImplementation((chunk) => { + written.push(String(chunk)); + return true; + }); + vi.spyOn(process.stderr, "write").mockImplementation((chunk) => { + errors.push(String(chunk)); + return true; + }); + pluginRemove.mockResolvedValue(undefined); + pluginList.mockResolvedValue(new Map([["claude", [{ name: "aidd-dev", version: "1.0.0" }]]])); + pluginInstall.mockResolvedValue({ kind: "marketplace", installed: ["aidd-dev"] }); + pluginSearch.mockResolvedValue({ hits: [] }); + pluginUpdate.mockResolvedValue(["aidd-dev"]); + activation.mockResolvedValue({ binaryMissing: [], errors: [] }); + menuSelect.mockResolvedValue("list"); + spawn.mockResolvedValue(0); +}); + +afterEach(() => { + vi.restoreAllMocks(); + process.exitCode = undefined; +}); + +async function run(...args: string[]): Promise { + const program = new Command(); + program.exitOverride(); + program.option("--verbose"); + registerPluginCommand(program); + await program.parseAsync(["node", "aidd", "plugin", ...args]); + return written.join("").split("\n").slice(0, -1); +} + +describe("aidd plugin — the group with no subcommand", () => { + it("offers the five things a person can do, then re-runs itself with the pick", async () => { + pretendTerminal(true); + + await run(); + + expect(menuSelect).toHaveBeenCalledWith("plugin: what do you want to do?", [ + { name: "Install plugin", value: "install" }, + { name: "List installed plugins", value: "list" }, + { name: "Search plugins", value: "search", description: "requires query arg" }, + { name: "Update plugins", value: "update" }, + { name: "Remove a plugin", value: "remove", description: "requires name arg" }, + ]); + expect(spawn).toHaveBeenCalledWith(["plugin", "list"]); + }); + + it("prints its own help off a terminal rather than asking a question nobody can answer", async () => { + await expect(run()).rejects.toThrow("(outputHelp)"); + + expect(written.join("").split("\n")[0]).toBe("Usage: aidd plugin [options] [command]"); + expect(menuSelect).not.toHaveBeenCalled(); + expect(spawn).not.toHaveBeenCalled(); + }); +}); + +describe("aidd plugin list", () => { + it("sweeps every installed tool when none was named", async () => { + expect(await run("list")).toEqual(["claude:", " aidd-dev@1.0.0"]); + expect(pluginList).toHaveBeenCalledWith({ toolIds: "all" }); + }); + + it("narrows to the one tool that was named", async () => { + await run("list", "--tool", "codex"); + + expect(pluginList).toHaveBeenCalledWith({ toolIds: ["codex"] }); + }); + + it("refuses a tool no profile declares, before any use case runs", async () => { + vi.spyOn(process, "exit").mockImplementation(() => { + throw new Error("exited"); + }); + + await expect(run("list", "--tool", "emacs")).rejects.toThrow("exited"); + + expect(errors.join("")).toBe( + `Error: Unknown AI tool: emacs. Valid AI tools: ${AI_TOOL_IDS.join(", ")}\n` + ); + expect(pluginList).not.toHaveBeenCalled(); + }); +}); + +describe("aidd plugin remove", () => { + it("removes the named plugin everywhere, re-drives activation, and says so", async () => { + expect(await run("remove", "aidd-dev")).toEqual(["Plugin 'aidd-dev' removed."]); + expect(pluginRemove).toHaveBeenCalledWith({ + pluginName: "aidd-dev", + toolIds: "all", + projectRoot: PROJECT_ROOT, + }); + expect(activation).toHaveBeenCalledWith({ + projectRoot: PROJECT_ROOT, + marketplaceNames: undefined, + }); + }); + + it("names a refused activation and fails the process, saying nothing was removed", async () => { + activation.mockResolvedValue({ + binaryMissing: [], + errors: [{ scope: "claude", message: "claude CLI refused" }], + }); + const exit = vi.spyOn(process, "exit").mockImplementation(() => { + throw new Error("exited"); + }); + + await expect(run("remove", "aidd-dev")).rejects.toThrow("exited"); + + expect(errors.join("")).toBe( + "Warning: [claude] claude CLI refused\nError: Sync failed for: claude. See the warnings above.\n" + ); + expect(exit).toHaveBeenCalledWith(1); + expect(written.join("")).toBe(""); + }); +}); + +describe("aidd plugin install", () => { + it("hands the pick, the tools and the terminal through, and names what landed", async () => { + expect(await run("install", "aidd-dev")).toEqual(["Installed 'aidd-dev'."]); + expect(pluginInstall).toHaveBeenCalledWith({ + pluginArg: "aidd-dev", + toolIds: "all", + projectRoot: PROJECT_ROOT, + interactive: false, + fromMarketplace: undefined, + token: undefined, + yes: undefined, + scope: undefined, + }); + expect(activation).toHaveBeenCalledWith({ + projectRoot: PROJECT_ROOT, + marketplaceNames: undefined, + }); + }); + + it("narrows activation to the marketplace the install was told to use", async () => { + await run("install", "aidd-dev", "--from", "market-b"); + + expect(pluginInstall).toHaveBeenCalledWith( + expect.objectContaining({ fromMarketplace: "market-b" }) + ); + expect(activation).toHaveBeenCalledWith({ + projectRoot: PROJECT_ROOT, + marketplaceNames: ["market-b"], + }); + }); + + it("carries the scope, the token and the auto-answer a scripted run gave", async () => { + await run( + "install", + "aidd-dev", + "--scope", + "user", + "--token", + "ghp_x", + "--yes", + "--tool", + "claude" + ); + + expect(pluginInstall).toHaveBeenCalledWith( + expect.objectContaining({ scope: "user", token: "ghp_x", yes: true, toolIds: ["claude"] }) + ); + }); + + it("refuses a scope that is neither project nor user, before any use case runs", async () => { + vi.spyOn(process, "exit").mockImplementation(() => { + throw new Error("exited"); + }); + + await expect(run("install", "aidd-dev", "--scope", "machine")).rejects.toThrow("exited"); + + expect(errors.join("")).toBe("Error: Invalid scope 'machine'. Expected 'project' or 'user'.\n"); + expect(pluginInstall).not.toHaveBeenCalled(); + }); +}); + +describe("aidd plugin search", () => { + it("asks for every plugin matching the query, recommended or not", async () => { + expect(await run("search", "dev")).toEqual(["No matches."]); + expect(pluginSearch).toHaveBeenCalledWith({ + query: "dev", + recommendedOnly: false, + marketplace: undefined, + projectRoot: PROJECT_ROOT, + }); + }); + + it("names a failed search on stderr and fails the process", async () => { + pluginSearch.mockRejectedValue(new Error("catalog is unreachable")); + const exit = vi.spyOn(process, "exit").mockImplementation(() => { + throw new Error("exited"); + }); + + await expect(run("search", "dev")).rejects.toThrow("exited"); + + expect(errors.join("")).toBe("Error: catalog is unreachable\n"); + expect(exit).toHaveBeenCalledWith(1); + }); + + it("narrows to the recommended ones of a single marketplace when asked", async () => { + await run("search", "dev", "--recommended", "--marketplace", "market-b"); + + expect(pluginSearch).toHaveBeenCalledWith({ + query: "dev", + recommendedOnly: true, + marketplace: "market-b", + projectRoot: PROJECT_ROOT, + }); + }); +}); + +describe("aidd plugin update", () => { + it("sweeps every plugin when none was named, and lists what moved", async () => { + expect(await run("update")).toEqual(["Updated: aidd-dev."]); + expect(pluginUpdate).toHaveBeenCalledWith({ + pluginNames: undefined, + toolIds: "all", + projectRoot: PROJECT_ROOT, + }); + expect(activation).toHaveBeenCalledWith({ + projectRoot: PROJECT_ROOT, + marketplaceNames: undefined, + }); + }); + + it("names a failed update on stderr and fails the process", async () => { + pluginUpdate.mockRejectedValue(new Error("plugin source moved")); + const exit = vi.spyOn(process, "exit").mockImplementation(() => { + throw new Error("exited"); + }); + + await expect(run("update")).rejects.toThrow("exited"); + + expect(errors.join("")).toBe("Error: plugin source moved\n"); + expect(exit).toHaveBeenCalledWith(1); + }); + + it("narrows to the one plugin that was named", async () => { + await run("update", "aidd-dev"); + + expect(pluginUpdate).toHaveBeenCalledWith( + expect.objectContaining({ pluginNames: ["aidd-dev"] }) + ); + }); +}); + +describe("aidd plugin — how every subcommand builds its graph", () => { + it.each([ + ["list"], + ["remove", "aidd-dev"], + ["install", "aidd-dev"], + ["search", "dev"], + ["update"], + ])("hands %j this run's verbosity, never an empty option set", async (...args) => { + await run(...args); + + expect(vi.mocked(createDeps)).toHaveBeenCalledWith( + PROJECT_ROOT, + { verbose: false }, + expect.anything() + ); + }); +}); + +describe("aidd plugin — the help surface", () => { + function pluginCommand(): Command { + const program = new Command(); + registerPluginCommand(program); + const plugin = program.commands.find((command) => command.name() === "plugin"); + if (plugin === undefined) throw new Error("plugin command was not registered"); + return plugin; + } + + function optionsOf(name: string): [string, string | undefined][] { + const child = pluginCommand().commands.find((candidate) => candidate.name() === name); + if (child === undefined) throw new Error(`no subcommand ${name}`); + return child.options.map((option) => [option.flags, option.description]); + } + + it("describes the group and every subcommand, in the order they are registered", () => { + expect(pluginCommand().description()).toBe("Manage plugins for AI tools"); + expect( + pluginCommand().commands.map((command) => [ + command.name(), + command.usage(), + command.description(), + ]) + ).toEqual([ + ["remove", "[options] ", "Remove a plugin from one or all AI tools"], + ["list", "[options]", "List installed plugins for one or all AI tools"], + [ + "install", + "[options] [plugin]", + "Install a plugin (marketplace name, local path, or interactive pick)", + ], + ["search", "[options] ", "Search registered marketplaces for plugins"], + ["update", "[options] [name]", "Update one or all plugins for one or all AI tools"], + ]); + }); + + it("offers the same tool narrowing to remove, list and update", () => { + const tool: [string, string][] = [ + ["--tool ", "Target AI tool (default: all installed)"], + ]; + + expect(optionsOf("remove")).toEqual(tool); + expect(optionsOf("list")).toEqual(tool); + expect(optionsOf("update")).toEqual(tool); + }); + + it("says what install may be told about the source, the scope and the prompts", () => { + expect(optionsOf("install")).toEqual([ + ["--from ", "Marketplace name (when multiple match)"], + ["--tool ", "Target AI tool (default: all installed)"], + ["--token ", "Auth token (host detected from source URL at fetch time)"], + ["--scope ", "Install scope; must match the tool's supported scope"], + ["--yes", "Auto-resolve interactive prompts (CI mode)"], + ]); + }); + + it("says how a search may be narrowed", () => { + expect(optionsOf("search")).toEqual([ + ["--recommended", "Show only recommended plugins"], + ["--marketplace ", "Limit to a single marketplace"], + ]); + }); +}); diff --git a/cli/tests/presentation/commands/setup-options.unit.test.ts b/cli/tests/presentation/commands/setup-options.unit.test.ts new file mode 100644 index 000000000..dd21fca09 --- /dev/null +++ b/cli/tests/presentation/commands/setup-options.unit.test.ts @@ -0,0 +1,141 @@ +import { resolve } from "node:path"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { AI_TOOL_IDS, IDE_TOOL_IDS } from "../../../src/kernel/tool.js"; +import { + expandAllKeyword, + parsePluginsFlag, + parseSourceFlag, + parseToolIds, +} from "../../../src/presentation/commands/setup.js"; +import { ErrorHandler } from "../../../src/presentation/error-handler.js"; +import { CapturingOutput } from "../../helpers/ports/capturing-output.js"; + +function refuseByThrowing() { + return vi.spyOn(process, "exit").mockImplementation(() => { + throw new Error("process.exit"); + }); +} + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe("parseSourceFlag", () => { + it("chooses no source at all when --source was not given", () => { + const output = new CapturingOutput(false); + + expect(parseSourceFlag({}, output)).toBeUndefined(); + }); + + it("carries the release tag into a remote source", () => { + const output = new CapturingOutput(false); + + const source = parseSourceFlag({ source: "remote", release: "v1.2.3" }, output); + + expect(source?.kind).toBe("remote"); + expect(source?.ref).toBe("v1.2.3"); + }); + + it("resolves a local source's path against the working directory", () => { + const output = new CapturingOutput(false); + + const source = parseSourceFlag({ source: "local", path: "./framework" }, output); + + expect(source?.kind).toBe("local"); + expect(source?.path).toBe(resolve("./framework")); + }); + + it("refuses a local source with no --path, naming the flag it needs", () => { + const output = new CapturingOutput(false); + const exit = refuseByThrowing(); + + expect(() => parseSourceFlag({ source: "local" }, output)).toThrow("process.exit"); + + expect(output.at("error")).toEqual(["--source local requires --path "]); + expect(exit).toHaveBeenCalledWith(1); + }); +}); + +describe("expandAllKeyword", () => { + it("selects nothing when the flag was not given", () => { + expect(expandAllKeyword(undefined, AI_TOOL_IDS)).toEqual([]); + }); + + it("expands 'all' into every id of that category, spacing and all", () => { + expect(expandAllKeyword("all", IDE_TOOL_IDS)).toEqual([...IDE_TOOL_IDS]); + expect(expandAllKeyword(" all ", IDE_TOOL_IDS)).toEqual([...IDE_TOOL_IDS]); + }); + + it("splits a comma-separated list, dropping the spacing and the empty entries", () => { + expect(expandAllKeyword(" claude , , cursor ", AI_TOOL_IDS)).toEqual(["claude", "cursor"]); + }); +}); + +describe("parseToolIds", () => { + it("selects no tool at all when neither category was given", () => { + const errorHandler = new ErrorHandler(new CapturingOutput(false)); + + expect(parseToolIds({}, errorHandler)).toEqual({ aiTools: [], ideTools: [] }); + }); + + it("splits the ids given per category", () => { + const errorHandler = new ErrorHandler(new CapturingOutput(false)); + + expect(parseToolIds({ ai: "claude", ide: "vscode" }, errorHandler)).toEqual({ + aiTools: ["claude"], + ideTools: ["vscode"], + }); + }); + + it("refuses an id belonging to the other category", () => { + const output = new CapturingOutput(false); + const exit = refuseByThrowing(); + + expect(() => parseToolIds({ ai: "vscode" }, new ErrorHandler(output))).toThrow("process.exit"); + + expect(exit).toHaveBeenCalledWith(1); + expect(output.at("error")).toHaveLength(1); + }); + + it("refuses an AI id given as an IDE one", () => { + const output = new CapturingOutput(false); + const exit = refuseByThrowing(); + + expect(() => parseToolIds({ ide: "claude" }, new ErrorHandler(output))).toThrow("process.exit"); + + expect(exit).toHaveBeenCalledWith(1); + expect(output.at("error")).toHaveLength(1); + }); + + it("accepts 'all' without checking it against the category", () => { + const errorHandler = new ErrorHandler(new CapturingOutput(false)); + + expect(parseToolIds({ ai: "all" }, errorHandler)).toEqual({ + aiTools: [...AI_TOOL_IDS], + ideTools: [], + }); + }); +}); + +describe("parsePluginsFlag", () => { + it("asks on a terminal when --plugins was not given", () => { + expect(parsePluginsFlag(undefined, true)).toEqual({ mode: "interactive", names: [] }); + }); + + it("installs nothing off a terminal when --plugins was not given", () => { + expect(parsePluginsFlag(undefined, false)).toEqual({ mode: "none", names: [] }); + }); + + it("reads each of the three keywords as its own mode, spacing and all", () => { + expect(parsePluginsFlag(" none ", true)).toEqual({ mode: "none", names: [] }); + expect(parsePluginsFlag("all", true)).toEqual({ mode: "all", names: [] }); + expect(parsePluginsFlag("recommended", true)).toEqual({ mode: "recommended", names: [] }); + }); + + it("reads anything else as the names to install", () => { + expect(parsePluginsFlag(" aidd-dev , aidd-pm ,", true)).toEqual({ + mode: "named", + names: ["aidd-dev", "aidd-pm"], + }); + }); +}); diff --git a/cli/tests/presentation/commands/setup-wiring.integration.test.ts b/cli/tests/presentation/commands/setup-wiring.integration.test.ts new file mode 100644 index 000000000..d8a348990 --- /dev/null +++ b/cli/tests/presentation/commands/setup-wiring.integration.test.ts @@ -0,0 +1,369 @@ +import { resolve } from "node:path"; +import { Command } from "commander"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import "../../../src/contexts/tools/domain/profiles/claude/profile.js"; +import "../../../src/contexts/tools/domain/profiles/codex/profile.js"; +import "../../../src/contexts/tools/domain/profiles/copilot/profile.js"; +import "../../../src/contexts/tools/domain/profiles/cursor/profile.js"; +import "../../../src/contexts/tools/domain/profiles/opencode/profile.js"; +import "../../../src/contexts/tools/domain/profiles/vscode/profile.js"; +import { MarketplaceSourceMode } from "../../../src/contexts/distribution/domain/marketplace-source-mode.js"; +import type { SetupFlow } from "../../../src/contexts/framework/domain/setup-flow.js"; +import { AI_TOOL_IDS } from "../../../src/kernel/tool.js"; + +const resolveSourceIfNeeded = vi.fn(); +const registerIfPresent = vi.fn(); +const activation = vi.fn(); +const setupTools = vi.fn(); +const setupPluginsPrompt = vi.fn(); +const setupToolsPrompt = vi.fn(); +const detectContext = vi.fn(); +const setupMachineScope = vi.fn(); +const loadManifest = vi.fn(); + +vi.mock("../../../src/runtime/wiring/framework.js", () => ({ + createDeps: vi.fn(async () => ({ + fs: {}, + manifestRepo: { load: loadManifest }, + setupMarketplaceRegistration: { resolveSourceIfNeeded, registerIfPresent }, + marketplaceSyncSettingsUseCase: { execute: activation }, + setupToolsUseCase: { execute: setupTools }, + setupPluginsPromptUseCase: { execute: setupPluginsPrompt }, + currentVersionProvider: { get: () => "5.2.2" }, + setupToolsPromptUseCase: { execute: setupToolsPrompt }, + projectContextDetector: { execute: detectContext }, + setupMachineScopeUseCase: { execute: setupMachineScope }, + })), + createMenuDeps: vi.fn(), +})); + +const { createDeps } = await import("../../../src/runtime/wiring/framework.js"); +const { registerSetupCommand } = await import("../../../src/presentation/commands/setup.js"); + +const PROJECT_ROOT = process.cwd(); + +let written: string[] = []; +let errors: string[] = []; + +function pretendTerminal(isTTY: boolean): void { + Object.defineProperty(process.stdout, "isTTY", { value: isTTY, configurable: true }); +} + +function builtFlow(): SetupFlow { + return resolveSourceIfNeeded.mock.calls[0][0] as SetupFlow; +} + +beforeEach(() => { + vi.clearAllMocks(); + written = []; + errors = []; + pretendTerminal(false); + vi.spyOn(process.stdout, "write").mockImplementation((chunk) => { + written.push(String(chunk)); + return true; + }); + vi.spyOn(process.stderr, "write").mockImplementation((chunk) => { + errors.push(String(chunk)); + return true; + }); + loadManifest.mockResolvedValue({}); + resolveSourceIfNeeded.mockResolvedValue(undefined); + registerIfPresent.mockResolvedValue(undefined); + activation.mockResolvedValue({ binaryMissing: [], errors: [] }); + setupTools.mockResolvedValue({ results: [] }); + setupPluginsPrompt.mockResolvedValue(undefined); + setupToolsPrompt.mockResolvedValue({ aiTools: ["claude"], ideTools: [] }); + detectContext.mockResolvedValue({ describe: () => "a TypeScript project" }); + setupMachineScope.mockResolvedValue({ + kind: "up-to-date", + install: { results: [] }, + activation: { binaryMissing: [], errors: [] }, + }); +}); + +afterEach(() => { + vi.restoreAllMocks(); + process.exitCode = undefined; +}); + +async function run(...args: string[]): Promise { + const program = new Command(); + program.exitOverride(); + program.option("--verbose"); + registerSetupCommand(program); + await program.parseAsync(["node", "aidd", "setup", ...args]); + return written.join("").split("\n").slice(0, -1); +} + +describe("aidd setup — the flow a scripted run builds", () => { + it("names the tools it was given, installs no plugin, and registers the default source", async () => { + expect(await run("--ai", "claude")).toEqual(["Project is up to date."]); + expect({ ...builtFlow() }).toEqual({ + projectRoot: PROJECT_ROOT, + source: undefined, + aiTools: ["claude"], + ideTools: [], + pluginMode: "none", + pluginNames: [], + interactive: false, + force: false, + registerDefaultMarketplace: true, + scope: "project", + }); + expect(setupTools).toHaveBeenCalledWith({ + projectRoot: PROJECT_ROOT, + aiTools: ["claude"], + ideTools: [], + force: false, + version: "5.2.2", + }); + }); + + it("expands the all keyword to every tool of that category", async () => { + await run("--ai", "all"); + + expect(builtFlow().aiTools).toEqual([...AI_TOOL_IDS]); + }); + + it("carries a named plugin list, and its mode, into the plugin prompt", async () => { + await run("--ai", "claude", "--plugins", "aidd-dev,aidd-pm"); + + expect(builtFlow().pluginMode).toBe("named"); + expect(setupPluginsPrompt).toHaveBeenCalledWith({ + projectRoot: PROJECT_ROOT, + mode: "named", + pluginNames: ["aidd-dev", "aidd-pm"], + interactive: false, + }); + }); + + it("asks for no plugin at all when the default marketplace is refused", async () => { + await run("--ai", "claude", "--no-default-marketplace"); + + expect(builtFlow().registerDefaultMarketplace).toBe(false); + expect(setupPluginsPrompt).not.toHaveBeenCalled(); + }); + + it("resolves a local source against the working directory", async () => { + await run("--source", "local", "--path", "vendor/framework", "--ai", "claude"); + + expect(builtFlow().source).toEqual(MarketplaceSourceMode.local(resolve("vendor/framework"))); + }); + + it("carries the release tag a remote source was pinned to", async () => { + await run("--source", "remote", "--release", "v1.2.3", "--ai", "claude"); + + expect(builtFlow().source).toEqual(MarketplaceSourceMode.remote(undefined, "v1.2.3")); + }); + + it("hands a user scope to the machine-wide setup alone", async () => { + await run("--ai", "claude", "--scope", "user"); + + const flow = setupMachineScope.mock.calls[0][0] as SetupFlow; + expect(flow.scope).toBe("user"); + expect(setupTools).not.toHaveBeenCalled(); + expect(resolveSourceIfNeeded).not.toHaveBeenCalled(); + }); +}); + +describe("aidd setup — a person at a terminal", () => { + it("greets, names what it detected, and ends with what to do next", async () => { + pretendTerminal(true); + setupTools.mockResolvedValue({ + results: [{ toolId: "claude", fileCount: 9, files: [], skipped: false, warnings: [] }], + }); + + expect(await run()).toEqual([ + "", + "AI-Driven Development setup", + "Wires your AI tools, registers the framework marketplace, installs plugins.", + "Press Ctrl-C any time to abort.", + "", + "Detected: a TypeScript project.", + "Project is up to date.", + "Installed claude (9 files)", + "", + "Next steps:", + " aidd doctor # verify drift", + " aidd marketplace list # see registered marketplaces", + " aidd plugin install # add plugins", + " aidd --help # explore commands", + ]); + expect(builtFlow().interactive).toBe(true); + expect(builtFlow().pluginMode).toBe("interactive"); + }); + + it("stops being interactive as soon as one scripting flag is given", async () => { + pretendTerminal(true); + + const lines = await run("--yes"); + + expect(builtFlow().interactive).toBe(false); + expect(builtFlow().pluginMode).toBe("none"); + expect(lines).toEqual(["Project is up to date."]); + }); + + it("hands this run's verbosity to the rendering, not only to the graph", async () => { + setupTools.mockResolvedValue({ + results: [ + { + toolId: "claude", + fileCount: 1, + files: [{ relativePath: "CLAUDE.md" }], + skipped: false, + warnings: [], + }, + ], + }); + + await run("--verbose", "--ai", "claude"); + + expect(errors).toEqual(["[verbose] Tool: claude\n", "[verbose] + CLAUDE.md\n"]); + }); +}); + +describe("aidd setup — every flag that makes a run a scripted one", () => { + it.each([ + [["--source", "remote"]], + [["--release", "v1.2.3"]], + [["--ai", "claude"]], + [["--ide", "vscode"]], + [["--plugins", "none"]], + [["--yes"]], + ])("stops prompting on its own once %j is given", async (args) => { + pretendTerminal(true); + + await run(...args); + + expect(builtFlow().interactive).toBe(false); + }); + + it("keeps the detection quiet when nothing about the project was detected", async () => { + pretendTerminal(true); + detectContext.mockResolvedValue(undefined); + + const lines = await run(); + + expect(lines).not.toContain("Detected: a TypeScript project."); + }); + + it("leaves the drift check out of the next steps when nothing was installed", async () => { + pretendTerminal(true); + + const lines = await run(); + + expect(lines).not.toContain(" aidd doctor # verify drift"); + expect(lines).toContain(" aidd marketplace list # see registered marketplaces"); + }); +}); + +describe("aidd setup — what it refuses and what it reports", () => { + it("refuses a local source with nowhere to read it from", async () => { + vi.spyOn(process, "exit").mockImplementation(() => { + throw new Error("exited"); + }); + + await expect(run("--source", "local")).rejects.toThrow("exited"); + + expect(errors[0]).toBe("Error: --source local requires --path \n"); + expect(vi.mocked(createDeps)).not.toHaveBeenCalled(); + }); + + it("refuses a scope it does not know", async () => { + vi.spyOn(process, "exit").mockImplementation(() => { + throw new Error("exited"); + }); + + await expect(run("--scope", "machine")).rejects.toThrow("exited"); + + expect(errors[0]).toBe('Error: Invalid --scope "machine" — expected "project" or "user".\n'); + expect(vi.mocked(createDeps)).not.toHaveBeenCalled(); + }); + + it("refuses an IDE id offered as an AI tool, before building anything", async () => { + const exit = vi.spyOn(process, "exit").mockImplementation(() => { + throw new Error("exited"); + }); + + await expect(run("--ai", "vscode")).rejects.toThrow("exited"); + + expect(exit).toHaveBeenCalledWith(1); + expect(vi.mocked(createDeps)).not.toHaveBeenCalled(); + }); + + it("names a refused activation and fails the process", async () => { + activation.mockResolvedValue({ + binaryMissing: [], + errors: [{ scope: "claude", message: "claude CLI refused" }], + }); + const exit = vi.spyOn(process, "exit").mockImplementation(() => { + throw new Error("exited"); + }); + + await expect(run("--ai", "claude")).rejects.toThrow("exited"); + + expect(errors.join("")).toBe( + "Warning: [claude] claude CLI refused\nError: Sync failed for: claude. See the warnings above.\n" + ); + expect(exit).toHaveBeenCalledWith(1); + }); + + it("names a failed install on stderr and fails the process", async () => { + setupTools.mockRejectedValue(new Error("assets are missing")); + const exit = vi.spyOn(process, "exit").mockImplementation(() => { + throw new Error("exited"); + }); + + await expect(run("--ai", "claude")).rejects.toThrow("exited"); + + expect(errors.join("")).toBe("Error: assets are missing\n"); + expect(exit).toHaveBeenCalledWith(1); + }); + + it("builds the graph for this project at this run's verbosity", async () => { + await run("--verbose", "--ai", "claude"); + + expect(vi.mocked(createDeps)).toHaveBeenCalledWith( + PROJECT_ROOT, + { verbose: true }, + expect.anything() + ); + }); +}); + +describe("aidd setup — the help surface", () => { + function setupCommand(): Command { + const program = new Command(); + registerSetupCommand(program); + const setup = program.commands.find((command) => command.name() === "setup"); + if (setup === undefined) throw new Error("setup command was not registered"); + return setup; + } + + it("describes itself against the command that acts on the framework alone", () => { + expect(setupCommand().description()).toBe( + "Set up or update the project to a correct state — bootstraps the whole project (marketplace, framework, tools, plugins); see `framework install`, which acts on the framework alone" + ); + }); + + it("offers a source, two tool lists, a plugin mode, a scope, and two switches", () => { + expect(setupCommand().options.map((option) => [option.flags, option.description])).toEqual([ + ["--source ", "Framework source: remote or local"], + ["--path ", "Absolute path to local framework (required with --source local)"], + ["--release ", "Marketplace release tag to fetch (e.g., v1.2.3)"], + ["--ai ", "Comma-separated AI tool IDs, or 'all' (e.g., claude,cursor or all)"], + ["--ide ", "Comma-separated IDE tool IDs, or 'all' (e.g., vscode or all)"], + ["--plugins ", "Plugin install mode: none | all | recommended | comma-separated names"], + [ + "--no-default-marketplace", + "Skip auto-registering aidd-framework (no source prompt, no plugin install)", + ], + ["--yes", "Accept defaults without prompting"], + [ + "--scope ", + "project (default) installs into this project alone; user registers the shared " + + "framework source and native activation machine-wide, writing nothing under this project", + ], + ]); + }); +}); diff --git a/cli/tests/presentation/commands/sync-wiring.integration.test.ts b/cli/tests/presentation/commands/sync-wiring.integration.test.ts new file mode 100644 index 000000000..e898d07d0 --- /dev/null +++ b/cli/tests/presentation/commands/sync-wiring.integration.test.ts @@ -0,0 +1,355 @@ +import { Command } from "commander"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const restoreAll = vi.fn(); +const restoreOne = vi.fn(); +const activation = vi.fn(); +const loadManifest = vi.fn(); +const getToolVersion = vi.fn(); +const currentVersion = vi.fn(); +const userManifestRepo = { load: vi.fn() }; + +vi.mock("../../../src/runtime/wiring/framework.js", () => ({ + createDeps: vi.fn(async () => ({ + restoreAllUseCase: { execute: restoreAll }, + restoreUseCase: { execute: restoreOne }, + marketplaceSyncSettingsUseCase: { execute: activation }, + manifestRepo: { load: loadManifest }, + userManifestRepo, + currentVersionProvider: { get: currentVersion }, + })), + createMenuDeps: vi.fn(), +})); + +const { createDeps } = await import("../../../src/runtime/wiring/framework.js"); +const { registerSyncCommand } = await import("../../../src/presentation/commands/sync.js"); + +const PROJECT_ROOT = process.cwd(); + +const NOTHING_RESTORED = { + errors: [], + totalRestored: 0, + totalKept: 0, + pluginNamesRestored: [], + unrestorable: [], +}; + +let written: string[] = []; +let errors: string[] = []; + +function pretendTerminal(isTTY: boolean): void { + Object.defineProperty(process.stdout, "isTTY", { value: isTTY, configurable: true }); +} + +beforeEach(() => { + vi.clearAllMocks(); + written = []; + errors = []; + pretendTerminal(false); + vi.spyOn(process.stdout, "write").mockImplementation((chunk) => { + written.push(String(chunk)); + return true; + }); + vi.spyOn(process.stderr, "write").mockImplementation((chunk) => { + errors.push(String(chunk)); + return true; + }); + restoreAll.mockResolvedValue(NOTHING_RESTORED); + restoreOne.mockResolvedValue({ + tools: [{ nothingToRestore: true }], + totalRestored: 0, + totalKept: 0, + unrestorable: [], + }); + activation.mockResolvedValue({ binaryMissing: [], errors: [], activated: [] }); + getToolVersion.mockReturnValue("5.1.0"); + loadManifest.mockResolvedValue({ getToolVersion }); + currentVersion.mockReturnValue("5.2.2"); + userManifestRepo.load.mockResolvedValue({ getInstalledToolIds: () => ["claude"] }); +}); + +afterEach(() => { + vi.restoreAllMocks(); + process.exitCode = undefined; +}); + +async function run(...args: string[]): Promise { + const program = new Command(); + program.exitOverride(); + program.option("--verbose"); + registerSyncCommand(program); + await program.parseAsync(["node", "aidd", "sync", ...args]); + return written.join("").split("\n").slice(0, -1); +} + +describe("aidd sync — the whole project", () => { + it("restores first, then re-drives every registered marketplace's activation", async () => { + expect(await run()).toEqual(["Nothing to restore — all files are unmodified."]); + expect(restoreAll).toHaveBeenCalledWith(PROJECT_ROOT, false, false); + expect(activation).toHaveBeenCalledWith({ + projectRoot: PROJECT_ROOT, + recreateFrameworkIfMissing: true, + }); + expect(restoreAll.mock.invocationCallOrder[0]).toBeLessThan( + activation.mock.invocationCallOrder[0] + ); + }); + + it("prompts on a terminal, and stops prompting once the run was forced", async () => { + pretendTerminal(true); + + await run(); + expect(restoreAll).toHaveBeenCalledWith(PROJECT_ROOT, false, true); + + await run("--force"); + expect(restoreAll).toHaveBeenLastCalledWith(PROJECT_ROOT, true, false); + }); + + it("names both the restoration's and the activation's refusals, and fails the process", async () => { + restoreAll.mockResolvedValue({ + ...NOTHING_RESTORED, + errors: [{ scope: "claude", message: "file is not ours" }], + }); + activation.mockResolvedValue({ + binaryMissing: [], + errors: [{ scope: "codex", message: "codex CLI refused" }], + activated: [], + }); + const exit = vi.spyOn(process, "exit").mockImplementation(() => { + throw new Error("exited"); + }); + + await expect(run()).rejects.toThrow("exited"); + + expect(errors.join("")).toBe( + "Warning: [claude] file is not ours\n" + + "Warning: [codex] codex CLI refused\n" + + "Error: Sync failed for: claude, codex. See the warnings above.\n" + ); + expect(exit).toHaveBeenCalledWith(1); + }); + + it("warns about a binary off PATH without failing the run", async () => { + activation.mockResolvedValue({ + binaryMissing: [{ toolId: "claude", binary: "claude" }], + errors: [], + activated: [], + }); + + await run(); + + expect(errors.join("")).toBe( + "Warning: claude: the plugin will not load until the claude CLI has run.\n" + ); + }); +}); + +describe("aidd sync --tool", () => { + it("restores that tool at the version the manifest recorded, then activates it alone", async () => { + expect(await run("--tool", "claude")).toEqual([ + "Nothing to restore — all files are unmodified.", + ]); + expect(restoreOne).toHaveBeenCalledWith({ + version: "5.1.0", + projectRoot: PROJECT_ROOT, + toolIds: ["claude"], + files: undefined, + force: false, + interactive: false, + manifest: expect.anything(), + pluginName: undefined, + }); + expect(activation).toHaveBeenCalledWith({ + projectRoot: PROJECT_ROOT, + toolIds: ["claude"], + recreateFrameworkIfMissing: true, + }); + expect(restoreAll).not.toHaveBeenCalled(); + }); + + it("falls back to this CLI's own version when the manifest records none for that tool", async () => { + getToolVersion.mockReturnValue(undefined); + + await run("--tool", "claude"); + + expect(restoreOne).toHaveBeenCalledWith(expect.objectContaining({ version: "5.2.2" })); + }); + + it("narrows to the files and the plugin that were named", async () => { + await run("--tool", "claude", "--plugin", "aidd-dev", "a.md", "b.md"); + + expect(restoreOne).toHaveBeenCalledWith( + expect.objectContaining({ files: ["a.md", "b.md"], pluginName: "aidd-dev" }) + ); + }); + + it("refuses to restore anything when this project has no manifest", async () => { + loadManifest.mockResolvedValue(null); + vi.spyOn(process, "exit").mockImplementation(() => { + throw new Error("exited"); + }); + + await expect(run("--tool", "claude")).rejects.toThrow("exited"); + + expect(errors.join("")).toBe( + "Error: No AIDD manifest found. Run `aidd setup` to initialize your project.\n" + ); + expect(restoreOne).not.toHaveBeenCalled(); + }); +}); + +describe("aidd sync --tool — a refused activation", () => { + it("names the refusal and fails the process rather than calling one tool synced", async () => { + activation.mockResolvedValue({ + binaryMissing: [], + errors: [{ scope: "claude", message: "claude CLI refused" }], + activated: [], + }); + const exit = vi.spyOn(process, "exit").mockImplementation(() => { + throw new Error("exited"); + }); + + await expect(run("--tool", "claude")).rejects.toThrow("exited"); + + expect(errors.join("")).toBe( + "Warning: [claude] claude CLI refused\n" + + "Error: Sync failed for: claude. See the warnings above.\n" + ); + expect(exit).toHaveBeenCalledWith(1); + }); +}); + +describe("aidd sync --scope user", () => { + it("drives activation off the machine-wide manifest and restores no project file", async () => { + activation.mockResolvedValue({ binaryMissing: [], errors: [], activated: ["claude"] }); + + expect(await run("--scope", "user")).toEqual(["Synced native activation for: claude"]); + expect(activation).toHaveBeenCalledWith({ + projectRoot: PROJECT_ROOT, + scope: "user", + manifestRepo: userManifestRepo, + toolIds: undefined, + recreateFrameworkIfMissing: true, + }); + expect(restoreAll).not.toHaveBeenCalled(); + expect(restoreOne).not.toHaveBeenCalled(); + }); + + it("says nothing is registered at user scope when activation touched no tool", async () => { + expect(await run("--scope", "user")).toEqual([ + "Nothing to sync — no tool is registered at user scope yet.", + ]); + }); + + it("narrows to the one tool named, still off the machine-wide manifest", async () => { + await run("--scope", "user", "--tool", "claude"); + + expect(activation).toHaveBeenCalledWith(expect.objectContaining({ toolIds: ["claude"] })); + }); + + it("refuses a plugin filter it tracks nothing to narrow", async () => { + vi.spyOn(process, "exit").mockImplementation(() => { + throw new Error("exited"); + }); + + await expect(run("--scope", "user", "--plugin", "aidd-dev")).rejects.toThrow("exited"); + + expect(errors.join("")).toBe( + "Error: --scope user tracks nothing --plugin can narrow — it names every requested tool, " + + "not one plugin or one file. Drop --plugin, or run `aidd sync --plugin ` at project scope.\n" + ); + expect(activation).not.toHaveBeenCalled(); + }); + + it("refuses a file argument it tracks nothing to narrow", async () => { + vi.spyOn(process, "exit").mockImplementation(() => { + throw new Error("exited"); + }); + + await expect(run("--scope", "user", "a.md")).rejects.toThrow("exited"); + + expect(errors.join("")).toBe( + "Error: --scope user tracks nothing a file argument can narrow — it names every requested tool, " + + "not one plugin or one file. Drop a file argument, or run `aidd sync ` at project scope.\n" + ); + expect(activation).not.toHaveBeenCalled(); + }); + + it("names a refused user-scope activation and fails before reporting success", async () => { + activation.mockResolvedValue({ + binaryMissing: [], + errors: [{ scope: "claude", message: "claude CLI refused" }], + activated: ["claude"], + }); + const exit = vi.spyOn(process, "exit").mockImplementation(() => { + throw new Error("exited"); + }); + + await expect(run("--scope", "user")).rejects.toThrow("exited"); + + expect(written.join("")).toBe(""); + expect(exit).toHaveBeenCalledWith(1); + }); + + it("refuses a scope it does not know before building anything", async () => { + vi.spyOn(process, "exit").mockImplementation(() => { + throw new Error("exited"); + }); + + await expect(run("--scope", "machine")).rejects.toThrow("exited"); + + expect(errors[0]).toBe('Error: Invalid --scope "machine" — expected "project" or "user".\n'); + }); +}); + +describe("aidd sync — how it builds its graph", () => { + it("builds the graph for this project at this run's verbosity", async () => { + await run("--verbose"); + + expect(vi.mocked(createDeps)).toHaveBeenCalledWith( + PROJECT_ROOT, + { verbose: true }, + expect.anything() + ); + }); +}); + +describe("aidd sync — the help surface", () => { + function syncCommand(): Command { + const program = new Command(); + registerSyncCommand(program); + const sync = program.commands.find((command) => command.name() === "sync"); + if (sync === undefined) throw new Error("sync command was not registered"); + return sync; + } + + it("describes itself against the command that records nothing", () => { + expect(syncCommand().description()).toBe( + "Rewrite owned files from what is already there — regenerate tracked files, driven by the manifest (see `translate`, which converts a source without recording anything)" + ); + }); + + it("takes any number of tracked files, none of them required", () => { + expect( + syncCommand().registeredArguments.map((argument) => [ + argument.name(), + argument.required, + argument.variadic, + argument.description, + ]) + ).toEqual([["files", false, true, "Limit sync to specific tracked files"]]); + }); + + it("offers a forced run and three narrowings, and asks nothing else", () => { + expect(syncCommand().options.map((option) => [option.flags, option.description])).toEqual([ + ["-f, --force", "Sync without prompting"], + ["--tool ", "Limit sync to a specific tool"], + ["--plugin ", "Limit sync to a specific plugin"], + [ + "--scope ", + "project (default) resolves this project's own manifest; user resolves the " + + "machine-wide manifest --scope user setup wrote, restoring no project files", + ], + ]); + }); +}); diff --git a/cli/tests/presentation/commands/telemetry-wiring.integration.test.ts b/cli/tests/presentation/commands/telemetry-wiring.integration.test.ts new file mode 100644 index 000000000..5c7b51946 --- /dev/null +++ b/cli/tests/presentation/commands/telemetry-wiring.integration.test.ts @@ -0,0 +1,622 @@ +import { Command } from "commander"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import "../../../src/contexts/tools/domain/profiles/claude/profile.js"; +import { buildCostReport } from "../../../src/contexts/telemetry/domain/cost-report.js"; +import { DEFAULT_REPORT_DAYS } from "../../../src/contexts/telemetry/domain/report-period.js"; +import type { TelemetryRemovalPreview } from "../../../src/contexts/telemetry/domain/telemetry-removal.js"; +import { ARTEFACT_AXES } from "../../../src/presentation/display/cost-report-artefact.js"; + +const telemetryOn = vi.fn(); +const telemetryOff = vi.fn(); +const readLocalCost = vi.fn(); +const reportCost = vi.fn(); +const diagnose = vi.fn(); +const forgetPreview = vi.fn(); +const forgetRemove = vi.fn(); +const identityStatus = vi.fn(); +const identityUse = vi.fn(); +const identityOff = vi.fn(); +const identityLink = vi.fn(); +const identityUnlink = vi.fn(); + +vi.mock("../../../src/runtime/wiring/framework.js", () => ({ + createDeps: vi.fn(async () => ({ + telemetryOnUseCase: { execute: telemetryOn }, + telemetryOffUseCase: { execute: telemetryOff }, + readLocalCostUseCase: { execute: readLocalCost }, + reportCostUseCase: { execute: reportCost }, + diagnoseTelemetryUseCase: { execute: diagnose }, + forgetTelemetryUseCase: { preview: forgetPreview, remove: forgetRemove }, + personIdentityUseCase: { + status: identityStatus, + use: identityUse, + off: identityOff, + link: identityLink, + unlink: identityUnlink, + }, + telemetrySink: { locatedBy: "default", rootDir: "/records" }, + })), + createMenuDeps: vi.fn(), +})); + +const { createDeps } = await import("../../../src/runtime/wiring/framework.js"); +const { registerTelemetryCommand } = await import( + "../../../src/presentation/commands/telemetry.js" +); + +const PROJECT_ROOT = process.cwd(); + +function emptyReport() { + return buildCostReport({ + fromDay: "2026-08-17", + toDay: "2026-08-17", + records: [], + journals: [], + declaredTools: [ + { + tool: "claude", + coverage: "covered", + capability: { + localRead: null, + export: null, + journalAttributable: false, + taskAttributable: false, + }, + }, + ], + undatedRecords: 0, + unreadableLines: 0, + measurementEnabled: true, + }); +} + +const NOTHING_TO_REMOVE: TelemetryRemovalPreview = { + journal: { scope: "project", path: "/repo/aidd_docs/runs", runFileNames: [] }, + sink: { scope: "machine", path: "/records", dayFileNames: [] }, + identity: { scope: "machine", path: "/h/identity.json", present: false, unreadable: false }, + history: { certainty: "none" }, +}; + +const ONE_RUN_FILE: TelemetryRemovalPreview = { + ...NOTHING_TO_REMOVE, + journal: { scope: "project", path: "/repo/aidd_docs/runs", runFileNames: ["a.jsonl"] }, +}; + +let written: string[] = []; + +beforeEach(() => { + vi.clearAllMocks(); + written = []; + vi.spyOn(process.stdout, "write").mockImplementation((chunk) => { + written.push(String(chunk)); + return true; + }); + vi.spyOn(process.stderr, "write").mockImplementation(() => true); + telemetryOn.mockResolvedValue({ switchPath: "/repo/.aidd/config.json", switchChanged: true }); + telemetryOff.mockResolvedValue({ switchPath: "/repo/.aidd/config.json", switchChanged: true }); + readLocalCost.mockResolvedValue({ sessions: [], toolReports: [] }); + reportCost.mockResolvedValue(emptyReport()); + diagnose.mockResolvedValue({ + gate: "measurement is off", + setup: checkSetup(), + leftoverExportConfig: [], + }); + forgetPreview.mockResolvedValue(NOTHING_TO_REMOVE); + forgetRemove.mockResolvedValue({ + journal: { removed: 0, failed: [] }, + sink: { removed: 0, failed: [] }, + identity: { removed: 0, failed: [] }, + history: { certainty: "none" }, + }); + identityStatus.mockResolvedValue({ filePath: "/h/identity.json", identity: null }); + identityUse.mockResolvedValue({ + filePath: "/h/identity.json", + identity: { personId: "p-1", origin: "minted", alsoMe: [] }, + outcome: "minted", + }); + identityOff.mockResolvedValue({ + filePath: "/h/identity.json", + removed: false, + discardedDamaged: false, + addedIdentifiersRemoved: 0, + }); + identityLink.mockResolvedValue({ + filePath: "/h/identity.json", + personId: "p-1", + identity: "machine-2", + alreadyListed: true, + }); + identityUnlink.mockResolvedValue({ + filePath: "/h/identity.json", + identity: "machine-2", + removed: false, + }); +}); + +afterEach(() => { + vi.restoreAllMocks(); + process.exitCode = undefined; +}); + +function checkSetup() { + return { + allowed: { + allowed: true, + readable: true, + location: "/repo/.aidd/config.json", + decidedBy: "project-switch", + }, + identity: { attached: false, path: "/h/identity.json", readable: true }, + recordsLocation: { path: "/records" }, + hostRegistration: { entries: [] }, + commitTrailer: { + delegate: "executable", + callSite: "present", + hookHasOtherContent: false, + hooksDir: "/repo/.git/hooks", + }, + recorderDeclaration: { + declared: true, + declaredAt: ["/repo/.aidd/manifest.json"], + locationsChecked: ["/repo/.aidd/manifest.json"], + unreadable: [], + }, + versions: { cli: "5.2.2", plugin: { kind: "nothing-journalled" } }, + } as const; +} + +async function run(...args: string[]): Promise { + const program = new Command(); + program.exitOverride(); + registerTelemetryCommand(program); + await program.parseAsync(["node", "aidd", "telemetry", ...args]); + return written.join("").split("\n").slice(0, -1); +} + +describe("aidd telemetry on | off", () => { + it("passes the flag a person confirmed with, and prints what the switch became", async () => { + expect(await run("on", "--yes")).toEqual([ + "AIDD telemetry: on (/repo/.aidd/config.json)", + "/repo/.aidd/config.json is git-tracked — this applies to everyone who clones.", + ]); + expect(telemetryOn).toHaveBeenCalledWith({ projectRoot: PROJECT_ROOT, confirmed: true }); + }); + + it("passes an unconfirmed run as unconfirmed, never as absent", async () => { + await run("on"); + + expect(telemetryOn).toHaveBeenCalledWith({ projectRoot: PROJECT_ROOT, confirmed: false }); + }); + + it("asks off for nothing but the project, and prints what stays behind", async () => { + const lines = await run("off"); + + expect(telemetryOff).toHaveBeenCalledWith({ projectRoot: PROJECT_ROOT }); + expect(lines[0]).toBe("AIDD telemetry: off (/repo/.aidd/config.json)"); + }); +}); + +describe("aidd telemetry read", () => { + it("sweeps every journalled session when none was named", async () => { + const lines = await run("read"); + + expect(Object.keys(readLocalCost.mock.calls[0][0]).sort()).toEqual(["env", "projectRoot"]); + expect(lines).toEqual([" No session journalled yet — nothing to read."]); + }); + + it("narrows to the one session a person named", async () => { + await run("read", "--session", "s-1"); + + expect(readLocalCost).toHaveBeenCalledWith({ + projectRoot: PROJECT_ROOT, + env: process.env, + sessionId: "s-1", + }); + }); +}); + +describe("aidd telemetry report — what it asks for", () => { + it("resolves the period from the days given, and carries no filter nobody gave", async () => { + await run("report", "--from", "2026-08-17", "--to", "2026-08-19"); + + const asked = reportCost.mock.calls[0][0]; + expect(asked.period).toEqual({ fromDay: "2026-08-17", toDay: "2026-08-19" }); + expect(Object.keys(asked).sort()).toEqual(["env", "filters", "period", "projectRoot"]); + expect(Object.keys(asked.filters)).toEqual([]); + }); + + it("carries the task and every filter a person named, each under its own key", async () => { + await run( + "report", + "--from", + "2026-08-17", + "--to", + "2026-08-19", + "--task", + "2026_08/x", + "--project", + "acme/widgets", + "--step", + "aidd-dev:02-implement", + "--model", + "opus", + "--tool", + "claude" + ); + + expect(reportCost).toHaveBeenCalledWith({ + period: { fromDay: "2026-08-17", toDay: "2026-08-19" }, + projectRoot: PROJECT_ROOT, + env: process.env, + task: "2026_08/x", + filters: { + project: "acme/widgets", + step: "aidd-dev:02-implement", + model: "opus", + tool: "claude", + }, + }); + }); + + it("counts back from the last day when a span, not a first day, was given", async () => { + await run("report", "--to", "2026-08-19", "--days", "3"); + + expect(reportCost).toHaveBeenCalledWith( + expect.objectContaining({ period: { fromDay: "2026-08-17", toDay: "2026-08-19" } }) + ); + }); +}); + +describe("aidd telemetry report — the rendering it picks", () => { + const PERIOD = ["--from", "2026-08-17", "--to", "2026-08-17"]; + + it("prints the terminal rendering when neither shape was asked for", async () => { + expect(await run("report", ...PERIOD)).toEqual([ + "period 2026-08-17 to 2026-08-17", + "", + " sessions nothing in this period", + " requests nothing in this period", + "", + " by tool", + " Claude Code nothing in this period", + "", + " by day", + " 2026-08-17 nothing in this period", + ]); + }); + + it("prints one parseable object, and nothing else, for --json", async () => { + const lines = await run("report", ...PERIOD, "--json"); + + expect(JSON.parse(lines.join("\n"))).toMatchObject({ + period: { from_day: "2026-08-17", to_day: "2026-08-17" }, + }); + }); + + it("prints the one axis asked for as a table, never the whole report", async () => { + expect(await run("report", ...PERIOD, "--axis", "total")).toEqual([ + "period 2026-08-17 to 2026-08-17 — axis: total", + "", + "nothing in this period", + ]); + }); + + it("prefers the object over the table when both were asked for", async () => { + const lines = await run("report", ...PERIOD, "--json", "--axis", "total"); + + expect(() => JSON.parse(lines.join("\n"))).not.toThrow(); + }); +}); + +describe("aidd telemetry check", () => { + it("asks for this project and its environment, then prints the report", async () => { + const lines = await run("check"); + + expect(diagnose).toHaveBeenCalledWith({ projectRoot: PROJECT_ROOT, env: process.env }); + expect(lines.at(-1)).toBe(" measurement is off"); + }); + + // A gated run judged nothing, so it can find nothing wanting: failing the process there + // would report a project that switched measurement off as broken. + it("leaves the exit code alone when the run was gated before judging", async () => { + diagnose.mockResolvedValue({ + gate: "measurement is off", + setup: checkSetup(), + claims: [{ claim: "hook-fired", verdict: "fail", reason: "none", detail: "none" }], + leftoverExportConfig: [], + }); + + await run("check"); + + expect(process.exitCode).toBeUndefined(); + }); + + it("fails the process on one claim found wanting among claims that held", async () => { + diagnose.mockResolvedValue({ + setup: checkSetup(), + claims: [ + { claim: "hook-fired", verdict: "ok", reason: "session-anchored", detail: "1 run file" }, + { + claim: "records-join", + verdict: "fail", + reason: "all-unattributed", + detail: "none", + }, + ], + uncovered: [], + leftoverExportConfig: [], + }); + + await run("check"); + + expect(process.exitCode).toBe(1); + }); + + it("leaves the exit code alone when every judged claim held", async () => { + diagnose.mockResolvedValue({ + setup: checkSetup(), + claims: [{ claim: "hook-fired", verdict: "ok", reason: "fired", detail: "1 run file(s)" }], + uncovered: [], + leftoverExportConfig: [], + }); + + await run("check"); + + expect(process.exitCode).toBeUndefined(); + }); +}); + +describe("aidd telemetry forget", () => { + it("shows the preview and removes nothing when there was never anything to remove", async () => { + const lines = await run("forget", "--yes"); + + expect(forgetPreview).toHaveBeenCalledWith({ projectRoot: PROJECT_ROOT }); + expect(forgetRemove).not.toHaveBeenCalled(); + expect(lines[0]).toContain("nothing was ever measured here"); + }); + + it("refuses without the flag, after showing exactly what would go", async () => { + forgetPreview.mockResolvedValue(ONE_RUN_FILE); + + const lines = await run("forget"); + + expect(forgetRemove).not.toHaveBeenCalled(); + expect(lines[0]).toBe("This would remove:"); + expect(lines.at(-1)).toBe( + "Nothing removed. Pass --yes to remove exactly what is listed above." + ); + }); + + it("removes exactly the preview it showed, once the flag confirms it", async () => { + forgetPreview.mockResolvedValue(ONE_RUN_FILE); + + const lines = await run("forget", "--yes"); + + expect(forgetRemove).toHaveBeenCalledWith(ONE_RUN_FILE); + expect(lines).toContain("AIDD telemetry: removed"); + }); +}); + +describe("aidd telemetry identity", () => { + it("answers the bare noun with the state, never a help screen", async () => { + const lines = await run("identity"); + + expect(identityStatus).toHaveBeenCalledWith(); + expect(lines).toEqual(["AIDD identity: off - records carry no person"]); + }); + + it("mints without an identifier or a name when neither was given", async () => { + await run("identity", "use"); + + expect(Object.keys(identityUse.mock.calls[0][0])).toEqual([]); + }); + + it("takes the identifier given, alone", async () => { + await run("identity", "use", "p-9"); + + expect(Object.keys(identityUse.mock.calls[0][0])).toEqual(["identifier"]); + expect(identityUse).toHaveBeenCalledWith({ identifier: "p-9" }); + }); + + it("attaches a display name to whichever identifier the call settles on", async () => { + await run("identity", "use", "--name", "Ada"); + + expect(Object.keys(identityUse.mock.calls[0][0])).toEqual(["displayName"]); + expect(identityUse).toHaveBeenCalledWith({ displayName: "Ada" }); + }); + + it("carries both an identifier and a name when both were given", async () => { + await run("identity", "use", "p-9", "--name", "Ada"); + + expect(identityUse).toHaveBeenCalledWith({ identifier: "p-9", displayName: "Ada" }); + }); + + it("opts out with no argument at all", async () => { + const lines = await run("identity", "off"); + + expect(identityOff).toHaveBeenCalledWith(); + expect(lines).toEqual(["AIDD identity: already off - nothing to withdraw"]); + }); + + it("links and unlinks the identifier named on the command line", async () => { + await run("identity", "link", "machine-2"); + await run("identity", "unlink", "machine-2"); + + expect(identityLink).toHaveBeenCalledWith("machine-2"); + expect(identityUnlink).toHaveBeenCalledWith("machine-2"); + }); +}); + +describe("aidd telemetry — a use case that throws", () => { + it.each([ + [["on"], telemetryOn], + [["off"], telemetryOff], + [["read"], readLocalCost], + [["report", "--from", "2026-08-17", "--to", "2026-08-17"], reportCost], + [["check"], diagnose], + [["forget"], forgetPreview], + [["identity"], identityStatus], + [["identity", "use"], identityUse], + [["identity", "off"], identityOff], + [["identity", "link", "m-2"], identityLink], + [["identity", "unlink", "m-2"], identityUnlink], + ])("names the failure of %j on stderr and fails the process", async (args, useCase) => { + useCase.mockRejectedValue(new Error("boom")); + const errors: string[] = []; + vi.spyOn(process.stderr, "write").mockImplementation((chunk) => { + errors.push(String(chunk)); + return true; + }); + const exit = vi.spyOn(process, "exit").mockImplementation(() => { + throw new Error("exited"); + }); + + await expect(run(...args)).rejects.toThrow("exited"); + + expect(errors.join("")).toBe("Error: boom\n"); + expect(exit).toHaveBeenCalledWith(1); + }); +}); + +describe("aidd telemetry — the help surface", () => { + function telemetryCommand(): Command { + const program = new Command(); + registerTelemetryCommand(program); + const telemetry = program.commands.find((command) => command.name() === "telemetry"); + if (telemetry === undefined) throw new Error("telemetry command was not registered"); + return telemetry; + } + + function describedCommands(parent: Command): [string, string][] { + return parent.commands.flatMap((command): [string, string][] => [ + [command.name(), command.description()], + ...describedCommands(command), + ]); + } + + function optionsOf(path: readonly string[]): [string, string][] { + let command = telemetryCommand(); + for (const name of path) { + const child = command.commands.find((candidate) => candidate.name() === name); + if (child === undefined) throw new Error(`no subcommand ${name}`); + command = child; + } + return command.options.map((option) => [option.flags, option.description]); + } + + it("describes the group by what a person decides with it", () => { + expect(telemetryCommand().description()).toBe("Control whether AIDD may measure this project"); + }); + + it("describes every subcommand, in the order they are registered", () => { + expect(describedCommands(telemetryCommand())).toEqual([ + ["on", "Turn on the AIDD telemetry switch and git-ignore the run journal"], + [ + "read", + "Read what sessions cost from the files their tools already wrote, with no process running", + ], + ["identity", "Whether this person's own identifier is attached to records read locally"], + [ + "use", + "Mint this person's identifier, or take one minted on another machine. --name attaches a display name", + ], + ["off", "Opt out: new records carry no person, from now on"], + [ + "link", + "Add an identifier this person cannot choose onto this same person - one row, not two, in a report", + ], + ["unlink", "Withdraw an added identifier from this person"], + ["check", "Check whether the measurement chain is actually recording for this project"], + [ + "report", + "Report what a period, or one task inside it, cost — tokens, models and steps, with how strongly each was attributed", + ], + [ + "off", + "Turn off the AIDD telemetry switch, warning if a tool's own settings file still exports", + ], + [ + "forget", + "Irreversibly remove what this tool measured: this project's run journal, this " + + "machine's stored records, and this machine's identity file", + ], + ]); + }); + + it("says what confirming on and confirming forget each mean", () => { + expect(optionsOf(["on"])).toEqual([ + [ + "--yes", + "Confirm writing the git-tracked switch — this turns measurement on for everyone who clones", + ], + ]); + expect(optionsOf(["forget"])).toEqual([ + ["--yes", "Confirm removal after seeing what would go — without it, nothing is removed"], + ]); + }); + + it("says what omitting read's own session means", () => { + expect(optionsOf(["read"])).toEqual([ + [ + "--session ", + "One session to read. Omitted, every session the run journal knows is read", + ], + ]); + }); + + it("names every day, filter and shape report accepts, and every axis by name", () => { + expect(optionsOf(["report"])).toEqual([ + ["--from ", "First UTC day to report, as YYYY-MM-DD"], + ["--to ", "Last UTC day to report, as YYYY-MM-DD (default today)"], + [ + "--days ", + `How many days back to report, ending at --to (default ${DEFAULT_REPORT_DAYS})`, + ], + [ + "--task ", + "Restrict to the sessions that wrote into this task, as /", + ], + ["--project ", "Restrict to this project"], + ["--step ", "Restrict to this step"], + ["--model ", "Restrict to this model"], + ["--tool ", "Restrict to this tool"], + [ + "--axis ", + `Print one axis as a table to paste elsewhere: ${ARTEFACT_AXES.join(" | ")}`, + ], + ["--json", "Print one object a program can parse, instead of text for a person"], + ]); + }); + + it("names the display name identity use attaches, and asks nothing else anywhere", () => { + expect(optionsOf(["identity", "use"])).toEqual([ + ["--name ", "A display name for whichever identifier this call settles on"], + ]); + expect(optionsOf(["identity"])).toEqual([]); + expect(optionsOf(["check"])).toEqual([]); + }); +}); + +describe("aidd telemetry — how every subcommand builds its graph", () => { + it.each([ + ["on"], + ["off"], + ["read"], + ["report", "--from", "2026-08-17", "--to", "2026-08-17"], + ["check"], + ["forget"], + ["identity"], + ["identity", "use"], + ["identity", "off"], + ["identity", "link", "m-2"], + ["identity", "unlink", "m-2"], + ])("hands %j's own graph this run's verbosity, never an empty option set", async (...args) => { + await run(...args); + + expect(vi.mocked(createDeps)).toHaveBeenCalledWith( + PROJECT_ROOT, + { verbose: false }, + expect.anything() + ); + }); +}); diff --git a/cli/tests/presentation/commands/translate-help-targets.unit.test.ts b/cli/tests/presentation/commands/translate-help-targets.unit.test.ts new file mode 100644 index 000000000..bfc36467d --- /dev/null +++ b/cli/tests/presentation/commands/translate-help-targets.unit.test.ts @@ -0,0 +1,24 @@ +import { Command } from "commander"; +import { describe, expect, it } from "vitest"; +import { supportedBuildTargets } from "../../../src/contexts/translate/domain/build-target.js"; +import { registerTranslateCommand } from "../../../src/presentation/commands/translate.js"; + +/** + * The help text names targets in prose nothing derives, while the validation beside it reads + * the profiles. Asserting the set, not the sentence, leaves the wording free. + */ +describe("translate --to help text", () => { + it("names exactly the targets the command accepts", () => { + const program = new Command(); + registerTranslateCommand(program); + + const translate = program.commands.find((command) => command.name() === "translate"); + const description = translate?.options.find((option) => option.long === "--to")?.description; + + const named = [...(description ?? "").matchAll(/[a-z][a-z-]+/g)] + .map((match) => match[0]) + .filter((word) => (supportedBuildTargets() as readonly string[]).includes(word)); + + expect([...named].sort()).toEqual([...supportedBuildTargets()].sort()); + }); +}); diff --git a/cli/tests/presentation/commands/translate-wiring.integration.test.ts b/cli/tests/presentation/commands/translate-wiring.integration.test.ts new file mode 100644 index 000000000..e54b82ef1 --- /dev/null +++ b/cli/tests/presentation/commands/translate-wiring.integration.test.ts @@ -0,0 +1,222 @@ +import { resolve } from "node:path"; +import { Command } from "commander"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import "../../../src/contexts/tools/domain/profiles/claude/profile.js"; +import "../../../src/contexts/tools/domain/profiles/codex/profile.js"; +import "../../../src/contexts/tools/domain/profiles/copilot/profile.js"; +import "../../../src/contexts/tools/domain/profiles/cursor/profile.js"; +import "../../../src/contexts/tools/domain/profiles/opencode/profile.js"; +import "../../../src/contexts/tools/domain/profiles/vscode/profile.js"; +import { supportedBuildTargets } from "../../../src/contexts/translate/domain/build-target.js"; + +const build = vi.fn(); +let buildUseCase: { execute: typeof build } | undefined; + +vi.mock("../../../src/runtime/wiring/framework.js", () => ({ + createDeps: vi.fn(async () => ({ fs: {}, assetProvider: {}, logger: {} })), + createMenuDeps: vi.fn(), +})); + +vi.mock("../../../src/runtime/wiring/translate.js", () => ({ + createFrameworkBuildUseCase: vi.fn(() => buildUseCase), +})); + +const { createDeps } = await import("../../../src/runtime/wiring/framework.js"); +const { createFrameworkBuildUseCase } = await import("../../../src/runtime/wiring/translate.js"); +const { registerTranslateCommand } = await import( + "../../../src/presentation/commands/translate.js" +); + +const PROJECT_ROOT = process.cwd(); +const SOURCE_DIR = resolve(PROJECT_ROOT, "framework"); +const OUT_DIR = resolve(PROJECT_ROOT, "dist-plugins"); + +let written: string[] = []; +let errors: string[] = []; + +beforeEach(() => { + vi.clearAllMocks(); + written = []; + errors = []; + vi.spyOn(process.stdout, "write").mockImplementation((chunk) => { + written.push(String(chunk)); + return true; + }); + vi.spyOn(process.stderr, "write").mockImplementation((chunk) => { + errors.push(String(chunk)); + return true; + }); + buildUseCase = { execute: build }; + build.mockResolvedValue({ plugins: ["a", "b"], totalFiles: 12, outDir: OUT_DIR }); +}); + +afterEach(() => { + vi.restoreAllMocks(); + process.exitCode = undefined; +}); + +async function run(...args: string[]): Promise { + const program = new Command(); + program.exitOverride(); + program.option("--verbose"); + registerTranslateCommand(program); + await program.parseAsync(["node", "aidd", ...args]); + return written.join("").split("\n").slice(0, -1); +} + +describe("aidd translate — what it hands the build", () => { + it("resolves both directories against the project and builds a marketplace tree", async () => { + expect(await run("translate", "framework", "--to", "claude", "--out", "dist-plugins")).toEqual([ + `Built 2 plugins, 12 files written to ${OUT_DIR}`, + ]); + expect(vi.mocked(createFrameworkBuildUseCase)).toHaveBeenCalledWith(expect.anything(), { + target: "claude", + mode: "marketplace", + outDir: OUT_DIR, + force: false, + }); + expect(build).toHaveBeenCalledWith({ + sourceDir: SOURCE_DIR, + outDir: OUT_DIR, + target: "claude", + mode: "marketplace", + }); + }); + + it("carries a flat layout into both the factory and the build, and says so", async () => { + expect( + await run("translate", "framework", "--to", "claude", "--out", "dist-plugins", "--as", "flat") + ).toEqual([`Flat-installed 2 plugins, 12 files written under ${OUT_DIR}`]); + expect(vi.mocked(createFrameworkBuildUseCase)).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ mode: "flat" }) + ); + expect(build).toHaveBeenCalledWith(expect.objectContaining({ mode: "flat" })); + }); + + it("carries an overwrite through to the factory alone", async () => { + await run("translate", "framework", "--to", "claude", "--out", "dist-plugins", "--force"); + + expect(vi.mocked(createFrameworkBuildUseCase)).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ force: true }) + ); + }); + + it("builds the graph for this project at this run's verbosity", async () => { + await run("--verbose", "translate", "framework", "--to", "claude", "--out", "dist-plugins"); + + expect(vi.mocked(createDeps)).toHaveBeenCalledWith( + PROJECT_ROOT, + { verbose: true }, + expect.anything() + ); + }); +}); + +describe("aidd translate — what it refuses", () => { + it("refuses a target no profile declares, and lists the ones that do", async () => { + const exit = vi.spyOn(process, "exit").mockImplementation(() => { + throw new Error("exited"); + }); + + await expect( + run("translate", "framework", "--to", "emacs", "--out", "dist-plugins") + ).rejects.toThrow("exited"); + + expect(errors.join("")).toBe( + `Error: Unsupported target 'emacs'. Supported targets: ${supportedBuildTargets().join(", ")}.\n` + ); + expect(exit).toHaveBeenCalledWith(1); + expect(vi.mocked(createDeps)).not.toHaveBeenCalled(); + }); + + it("refuses a layout that is neither marketplace nor flat", async () => { + const exit = vi.spyOn(process, "exit").mockImplementation(() => { + throw new Error("exited"); + }); + + await expect( + run("translate", "framework", "--to", "claude", "--out", "dist-plugins", "--as", "zip") + ).rejects.toThrow("exited"); + + expect(errors.join("")).toBe("Error: Invalid --as 'zip'. Expected 'marketplace' or 'flat'.\n"); + expect(exit).toHaveBeenCalledWith(1); + expect(vi.mocked(createDeps)).not.toHaveBeenCalled(); + }); + + it("names the target and layout together when no strategy pairs them", async () => { + buildUseCase = undefined; + const exit = vi.spyOn(process, "exit").mockImplementation(() => { + throw new Error("exited"); + }); + + await expect( + run("translate", "framework", "--to", "claude", "--out", "dist-plugins", "--as", "flat") + ).rejects.toThrow("exited"); + + expect(errors[0]).toBe("Error: Unsupported target/mode combination: claude (flat).\n"); + expect(exit).toHaveBeenCalledWith(1); + expect(build).not.toHaveBeenCalled(); + }); + + it("names a failed build on stderr and fails the process", async () => { + build.mockRejectedValue(new Error("source directory is empty")); + const exit = vi.spyOn(process, "exit").mockImplementation(() => { + throw new Error("exited"); + }); + + await expect( + run("translate", "framework", "--to", "claude", "--out", "dist-plugins") + ).rejects.toThrow("exited"); + + expect(errors.join("")).toBe("Error: source directory is empty\n"); + expect(exit).toHaveBeenCalledWith(1); + }); +}); + +describe("aidd translate — the help surface", () => { + function translateCommand(): Command { + const program = new Command(); + registerTranslateCommand(program); + const translate = program.commands.find((command) => command.name() === "translate"); + if (translate === undefined) throw new Error("translate command was not registered"); + return translate; + } + + it("describes itself against the command that records what it writes", () => { + expect(translateCommand().description()).toBe( + "Convert an arbitrary source into a target-native plugin tree — records nothing (see `sync` for the manifest-driven, tracked version)" + ); + }); + + it("takes one required source, named for what it points at", () => { + expect( + translateCommand().registeredArguments.map((argument) => [ + argument.name(), + argument.required, + argument.description, + ]) + ).toEqual([["source", true, "Path to the source framework directory"]]); + }); + + it("requires a target and an output directory, and defaults the layout", () => { + expect( + translateCommand().options.map((option) => [ + option.flags, + option.description, + option.defaultValue, + ]) + ).toEqual([ + ["--to ", "Conversion target (claude, cursor, copilot, codex, opencode)", undefined], + ["--out ", "Output directory (marketplace dist or project root)", undefined], + ["--as ", "Output layout", "marketplace"], + ["--force", "Overwrite existing files at canonical paths under --out", undefined], + ]); + expect( + translateCommand() + .options.filter((option) => option.mandatory) + .map((option) => option.long) + ).toEqual(["--to", "--out"]); + }); +}); diff --git a/cli/tests/presentation/commands/update-wiring.integration.test.ts b/cli/tests/presentation/commands/update-wiring.integration.test.ts new file mode 100644 index 000000000..cd4624a62 --- /dev/null +++ b/cli/tests/presentation/commands/update-wiring.integration.test.ts @@ -0,0 +1,147 @@ +import { Command } from "commander"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { SelfUpdateResult } from "../../../src/runtime/self-update/self-update-use-case.js"; + +const selfUpdate = vi.fn<() => Promise>(); + +vi.mock("../../../src/runtime/wiring/framework.js", () => ({ + createDeps: vi.fn(async () => ({ selfUpdateUseCase: { execute: selfUpdate } })), + createMenuDeps: vi.fn(), +})); + +const { createDeps } = await import("../../../src/runtime/wiring/framework.js"); +const { registerUpdateCommand } = await import("../../../src/presentation/commands/update.js"); + +const PROJECT_ROOT = process.cwd(); + +let written: string[] = []; +let errors: string[] = []; + +beforeEach(() => { + vi.clearAllMocks(); + written = []; + errors = []; + vi.spyOn(process.stdout, "write").mockImplementation((chunk) => { + written.push(String(chunk)); + return true; + }); + vi.spyOn(process.stderr, "write").mockImplementation((chunk) => { + errors.push(String(chunk)); + return true; + }); + selfUpdate.mockResolvedValue({ kind: "up-to-date", version: "5.2.2" }); +}); + +afterEach(() => { + vi.restoreAllMocks(); + process.exitCode = undefined; +}); + +async function run(...args: string[]): Promise { + const program = new Command(); + program.exitOverride(); + program.option("--verbose"); + registerUpdateCommand(program); + await program.parseAsync(["node", "aidd", ...args]); + return written.join("").split("\n").slice(0, -1); +} + +describe("aidd update — what it asks the self-updater", () => { + it("asks for a real install with every flag off when none was given", async () => { + expect(await run("update")).toEqual(["Already up to date (5.2.2)"]); + expect(selfUpdate).toHaveBeenCalledWith({ check: false, dryRun: false, force: false }); + }); + + it("asks only for a check, and names the version waiting", async () => { + selfUpdate.mockResolvedValue({ + kind: "check-available", + latestVersion: "5.3.0", + currentVersion: "5.2.2", + }); + + expect(await run("update", "--check")).toEqual([ + "New version available: 5.3.0 (current: 5.2.2)", + ]); + expect(selfUpdate).toHaveBeenCalledWith({ check: true, dryRun: false, force: false }); + }); + + it("asks for a preview, and names what it would install", async () => { + selfUpdate.mockResolvedValue({ kind: "dry-run", latestVersion: "5.3.0" }); + + expect(await run("update", "--dry-run")).toEqual(["Would install @ai-driven-dev/cli@5.3.0"]); + expect(selfUpdate).toHaveBeenCalledWith({ check: false, dryRun: true, force: false }); + }); + + it("carries a forced reinstall through, and names where the binary landed", async () => { + selfUpdate.mockResolvedValue({ + kind: "updated", + latestVersion: "5.3.0", + binaryPath: "/usr/local/bin/aidd", + }); + + expect(await run("update", "--force")).toEqual([ + "Successfully updated to version 5.3.0 (/usr/local/bin/aidd)", + ]); + expect(selfUpdate).toHaveBeenCalledWith({ check: false, dryRun: false, force: true }); + }); + + it("answers to upgrade as well as to update", async () => { + expect(await run("upgrade")).toEqual(["Already up to date (5.2.2)"]); + expect(selfUpdate).toHaveBeenCalledTimes(1); + }); + + it("builds the graph for this project at this run's verbosity", async () => { + await run("update"); + + expect(vi.mocked(createDeps)).toHaveBeenCalledWith( + PROJECT_ROOT, + { verbose: false }, + expect.anything() + ); + }); + + it("carries --verbose into the graph it builds", async () => { + await run("--verbose", "update"); + + expect(vi.mocked(createDeps)).toHaveBeenCalledWith( + PROJECT_ROOT, + { verbose: true }, + expect.anything() + ); + }); + + it("names the failure on stderr and fails the process", async () => { + selfUpdate.mockRejectedValue(new Error("registry unreachable")); + const exit = vi.spyOn(process, "exit").mockImplementation(() => { + throw new Error("exited"); + }); + + await expect(run("update")).rejects.toThrow("exited"); + + expect(errors.join("")).toBe("Error: registry unreachable\n"); + expect(exit).toHaveBeenCalledWith(1); + }); +}); + +describe("aidd update — the help surface", () => { + function updateCommand(): Command { + const program = new Command(); + registerUpdateCommand(program); + const update = program.commands.find((command) => command.name() === "update"); + if (update === undefined) throw new Error("update command was not registered"); + return update; + } + + it("describes the command by what it updates, and answers to one alias", () => { + expect(updateCommand().description()).toBe("Update the aidd CLI itself to the latest version"); + expect(updateCommand().aliases()).toEqual(["upgrade"]); + }); + + it("offers a check, a preview and a reinstall, and asks nothing else", () => { + expect(updateCommand().options.map((option) => [option.flags, option.description])).toEqual([ + ["--check", "Check if a newer version is available without installing"], + ["--dry-run", "Preview the update without installing"], + ["-f, --force", "Reinstall even if already up to date"], + ]); + }); +}); diff --git a/cli/tests/presentation/display/auth-display.unit.test.ts b/cli/tests/presentation/display/auth-display.unit.test.ts new file mode 100644 index 000000000..00fdbe23e --- /dev/null +++ b/cli/tests/presentation/display/auth-display.unit.test.ts @@ -0,0 +1,74 @@ +import { describe, expect, it } from "vitest"; +import { + printAuthenticated, + printAuthStatus, + printLogoutResult, +} from "../../../src/presentation/display/auth-display.js"; +import { CapturingOutput } from "../../helpers/ports/capturing-output.js"; + +describe("printAuthenticated", () => { + it("names the login and the level it was stored at", () => { + const output = new CapturingOutput(false); + + printAuthenticated(output, "octocat", "user"); + + expect(output.at("success")).toEqual(["Authenticated as octocat (user)"]); + }); +}); + +describe("printAuthStatus", () => { + it("says not authenticated when no credential resolved", () => { + const output = new CapturingOutput(false); + + printAuthStatus(output, { authenticated: false }); + + expect(output.captured).toEqual([{ level: "info", message: "Not authenticated." }]); + }); + + it("names the login and its level when one resolved", () => { + const output = new CapturingOutput(false); + + printAuthStatus(output, { authenticated: true, login: "octocat", level: "project" }); + + expect(output.captured).toEqual([ + { level: "success", message: "Authenticated as octocat (project)" }, + ]); + }); +}); + +describe("printLogoutResult", () => { + it("says not authenticated when there was nothing to remove", () => { + const output = new CapturingOutput(false); + + printLogoutResult(output, { found: false }); + + expect(output.captured).toEqual([{ level: "info", message: "Not authenticated." }]); + }); + + it("confirms the level a stored credential was removed from", () => { + const output = new CapturingOutput(false); + + printLogoutResult(output, { found: true, level: "user" }); + + expect(output.captured).toEqual([{ level: "success", message: "Logged out (user)" }]); + }); + + it("points at the external provider's own logout before confirming", () => { + const output = new CapturingOutput(false); + + printLogoutResult(output, { + found: true, + level: "project", + hint: "external-provider-cleanup", + }); + + expect(output.captured).toEqual([ + { + level: "info", + message: + "To fully logout, run the external provider's logout command (e.g. gh auth logout).", + }, + { level: "success", message: "Logged out (project)" }, + ]); + }); +}); diff --git a/cli/tests/presentation/display/clean-display.unit.test.ts b/cli/tests/presentation/display/clean-display.unit.test.ts new file mode 100644 index 000000000..c2294086d --- /dev/null +++ b/cli/tests/presentation/display/clean-display.unit.test.ts @@ -0,0 +1,250 @@ +import { describe, expect, it } from "vitest"; +import { + printProjectCleanOutcome, + printUserScopeCleanOutcome, +} from "../../../src/presentation/display/clean-display.js"; +import { CapturingOutput } from "../../helpers/ports/capturing-output.js"; + +const NO_PREVIEW = { tools: [], nativeRegistrations: [], totalFileCount: 0 }; + +describe("printProjectCleanOutcome", () => { + it("says nothing is there to clean when no manifest was found", () => { + const output = new CapturingOutput(false); + + printProjectCleanOutcome( + output, + { manifestFound: false, dryRun: false, preview: NO_PREVIEW, fileCount: 0 }, + false + ); + + expect(output.at("success")).toEqual(["Nothing to clean"]); + }); + + it("counts the files removed once the run was forced", () => { + const output = new CapturingOutput(false); + + printProjectCleanOutcome( + output, + { manifestFound: true, dryRun: false, preview: NO_PREVIEW, fileCount: 12 }, + false + ); + + expect(output.at("success")).toEqual(["Cleaned all AIDD files (12 files removed)"]); + }); + + it("names every tool, the manifest, and each registration a forced run would undo", () => { + const output = new CapturingOutput(false); + + printProjectCleanOutcome( + output, + { + manifestFound: true, + dryRun: true, + preview: { + tools: [ + { toolId: "claude", fileCount: 3 }, + { toolId: "cursor", fileCount: 1 }, + ], + nativeRegistrations: [ + { + toolId: "claude", + binary: "claude", + pluginRefCount: 2, + marketplaceCount: 1, + cachePaths: ["/home/me/.claude/plugins/cache"], + }, + ], + totalFileCount: 4, + }, + fileCount: 0, + }, + true + ); + + expect(output.lines).toEqual([ + "The following will be removed:", + " claude: 3 files", + " cursor: 1 files", + " manifest: .aidd/ (config.json, if present, is kept)", + " claude: claude will be asked to unregister 2 plugin ref(s) and 1 marketplace(s)", + " cache to purge once unregistered: /home/me/.claude/plugins/cache", + "No files removed.", + ]); + }); + + it("names the other projects still holding the shared source", () => { + const output = new CapturingOutput(false); + + printProjectCleanOutcome( + output, + { + manifestFound: true, + dryRun: true, + preview: { ...NO_PREVIEW, sharedSourceOtherProjects: ["/a", "/b"] }, + fileCount: 0, + }, + true + ); + + expect(output.lines).toEqual([ + "The following will be removed:", + " manifest: .aidd/ (config.json, if present, is kept)", + " aidd-framework: shared source, still referenced by: /a, /b", + "No files removed.", + ]); + }); + + it("says no other project holds the shared source when the list is empty", () => { + const output = new CapturingOutput(false); + + printProjectCleanOutcome( + output, + { + manifestFound: true, + dryRun: true, + preview: { ...NO_PREVIEW, sharedSourceOtherProjects: [] }, + fileCount: 0, + }, + true + ); + + expect(output.lines).toContain( + " aidd-framework: shared source, still referenced by: no other project" + ); + }); + + it("asks for --force in the singular off a terminal", () => { + const output = new CapturingOutput(false); + + printProjectCleanOutcome( + output, + { + manifestFound: true, + dryRun: true, + preview: { + tools: [{ toolId: "claude", fileCount: 1 }], + nativeRegistrations: [], + totalFileCount: 1, + }, + fileCount: 0, + }, + false + ); + + expect(output.at("success")).toEqual([ + "Would remove 1 file across 1 tool. Use --force to confirm.", + ]); + }); + + it("asks for --force in the plural off a terminal", () => { + const output = new CapturingOutput(false); + + printProjectCleanOutcome( + output, + { + manifestFound: true, + dryRun: true, + preview: { + tools: [ + { toolId: "claude", fileCount: 1 }, + { toolId: "cursor", fileCount: 1 }, + ], + nativeRegistrations: [], + totalFileCount: 2, + }, + fileCount: 0, + }, + false + ); + + expect(output.at("success")).toEqual([ + "Would remove 2 files across 2 tools. Use --force to confirm.", + ]); + }); +}); + +describe("printUserScopeCleanOutcome", () => { + it("names each tool, the built versions and the referencing projects before removing anything", () => { + const output = new CapturingOutput(false); + + printUserScopeCleanOutcome( + output, + { + dryRun: true, + manifestFound: true, + preview: { + toolIds: ["claude", "codex"], + builtVersions: ["7.0.0", "7.1.0"], + referencingProjects: ["/work/api", "/work/web"], + }, + }, + true + ); + + expect(output.lines).toEqual([ + "The following will be removed for this machine:", + " claude: registration will be undone through its own CLI", + " codex: registration will be undone through its own CLI", + " aidd-framework: shared source (versions: 7.0.0, 7.1.0)", + " still referenced by: /work/api, /work/web", + "No files removed.", + ]); + }); + + it("reports nothing built and no other project when both lists are empty", () => { + const output = new CapturingOutput(false); + + printUserScopeCleanOutcome( + output, + { + dryRun: true, + manifestFound: true, + preview: { toolIds: [], builtVersions: [], referencingProjects: [] }, + }, + false + ); + + expect(output.lines).toEqual([ + "The following will be removed for this machine:", + " aidd-framework: shared source (versions: none built yet)", + " still referenced by: no other project", + "Use --force to confirm.", + ]); + }); + + it("names the machine-local purge alone when no user-scope manifest was there", () => { + const output = new CapturingOutput(false); + + printUserScopeCleanOutcome( + output, + { + dryRun: false, + manifestFound: false, + preview: { toolIds: [], builtVersions: [], referencingProjects: [] }, + }, + false + ); + + expect(output.at("success")).toEqual([ + "Purged the shared aidd-framework source's machine-local state", + ]); + }); + + it("confirms the shared source was cleaned when a manifest was there", () => { + const output = new CapturingOutput(false); + + printUserScopeCleanOutcome( + output, + { + dryRun: false, + manifestFound: true, + preview: { toolIds: [], builtVersions: [], referencingProjects: [] }, + }, + false + ); + + expect(output.at("success")).toEqual([ + "Cleaned the shared aidd-framework source for this machine", + ]); + }); +}); diff --git a/cli/tests/presentation/display/cost-report-artefact.unit.test.ts b/cli/tests/presentation/display/cost-report-artefact.unit.test.ts new file mode 100644 index 000000000..829dc5321 --- /dev/null +++ b/cli/tests/presentation/display/cost-report-artefact.unit.test.ts @@ -0,0 +1,1040 @@ +import { describe, expect, it } from "vitest"; +import "../../../src/contexts/tools/domain/profiles/claude/profile.js"; +import "../../../src/contexts/tools/domain/profiles/codex/profile.js"; +import "../../../src/contexts/tools/domain/profiles/copilot/profile.js"; +import "../../../src/contexts/tools/domain/profiles/cursor/profile.js"; +import { + buildCostReport, + type CostReportInput, +} from "../../../src/contexts/telemetry/domain/cost-report.js"; +import { toCostReportEnvelope } from "../../../src/contexts/telemetry/domain/cost-report-envelope.js"; +import { bareOrchestratingSkillNames } from "../../../src/contexts/telemetry/domain/flow-attribution.js"; +import type { PersonIdentity } from "../../../src/contexts/telemetry/domain/ports/person-identity-reader.js"; +import type { TelemetrySinkRecord } from "../../../src/contexts/telemetry/domain/telemetry-sink-record.js"; +import { + ARTEFACT_AXES, + buildCostReportArtefact, + isArtefactAxis, +} from "../../../src/presentation/display/cost-report-artefact.js"; +import { printCostReport } from "../../../src/presentation/display/cost-report-display.js"; +import { CapturingOutput } from "../../helpers/ports/capturing-output.js"; + +const NO_CAPABILITY = { + localRead: null, + export: null, + journalAttributable: false, + taskAttributable: false, +} as const; + +const BASE: TelemetrySinkRecord = { + sink_schema_version: 2, + kind: "request", + provenance: "local-read", + tool: "claude", + vendor_id: "s-1", + vendor_field: "sessionId", + step_attribution: "unattributed", +}; + +function request(overrides: Partial = {}): TelemetrySinkRecord { + return { ...BASE, ...overrides }; +} + +function envelopeOf(overrides: Partial = {}) { + return toCostReportEnvelope( + buildCostReport({ + fromDay: "2026-08-17", + toDay: "2026-08-21", + records: [], + journals: [], + declaredTools: [{ tool: "claude", coverage: "covered", capability: NO_CAPABILITY }], + undatedRecords: 0, + unreadableLines: 0, + measurementEnabled: true, + ...overrides, + }) + ); +} + +function onePersonMapping(): PersonIdentity { + return { personId: "person-a", origin: "adopted", alsoMe: ["machine-1"], displayName: "Ada" }; +} + +describe("buildCostReportArtefact", () => { + // A resolution `personLabel` does not name must not fall through to the no-identifier + // label: a value added to `PersonResolution` would reach a reader as its opposite. + it("names a row this machine's identity claims after that person, not as nobody", () => { + const envelope = envelopeOf({ + records: [request({ turn_id: "a", event_timestamp: "2026-08-17T10:00:00Z" })], + identity: onePersonMapping(), + }); + + const artefact = buildCostReportArtefact(envelope, "person"); + + expect(artefact).toContain("Ada"); + expect(artefact).not.toContain("nobody opted in"); + }); + + it("lists person among the known axes", () => { + expect(ARTEFACT_AXES).toContain("person"); + expect(isArtefactAxis("person")).toBe(true); + }); + + it("answers the prompt axis with one dated row per prompt, and the remainder last", () => { + const artefact = buildCostReportArtefact( + envelopeOf({ + records: [ + request({ + turn_id: "a", + prompt_id: "p-1", + cost_usd: 2, + event_timestamp: "2026-08-18T09:00:00Z", + }), + request({ turn_id: "b", cost_usd: 1, event_timestamp: "2026-08-18T10:00:00Z" }), + ], + }), + "prompt" + ); + + expect(artefact).toContain("| Prompt | Started at | Total |"); + const lines = artefact + .split("\n") + .filter((line) => line.startsWith("| p-1") || line.includes("no prompt named")); + expect(lines[0]).toContain("| p-1 | 2026-08-18T09:00:00Z |"); + expect(lines[1]).toContain("| no prompt named | — |"); + }); + + it("refuses an unknown axis by name, listing the ones that exist", () => { + expect(() => buildCostReportArtefact(envelopeOf(), "bogus")).toThrow( + /Unknown axis 'bogus'.*person/su + ); + }); + + // A pasted table leaves the terminal behind, so it has to carry the switch being off itself. + it("names the project's switch being off in its own header, on every axis", () => { + const off = envelopeOf({ measurementEnabled: false }); + for (const axis of ARTEFACT_AXES) { + expect(buildCostReportArtefact(off, axis)).toContain("this project's switch is off"); + } + }); + + it("says nothing about the switch in the header when it is on", () => { + const on = envelopeOf({ measurementEnabled: true }); + expect(buildCostReportArtefact(on, "total")).not.toContain("switch is off"); + }); + + it("prints one row per person with the identities behind it, mapped rows first", () => { + const envelope = envelopeOf({ + identity: onePersonMapping(), + records: [request({ turn_id: "a", person_id: "machine-1" })], + }); + + const artefact = buildCostReportArtefact(envelope, "person"); + + expect(artefact).toContain("Ada"); + expect(artefact).toContain("machine-1"); + }); + + it("prints two unplaced identifiers as two labelled rows, never one bucket", () => { + const envelope = envelopeOf({ + identity: onePersonMapping(), + records: [ + request({ turn_id: "a", person_id: "a-stranger" }), + request({ turn_id: "b", person_id: "another-stranger" }), + ], + }); + + const artefact = buildCostReportArtefact(envelope, "person"); + + expect(artefact).toContain("a-stranger"); + expect(artefact).toContain("another-stranger"); + const unresolvedLines = artefact.split("\n").filter((line) => line.includes("unresolved")); + expect(unresolvedLines).toHaveLength(2); + }); + + // Only with no identity declared on this machine is "nobody opted in" true of a record + // carrying no identifier; with one declared, that record is this machine's own person. + it("labels the no-identifier row distinctly from an unresolved one", () => { + const envelope = envelopeOf({ + records: [request({ turn_id: "a" }), request({ turn_id: "b", person_id: "a-stranger" })], + }); + + const artefact = buildCostReportArtefact(envelope, "person"); + const rows = artefact + .split("\n") + .filter((line) => line.includes("nobody opted in") || line.includes("unresolved")); + + expect(rows).toHaveLength(2); + const [unresolvedRow] = rows.filter((line) => line.includes("unresolved")); + const [noneRow] = rows.filter((line) => line.includes("nobody opted in")); + // The two labels must never be interchangeable: neither row's label is a substring of + // the other's, so a reader can never mistake one bucket for the other. + expect(unresolvedRow).not.toContain("nobody opted in"); + expect(noneRow).not.toContain("unresolved"); + }); + + it("prints every figure and a caveat when the identity could not be read", () => { + const envelope = envelopeOf({ + records: [request({ turn_id: "a", cost_usd: 1, person_id: "machine-1" })], + identityUnusableCause: "unreadable", + }); + + const artefact = buildCostReportArtefact(envelope, "person"); + + expect(artefact).toContain("$1.00"); + expect(artefact).toMatch(/own identity could not be read/u); + }); + + it("prints every figure and a different caveat when no identity was declared at all", () => { + const envelope = envelopeOf({ + records: [request({ turn_id: "a", cost_usd: 1, person_id: "machine-1" })], + identityUnusableCause: "absent", + }); + + const artefact = buildCostReportArtefact(envelope, "person"); + + expect(artefact).toContain("$1.00"); + expect(artefact).toMatch(/no identity was declared/u); + }); + + it("prints no person caveat on the total axis when nobody opted in - that is the default state, not a degraded read", () => { + const envelope = envelopeOf({ + records: [request({ turn_id: "a", cost_usd: 1 })], + identityUnusableCause: "absent", + }); + + const artefact = buildCostReportArtefact(envelope, "total"); + + expect(artefact).toContain("$1.00"); + expect(artefact).not.toMatch(/no identity was declared/u); + }); + + it("still prints the unreadable caveat on the total axis - that one is real damage", () => { + const envelope = envelopeOf({ + records: [request({ turn_id: "a", cost_usd: 1 })], + identityUnusableCause: "unreadable", + }); + + const artefact = buildCostReportArtefact(envelope, "total"); + + expect(artefact).toMatch(/own identity could not be read/u); + }); + + it("names two different causes with two different caveats", () => { + const unreadable = buildCostReportArtefact( + envelopeOf({ identityUnusableCause: "unreadable" }), + "person" + ); + const absent = buildCostReportArtefact( + envelopeOf({ identityUnusableCause: "absent" }), + "person" + ); + + expect(unreadable).not.toBe(absent); + expect(unreadable).toMatch(/could not be read/u); + expect(absent).toMatch(/no identity was declared/u); + }); +}); + +// `by_step` is keyed on step plus attribution, so one skill can hold two rows sharing a name. +// A pasted table is the one place that column can be dropped silently. +describe("buildCostReportArtefact — by step, two rows sharing one name", () => { + const STEP = "aidd-dev:02-implement"; + + function ambiguousStepInput(): CostReportInput { + return { + fromDay: "2026-08-17", + toDay: "2026-08-21", + records: [ + request({ + turn_id: "a", + step_attribution: "tool-stated", + step: STEP, + input_tokens: 1000, + }), + request({ + turn_id: "b", + step_attribution: "journal-interval", + step: STEP, + input_tokens: 500, + }), + ], + journals: [], + declaredTools: [{ tool: "claude", coverage: "covered", capability: NO_CAPABILITY }], + undatedRecords: 0, + unreadableLines: 0, + measurementEnabled: true, + }; + } + + it("carries the attribution on every row, so two rows for one step are distinguishable on their own", () => { + const report = buildCostReport(ambiguousStepInput()); + const artefact = buildCostReportArtefact(toCostReportEnvelope(report), "step"); + + const stepLines = artefact.split("\n").filter((line) => line.startsWith(`| ${STEP} |`)); + expect(stepLines).toHaveLength(2); + expect(stepLines.some((line) => line.includes("stated by the tool"))).toBe(true); + expect(stepLines.some((line) => line.includes("from a journal interval"))).toBe(true); + }); + + it("reconciles to what the terminal prints for that step, row for row", () => { + const report = buildCostReport(ambiguousStepInput()); + const artefact = buildCostReportArtefact(toCostReportEnvelope(report), "step"); + const output = new CapturingOutput(); + printCostReport(output, report); + const terminalText = output.lines.join("\n"); + + // Both renderings read the same `bySteps` data; the true total for the step (never + // itself printed as one line, by either renderer) is what a reader sums the rows to. + expect(terminalText).toContain(STEP); + expect(terminalText).toMatch(/stated by the tool/u); + expect(terminalText).toMatch(/from a journal interval/u); + + const toolStatedRow = artefact + .split("\n") + .find((line) => line.startsWith(`| ${STEP} |`) && line.includes("stated by the tool")); + const journalIntervalRow = artefact + .split("\n") + .find((line) => line.startsWith(`| ${STEP} |`) && line.includes("journal interval")); + expect(toolStatedRow).toContain("1,000 tokens"); + expect(journalIntervalRow).toContain("500 tokens"); + // 1,500 total input tokens across the two records - never printed as one row by either + // renderer, but recoverable from the two rows a reader is given. + }); +}); + +describe("buildCostReportArtefact — the agent axis names which silence a row is", () => { + const NAMES_AGENTS = { + localRead: { tokenCounters: true, amount: false, toolStatedStep: false, agentName: true }, + export: null, + journalAttributable: false, + taskAttributable: false, + } as const; + + // Two rows carry no agent name and mean opposite things: printing "the main thread" for a + // tool that never names an agent states, of that tool, a fact nothing observed. + it("prints the main thread and a tool that names no agent as different rows", () => { + const envelope = envelopeOf({ + declaredTools: [ + { tool: "claude", coverage: "covered", capability: NAMES_AGENTS }, + { tool: "codex", coverage: "covered", capability: NO_CAPABILITY }, + ], + records: [ + request({ input_tokens: 10 }), + request({ tool: "codex", vendor_id: "s-codex", input_tokens: 10 }), + ], + }); + + const artefact = buildCostReportArtefact(envelope, "agent"); + + expect(artefact).toContain("| the main thread |"); + expect(artefact).toContain("| the tool names no agent |"); + }); + + it("prints the same two labels in the terminal rendering", () => { + const report = buildCostReport({ + fromDay: "2026-08-17", + toDay: "2026-08-21", + journals: [], + undatedRecords: 0, + unreadableLines: 0, + measurementEnabled: true, + declaredTools: [ + { tool: "claude", coverage: "covered", capability: NAMES_AGENTS }, + { tool: "codex", coverage: "covered", capability: NO_CAPABILITY }, + ], + records: [ + request({ input_tokens: 10 }), + request({ tool: "codex", vendor_id: "s-codex", input_tokens: 10 }), + ], + }); + const output = new CapturingOutput(); + + printCostReport(output, report); + + const printed = output.lines.join("\n"); + expect(printed).toContain("the main thread"); + expect(printed).toContain("the tool names no agent"); + }); +}); + +describe("buildCostReportArtefact — the flow axis states its own limits with the figures", () => { + const FLOW_JOURNAL = [ + { + vendorId: "s-1", + tool: "claude-code" as const, + writtenPaths: [], + taskIntervals: [], + flowIntervals: [ + { + skill: "aidd-orchestrator:01-sdlc", + startMs: Date.parse("2026-08-17T10:00:00Z"), + endMs: Date.parse("2026-08-17T11:00:00Z"), + closedBy: "boundary" as const, + }, + ], + }, + ]; + + function withOneFlow() { + return envelopeOf({ + records: [request({ event_timestamp: "2026-08-17T10:30:00Z", input_tokens: 10 })], + journals: FLOW_JOURNAL, + }); + } + + it("says a hand-run skill counts inside the flow it ran during", () => { + expect(buildCostReportArtefact(withOneFlow(), "flow")).toContain( + "a skill run by hand while a flow was open is counted inside it" + ); + }); + + it("says a same-named skill of the reader's own project opens a flow of its own", () => { + expect(buildCostReportArtefact(withOneFlow(), "flow")).toContain("opens a flow of its own"); + }); + + // The names are read from the declared set, never written out beside it: a hardcoded list + // would go on printing its own three once a fourth orchestrator is declared. + it("names every unqualified orchestrating skill the declared set holds, whatever it holds", () => { + const artefact = buildCostReportArtefact(withOneFlow(), "flow"); + const bare = bareOrchestratingSkillNames(); + + expect(bare.length).toBeGreaterThan(0); + for (const name of bare) expect(artefact).toContain(name); + }); + + it("says neither when the period names no flow at all - a limit that bit nothing is noise", () => { + const noFlow = envelopeOf({ + records: [request({ event_timestamp: "2026-08-17T10:30:00Z", input_tokens: 10 })], + journals: [], + }); + const artefact = buildCostReportArtefact(noFlow, "flow"); + expect(artefact).not.toContain("counted inside it"); + expect(artefact).not.toContain("opens a flow of its own"); + }); + + // A limit is a statement about a mechanism that ran, and a period whose only flow its own + // tool named never walked a step sequence. + it("states no journal limit for a period whose only flow its own tool named", () => { + const statedOnly = envelopeOf({ + records: [ + request({ + event_timestamp: "2026-08-17T10:30:00Z", + input_tokens: 10, + step_attribution: "tool-stated", + step: "aidd-orchestrator:01-sdlc", + }), + ], + journals: [], + }); + + const artefact = buildCostReportArtefact(statedOnly, "flow"); + + expect(artefact).toContain("is every run of that skill at once"); + expect(artefact).not.toContain("counted inside it"); + expect(artefact).not.toContain("opens a flow of its own"); + }); + + it("states no tool-stated limit for a period whose flows the journal all witnessed", () => { + expect(buildCostReportArtefact(withOneFlow(), "flow")).not.toContain( + "is every run of that skill at once" + ); + }); + + it("states them on the flow axis alone, never on every axis", () => { + const envelope = withOneFlow(); + for (const axis of ARTEFACT_AXES.filter((name) => name !== "flow")) { + expect(buildCostReportArtefact(envelope, axis)).not.toContain("counted inside it"); + } + }); +}); + +const THREE_TOOLS = [ + { + tool: "claude", + coverage: "covered", + capability: { + localRead: { tokenCounters: true, amount: true, toolStatedStep: true, agentName: true }, + export: null, + journalAttributable: true, + taskAttributable: true, + }, + }, + { tool: "codex", coverage: "covered", capability: NO_CAPABILITY }, + { + tool: "cursor", + coverage: "not-covered", + reason: "It writes no token count.", + capability: NO_CAPABILITY, + }, +] as const satisfies CostReportInput["declaredTools"]; + +function threeDayInput(overrides: Partial = {}): CostReportInput { + return { + fromDay: "2026-08-17", + toDay: "2026-08-19", + declaredTools: [...THREE_TOOLS], + records: [], + journals: [], + undatedRecords: 0, + unreadableLines: 0, + measurementEnabled: true, + ...overrides, + }; +} + +function threeDayRich(overrides: Partial = {}): CostReportInput { + return threeDayInput({ + records: [ + request({ + turn_id: "a", + prompt_id: "p-1", + cost_usd: 6, + input_tokens: 600, + model: "opus", + project_id: "acme/widgets", + person_id: "machine-1", + step: "aidd-dev:02-implement", + step_attribution: "tool-stated", + event_timestamp: "2026-08-17T10:00:00Z", + }), + request({ + turn_id: "b", + cost_usd: 3, + input_tokens: 300, + person_id: "a-stranger", + step: "aidd-dev:02-implement", + step_attribution: "journal-interval", + event_timestamp: "2026-08-18T10:00:00Z", + }), + request({ + turn_id: "c", + cost_usd: 1, + input_tokens: 100, + event_timestamp: "2026-08-18T11:00:00Z", + }), + ], + journals: [ + { + vendorId: "s-1", + tool: "claude-code", + writtenPaths: ["aidd_docs/tasks/2026_08/2026_08_17_reporting/plan.md"], + taskIntervals: [], + flowIntervals: [ + { + skill: "aidd-orchestrator:01-sdlc", + startMs: Date.parse("2026-08-17T09:00:00Z"), + endMs: Date.parse("2026-08-17T11:00:00Z"), + closedBy: "boundary", + }, + ], + }, + ], + identity: onePersonMapping(), + undatedRecords: 3, + unreadableLines: 2, + ...overrides, + }); +} + +function axisLines(from: CostReportInput, axis: string): string[] { + return buildCostReportArtefact(toCostReportEnvelope(buildCostReport(from)), axis).split("\n"); +} + +const RICH_CAVEATS = [ + "3 records carry no moment and are in no period", + "2 lines could not be read", +]; + +describe("buildCostReportArtefact — one period, every axis rendered whole", () => { + it("answers the total axis with the period, the one figure and what the read could not do", () => { + expect(axisLines(threeDayRich(), "total")).toEqual([ + "period 2026-08-17 to 2026-08-19 — axis: total", + "", + "$10.00 — 1,000 tokens, 3 requests", + ...RICH_CAVEATS, + ]); + }); + + it("gives the day axis a row per day the period spans, the empty one included", () => { + expect(axisLines(threeDayRich(), "day")).toEqual([ + "period 2026-08-17 to 2026-08-19 — axis: by day", + "", + "| Day | Total |", + "| --- | --- |", + "| 2026-08-17 | $6.00 — 600 tokens, 1 requests |", + "| 2026-08-18 | $4.00 — 400 tokens, 2 requests |", + "| 2026-08-19 | nothing in this period |", + ...RICH_CAVEATS, + ]); + }); + + it("keeps two rows sharing one step name apart by their attribution column", () => { + expect(axisLines(threeDayRich(), "step")).toEqual([ + "period 2026-08-17 to 2026-08-19 — axis: by step", + "", + "| Step | Attribution | Total |", + "| --- | --- | --- |", + "| aidd-dev:02-implement | stated by the tool | $6.00 — 600 tokens, 1 requests |", + "| aidd-dev:02-implement | from a journal interval | $3.00 — 300 tokens, 1 requests |", + "| unattributed | unattributed | $1.00 — 100 tokens, 1 requests |", + ...RICH_CAVEATS, + ]); + }); + + it("names the model axis's unnamed row rather than dropping it", () => { + expect(axisLines(threeDayRich(), "model")).toEqual([ + "period 2026-08-17 to 2026-08-19 — axis: by model", + "", + "| Model | Total |", + "| --- | --- |", + "| opus | $6.00 — 600 tokens, 1 requests |", + "| no known model | $4.00 — 400 tokens, 2 requests |", + ...RICH_CAVEATS, + ]); + }); + + it("calls the agent axis's own row the main thread when the tool names agents", () => { + expect(axisLines(threeDayRich(), "agent")).toEqual([ + "period 2026-08-17 to 2026-08-19 — axis: by agent", + "", + "| Agent | Total |", + "| --- | --- |", + "| the main thread | $10.00 — 1,000 tokens, 3 requests |", + ...RICH_CAVEATS, + ]); + }); + + it("dates a named prompt row and em-dashes the one drawn from many turns", () => { + expect(axisLines(threeDayRich(), "prompt")).toEqual([ + "period 2026-08-17 to 2026-08-19 — axis: by prompt", + "", + "| Prompt | Started at | Total |", + "| --- | --- | --- |", + "| p-1 | 2026-08-17T10:00:00Z | $6.00 — 600 tokens, 1 requests |", + "| no prompt named | — | $4.00 — 400 tokens, 2 requests |", + ...RICH_CAVEATS, + ]); + }); + + it("gives an unattributed task row its reason and no attribution", () => { + expect(axisLines(threeDayRich(), "task")).toEqual([ + "period 2026-08-17 to 2026-08-19 — axis: by task", + "", + "| Task | Attribution | Total |", + "| --- | --- | --- |", + "| no usable task declaration in this session | — | $10.00 — 1,000 tokens, 3 requests |", + ...RICH_CAVEATS, + ]); + }); + + it("gives the backlog axis two columns, never the task axis's third", () => { + expect(axisLines(threeDayRich(), "backlog")).toEqual([ + "period 2026-08-17 to 2026-08-19 — axis: by backlog", + "", + "| Backlog item | Total |", + "| --- | --- |", + "| no usable task declaration in this session | $10.00 — 1,000 tokens, 3 requests |", + ...RICH_CAVEATS, + ]); + }); + + it("carries the flow axis's two journal limits under its rows", () => { + expect(axisLines(threeDayRich(), "flow")).toEqual([ + "period 2026-08-17 to 2026-08-19 — axis: by flow", + "", + "| Flow | Attribution | Opened at | Total |", + "| --- | --- | --- | --- |", + "| aidd-orchestrator:01-sdlc | from a journal interval | 2026-08-17T09:00:00Z | $6.00 — 600 tokens, 1 requests |", + "| outside any flow | unattributed | — | $4.00 — 400 tokens, 2 requests |", + "a skill run by hand while a flow was open is counted inside it: the orchestrator's own call and a person's write the identical step_start line", + "a skill of this project named 00-async-dev, 01-sdlc or 02-backlog opens a flow of its own: outside a plugin a host names a skill by its folder alone, and this axis has only that name to go on", + ...RICH_CAVEATS, + ]); + }); + + it("gives every declared tool a row, the unread one included", () => { + expect(axisLines(threeDayRich(), "tool")).toEqual([ + "period 2026-08-17 to 2026-08-19 — axis: by tool", + "", + "| Tool | Total |", + "| --- | --- |", + "| Claude Code | $10.00 — 1,000 tokens, 3 requests |", + "| Codex | nothing in this period |", + "| Cursor | not covered — It writes no token count. |", + ...RICH_CAVEATS, + ]); + }); + + it("names the project axis's unnamed row rather than dropping it", () => { + expect(axisLines(threeDayRich(), "project")).toEqual([ + "period 2026-08-17 to 2026-08-19 — axis: by project", + "", + "| Project | Total |", + "| --- | --- |", + "| acme/widgets | $6.00 — 600 tokens, 1 requests |", + "| no known project | $4.00 — 400 tokens, 2 requests |", + ...RICH_CAVEATS, + ]); + }); + + it("carries the identities behind every person row as that row's own evidence", () => { + expect(axisLines(threeDayRich(), "person")).toEqual([ + "period 2026-08-17 to 2026-08-19 — axis: by person", + "", + "| Person | Identities | Total |", + "| --- | --- | --- |", + "| Ada | person-a, machine-1 | $6.00 — 600 tokens, 1 requests |", + "| Ada | person-a, machine-1 | $1.00 — 100 tokens, 1 requests |", + "| unresolved — not mapped to anyone (a-stranger) | a-stranger | $3.00 — 300 tokens, 1 requests |", + ...RICH_CAVEATS, + ]); + }); +}); + +describe("buildCostReportArtefact — what the header carries", () => { + it("appends the switch being off to the axis it names, in full", () => { + expect(axisLines(threeDayRich({ measurementEnabled: false }), "total")[0]).toBe( + "period 2026-08-17 to 2026-08-19 — axis: total — this project's switch is off, figures are the whole sink, not scoped to it" + ); + }); + + it("names the task and every filter that narrowed the period, before the axis", () => { + expect( + axisLines( + threeDayRich({ + task: "2026_08/2026_08_17_reporting", + filters: { project: "acme/widgets" }, + }), + "total" + )[0] + ).toBe( + "period 2026-08-17 to 2026-08-19, task 2026_08/2026_08_17_reporting, filters: project=acme/widgets — axis: total" + ); + }); + + it("names the filter that emptied a selection, with why its value was never seen", () => { + expect( + axisLines( + threeDayInput({ + records: [request({ turn_id: "a", cost_usd: 1 })], + filters: { project: "never-worked-here" }, + }), + "total" + ) + ).toEqual([ + "period 2026-08-17 to 2026-08-19, filters: project=never-worked-here — axis: total", + "", + "nothing in this selection", + "project 'never-worked-here' matched nothing — no record has ever named this project", + ]); + }); +}); + +describe("buildCostReportArtefact — a figure a row cannot state", () => { + it("prints an empty period's one figure as nothing measured, never as a zero", () => { + expect(axisLines(threeDayInput(), "total")).toEqual([ + "period 2026-08-17 to 2026-08-19 — axis: total", + "", + "nothing in this period", + ]); + }); + + it("prints an unknown amount beside the tokens a tool did count", () => { + expect( + axisLines( + threeDayInput({ records: [request({ tool: "codex", input_tokens: 8898 })] }), + "tool" + ) + ).toEqual([ + "period 2026-08-17 to 2026-08-19 — axis: by tool", + "", + "| Tool | Total |", + "| --- | --- |", + "| Claude Code | nothing in this period |", + "| Codex | amount unknown — 8,898 tokens, 1 requests |", + "| Cursor | not covered — It writes no token count. |", + ]); + }); + + it("prints a session total on its own tool row, never as nothing in this period", () => { + expect( + axisLines( + { + fromDay: "2026-08-17", + toDay: "2026-08-17", + declaredTools: [ + { + tool: "copilot", + coverage: "covered", + capability: { + localRead: { + tokenCounters: true, + amount: false, + toolStatedStep: false, + agentName: false, + }, + export: null, + journalAttributable: true, + taskAttributable: false, + }, + }, + ], + records: [ + request({ + tool: "copilot", + kind: "session", + input_tokens: 10, + output_tokens: 42, + cache_creation_tokens: 21070, + }), + ], + journals: [], + undatedRecords: 0, + unreadableLines: 0, + measurementEnabled: true, + }, + "tool" + ) + ).toEqual([ + "period 2026-08-17 to 2026-08-17 — axis: by tool", + "", + "| Tool | Total |", + "| --- | --- |", + "| GitHub Copilot | 21,122 tokens (session total, not requests) |", + ]); + }); + + it("tells a tool that named no agent from the main thread, row for row", () => { + expect( + axisLines( + threeDayInput({ + declaredTools: [THREE_TOOLS[0], THREE_TOOLS[1]], + records: [ + request({ turn_id: "a", input_tokens: 10 }), + request({ turn_id: "b", tool: "codex", vendor_id: "s-codex", input_tokens: 10 }), + ], + }), + "agent" + ) + ).toEqual([ + "period 2026-08-17 to 2026-08-19 — axis: by agent", + "", + "| Agent | Total |", + "| --- | --- |", + "| the main thread | amount unknown — 10 tokens, 1 requests |", + "| the tool names no agent | amount unknown — 10 tokens, 1 requests |", + ]); + }); + + it("em-dashes the identities column of the row nobody opted into", () => { + expect( + axisLines( + threeDayInput({ + records: [request({ turn_id: "a", cost_usd: 1 })], + identityUnusableCause: "absent", + }), + "person" + ) + ).toEqual([ + "period 2026-08-17 to 2026-08-19 — axis: by person", + "", + "| Person | Identities | Total |", + "| --- | --- | --- |", + "| no identity — nobody opted in | — | $1.00 — 0 tokens, 1 requests |", + "no identity was declared; every identifier is reported unresolved", + ]); + }); + + it("reports every identifier unresolved when this machine's identity could not be read", () => { + expect( + axisLines( + threeDayInput({ + records: [request({ turn_id: "a", cost_usd: 1, person_id: "machine-1" })], + identityUnusableCause: "unreadable", + }), + "person" + ) + ).toEqual([ + "period 2026-08-17 to 2026-08-19 — axis: by person", + "", + "| Person | Identities | Total |", + "| --- | --- | --- |", + "| unresolved — not mapped to anyone (machine-1) | machine-1 | $1.00 — 0 tokens, 1 requests |", + "this machine's own identity could not be read; every identifier is reported unresolved", + ]); + }); + + it("em-dashes the opening moment of a flow only a tool's own record named, and says why", () => { + expect( + axisLines( + threeDayInput({ + records: [ + request({ + turn_id: "a", + input_tokens: 10, + step_attribution: "tool-stated", + step: "aidd-orchestrator:01-sdlc", + event_timestamp: "2026-08-17T10:00:00Z", + }), + ], + }), + "flow" + ) + ).toEqual([ + "period 2026-08-17 to 2026-08-19 — axis: by flow", + "", + "| Flow | Attribution | Opened at | Total |", + "| --- | --- | --- | --- |", + "| aidd-orchestrator:01-sdlc | stated by the tool | — | amount unknown — 10 tokens, 1 requests |", + "a flow only a record's own tool named is every run of that skill at once: its journal opened no flow to bound one run from the next, so the row has no opening moment and its total is not one orchestration's", + ]); + }); +}); + +describe("buildCostReportArtefact — a task the journal declared", () => { + const DECLARED_TASK = threeDayInput({ + records: [ + request({ turn_id: "a", cost_usd: 2, event_timestamp: "2026-08-17T10:00:00Z" }), + request({ turn_id: "b", cost_usd: 1, event_timestamp: "2026-08-18T10:00:00Z" }), + ], + journals: [ + { + vendorId: "s-1", + tool: "claude-code", + writtenPaths: [], + taskIntervals: [ + { + path: "aidd_docs/tasks/2026_08/2026_08_17_reporting/plan.md", + startMs: Date.parse("2026-08-17T09:00:00Z"), + endMs: Date.parse("2026-08-17T11:00:00Z"), + }, + ], + flowIntervals: [], + }, + ], + }); + + it("says a named task row rests on a declaration, and gives the rest its reason", () => { + expect(axisLines(DECLARED_TASK, "task")).toEqual([ + "period 2026-08-17 to 2026-08-19 — axis: by task", + "", + "| Task | Attribution | Total |", + "| --- | --- | --- |", + "| 2026_08/2026_08_17_reporting | declared by the flow | $2.00 — 0 tokens, 1 requests |", + "| the journal falls silent before this record | — | $1.00 — 0 tokens, 1 requests |", + ]); + }); + + it("names a task whose backlog declaration could not be read, never leaving the row blank", () => { + expect( + axisLines( + { + ...DECLARED_TASK, + taskBacklogDeclarations: new Map([ + ["2026_08/2026_08_17_reporting", { kind: "unreadable" as const }], + ]), + }, + "backlog" + ) + ).toEqual([ + "period 2026-08-17 to 2026-08-19 — axis: by backlog", + "", + "| Backlog item | Total |", + "| --- | --- |", + "| this task's backlog declaration could not be read | $2.00 — 0 tokens, 1 requests |", + "| the journal falls silent before this record | $1.00 — 0 tokens, 1 requests |", + ]); + }); +}); + +describe("buildCostReportArtefact — a fact a row carries beside its figure", () => { + it("keeps a covered tool's own reason after the figure it qualifies", () => { + expect( + axisLines( + threeDayInput({ + declaredTools: [ + THREE_TOOLS[0], + { + tool: "codex", + coverage: "covered", + capability: NO_CAPABILITY, + reason: "Partial read.", + }, + ], + records: [request({ turn_id: "a", cost_usd: 1, tool: "codex" })], + }), + "tool" + ) + ).toEqual([ + "period 2026-08-17 to 2026-08-19 — axis: by tool", + "", + "| Tool | Total |", + "| --- | --- |", + "| Claude Code | nothing in this period |", + "| Codex | $1.00 — 0 tokens, 1 requests — Partial read. |", + ]); + }); + + it("sums all four token counters into a row's own figure", () => { + expect( + axisLines( + threeDayInput({ + records: [ + request({ + turn_id: "a", + cost_usd: 4, + input_tokens: 10, + output_tokens: 20, + cache_read_tokens: 30, + cache_creation_tokens: 40, + }), + ], + }), + "total" + )[2] + ).toBe("$4.00 — 100 tokens, 1 requests"); + }); + + it("names an agent a record's own tool stated, above the main thread's own row", () => { + expect( + axisLines( + threeDayInput({ + records: [ + request({ turn_id: "a", cost_usd: 4, input_tokens: 100, agent_name: "reviewer" }), + request({ turn_id: "b", cost_usd: 1, input_tokens: 5 }), + ], + }), + "agent" + ).slice(4) + ).toEqual([ + "| reviewer | $4.00 — 100 tokens, 1 requests |", + "| the main thread | $1.00 — 5 tokens, 1 requests |", + ]); + }); + + it("says a filter emptied a selection only in combination with the rest of it", () => { + expect( + axisLines( + threeDayInput({ + records: [ + request({ turn_id: "a", cost_usd: 1, project_id: "acme/widgets", model: "opus" }), + request({ turn_id: "b", cost_usd: 1, project_id: "acme/gadgets", model: "haiku" }), + ], + knownValues: { + projects: new Set(["acme/widgets"]), + steps: new Set(), + models: new Set(["haiku"]), + }, + filters: { project: "acme/widgets", model: "haiku" }, + }), + "total" + ) + ).toEqual([ + "period 2026-08-17 to 2026-08-19, filters: project=acme/widgets, model=haiku — axis: total", + "", + "nothing in this selection", + "model 'haiku' matched nothing combined with the rest of this selection", + ]); + }); +}); diff --git a/cli/tests/presentation/display/cost-report-display.unit.test.ts b/cli/tests/presentation/display/cost-report-display.unit.test.ts new file mode 100644 index 000000000..6e66f6e21 --- /dev/null +++ b/cli/tests/presentation/display/cost-report-display.unit.test.ts @@ -0,0 +1,988 @@ +import { describe, expect, it } from "vitest"; +import "../../../src/contexts/tools/domain/profiles/claude/profile.js"; +import "../../../src/contexts/tools/domain/profiles/codex/profile.js"; +import "../../../src/contexts/tools/domain/profiles/copilot/profile.js"; +import "../../../src/contexts/tools/domain/profiles/cursor/profile.js"; +import "../../../src/contexts/tools/domain/profiles/opencode/profile.js"; +import { + buildCostReport, + type CostReportInput, +} from "../../../src/contexts/telemetry/domain/cost-report.js"; +import type { TelemetrySinkRecord } from "../../../src/contexts/telemetry/domain/telemetry-sink-record.js"; +import { padTo, printCostReport } from "../../../src/presentation/display/cost-report-display.js"; +import { CapturingOutput } from "../../helpers/ports/capturing-output.js"; + +function record(overrides: Partial): TelemetrySinkRecord { + return { + sink_schema_version: 2, + kind: "request", + provenance: "local-read", + tool: "claude", + vendor_id: "s-1", + vendor_field: "sessionId", + step_attribution: "unattributed", + ...overrides, + }; +} + +/** What a tool can supply is not what these tests are about: the minimum the type requires, + * whose own truth is checked against captured files elsewhere. */ +const NO_CAPABILITY = { + localRead: null, + export: null, + journalAttributable: false, + taskAttributable: false, +} as const; + +function printed(overrides: Partial = {}): string { + const output = new CapturingOutput(); + printCostReport( + output, + buildCostReport({ + fromDay: "2026-08-17", + toDay: "2026-08-21", + records: [], + journals: [], + declaredTools: [ + { tool: "claude", coverage: "covered", capability: NO_CAPABILITY }, + { tool: "codex", coverage: "covered", capability: NO_CAPABILITY }, + { + tool: "cursor", + coverage: "not-covered", + reason: "It writes no token count.", + capability: NO_CAPABILITY, + }, + ], + undatedRecords: 0, + unreadableLines: 0, + measurementEnabled: true, + ...overrides, + }) + ); + return output.lines.join("\n"); +} + +describe("printCostReport", () => { + it("answers the question before any breakdown is read", () => { + const out = printed({ + records: [record({ cost_usd: 4.2, input_tokens: 100, cache_read_tokens: 900 })], + }); + const [first, , sessions, requests, tokens, cost] = out.split("\n"); + + expect(first).toContain("2026-08-17 to 2026-08-21"); + expect(sessions).toContain("sessions"); + expect(requests).toContain("requests"); + expect(tokens).toContain("1,000"); + expect(tokens).toContain("90% cache"); + expect(cost).toContain("$4.20"); + }); + + it("says which selection it answered, in the header", () => { + const out = printed({ + records: [record({ cost_usd: 1, project_id: "acme/widgets" })], + filters: { project: "acme/widgets" }, + }); + + expect(out.split("\n")[0]).toContain("filters: project=acme/widgets"); + }); + + it("names the filter that emptied a selection, and suppresses the noise under it", () => { + const out = printed({ + records: [record({ cost_usd: 1, project_id: "acme/widgets" })], + knownValues: { projects: new Set(["acme/widgets"]), steps: new Set(), models: new Set() }, + filters: { project: "never-worked-here" }, + }); + + expect(out).toContain("no record has ever named this project"); + expect(out).not.toContain("by tool"); + expect(out).not.toContain("by day"); + }); + + it("says a task or a tool was never seen without claiming a record check it never ran", () => { + const out = printed({ + records: [record({ cost_usd: 1 })], + filters: { tool: "opencode" }, + }); + + expect(out).toContain("it is not one of the tools this build knows"); + expect(out).not.toContain("no record has ever named this tool"); + }); + + it("calls a zero row 'nothing in this selection', never 'this period', once a filter is active", () => { + // Codex only has a gadgets record - filtered to widgets alone, its row is zero, but + // the selection is why, not real idleness. + const out = printed({ + records: [ + record({ turn_id: "a", cost_usd: 1, tool: "claude", project_id: "acme/widgets" }), + record({ turn_id: "b", cost_usd: 1, tool: "codex", project_id: "acme/gadgets" }), + ], + filters: { project: "acme/widgets" }, + }); + + expect(out).toMatch(/Codex\s+nothing in this selection/u); + expect(out).not.toContain("nothing in this period"); + }); + + it("still calls a zero row 'nothing in this period' when the whole period, not a filter, is why", () => { + const out = printed({ records: [] }); + + expect(out).toContain("nothing in this period"); + expect(out).not.toContain("nothing in this selection"); + }); + + it("calls a task selection's own zero rows 'nothing in this selection' too", () => { + const out = printed({ + records: [record({ vendor_id: "s-1", cost_usd: 1, event_timestamp: "2026-08-17T10:00:00Z" })], + journals: [ + { + vendorId: "s-1", + tool: "claude", + writtenPaths: ["aidd_docs/tasks/2026_08/2026_08_01_x/plan.md"], + taskIntervals: [], + flowIntervals: [], + }, + ], + task: "2026_08/2026_08_01_x", + }); + + expect(out).toMatch(/2026-08-18\s+nothing in this selection/u); + }); + + it("labels active time as per-session and keeps it out of every breakdown", () => { + const out = printed({ + records: [ + record({ cost_usd: 1, step: "aidd-dev:02-implement", step_attribution: "tool-stated" }), + record({ kind: "session", active_time_s: 2820 }), + ], + }); + + expect(out).toContain("47 min"); + expect(out).toContain("not attributable to steps"); + const breakdown = out.slice(out.indexOf("by step")); + expect(breakdown).not.toContain("min"); + }); + + it("prints the three attribution shares together", () => { + const out = printed({ + records: [ + record({ turn_id: "a", cost_usd: 6, step: "s", step_attribution: "tool-stated" }), + record({ turn_id: "b", cost_usd: 3, step: "s", step_attribution: "journal-interval" }), + record({ turn_id: "c", cost_usd: 1 }), + ], + }); + const mix = out.slice(out.indexOf("attribution ")); + + expect(mix).toContain("stated by the tool"); + expect(mix).toContain("from a journal interval"); + expect(mix).toContain("unattributed"); + expect(mix).toContain(" 60%"); + expect(mix).toContain(" 30%"); + expect(mix).toContain(" 10%"); + }); + + it("never says work ran outside every step, and never calls it a residual", () => { + const out = printed({ records: [record({ cost_usd: 1 })] }); + + expect(out).toContain("unattributed"); + expect(out).not.toContain("residual"); + expect(out).not.toContain("no step"); + expect(out).not.toContain("outside"); + }); + + it("prints an unknown amount for a tool whose records carry none, never a zero", () => { + const out = printed({ records: [record({ tool: "codex", input_tokens: 8898 })] }); + + expect(out).toContain("amount unknown"); + expect(out).not.toContain("$0.00"); + }); + + it("prints a tool that cannot be read as not covered, with its own reason", () => { + const out = printed({ records: [record({ cost_usd: 1 })] }); + + expect(out).toContain("Cursor"); + expect(out).toContain("not covered — It writes no token count."); + }); + + it("prints a session total on its own tool row, not 'nothing in this period' (#697)", () => { + const output = new CapturingOutput(); + const COPILOT_CAPABILITY = { + localRead: { tokenCounters: true, amount: false, toolStatedStep: false, agentName: false }, + export: { tokenCounters: false, amount: false, toolStatedStep: false, agentName: false }, + journalAttributable: true, + taskAttributable: false, + } as const; + printCostReport( + output, + buildCostReport({ + fromDay: "2026-08-17", + toDay: "2026-08-21", + records: [ + record({ + tool: "copilot", + kind: "session", + provenance: "local-read", + input_tokens: 10, + output_tokens: 42, + cache_read_tokens: 0, + cache_creation_tokens: 21070, + }), + ], + journals: [], + declaredTools: [{ tool: "copilot", coverage: "covered", capability: COPILOT_CAPABILITY }], + undatedRecords: 0, + unreadableLines: 0, + measurementEnabled: true, + }) + ); + const out = output.lines.join("\n"); + + expect(out).toContain("21,122 tokens (session total, not requests)"); + const copilotRow = out.split("\n").find((line) => line.includes("Copilot")) ?? ""; + expect(copilotRow).not.toContain("nothing in this period"); + }); + + it("separates a tool that measured nothing from one that could not be read", () => { + const out = printed({ records: [record({ cost_usd: 1 })] }); + const codexRow = out.split("\n").find((line) => line.includes("Codex")) ?? ""; + const cursorRow = out.split("\n").find((line) => line.includes("Cursor")) ?? ""; + + expect(codexRow).toContain("nothing in this period"); + expect(cursorRow).toContain("not covered"); + expect(codexRow).not.toContain("not covered"); + }); + + it("prints an empty period as nothing measured, not as zeros", () => { + const out = printed(); + + expect(out).toContain("nothing in this period"); + expect(out).not.toContain("$0.00"); + expect(out).not.toContain("by step"); + }); + + it("says how much of the read it could not place or could not parse", () => { + const out = printed({ undatedRecords: 3, unreadableLines: 2 }); + + expect(out).toContain("3 records carry no moment and are in no period"); + expect(out).toContain("2 lines could not be read"); + }); + + it("breaks a period down by tokens when no amount exists anywhere in it", () => { + const out = printed({ + records: [record({ tool: "codex", model: "gpt-5.6-sol", input_tokens: 10 })], + }); + + expect(out).toContain("of tokens"); + expect(out).not.toContain("of cost"); + }); + + it("names a task by its identity, never by a path it was derived from", () => { + const out = printed({ + records: [record({ vendor_id: "s-1", cost_usd: 1 })], + journals: [ + { + vendorId: "s-1", + tool: "claude-code", + writtenPaths: ["aidd_docs/tasks/2026_08/2026_08_21_cost-reporter/plan.md"], + taskIntervals: [], + flowIntervals: [], + }, + ], + task: "2026_08/2026_08_21_cost-reporter", + }); + + expect(out).toContain("task 2026_08/2026_08_21_cost-reporter"); + expect(out).not.toContain("aidd_docs/"); + expect(out).not.toContain("plan.md"); + }); + + it("carries no prompt, code or diff, over records and journals that hold them", () => { + const out = printed({ + records: [ + record({ + cost_usd: 1, + model: "opus", + step: "aidd-dev:02-implement", + step_attribution: "tool-stated", + }), + ], + journals: [ + { + vendorId: "s-1", + tool: "claude-code", + projectId: "acme-widgets", + writtenPaths: ["aidd_docs/tasks/2026_08/2026_08_21_cost-reporter/plan.md"], + taskIntervals: [], + flowIntervals: [], + }, + ], + }); + + // Named rather than "no slash at all": a task's identity legitimately carries one, and + // an assertion that broke on it would say nothing about a leaked path. + expect(out).not.toContain("aidd_docs"); + expect(out).not.toContain(".md"); + expect(out).not.toContain("acme-widgets"); + }); + + it("prints a day with nothing as a row of zeros, never an omitted row", () => { + const out = printed({ + records: [record({ cost_usd: 1, event_timestamp: "2026-08-17T10:00:00Z" })], + }); + + expect(out).toMatch(/2026-08-18\s+nothing in this period/u); + }); + + it("names how many days a long period carries, rather than printing every row", () => { + const records = Array.from({ length: 40 }, (_, i) => + record({ + turn_id: `t-${i}`, + cost_usd: 1, + event_timestamp: `2026-01-${String((i % 27) + 1).padStart(2, "0")}T00:00:00Z`, + }) + ); + const out = printed({ fromDay: "2026-01-01", toDay: "2026-02-09", records }); + + expect(out).toContain("40 days in this period"); + expect(out).toContain("--json"); + expect(out).not.toContain("2026-01-15"); + }); + + it("prints the prompt that caused the work, dated, largest first", () => { + const out = printed({ + records: [ + record({ + turn_id: "a", + prompt_id: "p-1", + cost_usd: 2, + event_timestamp: "2026-08-18T09:00:00Z", + }), + record({ + turn_id: "b", + prompt_id: "p-2", + cost_usd: 1, + event_timestamp: "2026-08-18T10:00:00Z", + }), + ], + }); + + expect(out).toContain("by prompt"); + const prompts = out.split("\n").filter((line) => line.includes("p-1") || line.includes("p-2")); + expect(prompts[0]).toContain("p-1"); + expect(prompts[0]).toContain("2026-08-18T09:00:00Z"); + }); + + // The first axis whose cardinality is unbounded, so it truncates rather than suppressing + // every row: a partial series is a lie about continuity, a top N of a ranking is not. + it("names how many prompts a long period carries beyond the ones it prints", () => { + const records = Array.from({ length: 30 }, (_, i) => + record({ turn_id: `t-${i}`, prompt_id: `p-${i}`, cost_usd: 30 - i }) + ); + const out = printed({ records }); + + expect(out).toContain("p-0"); + expect(out).not.toContain("p-29"); + expect(out).toContain("20 more prompts"); + expect(out).toContain("--json"); + }); + + it("gives a record with no project its own row, named as unknown", () => { + const out = printed({ + records: [ + record({ turn_id: "a", cost_usd: 2, project_id: "acme/widgets" }), + record({ turn_id: "b", cost_usd: 1 }), + ], + }); + const projects = out.slice(out.indexOf("by project")); + + expect(projects).toContain("acme/widgets"); + expect(projects).toContain("no known project"); + }); + + it("gives a record with no model its own row, named as unknown, rather than vanishing", () => { + const out = printed({ + records: [ + record({ turn_id: "a", cost_usd: 2, model: "opus" }), + record({ turn_id: "b", cost_usd: 1 }), + ], + }); + const models = out.slice(out.indexOf("by model"), out.indexOf("by project")); + + expect(models).toContain("opus"); + expect(models).toContain("no known model"); + }); +}); + +describe("printCostReport — a label wider than its column", () => { + // Measured on a real report: a project id can be a remote 41 characters wide against a + // 26-wide column, and `padEnd` returns a longer string unchanged. + it("keeps a separator between an overlong label and its share", () => { + const long = "git@github.com:ai-driven-dev/framework.git"; + + const out = printed({ records: [record({ cost_usd: 1, project_id: long })] }); + const row = out.split("\n").find((line) => line.includes(long)); + + expect(row).toBeDefined(); + expect(row).not.toContain(`${long}100%`); + expect(row).toMatch(new RegExp(`${long.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&")}\\s`, "u")); + }); + + it("still separates a label exactly as wide as its column from what follows it", () => { + // 26 is `LABEL_WIDTH`, private to this module: the only length where `padTo`'s `>=` and + // `>` decide something different, and nothing above ever sits on that boundary. + const exact = "a".repeat(26); + + const padded = padTo(exact, 26); + + expect(padded).toBe(`${exact} `); + }); +}); + +describe("printCostReport — measurement is off", () => { + it("says the project's switch is off, on an empty period", () => { + const out = printed({ measurementEnabled: false }); + + expect(out).toMatch(/this project's own switch is off/u); + expect(out).not.toMatch(/\bsessions\s+0\b/u); + expect(out).toContain("nothing in this period"); + }); + + it("says nothing about the switch when it is on, even on an empty period", () => { + const out = printed({ measurementEnabled: true }); + + expect(out).not.toContain("switch is off"); + expect(out).not.toMatch(/\bsessions\s+0\b/u); + }); + + // The sink is person-scoped while the switch is project-scoped, so a genuine figure below + // an "off" claim is ordinary: the sentence must name its scope, not deny the figure. + it("names the sink's real scope, never denying the figure it sits beside", () => { + const out = printed({ + measurementEnabled: false, + records: [record({ cost_usd: 4.2, input_tokens: 100 })], + }); + + expect(out).toMatch(/this project's own switch is off/u); + expect(out).toMatch(/not scoped to it/u); + expect(out).toContain("$4.20"); + expect(out).toMatch(/\bcost\s+\$4\.20/u); + }); +}); + +const THREE_DAY_TOOLS: CostReportInput["declaredTools"] = [ + { + tool: "claude", + coverage: "covered", + capability: { + localRead: { tokenCounters: true, amount: true, toolStatedStep: true, agentName: true }, + export: null, + journalAttributable: true, + taskAttributable: true, + }, + }, + { tool: "codex", coverage: "covered", capability: NO_CAPABILITY }, + { + tool: "cursor", + coverage: "not-covered", + reason: "It writes no token count.", + capability: NO_CAPABILITY, + }, +]; + +function threeDay(overrides: Partial = {}): string[] { + const output = new CapturingOutput(); + printCostReport( + output, + buildCostReport({ + fromDay: "2026-08-17", + toDay: "2026-08-19", + declaredTools: THREE_DAY_TOOLS, + records: [], + journals: [], + undatedRecords: 0, + unreadableLines: 0, + measurementEnabled: true, + ...overrides, + }) + ); + return output.lines; +} + +const THREE_TOOL_ROWS = [ + "", + " by tool", + " Claude Code nothing in this period", + " Codex nothing in this period", + " Cursor not covered — It writes no token count.", +]; + +describe("printCostReport — one period, rendered whole", () => { + it("prints every heading and every row of a period holding work", () => { + expect( + threeDay({ + records: [ + record({ + turn_id: "a", + prompt_id: "p-1", + cost_usd: 6, + input_tokens: 600, + model: "opus", + project_id: "acme/widgets", + step: "aidd-dev:02-implement", + step_attribution: "tool-stated", + event_timestamp: "2026-08-17T10:00:00Z", + }), + record({ + turn_id: "b", + cost_usd: 3, + input_tokens: 300, + step: "aidd-dev:02-implement", + step_attribution: "journal-interval", + event_timestamp: "2026-08-18T10:00:00Z", + }), + record({ + turn_id: "c", + cost_usd: 1, + input_tokens: 100, + event_timestamp: "2026-08-18T11:00:00Z", + }), + ], + undatedRecords: 3, + unreadableLines: 2, + }) + ).toEqual([ + "period 2026-08-17 to 2026-08-19", + "", + " sessions 1", + " requests 3", + " tokens 1,000 0% cache", + " cost $10.00", + "", + " by step of cost", + " aidd-dev:02-implement 60% $6.00 stated by the tool", + " aidd-dev:02-implement 30% $3.00 from a journal interval", + " unattributed 10% $1.00", + "", + " attribution of cost", + " stated by the tool 60%", + " matched on the prompt 0%", + " from a journal interval 30%", + " unattributed 10%", + "", + " by agent of cost", + " the main thread 100% $10.00", + "", + " by prompt of cost", + " p-1 2026-08-17T10:00:00Z 60% $6.00", + " no prompt named 40% $4.00", + "", + " by model of cost", + " opus 60% $6.00", + " no known model 40% $4.00", + "", + " by project of cost", + " acme/widgets 60% $6.00", + " no known project 40% $4.00", + "", + " by task of cost", + " no usable run journal for this session 100% $10.00", + "", + " by backlog item of cost", + " no usable run journal for this session 100% $10.00", + "", + " by tool", + " Claude Code $10.00 1,000 tokens", + " Codex nothing in this period", + " Cursor not covered — It writes no token count.", + "", + " by day", + " 2026-08-17 $6.00 600 tokens", + " 2026-08-18 $4.00 400 tokens", + " 2026-08-19 nothing in this period", + " 3 records carry no moment and are in no period", + " 2 lines could not be read", + ]); + }); + + it("prints an empty period as its two totals and its zero rows, under the off line", () => { + expect(threeDay({ measurementEnabled: false })).toEqual([ + "period 2026-08-17 to 2026-08-19", + "this project's own switch is off — the figures below are not scoped to it, they are the whole sink; turn this project's measurement on with `aidd telemetry on`", + "", + " sessions nothing in this period", + " requests nothing in this period", + ...THREE_TOOL_ROWS, + "", + " by day", + " 2026-08-17 nothing in this period", + " 2026-08-18 nothing in this period", + " 2026-08-19 nothing in this period", + ]); + }); + + it("breaks a period with no amount anywhere down by tokens, naming the basis on every heading", () => { + expect( + threeDay({ records: [record({ tool: "codex", model: "gpt-5", input_tokens: 10 })] }) + ).toEqual([ + "period 2026-08-17 to 2026-08-19", + "", + " sessions 1", + " requests 1", + " tokens 10 0% cache", + " cost amount unknown", + "", + " by step of tokens", + " unattributed 100% 10 tokens", + "", + " attribution of tokens", + " stated by the tool 0%", + " matched on the prompt 0%", + " from a journal interval 0%", + " unattributed 100%", + "", + " by agent of tokens", + " the tool names no agent 100% 10 tokens", + "", + " by prompt of tokens", + " no prompt named 100% 10 tokens", + "", + " by model of tokens", + " gpt-5 100% 10 tokens", + "", + " by project of tokens", + " no known project 100% 10 tokens", + "", + " by task of tokens", + " no usable run journal for this session 100% 10 tokens", + "", + " by backlog item of tokens", + " no usable run journal for this session 100% 10 tokens", + "", + " by tool", + " Claude Code nothing in this period", + " Codex amount unknown 10 tokens", + " Cursor not covered — It writes no token count.", + "", + " by day", + " 2026-08-17 nothing in this period", + " 2026-08-18 nothing in this period", + " 2026-08-19 nothing in this period", + ]); + }); +}); + +describe("printCostReport — a share with nothing to divide", () => { + it("prints a dash in place of every share when the basis is zero", () => { + const lines = threeDay({ records: [record({ turn_id: "a", cost_usd: 0 })] }); + + expect(lines).toContain(" unattributed - $0.00"); + expect(lines).toContain(" stated by the tool - "); + expect(lines).toContain(" the main thread - $0.00"); + }); +}); + +describe("printCostReport — the lines a period only sometimes carries", () => { + it("prints active time as its own row, after the cost", () => { + const lines = threeDay({ + records: [ + record({ turn_id: "a", cost_usd: 1, event_timestamp: "2026-08-17T10:00:00Z" }), + record({ kind: "session", active_time_s: 2820 }), + ], + }); + + expect(lines.slice(0, 7)).toEqual([ + "period 2026-08-17 to 2026-08-19", + "", + " sessions 1", + " requests 1", + " tokens 0 0% cache", + " cost $1.00", + " active time 47 min per session; not attributable to steps", + ]); + }); + + it("names how many prompts it withheld, under the ten it printed", () => { + const lines = threeDay({ + records: Array.from({ length: 12 }, (_, i) => + record({ turn_id: `t-${i}`, prompt_id: `p-${i}`, cost_usd: 12 - i }) + ), + }); + const first = lines.indexOf(" by prompt of cost"); + + expect(lines.slice(first, first + 12)).toEqual([ + " by prompt of cost", + " p-0 15% $12.00", + " p-1 14% $11.00", + " p-2 13% $10.00", + " p-3 12% $9.00", + " p-4 10% $8.00", + " p-5 9% $7.00", + " p-6 8% $6.00", + " p-7 6% $5.00", + " p-8 5% $4.00", + " p-9 4% $3.00", + " 2 more prompts — see --json for all of them", + ]); + }); + + it("replaces a long period's daily rows with their count, never a partial series", () => { + const lines = threeDay({ + fromDay: "2026-01-01", + toDay: "2026-02-09", + records: [record({ turn_id: "a", cost_usd: 1, event_timestamp: "2026-01-05T00:00:00Z" })], + }); + + expect(lines.slice(-2)).toEqual([ + " by day", + " 40 days in this period — see --json for the daily breakdown", + ]); + }); + + it("prints a session total on its own tool row, never as nothing in this period", () => { + const output = new CapturingOutput(); + printCostReport( + output, + buildCostReport({ + fromDay: "2026-08-17", + toDay: "2026-08-17", + declaredTools: [ + { + tool: "copilot", + coverage: "covered", + capability: { + localRead: { + tokenCounters: true, + amount: false, + toolStatedStep: false, + agentName: false, + }, + export: null, + journalAttributable: true, + taskAttributable: false, + }, + }, + ], + records: [ + record({ + tool: "copilot", + kind: "session", + input_tokens: 10, + output_tokens: 42, + cache_creation_tokens: 21070, + }), + ], + journals: [], + undatedRecords: 0, + unreadableLines: 0, + measurementEnabled: true, + }) + ); + + expect(output.lines).toEqual([ + "period 2026-08-17 to 2026-08-17", + "", + " sessions 1", + " requests nothing in this period", + "", + " by tool", + " GitHub Copilot 21,122 tokens (session total, not requests)", + "", + " by day", + " 2026-08-17 nothing in this period", + ]); + }); +}); + +describe("printCostReport — a task the journal declared", () => { + it("says which route named a task row, and gives the rest its own reason", () => { + const lines = threeDay({ + records: [ + record({ turn_id: "a", cost_usd: 2, event_timestamp: "2026-08-17T10:00:00Z" }), + record({ turn_id: "b", cost_usd: 1, event_timestamp: "2026-08-18T10:00:00Z" }), + ], + journals: [ + { + vendorId: "s-1", + tool: "claude-code", + writtenPaths: [], + taskIntervals: [ + { + path: "aidd_docs/tasks/2026_08/2026_08_17_reporting/plan.md", + startMs: Date.parse("2026-08-17T09:00:00Z"), + endMs: Date.parse("2026-08-17T11:00:00Z"), + }, + ], + flowIntervals: [], + }, + ], + }); + const first = lines.indexOf(" by task of cost"); + + expect(lines.slice(first, first + 7)).toEqual([ + " by task of cost", + " 2026_08/2026_08_17_reporting 67% $2.00 declared by the flow", + " the journal falls silent before this record 33% $1.00", + "", + " by backlog item of cost", + " this task declares no backlog item 67% $2.00", + " the journal falls silent before this record 33% $1.00", + ]); + }); +}); + +describe("printCostReport — a selection a filter emptied", () => { + it("says a filter's value was never seen anywhere, and prints no breakdown under it", () => { + expect( + threeDay({ + records: [record({ turn_id: "a", cost_usd: 1 })], + filters: { project: "never-worked-here" }, + }) + ).toEqual([ + "period 2026-08-17 to 2026-08-19 filters: project=never-worked-here", + "", + " project 'never-worked-here' matched nothing — no record has ever named this project", + "", + " sessions nothing in this selection", + " requests nothing in this selection", + ]); + }); + + it("says a known value simply did no work here, rather than never being seen", () => { + expect( + threeDay({ + records: [record({ turn_id: "a", cost_usd: 1, project_id: "acme/widgets" })], + knownValues: { projects: new Set(["gone"]), steps: new Set(), models: new Set() }, + filters: { project: "gone" }, + })[2] + ).toBe(" project 'gone' matched nothing in this selection — known, but no work here"); + }); +}); + +const CODEX_WITH_REASON: CostReportInput["declaredTools"] = [ + { tool: "claude", coverage: "covered", capability: NO_CAPABILITY }, + { tool: "codex", coverage: "covered", capability: NO_CAPABILITY, reason: "Partial read." }, +]; + +describe("printCostReport — a fact a row carries beside its figure", () => { + it("keeps a covered tool's own reason after the figure it qualifies", () => { + const output = new CapturingOutput(); + printCostReport( + output, + buildCostReport({ + fromDay: "2026-08-17", + toDay: "2026-08-17", + declaredTools: CODEX_WITH_REASON, + records: [record({ turn_id: "a", cost_usd: 1, tool: "codex" })], + journals: [], + undatedRecords: 0, + unreadableLines: 0, + measurementEnabled: true, + }) + ); + + expect(output.lines).toContain( + " Codex $1.00 0 tokens — Partial read." + ); + }); + + it("sums all four token counters into the headline, and reports the cache share of them", () => { + const lines = threeDay({ + records: [ + record({ + turn_id: "a", + cost_usd: 4, + input_tokens: 10, + output_tokens: 20, + cache_read_tokens: 30, + cache_creation_tokens: 40, + }), + ], + }); + + expect(lines[4]).toBe(" tokens 100 30% cache"); + }); + + it("names an agent a record's own tool stated, above the main thread's own row", () => { + const lines = threeDay({ + records: [ + record({ turn_id: "a", cost_usd: 4, agent_name: "reviewer" }), + record({ turn_id: "b", cost_usd: 1 }), + ], + }); + const first = lines.indexOf(" by agent of cost"); + + expect(lines.slice(first, first + 3)).toEqual([ + " by agent of cost", + " reviewer 80% $4.00", + " the main thread 20% $1.00", + ]); + }); +}); + +describe("printCostReport — a report narrowed to one task", () => { + it("names the task in the header, and breaks down which route knew it", () => { + const lines = threeDay({ + records: [record({ turn_id: "a", cost_usd: 2, event_timestamp: "2026-08-17T10:00:00Z" })], + journals: [ + { + vendorId: "s-1", + tool: "claude-code", + writtenPaths: ["aidd_docs/tasks/2026_08/2026_08_17_reporting/plan.md"], + taskIntervals: [], + flowIntervals: [], + }, + ], + task: "2026_08/2026_08_17_reporting", + }); + + expect(lines[0]).toBe("task 2026_08/2026_08_17_reporting 2026-08-17 to 2026-08-19"); + expect(lines.slice(7, 11)).toEqual([ + " ticket known of cost", + " declared by the flow 0%", + " inferred from a written file 100%", + "", + ]); + }); + + it("says a filter emptied a selection only in combination with the rest of it", () => { + const lines = threeDay({ + records: [ + record({ turn_id: "a", cost_usd: 1, project_id: "acme/widgets", model: "opus" }), + record({ turn_id: "b", cost_usd: 1, project_id: "acme/gadgets", model: "haiku" }), + ], + knownValues: { + projects: new Set(["acme/widgets"]), + steps: new Set(), + models: new Set(["haiku"]), + }, + filters: { project: "acme/widgets", model: "haiku" }, + }); + + expect(lines[2]).toBe( + " model 'haiku' matched nothing combined with the rest of this selection" + ); + }); +}); + +describe("printCostReport — the boundary each cap sits on", () => { + it("withholds nothing, and says nothing about withholding, at exactly ten prompts", () => { + const lines = threeDay({ + records: Array.from({ length: 10 }, (_, i) => + record({ turn_id: `t-${i}`, prompt_id: `p-${i}`, cost_usd: 10 - i }) + ), + }); + + expect(lines.filter((line) => line.includes("p-"))).toHaveLength(10); + expect(lines.some((line) => line.includes("more prompts"))).toBe(false); + }); + + it("still prints every daily row at exactly thirty-one days", () => { + const lines = threeDay({ + fromDay: "2026-01-01", + toDay: "2026-01-31", + records: [record({ turn_id: "a", cost_usd: 1, event_timestamp: "2026-01-01T00:00:00Z" })], + }); + + expect(lines.at(-1)).toBe(" 2026-01-31 nothing in this period"); + expect(lines.some((line) => line.includes("days in this period"))).toBe(false); + }); +}); diff --git a/cli/tests/presentation/display/doctor-display.unit.test.ts b/cli/tests/presentation/display/doctor-display.unit.test.ts new file mode 100644 index 000000000..a1cbff8f5 --- /dev/null +++ b/cli/tests/presentation/display/doctor-display.unit.test.ts @@ -0,0 +1,229 @@ +import { describe, expect, it } from "vitest"; +import { + printAllToolsDrift, + printInventory, + printPluginIssues, + printReportErrors, + printScopeIssues, + printToolDrift, + printUserScopeTools, +} from "../../../src/presentation/display/doctor-display.js"; +import { CapturingOutput } from "../../helpers/ports/capturing-output.js"; + +describe("printPluginIssues", () => { + it("prints nothing when there are no issues", () => { + const output = new CapturingOutput(false); + + printPluginIssues(output, []); + + expect(output.lines).toEqual([]); + }); + + it("collapses every not-installed-on-machine issue for a tool into one line", () => { + const output = new CapturingOutput(false); + + printPluginIssues(output, [ + { toolId: "cursor", pluginName: "aidd-context", issue: "not-installed-on-machine" }, + { toolId: "cursor", pluginName: "aidd-test", issue: "not-installed-on-machine" }, + ]); + + expect(output.lines).toEqual([ + "\nPlugins:", + " cursor: plugins not installed on this machine, run `aidd sync`", + ]); + }); + + it("still prints one line per file for a genuinely drifted plugin", () => { + const output = new CapturingOutput(false); + + printPluginIssues(output, [ + { toolId: "claude", pluginName: "my-plugin", issue: "missing", filePath: "commands/cmd.md" }, + ]); + + expect(output.lines).toEqual([ + "\nPlugins:", + " Plugin my-plugin (claude): missing — commands/cmd.md\n Fix: Run `aidd sync`", + ]); + }); +}); + +describe("printScopeIssues", () => { + it("prints nothing when the scope was never reported on", () => { + const output = new CapturingOutput(false); + + printScopeIssues(output, "AI", null); + + expect(output.lines).toEqual([]); + }); + + it("prints nothing when the scope reported no issue", () => { + const output = new CapturingOutput(false); + + printScopeIssues(output, "AI", { issues: [] }); + + expect(output.lines).toEqual([]); + }); + + it("heads the scope, then warns every informational issue before the rest", () => { + const output = new CapturingOutput(false); + + printScopeIssues(output, "AI", { + issues: [ + { severity: "error", message: "claude is not registered", fix: "Run `aidd sync`" }, + { severity: "info", message: "claude never ran", fix: "Start claude once" }, + { severity: "warning", message: "codex is ahead", fix: "Run `aidd update`" }, + ], + }); + + expect(output.lines).toEqual([ + "\nAI:", + " claude never ran\n Fix: Start claude once", + " claude is not registered\n Fix: Run `aidd sync`", + " codex is ahead\n Fix: Run `aidd update`", + ]); + }); + + it("sends an error issue to the error channel and everything else to the warning one", () => { + const output = new CapturingOutput(false); + + printScopeIssues(output, "User scope", { + issues: [ + { severity: "error", message: "claude is not registered", fix: "Run `aidd sync`" }, + { severity: "warning", message: "codex is ahead", fix: "Run `aidd update`" }, + ], + }); + + expect(output.at("error")).toEqual([" claude is not registered\n Fix: Run `aidd sync`"]); + expect(output.at("warn")).toEqual([" codex is ahead\n Fix: Run `aidd update`"]); + }); +}); + +describe("printInventory", () => { + it("prints nothing when the category holds no tool", () => { + const output = new CapturingOutput(false); + + printInventory(output, "AI", { toolHealth: [] }, []); + + expect(output.lines).toEqual([]); + }); + + it("prints nothing when the category was never reported on", () => { + const output = new CapturingOutput(false); + + printInventory(output, "AI", null, [{ toolId: "claude", version: "7.0.0" }]); + + expect(output.lines).toEqual([]); + }); + + it("heads the category, then counts each tool's files and merge files at its version", () => { + const output = new CapturingOutput(false); + + printInventory( + output, + "AI", + { toolHealth: [{ toolId: "claude", fileCount: 12, mergeFileCount: 2 }] }, + [ + { toolId: "cursor", version: "6.0.0" }, + { toolId: "claude", version: "7.0.0" }, + ] + ); + + expect(output.lines).toEqual(["\nAI tools:", " claude (v7.0.0): 12 files, 2 merge files"]); + }); + + it("calls a version the status report never named unknown", () => { + const output = new CapturingOutput(false); + + printInventory( + output, + "IDE", + { toolHealth: [{ toolId: "vscode", fileCount: 1, mergeFileCount: 0 }] }, + [] + ); + + expect(output.lines).toEqual(["\nIDE tools:", " vscode (vunknown): 1 files, 0 merge files"]); + }); +}); + +describe("printReportErrors", () => { + it("prints nothing when the run reported no error", () => { + const output = new CapturingOutput(false); + + printReportErrors(output, []); + + expect(output.lines).toEqual([]); + }); + + it("warns each error under the scope it came from", () => { + const output = new CapturingOutput(false); + + printReportErrors(output, [{ scope: "claude", message: "settings unreadable" }]); + + expect(output.at("warn")).toEqual(["[claude] settings unreadable"]); + }); +}); + +describe("printAllToolsDrift", () => { + it("heads drift, then reports AI tools, IDE tools and plugins in that order", () => { + const output = new CapturingOutput(false); + + printAllToolsDrift(output, { + aiTools: { tools: [{ toolId: "claude", version: "7.0.0", drifted: [] }] }, + ideTools: { tools: [] }, + pluginDrift: [], + }); + + expect(output.lines).toEqual([ + "\nDrift:", + "AI tools:", + " claude (v7.0.0): in sync", + "IDE tools:", + " (none installed)", + "Plugins:", + " (all in sync)", + ]); + }); +}); + +describe("printToolDrift", () => { + it("heads drift, then reports the one tool and its plugins, naming no category", () => { + const output = new CapturingOutput(false); + + printToolDrift(output, { + tools: [{ toolId: "claude", version: "7.0.0", drifted: [] }], + pluginDrift: [], + }); + + expect(output.lines).toEqual([ + "\nDrift:", + " claude (v7.0.0): in sync", + "Plugins:", + " (all in sync)", + ]); + }); +}); + +describe("printUserScopeTools", () => { + it("heads the machine-wide tools even when none is registered", () => { + const output = new CapturingOutput(false); + + printUserScopeTools(output, []); + + expect(output.lines).toEqual(["User-scope tools:"]); + }); + + it("names each tool, its version and the file its activation is expected in", () => { + const output = new CapturingOutput(false); + + printUserScopeTools(output, [ + { toolId: "claude", version: "7.0.0", settings: "/home/me/.claude/settings.json" }, + { toolId: "codex", version: "unknown", settings: "no user-scope settings file" }, + ]); + + expect(output.lines).toEqual([ + "User-scope tools:", + " claude (v7.0.0): expects activation in /home/me/.claude/settings.json", + " codex (vunknown): expects activation in no user-scope settings file", + ]); + }); +}); diff --git a/cli/tests/presentation/display/framework-display.unit.test.ts b/cli/tests/presentation/display/framework-display.unit.test.ts new file mode 100644 index 000000000..08136df26 --- /dev/null +++ b/cli/tests/presentation/display/framework-display.unit.test.ts @@ -0,0 +1,117 @@ +import { describe, expect, it } from "vitest"; +import { + printScopedFailures, + printToolAlreadyInstalled, + printToolInstalled, + printToolRemoved, + printUpdateResult, +} from "../../../src/presentation/display/framework-display.js"; +import { CapturingOutput } from "../../helpers/ports/capturing-output.js"; + +describe("printToolAlreadyInstalled", () => { + it("warns that the tool is there and names the flag that reinstalls it", () => { + const output = new CapturingOutput(false); + + printToolAlreadyInstalled(output, "claude"); + + expect(output.at("warn")).toEqual(["claude is already installed. Use `--force` to reinstall."]); + }); +}); + +describe("printToolInstalled", () => { + it("announces the tool and its file count when nothing warned", () => { + const output = new CapturingOutput(false); + + printToolInstalled(output, "cursor", 12, []); + + expect(output.at("success")).toEqual(["Installed cursor (12 files)"]); + }); + + it("puts every warning before the success line, in the order given", () => { + const output = new CapturingOutput(false); + + printToolInstalled(output, "codex", 3, ["first", "second"]); + + expect(output.captured).toEqual([ + { level: "warn", message: "first" }, + { level: "warn", message: "second" }, + { level: "success", message: "Installed codex (3 files)" }, + ]); + }); +}); + +describe("printToolRemoved", () => { + it("announces the tool and how many files went with it", () => { + const output = new CapturingOutput(false); + + printToolRemoved(output, "claude", 7); + + expect(output.at("success")).toEqual(["Removed claude (7 files removed)"]); + }); +}); + +describe("printUpdateResult", () => { + it("says no tool is installed when there is neither an update nor an error", () => { + const output = new CapturingOutput(false); + + printUpdateResult(output, [], []); + + expect(output.captured).toEqual([{ level: "info", message: "No tools installed." }]); + }); + + it("prints one success per updated tool, then every error", () => { + const output = new CapturingOutput(false); + + printUpdateResult( + output, + [ + { toolId: "claude", fileCount: 4 }, + { toolId: "cursor", fileCount: 1 }, + ], + [{ scope: "codex", message: "binary missing" }] + ); + + expect(output.captured).toEqual([ + { level: "success", message: "Updated claude (4 files)" }, + { level: "success", message: "Updated cursor (1 files)" }, + { level: "warn", message: "[codex] binary missing" }, + ]); + }); + + it("stays silent about missing tools once an update alone came back", () => { + const output = new CapturingOutput(false); + + printUpdateResult(output, [{ toolId: "claude", fileCount: 2 }], []); + + expect(output.captured).toEqual([{ level: "success", message: "Updated claude (2 files)" }]); + }); + + it("stays silent about missing tools once an error alone came back", () => { + const output = new CapturingOutput(false); + + printUpdateResult(output, [], [{ scope: "claude", message: "refused" }]); + + expect(output.captured).toEqual([{ level: "warn", message: "[claude] refused" }]); + }); +}); + +describe("printScopedFailures", () => { + it("prints nothing when nothing failed", () => { + const output = new CapturingOutput(false); + + printScopedFailures(output, []); + + expect(output.lines).toEqual([]); + }); + + it("warns one bracketed scope per failure, in the order given", () => { + const output = new CapturingOutput(false); + + printScopedFailures(output, [ + { scope: "claude", message: "add refused" }, + { scope: "codex", message: "not on PATH" }, + ]); + + expect(output.at("warn")).toEqual(["[claude] add refused", "[codex] not on PATH"]); + }); +}); diff --git a/cli/tests/presentation/display/installed-rules-display.unit.test.ts b/cli/tests/presentation/display/installed-rules-display.unit.test.ts new file mode 100644 index 000000000..00ec75a1e --- /dev/null +++ b/cli/tests/presentation/display/installed-rules-display.unit.test.ts @@ -0,0 +1,94 @@ +import { describe, expect, it } from "vitest"; +import { + printInstalledRules, + printInstalledRulesJson, +} from "../../../src/presentation/display/installed-rules-display.js"; +import { CapturingOutput } from "../../helpers/ports/capturing-output.js"; + +describe("printInstalledRulesJson", () => { + it("prints an empty inventory as an empty array, not as nothing", () => { + const output = new CapturingOutput(false); + + printInstalledRulesJson(output, []); + + expect(output.at("print")).toEqual(["[]"]); + }); + + it("prints the inventory two-space indented, the shape its readers parse", () => { + const output = new CapturingOutput(false); + + printInstalledRulesJson(output, [ + { + tool: "claude", + path: ".claude/rules/a.md", + name: "a", + description: "a rule", + paths: ["src/**"], + }, + ]); + + expect(output.at("print")).toEqual([ + '[\n {\n "tool": "claude",\n "path": ".claude/rules/a.md",\n "name": "a",\n "description": "a rule",\n "paths": [\n "src/**"\n ]\n }\n]', + ]); + }); +}); + +describe("printInstalledRules", () => { + it("says no rule is installed rather than printing nothing", () => { + const output = new CapturingOutput(false); + + printInstalledRules(output, []); + + expect(output.captured).toEqual([ + { level: "info", message: "No rules installed for any AI tool." }, + ]); + }); + + it("prints a rule's tool, path, description and the paths it is scoped to", () => { + const output = new CapturingOutput(false); + + printInstalledRules(output, [ + { + tool: "claude", + path: ".claude/rules/a.md", + name: "a", + description: "a rule", + paths: ["src/**", "tests/**"], + }, + ]); + + expect(output.at("print")).toEqual([ + "claude .claude/rules/a.md", + " a rule", + " applies to: src/**, tests/**", + ]); + }); + + it("reads an absent path list as every file, not as an empty scope", () => { + const output = new CapturingOutput(false); + + printInstalledRules(output, [ + { tool: "cursor", path: ".cursor/rules/a.mdc", name: "a", description: "a rule" }, + ]); + + expect(output.at("print")).toEqual([ + "cursor .cursor/rules/a.mdc", + " a rule", + " applies to: every file", + ]); + }); + + it("names an empty description rather than printing a bare indent", () => { + const output = new CapturingOutput(false); + + printInstalledRules(output, [ + { tool: "claude", path: ".claude/rules/a.md", name: "a", description: "", paths: [] }, + ]); + + expect(output.at("print")).toEqual([ + "claude .claude/rules/a.md", + " (no description)", + " applies to: ", + ]); + }); +}); diff --git a/cli/tests/presentation/display/marketplace-display.unit.test.ts b/cli/tests/presentation/display/marketplace-display.unit.test.ts new file mode 100644 index 000000000..6f2db1264 --- /dev/null +++ b/cli/tests/presentation/display/marketplace-display.unit.test.ts @@ -0,0 +1,210 @@ +import { describe, expect, it } from "vitest"; +import type { PluginCatalog } from "../../../src/contexts/distribution/domain/catalog.js"; +import { Marketplace } from "../../../src/contexts/distribution/domain/marketplace.js"; +import { + printCatalogEntries, + printMarketplaceCheck, + printMarketplaceRegistered, + printMarketplaceRemoved, + printRefreshResults, + printRegisteredMarketplaces, +} from "../../../src/presentation/display/marketplace-display.js"; +import { CapturingOutput } from "../../helpers/ports/capturing-output.js"; + +function marketplace(name: string, version?: string): Marketplace { + return Marketplace.fromJSON({ + name, + source: { kind: "local", path: `/tmp/${name}` }, + scope: "project", + addedAt: "2020-01-01T00:00:00.000Z", + ...(version === undefined ? {} : { version }), + }); +} + +function catalog(entries: PluginCatalog["plugins"]): PluginCatalog { + return { plugins: entries }; +} + +describe("printCatalogEntries", () => { + it("warns instead of listing when the catalog could not be fetched", () => { + const output = new CapturingOutput(false); + + printCatalogEntries(output, "acme", new Map()); + + expect(output.at("warn")).toEqual([" (could not fetch catalog for 'acme')"]); + }); + + it("prints one indented row per plugin, source described", () => { + const output = new CapturingOutput(false); + const catalogs = new Map([ + [ + "acme", + catalog([ + { + name: "widget", + version: "1.2.3", + description: "does widgets", + source: { kind: "local", path: "/tmp/widget" }, + recommended: false, + strict: false, + }, + ]), + ], + ]); + + printCatalogEntries(output, "acme", catalogs); + + expect(output.at("print")).toEqual([" widget@1.2.3 — does widgets — /tmp/widget"]); + }); + + it("marks a recommended plugin and falls back to ? for a version it has none of", () => { + const output = new CapturingOutput(false); + const catalogs = new Map([ + [ + "acme", + catalog([ + { + name: "widget", + source: { kind: "local", path: "/tmp/widget" }, + recommended: true, + strict: false, + }, + ]), + ], + ]); + + printCatalogEntries(output, "acme", catalogs); + + expect(output.at("print")).toEqual([" widget@? — — /tmp/widget (recommended)"]); + }); +}); + +describe("printRegisteredMarketplaces", () => { + it("says none is registered rather than printing nothing", () => { + const output = new CapturingOutput(false); + + printRegisteredMarketplaces(output, [], undefined); + + expect(output.captured).toEqual([{ level: "info", message: "No marketplaces registered." }]); + }); + + it("prints a name with its scope, and its version only when it has one", () => { + const output = new CapturingOutput(false); + + printRegisteredMarketplaces( + output, + [marketplace("acme", "2.0.0"), marketplace("beta")], + undefined + ); + + expect(output.at("print")).toEqual(["acme v2.0.0 [project]", "beta [project]"]); + }); + + it("follows each marketplace with its catalog when catalogs were fetched", () => { + const output = new CapturingOutput(false); + const catalogs = new Map([["acme", catalog([])]]); + + printRegisteredMarketplaces(output, [marketplace("acme"), marketplace("beta")], catalogs); + + expect(output.captured).toEqual([ + { level: "print", message: "acme [project]" }, + { level: "print", message: "beta [project]" }, + { level: "warn", message: " (could not fetch catalog for 'beta')" }, + ]); + }); +}); + +describe("printMarketplaceRegistered", () => { + it("names the marketplace that was registered", () => { + const output = new CapturingOutput(false); + + printMarketplaceRegistered(output, "acme"); + + expect(output.at("success")).toEqual(["Marketplace 'acme' registered."]); + }); +}); + +describe("printMarketplaceRemoved", () => { + it("names the marketplace and how many plugins went with it", () => { + const output = new CapturingOutput(false); + + printMarketplaceRemoved(output, "acme", 2); + + expect(output.at("success")).toEqual(["Marketplace 'acme' removed (2 plugin(s) cleaned up)."]); + }); +}); + +describe("printRefreshResults", () => { + it("prints a status per marketplace, appending an error only where there is one", () => { + const output = new CapturingOutput(false); + + printRefreshResults(output, [ + { name: "acme", status: "ok" }, + { name: "beta", status: "failed", error: "404" }, + ]); + + expect(output.at("print")).toEqual(["acme: ok", "beta: failed (404)"]); + }); +}); + +describe("printMarketplaceCheck", () => { + it("reports everything fresh when nothing is stale, removed or skipped", () => { + const output = new CapturingOutput(false); + + printMarketplaceCheck(output, { stale: [], upstreamRemoved: [], skipped: [] }); + + expect(output.captured).toEqual([{ level: "success", message: "All marketplaces fresh." }]); + }); + + it("lists stale marketplaces, then upstream removals, then skips", () => { + const output = new CapturingOutput(false); + + printMarketplaceCheck(output, { + stale: [marketplace("acme")], + upstreamRemoved: [{ marketplace: "beta", plugin: "widget", toolId: "claude" }], + skipped: [{ marketplace: "gamma", error: "unreachable" }], + }); + + expect(output.captured).toEqual([ + { level: "print", message: "stale: acme" }, + { level: "print", message: "removed: beta/widget (claude)" }, + { level: "warn", message: "skipped: gamma — unreachable" }, + ]); + }); + + it("withholds the all-fresh line as soon as one marketplace is stale", () => { + const output = new CapturingOutput(false); + + printMarketplaceCheck(output, { + stale: [marketplace("acme")], + upstreamRemoved: [], + skipped: [], + }); + + expect(output.captured).toEqual([{ level: "print", message: "stale: acme" }]); + }); + + it("withholds the all-fresh line as soon as one plugin went upstream", () => { + const output = new CapturingOutput(false); + + printMarketplaceCheck(output, { + stale: [], + upstreamRemoved: [{ marketplace: "beta", plugin: "widget", toolId: "claude" }], + skipped: [], + }); + + expect(output.captured).toEqual([{ level: "print", message: "removed: beta/widget (claude)" }]); + }); + + it("withholds the all-fresh line as soon as one marketplace was skipped", () => { + const output = new CapturingOutput(false); + + printMarketplaceCheck(output, { + stale: [], + upstreamRemoved: [], + skipped: [{ marketplace: "gamma", error: "unreachable" }], + }); + + expect(output.captured).toEqual([{ level: "warn", message: "skipped: gamma — unreachable" }]); + }); +}); diff --git a/cli/tests/presentation/display/menu-display.unit.test.ts b/cli/tests/presentation/display/menu-display.unit.test.ts new file mode 100644 index 000000000..d1387c731 --- /dev/null +++ b/cli/tests/presentation/display/menu-display.unit.test.ts @@ -0,0 +1,19 @@ +import { describe, expect, it } from "vitest"; +import { printBanner } from "../../../src/presentation/display/menu-display.js"; +import { CapturingOutput } from "../../helpers/ports/capturing-output.js"; + +describe("printBanner", () => { + it("prints the ASCII wordmark under a blank line, ending on the product name", () => { + const output = new CapturingOutput(false); + + printBanner(output); + + expect(output.captured).toEqual([ + { + level: "print", + message: + "\n _ ___ ___ ___\n /_\\ |_ _| \\| \\\n / _ \\ | || |) | |) |\n/_/ \\_\\|___|___/|___/\n\n AI-Driven Development CLI", + }, + ]); + }); +}); diff --git a/cli/tests/presentation/display/plugin-display.unit.test.ts b/cli/tests/presentation/display/plugin-display.unit.test.ts new file mode 100644 index 000000000..8fe2017e2 --- /dev/null +++ b/cli/tests/presentation/display/plugin-display.unit.test.ts @@ -0,0 +1,179 @@ +import { describe, expect, it } from "vitest"; +import { Marketplace } from "../../../src/contexts/distribution/domain/marketplace.js"; +import { + printInstalledPlugins, + printPluginInstallOutcome, + printPluginRemoved, + printPluginSearchHits, + printPluginsUpdated, +} from "../../../src/presentation/display/plugin-display.js"; +import { CapturingOutput } from "../../helpers/ports/capturing-output.js"; + +function marketplace(name: string): Marketplace { + return Marketplace.create({ + name, + source: { kind: "local", path: `/tmp/${name}` }, + scope: "project", + addedAt: "2020-01-01T00:00:00.000Z", + }); +} + +describe("printInstalledPlugins", () => { + it("says none is installed when no tool holds one", () => { + const output = new CapturingOutput(false); + + printInstalledPlugins(output, new Map()); + + expect(output.captured).toEqual([{ level: "info", message: "No plugins installed." }]); + }); + + it("heads each tool and indents its plugins with their versions", () => { + const output = new CapturingOutput(false); + + printInstalledPlugins(output, new Map([["claude", [{ name: "aidd-dev", version: "1.0.0" }]]])); + + expect(output.captured).toEqual([ + { level: "print", message: "claude:" }, + { level: "print", message: " aidd-dev@1.0.0" }, + ]); + }); + + it("skips a tool holding nothing rather than heading an empty list", () => { + const output = new CapturingOutput(false); + + printInstalledPlugins( + output, + new Map([ + ["cursor", []], + ["claude", [{ name: "aidd-dev", version: "1.0.0" }]], + ]) + ); + + expect(output.captured).toEqual([ + { level: "print", message: "claude:" }, + { level: "print", message: " aidd-dev@1.0.0" }, + ]); + }); + + it("says none is installed when every tool holds an empty list", () => { + const output = new CapturingOutput(false); + + printInstalledPlugins(output, new Map([["cursor", []]])); + + expect(output.captured).toEqual([{ level: "info", message: "No plugins installed." }]); + }); +}); + +describe("printPluginInstallOutcome", () => { + it("says nothing was selected when the interactive pick installed none", () => { + const output = new CapturingOutput(false); + + printPluginInstallOutcome(output, { kind: "picked", installed: [] }); + + expect(output.captured).toEqual([{ level: "info", message: "No plugins selected." }]); + }); + + it("counts and names what an interactive pick installed", () => { + const output = new CapturingOutput(false); + + printPluginInstallOutcome(output, { kind: "picked", installed: ["one", "two"] }); + + expect(output.at("success")).toEqual(["Installed 2 plugin(s): one, two"]); + }); + + it("names no plugin for a local install, which carries none", () => { + const output = new CapturingOutput(false); + + printPluginInstallOutcome(output, { kind: "local", installed: [] }); + + expect(output.at("success")).toEqual(["Plugin added successfully."]); + }); + + it("quotes the single plugin a marketplace install brought in", () => { + const output = new CapturingOutput(false); + + printPluginInstallOutcome(output, { kind: "marketplace", installed: ["aidd-dev"] }); + + expect(output.at("success")).toEqual(["Installed 'aidd-dev'."]); + }); +}); + +describe("printPluginSearchHits", () => { + it("says there is no match rather than printing nothing", () => { + const output = new CapturingOutput(false); + + printPluginSearchHits(output, []); + + expect(output.captured).toEqual([{ level: "info", message: "No matches." }]); + }); + + it("prints a hit with its version, description and marketplace", () => { + const output = new CapturingOutput(false); + + printPluginSearchHits(output, [ + { + entry: { + name: "widget", + version: "1.2.3", + description: "does widgets", + source: { kind: "local", path: "/tmp/widget" }, + recommended: false, + strict: false, + }, + marketplace: marketplace("acme"), + }, + ]); + + expect(output.captured).toEqual([ + { level: "print", message: "widget@1.2.3 — does widgets — marketplace: acme" }, + ]); + }); + + it("marks a recommended hit and falls back to ? for a version it has none of", () => { + const output = new CapturingOutput(false); + + printPluginSearchHits(output, [ + { + entry: { + name: "widget", + source: { kind: "local", path: "/tmp/widget" }, + recommended: true, + strict: false, + }, + marketplace: marketplace("acme"), + }, + ]); + + expect(output.captured).toEqual([ + { level: "print", message: "widget@? — — marketplace: acme (recommended)" }, + ]); + }); +}); + +describe("printPluginsUpdated", () => { + it("reports everything current when nothing moved", () => { + const output = new CapturingOutput(false); + + printPluginsUpdated(output, []); + + expect(output.at("success")).toEqual(["All plugins are up to date."]); + }); + + it("names what moved, comma separated", () => { + const output = new CapturingOutput(false); + + printPluginsUpdated(output, ["one", "two"]); + + expect(output.at("success")).toEqual(["Updated: one, two."]); + }); +}); + +describe("printPluginRemoved", () => { + it("quotes the plugin that was removed", () => { + const output = new CapturingOutput(false); + + printPluginRemoved(output, "aidd-dev"); + + expect(output.at("success")).toEqual(["Plugin 'aidd-dev' removed."]); + }); +}); diff --git a/cli/tests/presentation/display/restore-display.unit.test.ts b/cli/tests/presentation/display/restore-display.unit.test.ts new file mode 100644 index 000000000..7a47d9171 --- /dev/null +++ b/cli/tests/presentation/display/restore-display.unit.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it } from "vitest"; +import { printNativeActivation } from "../../../src/presentation/display/restore-display.js"; +import { CapturingOutput } from "../../helpers/ports/capturing-output.js"; + +describe("printNativeActivation", () => { + it("prints nothing when every tool's CLI ran", () => { + const output = new CapturingOutput(false); + + printNativeActivation(output, []); + + expect(output.lines).toEqual([]); + }); + + it("names each tool and binary whose CLI was not on PATH, and what that means for the plugin", () => { + const output = new CapturingOutput(false); + + printNativeActivation(output, [ + { toolId: "claude", binary: "claude" }, + { toolId: "codex", binary: "codex" }, + ]); + + expect(output.lines).toEqual([ + "claude: the plugin will not load until the claude CLI has run.", + "codex: the plugin will not load until the codex CLI has run.", + ]); + }); +}); diff --git a/cli/tests/presentation/display/setup-display.unit.test.ts b/cli/tests/presentation/display/setup-display.unit.test.ts new file mode 100644 index 000000000..bdb68c3f5 --- /dev/null +++ b/cli/tests/presentation/display/setup-display.unit.test.ts @@ -0,0 +1,174 @@ +import { describe, expect, it } from "vitest"; +import { + printDetectedContext, + printNextSteps, + printSetupOutcome, + printWelcomeBanner, +} from "../../../src/presentation/display/setup-display.js"; +import { CapturingOutput } from "../../helpers/ports/capturing-output.js"; + +const CLAUDE_INSTALLED = { + toolId: "claude", + fileCount: 2, + files: [{ relativePath: "skills/plan.md" }, { relativePath: "skills/test.md" }], + skipped: false, + warnings: [], +}; + +describe("printWelcomeBanner", () => { + it("frames the run between two blank lines and names what it is about to wire", () => { + const output = new CapturingOutput(false); + + printWelcomeBanner(output); + + expect(output.lines).toEqual([ + "", + "AI-Driven Development setup", + "Wires your AI tools, registers the framework marketplace, installs plugins.", + "Press Ctrl-C any time to abort.", + "", + ]); + }); +}); + +describe("printNextSteps", () => { + it("offers doctor first when something was installed", () => { + const output = new CapturingOutput(false); + + printNextSteps(output, true); + + expect(output.lines).toEqual([ + "", + "Next steps:", + " aidd doctor # verify drift", + " aidd marketplace list # see registered marketplaces", + " aidd plugin install # add plugins", + " aidd --help # explore commands", + ]); + }); + + it("leaves doctor out when nothing was installed", () => { + const output = new CapturingOutput(false); + + printNextSteps(output, false); + + expect(output.lines).toEqual([ + "", + "Next steps:", + " aidd marketplace list # see registered marketplaces", + " aidd plugin install # add plugins", + " aidd --help # explore commands", + ]); + }); +}); + +describe("printDetectedContext", () => { + it("names what the project was detected as, ended by a full stop", () => { + const output = new CapturingOutput(false); + + printDetectedContext(output, "a TypeScript project"); + + expect(output.at("info")).toEqual(["Detected: a TypeScript project."]); + }); +}); + +describe("printSetupOutcome", () => { + it("announces a first install as initialized", () => { + const output = new CapturingOutput(false); + + printSetupOutcome(output, { kind: "initialized", install: { results: [] } }, false); + + expect(output.at("success")).toEqual(["Project initialized."]); + }); + + it("announces a repeat run as up to date, on the info channel", () => { + const output = new CapturingOutput(false); + + printSetupOutcome(output, { kind: "up-to-date", install: { results: [] } }, false); + + expect(output.at("info")).toEqual(["Project is up to date."]); + expect(output.at("success")).toEqual([]); + }); + + it("warns about a tool already installed rather than counting it as installed", () => { + const output = new CapturingOutput(false); + + printSetupOutcome( + output, + { + kind: "up-to-date", + install: { results: [{ ...CLAUDE_INSTALLED, skipped: true }] }, + }, + false + ); + + expect(output.lines).toEqual(["Project is up to date.", "claude is already installed."]); + }); + + it("surfaces each installed tool's own warnings", () => { + const output = new CapturingOutput(false); + + printSetupOutcome( + output, + { + kind: "initialized", + install: { results: [{ ...CLAUDE_INSTALLED, warnings: ["settings.json was merged"] }] }, + }, + false + ); + + expect(output.at("warn")).toEqual(["settings.json was merged"]); + }); + + it("counts one installed tool and its files", () => { + const output = new CapturingOutput(false); + + printSetupOutcome( + output, + { kind: "initialized", install: { results: [CLAUDE_INSTALLED] } }, + false + ); + + expect(output.at("success")).toEqual(["Project initialized.", "Installed claude (2 files)"]); + expect(output.at("debug")).toEqual([]); + }); + + it("names every installed tool and totals their files", () => { + const output = new CapturingOutput(false); + + printSetupOutcome( + output, + { + kind: "initialized", + install: { + results: [ + CLAUDE_INSTALLED, + { ...CLAUDE_INSTALLED, toolId: "cursor", fileCount: 3, files: [] }, + ], + }, + }, + false + ); + + expect(output.at("success")).toEqual([ + "Project initialized.", + "Installed claude, cursor (5 files)", + ]); + }); + + it("lists every written file only when verbose", () => { + const output = new CapturingOutput(false); + + printSetupOutcome( + output, + { kind: "initialized", install: { results: [CLAUDE_INSTALLED] } }, + true + ); + + expect(output.at("debug")).toEqual([ + "Tool: claude", + " + skills/plan.md", + " + skills/test.md", + ]); + }); +}); diff --git a/cli/tests/presentation/display/status-display.unit.test.ts b/cli/tests/presentation/display/status-display.unit.test.ts new file mode 100644 index 000000000..8b19fc48d --- /dev/null +++ b/cli/tests/presentation/display/status-display.unit.test.ts @@ -0,0 +1,134 @@ +import { describe, expect, it } from "vitest"; +import { + printDriftStats, + printPluginDrift, + printScopeReport, +} from "../../../src/presentation/display/status-display.js"; +import { CapturingOutput } from "../../helpers/ports/capturing-output.js"; + +describe("printScopeReport", () => { + it("says nothing is installed when the scope carries no tool", () => { + const output = new CapturingOutput(false); + + printScopeReport(output, { tools: [] }); + + expect(output.lines).toEqual([" (none installed)"]); + }); + + it("names a tool and its version as in sync when no file drifted", () => { + const output = new CapturingOutput(false); + + printScopeReport(output, { tools: [{ toolId: "claude", version: "7.0.0", drifted: [] }] }); + + expect(output.lines).toEqual([" claude (v7.0.0): in sync"]); + }); + + it("marks a modified file '~', a deleted one '-' and an added one '+', then counts them", () => { + const output = new CapturingOutput(false); + + printScopeReport(output, { + tools: [ + { + toolId: "claude", + version: "7.0.0", + drifted: [ + { status: "modified", relativePath: "a.md" }, + { status: "deleted", relativePath: "b.md" }, + { status: "added", relativePath: "c.md" }, + ], + }, + ], + }); + + expect(output.lines).toEqual([ + " claude (v7.0.0):", + " ~ a.md", + " - b.md", + " + c.md", + " 1 modified, 1 deleted, 1 added", + ]); + }); + + it("marks a status it has no symbol for '?'", () => { + const output = new CapturingOutput(false); + + printScopeReport(output, { + tools: [ + { + toolId: "cursor", + version: "7.0.0", + drifted: [{ status: "renamed", relativePath: "d.md" }], + }, + ], + }); + + expect(output.lines).toEqual([ + " cursor (v7.0.0):", + " ? d.md", + " 0 modified, 0 deleted, 0 added", + ]); + }); +}); + +describe("printDriftStats", () => { + it("counts each drift kind on one line", () => { + const output = new CapturingOutput(false); + + printDriftStats(output, [ + { status: "modified" }, + { status: "modified" }, + { status: "deleted" }, + { status: "added" }, + { status: "added" }, + { status: "added" }, + ]); + + expect(output.lines).toEqual([" 2 modified, 1 deleted, 3 added"]); + }); +}); + +describe("printPluginDrift", () => { + it("prints '(all in sync)' only when nothing was skipped and nothing drifted", () => { + const output = new CapturingOutput(false); + + printPluginDrift(output, { pluginDrift: [] }); + + expect(output.lines).toEqual([" (all in sync)"]); + }); + + it("prints one line per tool for a plugin never installed on this machine", () => { + const output = new CapturingOutput(false); + + printPluginDrift(output, { + pluginDrift: [ + { + toolId: "cursor", + pluginName: "aidd-test", + driftedFiles: [], + notInstalledOnMachine: true, + }, + ], + }); + + expect(output.lines).toEqual([ + " cursor: plugins not installed on this machine, run `aidd sync`", + ]); + }); + + it("still prints per-file drift lines for a genuinely modified plugin", () => { + const output = new CapturingOutput(false); + + printPluginDrift(output, { + pluginDrift: [ + { + toolId: "claude", + pluginName: "my-plugin", + driftedFiles: ["commands/cmd.md"], + notInstalledOnMachine: false, + }, + ], + }); + + expect(output.lines).toEqual([" plugin my-plugin (claude):", " ~ commands/cmd.md"]); + }); +}); diff --git a/cli/tests/presentation/display/sync-display.unit.test.ts b/cli/tests/presentation/display/sync-display.unit.test.ts new file mode 100644 index 000000000..70e551aa5 --- /dev/null +++ b/cli/tests/presentation/display/sync-display.unit.test.ts @@ -0,0 +1,184 @@ +import { describe, expect, it } from "vitest"; +import { + printActivationOutcome, + printRestoreOutcome, + printToolRestoreOutcome, + printUserScopeSyncOutcome, +} from "../../../src/presentation/display/sync-display.js"; +import { CapturingOutput } from "../../helpers/ports/capturing-output.js"; + +describe("printRestoreOutcome", () => { + it("says nothing was modified when nothing errored, restored or resisted restoration", () => { + const output = new CapturingOutput(false); + + printRestoreOutcome(output, { + errors: [], + totalRestored: 0, + totalKept: 0, + pluginNamesRestored: [], + unrestorable: [], + }); + + expect(output.lines).toEqual(["Nothing to restore — all files are unmodified."]); + }); + + it("warns each error under its scope before anything else, and never calls the run clean", () => { + const output = new CapturingOutput(false); + + printRestoreOutcome(output, { + errors: [{ scope: "claude", message: "settings unreadable" }], + totalRestored: 0, + totalKept: 0, + pluginNamesRestored: [], + unrestorable: [], + }); + + expect(output.lines).toEqual(["[claude] settings unreadable"]); + expect(output.at("warn")).toEqual(["[claude] settings unreadable"]); + }); + + it("counts restored and kept files when files were the only thing restored", () => { + const output = new CapturingOutput(false); + + printRestoreOutcome(output, { + errors: [], + totalRestored: 3, + totalKept: 2, + pluginNamesRestored: [], + unrestorable: [], + }); + + expect(output.lines).toEqual(["Restored 3 file(s), kept 2 file(s)"]); + }); + + it("names the restored plugins even when no tracked file was restored", () => { + const output = new CapturingOutput(false); + + printRestoreOutcome(output, { + errors: [], + totalRestored: 0, + totalKept: 0, + pluginNamesRestored: ["aidd-dev", "aidd-pm"], + unrestorable: [], + }); + + expect(output.lines).toEqual(["Restored plugins: aidd-dev, aidd-pm"]); + }); + + it("warns about files the current distribution no longer carries, restoring nothing else", () => { + const output = new CapturingOutput(false); + + printRestoreOutcome(output, { + errors: [], + totalRestored: 0, + totalKept: 0, + pluginNamesRestored: [], + unrestorable: ["old/skill.md"], + }); + + expect(output.lines).toEqual([ + "Could not restore 1 file(s) no longer part of the current distribution: old/skill.md", + ]); + }); +}); + +describe("printToolRestoreOutcome", () => { + it("says nothing was modified when every tool had nothing to restore", () => { + const output = new CapturingOutput(false); + + printToolRestoreOutcome(output, { + tools: [{ nothingToRestore: true }], + totalRestored: 0, + totalKept: 0, + unrestorable: [], + }); + + expect(output.lines).toEqual(["Nothing to restore — all files are unmodified."]); + }); + + it("restores when one tool of several had something to restore", () => { + const output = new CapturingOutput(false); + + printToolRestoreOutcome(output, { + tools: [{ nothingToRestore: true }, { nothingToRestore: false }], + totalRestored: 1, + totalKept: 0, + unrestorable: [], + }); + + expect(output.lines).toEqual(["Restored 1 file, kept 0 files"]); + }); + + it("counts one restored and one kept file in the singular", () => { + const output = new CapturingOutput(false); + + printToolRestoreOutcome(output, { + tools: [{ nothingToRestore: false }], + totalRestored: 1, + totalKept: 1, + unrestorable: [], + }); + + expect(output.lines).toEqual(["Restored 1 file, kept 1 file"]); + }); + + it("counts several restored and kept files in the plural, then what could not be restored", () => { + const output = new CapturingOutput(false); + + printToolRestoreOutcome(output, { + tools: [{ nothingToRestore: false }], + totalRestored: 2, + totalKept: 3, + unrestorable: ["gone.md"], + }); + + expect(output.lines).toEqual([ + "Restored 2 files, kept 3 files", + "Could not restore 1 file(s) no longer part of the current distribution: gone.md", + ]); + }); +}); + +describe("printActivationOutcome", () => { + it("prints nothing when every binary was there and nothing errored", () => { + const output = new CapturingOutput(false); + + printActivationOutcome(output, { binaryMissing: [], errors: [] }); + + expect(output.lines).toEqual([]); + }); + + it("warns about a missing host binary before the errors of the same run", () => { + const output = new CapturingOutput(false); + + printActivationOutcome(output, { + binaryMissing: [{ toolId: "claude", binary: "claude" }], + errors: [{ scope: "codex", message: "add refused" }], + }); + + expect(output.at("warn")).toEqual([ + "claude: the plugin will not load until the claude CLI has run.", + "[codex] add refused", + ]); + }); +}); + +describe("printUserScopeSyncOutcome", () => { + it("says no tool is registered at user scope when none was activated", () => { + const output = new CapturingOutput(false); + + printUserScopeSyncOutcome(output, []); + + expect(output.at("success")).toEqual([ + "Nothing to sync — no tool is registered at user scope yet.", + ]); + }); + + it("names every tool whose native activation was synced", () => { + const output = new CapturingOutput(false); + + printUserScopeSyncOutcome(output, ["claude", "codex"]); + + expect(output.at("success")).toEqual(["Synced native activation for: claude, codex"]); + }); +}); diff --git a/cli/tests/presentation/display/telemetry-check-display.unit.test.ts b/cli/tests/presentation/display/telemetry-check-display.unit.test.ts new file mode 100644 index 000000000..79cb3af93 --- /dev/null +++ b/cli/tests/presentation/display/telemetry-check-display.unit.test.ts @@ -0,0 +1,868 @@ +import { describe, expect, it } from "vitest"; +import type { TelemetrySetup } from "../../../src/contexts/telemetry/domain/telemetry-setup.js"; +import type { HostRegistrationEntry } from "../../../src/contexts/tools/domain/host-plugin-registration.js"; +import { printTelemetryCheckReport } from "../../../src/presentation/display/telemetry-check-display.js"; +import { CapturingOutput } from "../../helpers/ports/capturing-output.js"; + +/** Everything here is about not sending a person to the wrong place: a gated run must not + * look like a judged one, nor an unreadable thing like an absent one. */ +function reportText(output: CapturingOutput): string { + return output.at("print").join("\n"); +} + +function setup(overrides: Partial = {}): TelemetrySetup { + return { + allowed: { + allowed: true, + readable: true, + location: "/repo/.aidd/config.json", + decidedBy: "project-switch", + }, + identity: { attached: false, path: "/home/.config/aidd/identity.json", readable: true }, + recordsLocation: { path: "/home/.config/aidd/telemetry" }, + hostRegistration: { entries: [] }, + commitTrailer: { + delegate: "executable", + callSite: "present", + hookHasOtherContent: false, + hooksDir: "/repo/.git/hooks", + recentlyCarrying: { carrying: 3, examined: 5 }, + }, + recorderDeclaration: { + declared: true, + declaredAt: ["/repo/.aidd/manifest.json"], + locationsChecked: ["/repo/.aidd/manifest.json"], + unreadable: [], + }, + versions: { cli: "5.2.2", plugin: { kind: "recorded", version: "1.0.0" } }, + ...overrides, + }; +} + +describe("the setup a person reads before any claim", () => { + // A gated run judges nothing — but what is in place is exactly what a person switched off + // still needs to see, so the setup is printed on both sides of the gate. + it("prints the setup even when the run was gated before judging anything", () => { + const output = new CapturingOutput(); + + printTelemetryCheckReport(output, { + gate: "measurement is off — nothing to check until it is turned on", + setup: setup(), + leftoverExportConfig: [], + }); + + expect(reportText(output)).toContain("measurement allowed"); + expect(reportText(output)).toContain("records kept at"); + expect(reportText(output)).toContain("measurement is off"); + }); + + // The person's own refusal is a different fact from a project that never turned it on, + // and only one of them is changed by editing the project's file. + it("names a person's own refusal rather than reporting the project as off", () => { + const output = new CapturingOutput(); + + printTelemetryCheckReport(output, { + gate: "measurement is off", + setup: setup({ + allowed: { + allowed: false, + readable: true, + location: "AIDD_TELEMETRY", + decidedBy: "person-refusal", + }, + }), + leftoverExportConfig: [], + }); + + expect(reportText(output)).toContain("this person's own refusal"); + }); + + // Nothing declared is a person's cue to go and declare it somewhere, so the row lists + // every candidate rather than saying only that none matched. + it("lists every location it looked in when nothing declares the recorder", () => { + const output = new CapturingOutput(); + + printTelemetryCheckReport(output, { + setup: setup({ + recorderDeclaration: { + declared: false, + declaredAt: [], + locationsChecked: ["/repo/.aidd/manifest.json", "/repo/.claude/settings.json"], + unreadable: [], + }, + }), + claims: [], + uncovered: [], + leftoverExportConfig: [], + }); + + expect(reportText(output)).toContain("nowhere this build checks"); + expect(reportText(output)).toContain("/repo/.claude/settings.json"); + }); + + // A damaged file is something to fix; an absent declaration is an ordinary state. The row + // must not print the first as the second. + it("reads a damaged declaration location as unreadable, not as undeclared", () => { + const output = new CapturingOutput(); + + printTelemetryCheckReport(output, { + setup: setup({ + recorderDeclaration: { + declared: false, + declaredAt: [], + locationsChecked: [], + unreadable: ["/repo/.aidd/manifest.json"], + }, + }), + claims: [], + uncovered: [], + leftoverExportConfig: [], + }); + + expect(reportText(output)).toContain("could not be read"); + expect(reportText(output)).not.toContain("nowhere this build checks"); + }); + + it("names a plugin version nothing journalled apart from one that was never stamped", () => { + const nothing = new CapturingOutput(); + printTelemetryCheckReport(nothing, { + setup: setup({ versions: { cli: "5.2.2", plugin: { kind: "nothing-journalled" } } }), + claims: [], + uncovered: [], + leftoverExportConfig: [], + }); + const unstamped = new CapturingOutput(); + printTelemetryCheckReport(unstamped, { + setup: setup({ versions: { cli: "5.2.2", plugin: { kind: "unrecorded" } } }), + claims: [], + uncovered: [], + leftoverExportConfig: [], + }); + + const line = (o: CapturingOutput) => + o.at("print").find((l) => l.includes("plugin version")) ?? ""; + expect(line(nothing)).not.toBe(line(unstamped)); + }); +}); + +describe("the claims, and what is deliberately not one", () => { + it("prints a verdict and its detail for every claim judged", () => { + const output = new CapturingOutput(); + + printTelemetryCheckReport(output, { + setup: setup(), + claims: [ + { + claim: "hook-fired", + verdict: "ok", + reason: "session-anchored", + detail: "2 run file(s)", + }, + { + claim: "records-join", + verdict: "fail", + reason: "all-unattributed", + detail: "nothing joined", + }, + ], + uncovered: [], + leftoverExportConfig: [], + }); + + expect(reportText(output)).toContain("2 run file(s)"); + expect(reportText(output)).toContain("nothing joined"); + }); + + it("names a tool nothing can read with its own reason, never as a failing claim", () => { + const output = new CapturingOutput(); + + printTelemetryCheckReport(output, { + setup: setup(), + claims: [], + uncovered: [{ tool: "cursor", reason: "It writes no token count in any file it produces." }], + leftoverExportConfig: [], + }); + + expect(reportText(output)).toContain("not covered: cursor"); + expect(reportText(output)).toContain("no token count"); + }); + + // A stale export lives in a tool's own settings file, which no claim here can see — so it + // is a warning on stderr, never one of the judged four. + it("warns about a leftover export on stderr, on both sides of the gate", () => { + const leftoverExportConfig = [ + { path: "/repo/.claude/settings.json", keys: ["OTEL_EXPORTER_OTLP_ENDPOINT"] }, + ]; + + const judged = new CapturingOutput(); + printTelemetryCheckReport(judged, { + setup: setup(), + claims: [], + uncovered: [], + leftoverExportConfig, + }); + const gated = new CapturingOutput(); + printTelemetryCheckReport(gated, { + gate: "measurement is off", + setup: setup(), + leftoverExportConfig, + }); + + for (const output of [judged, gated]) { + expect(output.at("warn").join("\n")).toContain("OTEL_EXPORTER_OTLP_ENDPOINT"); + expect(reportText(output)).not.toContain("OTEL_EXPORTER_OTLP_ENDPOINT"); + } + }); +}); + +describe("the row saying whether the host will load what aidd installed", () => { + function report(hostRegistration: TelemetrySetup["hostRegistration"]): string { + const output = new CapturingOutput(); + printTelemetryCheckReport(output, { + gate: "measurement is off", + setup: setup({ hostRegistration }), + leftoverExportConfig: [], + }); + return reportText(output); + } + + const REGISTRY = "/home/dev/.claude/plugins/installed_plugins.json"; + + function entry(answer: HostRegistrationEntry["answer"], plugin: string): HostRegistrationEntry { + return { tool: "claude", plugin, answer, detail: REGISTRY }; + } + + // The one thing a person must not miss is what will not load. A reader who stops after the + // first line has still read the problem. + it("puts what will not load above what is fine", () => { + const text = report({ + entries: [entry("registered", "fine"), entry("not-registered", "broken")], + }); + + expect(text.indexOf("broken")).toBeLessThan(text.indexOf("fine")); + }); + + it("orders a disabled registration and an unanswerable one between the two", () => { + const text = report({ + entries: [ + entry("registered", "fine"), + entry("unanswerable", "unknown"), + entry("registered-disabled", "off"), + entry("not-registered", "broken"), + ], + }); + const at = (plugin: string) => text.indexOf(plugin); + + expect(at("broken")).toBeLessThan(at("off")); + expect(at("off")).toBeLessThan(at("unknown")); + expect(at("unknown")).toBeLessThan(at("fine")); + }); + + it("names the answer and the detail on each line, never a bare pass", () => { + const text = report({ + entries: [{ ...entry("not-registered", "aidd-telemetry"), detail: "does not carry it" }], + }); + + expect(text).toContain("claude/aidd-telemetry: not-registered — does not carry it"); + }); + + // A project with nothing installed is healthy, and an empty block would read as a failure + // to look rather than as an answer. + it("says a project has no plugin recorded rather than printing nothing", () => { + expect(report({ entries: [] })).toContain("no plugin recorded for any tool"); + }); + + // The crash guard, made visible: a manifest that cannot be parsed is its own sentence, and + // must never print as the empty case above — one is damage, the other is a normal state. + it("says the manifest could not be read, distinctly from having nothing installed", () => { + const text = report({ + entries: [], + manifestUnreadable: "Cannot read properties of undefined (reading 'map')", + }); + + expect(text).toContain("AIDD's own manifest could not be read"); + expect(text).not.toContain("no plugin recorded"); + }); +}); + +describe("the row saying whether commits carry their session", () => { + function report(commitTrailer: TelemetrySetup["commitTrailer"]): string { + const output = new CapturingOutput(); + printTelemetryCheckReport(output, { + gate: "measurement is off", + setup: setup({ commitTrailer }), + leftoverExportConfig: [], + }); + return reportText(output); + } + + const HEALTHY = { + delegate: "executable", + callSite: "present", + hookHasOtherContent: false, + hooksDir: "/repo/.git/hooks", + } as const; + + // The count leads, because it is the only fact here about the chain rather than its parts. + // A person who reads one line has read whether it is working. + it("leads with how many recent commits carry it", () => { + const text = report({ ...HEALTHY, recentlyCarrying: { carrying: 4, examined: 20 } }); + + expect(text).toContain("4 of the last 20 commits carry it"); + }); + + it("says nothing about pieces when every piece is in place", () => { + const text = report({ ...HEALTHY, recentlyCarrying: { carrying: 20, examined: 20 } }); + // Scoped to this row: every other setup row uses a dash of its own, so asserting over + // the whole report would only prove the report has dashes in it. + const row = text.split("\n").find((line) => line.includes("commit trailer")) ?? ""; + + expect(row).not.toContain("—"); + expect(text).toContain("hooks run from /repo/.git/hooks"); + }); + + it("names each missing piece after the count", () => { + const text = report({ + ...HEALTHY, + delegate: "absent", + callSite: "missing", + recentlyCarrying: { carrying: 0, examined: 20 }, + }); + + expect(text).toContain("0 of the last 20 commits carry it"); + expect(text).toContain("nothing installed to write it"); + expect(text).toContain("prepare-commit-msg does not call it"); + }); + + // Zero with every part in place is the finding this row exists to surface, so excusing it + // as by-design is the one outcome that must be impossible. + it("never excuses zero, whatever else is in place", () => { + const text = report({ ...HEALTHY, recentlyCarrying: { carrying: 0, examined: 20 } }); + + expect(text).toContain("0 of the last 20 commits carry it"); + expect(text).not.toContain("by design"); + }); + + it("says a hook git will not run is not executable", () => { + const text = report({ ...HEALTHY, hookExecutable: false }); + + expect(text).toContain("prepare-commit-msg is not executable"); + }); + + it("says a delegate that is not executable will not be run", () => { + expect(report({ ...HEALTHY, delegate: "not-executable" })).toContain("not executable"); + }); + + // Said, never named. Which tool owns the file changes nothing a person does about it. + it("says the hook is somebody else's without naming a tool", () => { + const text = report({ ...HEALTHY, hookHasOtherContent: true }); + + expect(text).toContain("somebody else's"); + expect(text).not.toMatch(/lefthook|husky/iu); + }); + + /** `hookManager` is read from a root marker file, never from the hook's own contents, which + * is what makes naming a tool honest: a hand-written hook stays unnamed. */ + describe("where lefthook or husky owns prepare-commit-msg", () => { + it("names lefthook and prints the job to add, when its config does not call the delegate", () => { + const text = report({ + delegate: "absent", + callSite: "missing", + hookHasOtherContent: true, + hooksDir: "/repo/.git/hooks", + hookManager: "lefthook", + managerCallsDelegate: false, + }); + + expect(text).toContain("lefthook"); + expect(text).toContain("prepare-commit-msg:"); + expect(text).toContain("add this command under `prepare-commit-msg:`"); + expect(text).not.toContain("does not call it"); + }); + + it("names husky and prints the line to add, when its config does not call the delegate", () => { + const text = report({ + delegate: "absent", + callSite: "missing", + hookHasOtherContent: true, + hooksDir: "/repo/.git/hooks", + hookManager: "husky", + managerCallsDelegate: false, + }); + + expect(text).toContain("husky"); + expect(text).toContain(".husky/prepare-commit-msg"); + expect(text).toContain("add this line to .husky/prepare-commit-msg"); + expect(text).not.toContain("does not call it"); + }); + + // `callSite: "missing"` describes the absolute-path line this CLI looks for, which a + // manager never calls the delegate through — not a fault once its own config does. + it("reports the chain wired through the manager and prints no job, once its config already calls the delegate", () => { + const text = report({ + delegate: "executable", + callSite: "missing", + hookHasOtherContent: true, + hooksDir: "/repo/.git/hooks", + hookManager: "lefthook", + managerCallsDelegate: true, + }); + + expect(text).toContain("wired through lefthook"); + expect(text).not.toContain("does not call it"); + expect(text).not.toContain("add this command"); + expect(text).not.toContain("prepare-commit-msg:"); + }); + + // A config naming the job is not the delegate being there to answer it: a checkout where + // `telemetry on` was never run must not read as wired. + it("does not report wired when nothing was ever installed to answer the call", () => { + const text = report({ + delegate: "absent", + callSite: "missing", + hookHasOtherContent: false, + hooksDir: "/repo/.git/hooks", + hookManager: "lefthook", + managerCallsDelegate: true, + }); + + expect(text).not.toContain("wired through lefthook's own prepare-commit-msg"); + expect(text).toContain("nothing installed to write it"); + expect(text).toContain("aidd telemetry on"); + }); + + it("says the script is not executable rather than wired, when it cannot run", () => { + const text = report({ + delegate: "not-executable", + callSite: "missing", + hookHasOtherContent: false, + hooksDir: "/repo/.git/hooks", + hookManager: "husky", + managerCallsDelegate: true, + }); + + expect(text).not.toContain("wired through husky's own prepare-commit-msg"); + expect(text).toContain("not executable"); + }); + }); + + // A commit no session made carries no trailer by design and merges are skipped, so a bare + // "4 of 20" would read as a fault when it is not one. + it("says a shortfall is expected when every part is in place", () => { + const text = report({ ...HEALTHY, recentlyCarrying: { carrying: 4, examined: 20 } }); + + expect(text).toContain("a commit no session made carries none, by design"); + }); + + it("does not excuse a shortfall when a part is broken", () => { + const text = report({ + ...HEALTHY, + callSite: "missing", + recentlyCarrying: { carrying: 4, examined: 20 }, + }); + + expect(text).not.toContain("by design"); + expect(text).toContain("prepare-commit-msg does not call it"); + }); + + // Outside a repository there is no hook to carry anything, which the claims below already + // refuse to read as a failure. "nothing installed" would describe a repository we are not in. + it("says there is no repository rather than listing missing pieces", () => { + const text = report({ + delegate: "absent", + callSite: "no-hook-file", + hookHasOtherContent: false, + hooksDirMissing: "no-repository", + }); + + expect(text).toContain("no repository here"); + expect(text).not.toContain("nothing installed"); + }); + + // A git that rejects `--git-path` still sits inside a repository with a history, so the + // count stays the fact that matters. + it("keeps the count when git could not name the hooks directory", () => { + const text = report({ + delegate: "absent", + callSite: "no-hook-file", + hookHasOtherContent: false, + hooksDirMissing: "unresolved", + recentlyCarrying: { carrying: 1, examined: 4 }, + }); + + expect(text).toContain("1 of the last 4 commits carry it"); + expect(text).not.toContain("no repository here"); + }); + + // No commits and no commits carrying it are different facts, and only the second is + // something to act on. + it("says there is no history to read rather than reporting zero", () => { + const text = report(HEALTHY); + + expect(text).toContain("no commit history to read"); + expect(text).not.toContain("0 of the last"); + }); +}); + +const RECORDS_ROW = + " records kept at /home/.config/aidd/telemetry (override with AIDD_TELEMETRY_DIR)"; +const BY_DESIGN_TRAILER_ROW = + " commit trailer 3 of the last 5 commits carry it — a commit no session made " + + "carries none, by design\n hooks run from /repo/.git/hooks"; + +function reportLines(result: Parameters[1]): string[] { + const output = new CapturingOutput(); + printTelemetryCheckReport(output, result); + return output.at("print"); +} + +describe("printTelemetryCheckReport — the setup block, whole", () => { + it("prints eight labelled rows, a blank line, then the gate that stopped the run", () => { + expect( + reportLines({ + gate: "measurement is off — nothing to check until it is turned on", + setup: setup(), + leftoverExportConfig: [], + }) + ).toEqual([ + " measurement allowed yes — /repo/.aidd/config.json", + " identity attached no — /home/.config/aidd/identity.json", + RECORDS_ROW, + " recorder declared yes — /repo/.aidd/manifest.json", + " plugins registered no plugin recorded for any tool", + BY_DESIGN_TRAILER_ROW, + " cli version 5.2.2", + " plugin version 1.0.0 (as the hook recorded it)", + "", + " measurement is off — nothing to check until it is turned on", + ]); + }); + + it("reads every unreadable location as unreadable, never as an absent one", () => { + expect( + reportLines({ + gate: "off", + setup: setup({ + allowed: { + allowed: false, + readable: false, + location: "/repo/.aidd/config.json", + decidedBy: "project-switch", + }, + identity: { attached: false, path: "/h/identity.json", readable: false }, + recorderDeclaration: { + declared: false, + declaredAt: [], + locationsChecked: [], + unreadable: ["/repo/.aidd/manifest.json"], + }, + hostRegistration: { entries: [], manifestUnreadable: "boom" }, + versions: { cli: "5.2.2", plugin: { kind: "nothing-journalled" } }, + }), + leftoverExportConfig: [], + }).slice(0, 8) + ).toEqual([ + " measurement allowed could not be read — /repo/.aidd/config.json", + " identity attached could not be read — /h/identity.json", + RECORDS_ROW, + " recorder declared could not be read — /repo/.aidd/manifest.json", + " plugins registered AIDD's own manifest could not be read — boom", + BY_DESIGN_TRAILER_ROW, + " cli version 5.2.2", + " plugin version no session journalled yet", + ]); + }); + + it("names a person's own refusal, an attached identity and the locations it looked in", () => { + expect( + reportLines({ + gate: "off", + setup: setup({ + allowed: { + allowed: false, + readable: true, + location: "AIDD_TELEMETRY", + decidedBy: "person-refusal", + }, + identity: { attached: true, path: "/h/identity.json", readable: true }, + recorderDeclaration: { + declared: false, + declaredAt: [], + locationsChecked: ["/repo/.aidd/manifest.json", "/repo/.claude/settings.json"], + unreadable: [], + }, + versions: { cli: "5.2.2", plugin: { kind: "unrecorded" } }, + }), + leftoverExportConfig: [], + }).slice(0, 8) + ).toEqual([ + " measurement allowed no — this person's own refusal (AIDD_TELEMETRY)", + " identity attached yes — /h/identity.json", + RECORDS_ROW, + " recorder declared nowhere this build checks — looked in:\n" + + " /repo/.aidd/manifest.json\n /repo/.claude/settings.json", + " plugins registered no plugin recorded for any tool", + BY_DESIGN_TRAILER_ROW, + " cli version 5.2.2", + " plugin version unknown — no journalled session names one. The plugin's own " + + "manifest was not beside its hooks and no `aidd` install recorded it; `aidd plugin " + + "install aidd-telemetry` would make it known.", + ]); + }); +}); + +describe("printTelemetryCheckReport — every claim, in its own column", () => { + it("prints each claim's own name and verdict token, then its detail", () => { + expect( + reportLines({ + setup: setup(), + claims: [ + { + claim: "hook-fired", + verdict: "ok", + reason: "session-anchored", + detail: "2 run file(s)", + }, + { + claim: "session-journalled", + verdict: "unknown", + reason: "no-session-named", + detail: "none yet", + }, + { + claim: "tool-files-readable", + verdict: "fail", + reason: "no-run-file-to-read", + detail: "EACCES", + }, + { claim: "records-join", verdict: "fail", reason: "all-unattributed", detail: "none" }, + ], + uncovered: [{ tool: "cursor", reason: "It writes no token count." }], + leftoverExportConfig: [], + }).slice(9) + ).toEqual([ + " hook fired ok 2 run file(s)", + " session journalled -- none yet", + " tool files readable FAIL EACCES", + " records join FAIL none", + " not covered: cursor -- It writes no token count.", + ]); + }); + + it("names every key of a leftover export, and what to do about it, on stderr", () => { + const output = new CapturingOutput(); + + printTelemetryCheckReport(output, { + gate: "off", + setup: setup(), + leftoverExportConfig: [ + { path: "/repo/.claude/settings.json", keys: ["OTEL_EXPORTER_OTLP_ENDPOINT", "OTEL_A"] }, + ], + }); + + expect(output.at("warn")).toEqual([ + "/repo/.claude/settings.json still sets OTEL_EXPORTER_OTLP_ENDPOINT, OTEL_A — delete " + + "these keys from its `env` block by hand to stop that export; nothing here can do it " + + "for you.", + ]); + }); +}); + +describe("printTelemetryCheckReport — the plugins-registered headline", () => { + function pluginsRow(hostRegistration: TelemetrySetup["hostRegistration"]): string { + return ( + reportLines({ + gate: "off", + setup: setup({ hostRegistration }), + leftoverExportConfig: [], + })[4] ?? "" + ); + } + + it("counts what will not load against the total, worst first", () => { + expect( + pluginsRow({ + entries: [ + { tool: "claude", plugin: "fine", answer: "registered", detail: "R" }, + { tool: "claude", plugin: "broken", answer: "not-registered", detail: "R" }, + ], + }) + ).toBe( + " plugins registered 1 of 2 will not load, or could not be answered\n" + + " claude/broken: not-registered — R\n claude/fine: registered — R" + ); + }); + + it("says all of them will load when none is in trouble", () => { + expect( + pluginsRow({ + entries: [ + { tool: "claude", plugin: "a", answer: "registered", detail: "R" }, + { tool: "codex", plugin: "b", answer: "registered", detail: "R" }, + ], + }) + ).toBe( + " plugins registered all 2 will load\n claude/a: registered — R\n" + + " codex/b: registered — R" + ); + }); +}); + +describe("printTelemetryCheckReport — the commit-trailer row, word for word", () => { + const HEALTHY_ROW = { + delegate: "executable", + callSite: "present", + hookHasOtherContent: false, + hooksDir: "/repo/.git/hooks", + } as const; + const HOOKS_FROM = "\n hooks run from /repo/.git/hooks"; + + function trailerRow(commitTrailer: TelemetrySetup["commitTrailer"]): string { + return ( + reportLines({ gate: "off", setup: setup({ commitTrailer }), leftoverExportConfig: [] })[5] ?? + "" + ); + } + + it("says only the count when every commit carries it", () => { + expect(trailerRow({ ...HEALTHY_ROW, recentlyCarrying: { carrying: 20, examined: 20 } })).toBe( + ` commit trailer 20 of the last 20 commits carry it${HOOKS_FROM}` + ); + }); + + it("excuses a shortfall only where some commits carry it and every part is in place", () => { + expect(trailerRow({ ...HEALTHY_ROW, recentlyCarrying: { carrying: 4, examined: 20 } })).toBe( + " commit trailer 4 of the last 20 commits carry it — a commit no session made " + + `carries none, by design${HOOKS_FROM}` + ); + }); + + it("never excuses zero, however healthy every part is", () => { + expect(trailerRow({ ...HEALTHY_ROW, recentlyCarrying: { carrying: 0, examined: 20 } })).toBe( + ` commit trailer 0 of the last 20 commits carry it${HOOKS_FROM}` + ); + }); + + it("says there is no history to read rather than reporting a zero", () => { + expect(trailerRow(HEALTHY_ROW)).toBe( + ` commit trailer no commit history to read${HOOKS_FROM}` + ); + }); + + it("names every broken piece after the count, in one sentence", () => { + expect( + trailerRow({ + ...HEALTHY_ROW, + delegate: "absent", + callSite: "missing", + hookExecutable: false, + hookHasOtherContent: true, + recentlyCarrying: { carrying: 0, examined: 20 }, + }) + ).toBe( + " commit trailer 0 of the last 20 commits carry it — nothing installed to write " + + "it; prepare-commit-msg does not call it; prepare-commit-msg is not executable, so git " + + `ignores it; that hook is somebody else's too${HOOKS_FROM}` + ); + }); + + it("says a delegate git cannot run is not executable", () => { + expect(trailerRow({ ...HEALTHY_ROW, delegate: "not-executable" })).toBe( + " commit trailer no commit history to read — its script is not executable, so " + + `git will not run it${HOOKS_FROM}` + ); + }); + + it("says there is no hook file at all, rather than that one does not call the delegate", () => { + expect(trailerRow({ ...HEALTHY_ROW, callSite: "no-hook-file" })).toBe( + ` commit trailer no commit history to read — there is no prepare-commit-msg${HOOKS_FROM}` + ); + }); + + it("says there is no repository, and names no hooks directory it does not have", () => { + expect( + trailerRow({ + delegate: "absent", + callSite: "no-hook-file", + hookHasOtherContent: false, + hooksDirMissing: "no-repository", + }) + ).toBe(" commit trailer no repository here, so no hook to carry it"); + }); + + it("keeps the count when git would not say where it runs hooks from", () => { + expect( + trailerRow({ + delegate: "absent", + callSite: "no-hook-file", + hookHasOtherContent: false, + hooksDirMissing: "unresolved", + recentlyCarrying: { carrying: 1, examined: 4 }, + }) + ).toBe( + " commit trailer 1 of the last 4 commits carry it — git could not say where it " + + "runs hooks from" + ); + }); +}); + +describe("printTelemetryCheckReport — where lefthook or husky owns the hook", () => { + const HOOKS_FROM = "\n hooks run from /repo/.git/hooks"; + + function trailerRow(commitTrailer: TelemetrySetup["commitTrailer"]): string { + return ( + reportLines({ gate: "off", setup: setup({ commitTrailer }), leftoverExportConfig: [] })[5] ?? + "" + ); + } + + it("reports the chain wired through the manager once its config calls the delegate", () => { + expect( + trailerRow({ + delegate: "executable", + callSite: "missing", + hookHasOtherContent: true, + hooksDir: "/repo/.git/hooks", + hookManager: "lefthook", + managerCallsDelegate: true, + recentlyCarrying: { carrying: 2, examined: 4 }, + }) + ).toBe( + " commit trailer 2 of the last 4 commits carry it — a commit no session made " + + "carries none, by design — wired through lefthook's own prepare-commit-msg" + + HOOKS_FROM + ); + }); + + it("refuses to call a checkout wired when nothing was installed to answer the call", () => { + expect( + trailerRow({ + delegate: "absent", + callSite: "missing", + hookHasOtherContent: false, + hooksDir: "/repo/.git/hooks", + hookManager: "lefthook", + managerCallsDelegate: true, + }) + ).toBe( + " commit trailer no commit history to read — wired through lefthook, but nothing " + + `installed to write it; run \`aidd telemetry on\`${HOOKS_FROM}` + ); + }); + + it("names an unrunnable script rather than reporting the manager as wired", () => { + expect( + trailerRow({ + delegate: "not-executable", + callSite: "missing", + hookHasOtherContent: false, + hooksDir: "/repo/.git/hooks", + hookManager: "husky", + managerCallsDelegate: true, + }) + ).toBe( + " commit trailer no commit history to read — wired through husky, but its script " + + `is not executable, so git will not run it; run \`aidd telemetry on\`${HOOKS_FROM}` + ); + }); +}); diff --git a/cli/tests/presentation/display/telemetry-display.unit.test.ts b/cli/tests/presentation/display/telemetry-display.unit.test.ts new file mode 100644 index 000000000..f9868f2eb --- /dev/null +++ b/cli/tests/presentation/display/telemetry-display.unit.test.ts @@ -0,0 +1,817 @@ +import { describe, expect, it } from "vitest"; +import "../../../src/contexts/tools/domain/profiles/claude/profile.js"; +import "../../../src/contexts/tools/domain/profiles/codex/profile.js"; +import "../../../src/contexts/tools/domain/profiles/copilot/profile.js"; +import "../../../src/contexts/tools/domain/profiles/cursor/profile.js"; +import "../../../src/contexts/tools/domain/profiles/opencode/profile.js"; +import type { + LocalCostToolReport, + LocalCostToolStatus, + ReadLocalCostResult, +} from "../../../src/contexts/telemetry/application/read-local-cost-use-case.js"; +import type { TelemetrySink } from "../../../src/contexts/telemetry/domain/ports/telemetry-sink.js"; +import { + printLocalCostReadReport, + printPersonIdentityLink, + printPersonIdentityOff, + printPersonIdentityStatus, + printPersonIdentityUnlink, + printPersonIdentityUse, + printTelemetryOffReport, + printTelemetryOnReport, + warnIfFiguresMoveTheTokenToo, +} from "../../../src/presentation/display/telemetry-display.js"; +import { CapturingOutput } from "../../helpers/ports/capturing-output.js"; +import { InMemoryTelemetrySink } from "../../helpers/ports/in-memory-telemetry-sink.js"; + +function textOf(output: CapturingOutput): string { + return output.lines.join("\n"); +} + +function toolReport(overrides: Partial = {}): LocalCostToolReport { + return { + tool: "claude", + status: "found", + recordsFound: 0, + recordsStored: 0, + sessionsFailed: 0, + ...overrides, + }; +} + +function readResult(overrides: Partial = {}): ReadLocalCostResult { + return { sessions: [], toolReports: [], ...overrides }; +} + +describe("what `telemetry read` says about each tool", () => { + /** Five of the six statuses mean something other than "this tool billed nothing", so the + * labels are held apart rather than matched against a wording that may change. */ + it("gives every status a label of its own, so no two can be read as the same fact", () => { + const statuses: readonly LocalCostToolStatus[] = [ + "found", + "empty", + "not-found", + "unreadable", + "not-covered", + "not-asked", + ]; + + const labels = statuses.map((status) => { + const output = new CapturingOutput(); + printLocalCostReadReport( + output, + readResult({ + sessions: [{ sessionId: "s-1", toolReports: [] }], + toolReports: [toolReport({ status })], + }) + ); + return output.lines[output.lines.length - 1]; + }); + + expect(new Set(labels).size).toBe(statuses.length); + }); + + it("never says a tool found nothing when nothing was ever asked of it", () => { + const output = new CapturingOutput(); + + printLocalCostReadReport( + output, + readResult({ + sessions: [{ sessionId: "s-1", toolReports: [] }], + toolReports: [toolReport({ status: "not-asked" })], + }) + ); + + expect(textOf(output)).not.toMatch(/nothing found/u); + }); + + // A sweep that read nineteen sessions and failed the twentieth still reports the figures + // as read; the failure has to survive beside the status, never inside it. + it("names a session that could not be read beside a tool that otherwise read fine", () => { + const output = new CapturingOutput(); + + printLocalCostReadReport( + output, + readResult({ + sessions: [{ sessionId: "s-1", toolReports: [] }], + toolReports: [ + toolReport({ + status: "found", + recordsFound: 3, + recordsStored: 3, + sessionsFailed: 1, + failureReason: "EACCES", + }), + ], + }) + ); + + expect(textOf(output)).toContain("1 session could not be read"); + expect(textOf(output)).toContain("EACCES"); + }); + + // A refusal read nothing and stored nothing. "No session journalled yet" is a fact about + // the journal; conflating the two sends a person to look at the wrong thing. + it("tells a refusal apart from an empty journal", () => { + const refused = new CapturingOutput(); + printLocalCostReadReport(refused, readResult({ refusedReason: "measurement is off" })); + + const empty = new CapturingOutput(); + printLocalCostReadReport(empty, readResult()); + + expect(textOf(refused)).toContain("measurement is off"); + expect(textOf(empty)).toContain("No session journalled yet"); + expect(textOf(refused)).not.toContain("No session journalled yet"); + }); + + it("leads with how many sessions it covered, not one line per tool per session", () => { + const output = new CapturingOutput(); + + printLocalCostReadReport( + output, + readResult({ + sessions: [ + { sessionId: "s-1", toolReports: [] }, + { sessionId: "s-2", toolReports: [] }, + ], + toolReports: [toolReport({ status: "found", recordsFound: 2, recordsStored: 2 })], + }) + ); + + expect(output.lines[0]).toContain("2 sessions read"); + }); +}); + +describe("what the switch says when it is flipped", () => { + it("says the file is tracked, because turning it on decides for everyone who clones", () => { + const output = new CapturingOutput(); + + printTelemetryOnReport(output, { switchPath: "/repo/.aidd/config.json", switchChanged: true }); + + expect(textOf(output)).toContain("git-tracked"); + }); + + it("tells an already-on project from one it just turned on", () => { + const changed = new CapturingOutput(); + printTelemetryOnReport(changed, { switchPath: "/p/.aidd/config.json", switchChanged: true }); + const unchanged = new CapturingOutput(); + printTelemetryOnReport(unchanged, { switchPath: "/p/.aidd/config.json", switchChanged: false }); + + expect(textOf(changed)).not.toContain("already on"); + expect(textOf(unchanged)).toContain("already on"); + }); + + // Turning recording off is not erasing what was recorded, and a person who wanted the + // second has to be told which command does it. + it("says off stops new recording only, and names what removes the rest", () => { + const output = new CapturingOutput(); + + printTelemetryOffReport(output, { switchPath: "/p/.aidd/config.json", switchChanged: true }); + + expect(textOf(output)).toContain("stops new recording only"); + expect(textOf(output)).toContain("aidd telemetry forget"); + }); +}); + +describe("what the identity commands say", () => { + it("says records carry no person when nobody has chosen", () => { + const output = new CapturingOutput(); + + printPersonIdentityStatus(output, { filePath: "/h/identity.json", identity: null }); + + expect(textOf(output)).toContain("records carry no person"); + }); + + it("tells an identifier minted here from one taken from another machine", () => { + const minted = new CapturingOutput(); + printPersonIdentityStatus(minted, { + filePath: "/h/identity.json", + identity: { personId: "p-1", origin: "minted", alsoMe: [] }, + }); + const adopted = new CapturingOutput(); + printPersonIdentityStatus(adopted, { + filePath: "/h/identity.json", + identity: { personId: "p-1", origin: "adopted", alsoMe: [] }, + }); + + expect(textOf(minted)).toContain("minted on this machine"); + expect(textOf(adopted)).toContain("taken from another machine"); + }); + + // Taking a different identifier does not rewrite what is already stored, and a person + // has to be told which identifier those records keep. + it("names the identifier that was replaced, when one was", () => { + const output = new CapturingOutput(); + + printPersonIdentityUse(output, { + filePath: "/h/identity.json", + identity: { personId: "p-2", origin: "adopted", alsoMe: [] }, + outcome: "adopted", + replacedPersonId: "p-1", + }); + + expect(textOf(output)).toContain("p-1"); + }); + + it("says withdrawing takes the added identifiers with it", () => { + const output = new CapturingOutput(); + + printPersonIdentityOff(output, { + filePath: "/h/identity.json", + removed: true, + discardedDamaged: false, + addedIdentifiersRemoved: 2, + }); + + expect(textOf(output)).toMatch(/2/u); + }); +}); + +describe("the warning about where the figures land", () => { + /** The real in-memory sink with only the field this printer reads — never an object literal + * widened into the port, which would stop failing the day the port grows a member. */ + function sink(locatedBy: TelemetrySink["locatedBy"]): TelemetrySink { + const built = new InMemoryTelemetrySink(); + built.locatedBy = locatedBy; + return built; + } + + // `AIDD_USER_CONFIG_DIR` names the directory that also holds `auth.json`. A person who + // pointed it somewhere shared moved their GitHub token there too, and nothing else says so. + it("warns when the figures were placed by the variable that also moves the token", () => { + const output = new CapturingOutput(); + + warnIfFiguresMoveTheTokenToo(output, sink("user-config-dir")); + + expect(textOf(output)).toContain("auth.json"); + }); + + it("says nothing when the directory was named outright, or defaulted", () => { + for (const locatedBy of ["telemetry-dir", "default"] as const) { + const output = new CapturingOutput(); + warnIfFiguresMoveTheTokenToo(output, sink(locatedBy)); + expect(output.lines).toEqual([]); + } + }); +}); + +describe("linking an identifier this person could not simply take as their own", () => { + // `link` is a claim the tool cannot verify — it never checks who is running it — so every + // path that writes one has to say so. + it("says a fresh link is a declaration nothing here can check", () => { + const output = new CapturingOutput(); + + printPersonIdentityLink(output, { + filePath: "/h/identity.json", + personId: "p-1", + identity: "machine-2", + alreadyListed: false, + }); + + expect(textOf(output)).toContain("linked 'machine-2'"); + expect(textOf(output)).toContain("cannot check"); + }); + + // Already listed is a no-op, not a second write, and a caller that links before reporting + // has to be able to tell the two apart. + it("reports one already listed as already listed, never as a fresh write", () => { + const output = new CapturingOutput(); + + printPersonIdentityLink(output, { + filePath: "/h/identity.json", + personId: "p-1", + identity: "machine-2", + alreadyListed: true, + }); + + expect(textOf(output)).toContain("already listed"); + expect(textOf(output)).not.toContain("linked 'machine-2'"); + }); + + it("reports unlinking one nobody listed as nothing to remove, never a failure", () => { + const output = new CapturingOutput(); + + printPersonIdentityUnlink(output, { + filePath: "/h/identity.json", + identity: "machine-9", + removed: false, + }); + + expect(textOf(output)).toContain("nothing to remove"); + }); + + it("names the identifier it withdrew", () => { + const output = new CapturingOutput(); + + printPersonIdentityUnlink(output, { + filePath: "/h/identity.json", + identity: "machine-2", + removed: true, + }); + + expect(textOf(output)).toContain("unlinked 'machine-2'"); + }); +}); + +describe("what minting says it does, and does not do", () => { + // The consent a person gives is to this sentence, so both halves of it are asserted. + it("names what the identifier attaches to, and what it never attaches to", () => { + const output = new CapturingOutput(); + + printPersonIdentityUse(output, { + filePath: "/h/identity.json", + identity: { personId: "p-1", origin: "minted", alsoMe: [] }, + outcome: "minted", + }); + + expect(textOf(output)).toContain("records this machine reads locally"); + expect(textOf(output)).toContain("Never attaches to"); + }); + + // "Already in effect" is true of the identifier and false of the file once a name came + // with the call: something was written, and the first line must not say otherwise. + it("does not claim nothing changed when a display name was set alongside", () => { + const output = new CapturingOutput(); + + printPersonIdentityUse(output, { + filePath: "/h/identity.json", + identity: { personId: "p-1", origin: "minted", alsoMe: [], displayName: "Ada" }, + outcome: "unchanged", + displayNameSet: "Ada", + }); + + expect(textOf(output)).toContain("display name set"); + expect(textOf(output)).toContain("Ada"); + }); + + it("says withdrawing never gives the same identifier back", () => { + const output = new CapturingOutput(); + + printPersonIdentityOff(output, { + filePath: "/h/identity.json", + removed: true, + discardedDamaged: false, + addedIdentifiersRemoved: 0, + }); + + expect(textOf(output)).toContain("mints a fresh identifier, never this one back"); + }); + + it("reports nothing to withdraw when nobody had chosen", () => { + const output = new CapturingOutput(); + + printPersonIdentityOff(output, { + filePath: "/h/identity.json", + removed: false, + discardedDamaged: false, + addedIdentifiersRemoved: 0, + }); + + expect(textOf(output)).toContain("already off"); + }); + + // Withdrawing has to work exactly when the file is too damaged to read, and say that it + // discarded rather than read it. + it("says a damaged file was discarded rather than left behind", () => { + const output = new CapturingOutput(); + + printPersonIdentityOff(output, { + filePath: "/h/identity.json", + removed: true, + discardedDamaged: true, + addedIdentifiersRemoved: 0, + }); + + expect(textOf(output)).toContain("discarded rather than left behind"); + }); +}); + +const DISCLAIMER = + " This is a declaration the tool cannot check - it never verifies who is running it."; +const IDENTITY_FILE = "/h/identity.json"; + +function printed(print: (output: CapturingOutput) => void): string[] { + const output = new CapturingOutput(); + print(output); + return output.lines; +} + +describe("printTelemetryOnReport", () => { + it("names the switch file it wrote, then that everyone who clones gets it", () => { + expect( + printed((output) => + printTelemetryOnReport(output, { + switchPath: "/repo/.aidd/config.json", + switchChanged: true, + }) + ) + ).toEqual([ + "AIDD telemetry: on (/repo/.aidd/config.json)", + "/repo/.aidd/config.json is git-tracked — this applies to everyone who clones.", + ]); + }); + + it("says already on where nothing was written", () => { + expect( + printed((output) => + printTelemetryOnReport(output, { + switchPath: "/repo/.aidd/config.json", + switchChanged: false, + }) + )[0] + ).toBe("AIDD telemetry: already on (/repo/.aidd/config.json)"); + }); +}); + +describe("printTelemetryOffReport", () => { + it("names what stops and what stays, and the command that removes the rest", () => { + expect( + printed((output) => + printTelemetryOffReport(output, { + switchPath: "/p/.aidd/config.json", + switchChanged: true, + }) + ) + ).toEqual([ + "AIDD telemetry: off (/p/.aidd/config.json)", + "This stops new recording only — sessions already journalled stay in aidd_docs/runs/ " + + "and whatever `aidd telemetry read` already stored, and `aidd telemetry report` still " + + "reports them. Run `aidd telemetry forget` to remove what was already measured.", + ]); + }); + + it("says already off where nothing was written", () => { + expect( + printed((output) => + printTelemetryOffReport(output, { + switchPath: "/p/.aidd/config.json", + switchChanged: false, + }) + )[0] + ).toBe("AIDD telemetry: already off (/p/.aidd/config.json)"); + }); +}); + +describe("printLocalCostReadReport — the line each tool gets", () => { + function toolLine(overrides: Partial): string { + return printed((output) => + printLocalCostReadReport( + output, + readResult({ + sessions: [{ sessionId: "s-1", toolReports: [] }], + toolReports: [toolReport(overrides)], + }) + ) + )[1] as string; + } + + it("counts what was stored against what was found, on a tool that read something", () => { + expect(toolLine({ status: "found", recordsFound: 5, recordsStored: 3 })).toBe( + " Claude Code: read (3 new of 5)" + ); + }); + + it("counts nothing on a status other than found, whose counts would mean nothing", () => { + expect(toolLine({ status: "empty", recordsFound: 5, recordsStored: 3 })).toBe( + " Claude Code: read, nothing found" + ); + }); + + it("gives each status its own words, none of them readable as another", () => { + expect([ + toolLine({ status: "not-found" }), + toolLine({ status: "unreadable" }), + toolLine({ status: "not-covered" }), + toolLine({ status: "not-asked" }), + ]).toEqual([ + " Claude Code: no session found", + " Claude Code: could not be read", + " Claude Code: not covered", + " Claude Code: no session read belongs to it", + ]); + }); + + it("carries a reason after the status, and a failed-session count after that", () => { + expect( + toolLine({ + status: "found", + recordsFound: 5, + recordsStored: 3, + reason: "one transcript was truncated", + sessionsFailed: 2, + failureReason: "EACCES", + }) + ).toBe( + " Claude Code: read (3 new of 5) — one transcript was truncated " + + "[2 sessions could not be read: EACCES]" + ); + }); + + it("counts one failed session in the singular", () => { + expect(toolLine({ status: "found", sessionsFailed: 1, failureReason: "EACCES" })).toBe( + " Claude Code: read (0 new of 0) [1 session could not be read: EACCES]" + ); + }); +}); + +describe("printLocalCostReadReport — the line the sweep leads with", () => { + it("counts the sessions it read and how many of them yielded a record", () => { + expect( + printed((output) => + printLocalCostReadReport( + output, + readResult({ + sessions: [ + { sessionId: "s-1", toolReports: [toolReport({ recordsFound: 2 })] }, + { sessionId: "s-2", toolReports: [toolReport({ recordsFound: 0 })] }, + ], + toolReports: [], + }) + ) + ) + ).toEqual([" 2 sessions read, 1 with records"]); + }); + + it("counts every session that yielded a record, not the ones that yielded none", () => { + expect( + printed((output) => + printLocalCostReadReport( + output, + readResult({ + sessions: [ + { sessionId: "s-1", toolReports: [toolReport({ recordsFound: 2 })] }, + { sessionId: "s-2", toolReports: [toolReport({ recordsFound: 4 })] }, + ], + toolReports: [], + }) + ) + ) + ).toEqual([" 2 sessions read, 2 with records"]); + }); + + it("counts one session in the singular", () => { + expect( + printed((output) => + printLocalCostReadReport( + output, + readResult({ sessions: [{ sessionId: "s-1", toolReports: [] }], toolReports: [] }) + ) + ) + ).toEqual([" 1 session read, 0 with records"]); + }); + + it("says nothing was journalled rather than that nothing was read", () => { + expect(printed((output) => printLocalCostReadReport(output, readResult()))).toEqual([ + " No session journalled yet — nothing to read.", + ]); + }); + + it("prints a refusal alone, never the journal's own count beside it", () => { + expect( + printed((output) => + printLocalCostReadReport( + output, + readResult({ + refusedReason: "measurement is off for this project", + sessions: [{ sessionId: "s-1", toolReports: [] }], + toolReports: [toolReport({})], + }) + ) + ) + ).toEqual([" measurement is off for this project"]); + }); +}); + +describe("printPersonIdentityStatus", () => { + it("says off, in the words the switch beside it never uses", () => { + expect( + printed((output) => + printPersonIdentityStatus(output, { filePath: IDENTITY_FILE, identity: null }) + ) + ).toEqual(["AIDD identity: off - records carry no person"]); + }); + + it("names the identifier, where it came from and the file holding it", () => { + expect( + printed((output) => + printPersonIdentityStatus(output, { + filePath: IDENTITY_FILE, + identity: { personId: "p-1", origin: "minted", alsoMe: [] }, + }) + ) + ).toEqual([`AIDD identity: on, p-1 (minted on this machine) (${IDENTITY_FILE})`]); + }); + + it("quotes a display name between the origin and the file, and lists the added identifiers", () => { + expect( + printed((output) => + printPersonIdentityStatus(output, { + filePath: IDENTITY_FILE, + identity: { + personId: "p-1", + origin: "adopted", + alsoMe: ["machine-1", "machine-2"], + displayName: "Ada", + }, + }) + ) + ).toEqual([ + `AIDD identity: on, p-1 (taken from another machine), display name "Ada" (${IDENTITY_FILE})`, + " Identifiers added onto this person: machine-1, machine-2", + ]); + }); +}); + +describe("printPersonIdentityUse", () => { + it("discloses what a minted identifier attaches to, and what it never attaches to", () => { + expect( + printed((output) => + printPersonIdentityUse(output, { + filePath: IDENTITY_FILE, + identity: { personId: "p-1", origin: "minted", alsoMe: [] }, + outcome: "minted", + }) + ) + ).toEqual([ + `AIDD identity: on, p-1 (${IDENTITY_FILE})`, + " Attaches to: records this machine reads locally, from now on.", + " Never attaches to: the run journal, a session already recorded, or a tool's own export.", + ]); + }); + + it("names what an adopted identifier replaced, and what the old records keep", () => { + expect( + printed((output) => + printPersonIdentityUse(output, { + filePath: IDENTITY_FILE, + identity: { personId: "p-2", origin: "adopted", alsoMe: [] }, + outcome: "adopted", + replacedPersonId: "p-1", + }) + ) + ).toEqual([ + `AIDD identity: now p-2 (replacing p-1) (${IDENTITY_FILE})`, + " Records already written keep the identifier they were written with.", + DISCLAIMER, + ]); + }); + + it("says nothing about replacing when the identifier taken was nobody's before", () => { + expect( + printed((output) => + printPersonIdentityUse(output, { + filePath: IDENTITY_FILE, + identity: { personId: "p-2", origin: "adopted", alsoMe: [] }, + outcome: "adopted", + }) + ) + ).toEqual([`AIDD identity: now p-2 (${IDENTITY_FILE})`, DISCLAIMER]); + }); + + it("claims nothing was written for an unchanged identifier with no name alongside", () => { + expect( + printed((output) => + printPersonIdentityUse(output, { + filePath: IDENTITY_FILE, + identity: { personId: "p-1", origin: "minted", alsoMe: [] }, + outcome: "unchanged", + }) + ) + ).toEqual([`AIDD identity: p-1 already in effect (${IDENTITY_FILE})`]); + }); + + it("says a display name was set on the same line, then prints it", () => { + expect( + printed((output) => + printPersonIdentityUse(output, { + filePath: IDENTITY_FILE, + identity: { personId: "p-1", origin: "minted", alsoMe: [], displayName: "Ada" }, + outcome: "unchanged", + displayNameSet: "Ada", + }) + ) + ).toEqual([ + `AIDD identity: p-1 already in effect, display name set (${IDENTITY_FILE})`, + " Display name: Ada", + ]); + }); +}); + +describe("printPersonIdentityOff", () => { + it("says already off without claiming a file was removed", () => { + expect( + printed((output) => + printPersonIdentityOff(output, { + filePath: IDENTITY_FILE, + removed: false, + discardedDamaged: false, + addedIdentifiersRemoved: 0, + }) + ) + ).toEqual(["AIDD identity: already off - nothing to withdraw"]); + }); + + it("names the file, what stays, that opting in again mints afresh, and the count removed", () => { + expect( + printed((output) => + printPersonIdentityOff(output, { + filePath: IDENTITY_FILE, + removed: true, + discardedDamaged: false, + addedIdentifiersRemoved: 2, + }) + ) + ).toEqual([ + `AIDD identity: off (${IDENTITY_FILE} removed)`, + " New records carry no person, from now on.", + " Records already stored keep the identifier they were written with - none are changed.", + " Opting in again later mints a fresh identifier, never this one back.", + " 2 added identifiers removed with it.", + ]); + }); + + it("says a damaged file was discarded rather than read, and counts one in the singular", () => { + expect( + printed((output) => + printPersonIdentityOff(output, { + filePath: IDENTITY_FILE, + removed: true, + discardedDamaged: true, + addedIdentifiersRemoved: 1, + }) + ).filter((line) => line.includes("discarded") || line.includes("added identifier")) + ).toEqual([ + " The identity file could not be read, so it was discarded rather than left behind.", + " 1 added identifier removed with it.", + ]); + }); +}); + +describe("printPersonIdentityLink and printPersonIdentityUnlink", () => { + it("names the identifier, the person it joined and the file, then the disclaimer", () => { + expect( + printed((output) => + printPersonIdentityLink(output, { + filePath: IDENTITY_FILE, + personId: "p-1", + identity: "machine-2", + alreadyListed: false, + }) + ) + ).toEqual([`AIDD identity: linked 'machine-2' to p-1 (${IDENTITY_FILE})`, DISCLAIMER]); + }); + + it("says an identifier already listed is already listed, and adds no disclaimer", () => { + expect( + printed((output) => + printPersonIdentityLink(output, { + filePath: IDENTITY_FILE, + personId: "p-1", + identity: "machine-2", + alreadyListed: true, + }) + ) + ).toEqual([`AIDD identity: 'machine-2' is already listed under p-1 (${IDENTITY_FILE})`]); + }); + + it("names the identifier it withdrew, with the file it left", () => { + expect( + printed((output) => + printPersonIdentityUnlink(output, { + filePath: IDENTITY_FILE, + identity: "machine-2", + removed: true, + }) + ) + ).toEqual([`AIDD identity: unlinked 'machine-2' (${IDENTITY_FILE})`]); + }); + + it("says one nobody listed was never listed, never that removal failed", () => { + expect( + printed((output) => + printPersonIdentityUnlink(output, { + filePath: IDENTITY_FILE, + identity: "machine-9", + removed: false, + }) + ) + ).toEqual(["AIDD identity: 'machine-9' was not listed - nothing to remove"]); + }); +}); + +describe("warnIfFiguresMoveTheTokenToo", () => { + it("names the directory, the variable and the token it also moves, on stderr", () => { + const output = new CapturingOutput(); + const sink = new InMemoryTelemetrySink(); + sink.locatedBy = "user-config-dir"; + + warnIfFiguresMoveTheTokenToo(output, sink); + + expect(output.at("warn")).toEqual([ + "Figures are kept at /fake/telemetry, located through AIDD_USER_CONFIG_DIR — which also " + + "moves auth.json, this machine's GitHub token. If that directory is shared, the token " + + "is in it. Set AIDD_TELEMETRY_DIR to the same path instead: it moves the figures and " + + "nothing else.", + ]); + }); +}); diff --git a/cli/tests/presentation/display/telemetry-forget-display.unit.test.ts b/cli/tests/presentation/display/telemetry-forget-display.unit.test.ts new file mode 100644 index 000000000..897af2051 --- /dev/null +++ b/cli/tests/presentation/display/telemetry-forget-display.unit.test.ts @@ -0,0 +1,228 @@ +import { describe, expect, it } from "vitest"; +import type { TelemetryRemovalPreview } from "../../../src/contexts/telemetry/domain/telemetry-removal.js"; +import { + printTelemetryForgetPreview, + printTelemetryForgetRefused, + printTelemetryForgetResult, +} from "../../../src/presentation/display/telemetry-forget-display.js"; +import { CapturingOutput } from "../../helpers/ports/capturing-output.js"; + +function preview(overrides: Partial = {}): TelemetryRemovalPreview { + return { + journal: { scope: "project", path: "/repo/aidd_docs/runs", runFileNames: [] }, + sink: { scope: "machine", path: "/home/.config/aidd/telemetry", dayFileNames: [] }, + identity: { + scope: "machine", + path: "/home/.config/aidd/identity.json", + present: false, + unreadable: false, + }, + history: { certainty: "none" }, + ...overrides, + }; +} + +const NOTHING_MEASURED = + "AIDD telemetry: nothing was ever measured here — this project's journal, this machine's " + + "stored records and this machine's identity are all already empty. Nothing to remove."; + +const NO_REPOSITORY = + "This project is not a git repository, so no history holds this project's run journal."; + +function printedPreview(overrides: Partial = {}): string[] { + const output = new CapturingOutput(); + printTelemetryForgetPreview(output, preview(overrides)); + return output.lines; +} + +const ONE_RUN_FILE = { + scope: "project", + path: "/repo/aidd_docs/runs", + runFileNames: ["a.jsonl"], +} as const; + +describe("printTelemetryForgetPreview", () => { + it("says nothing was ever measured, and lists nothing, when every location is empty", () => { + expect(printedPreview()).toEqual([NOTHING_MEASURED, NO_REPOSITORY]); + }); + + it("names each location with its own path and count, machine scope spelled out", () => { + expect( + printedPreview({ + journal: { + scope: "project", + path: "/repo/aidd_docs/runs", + runFileNames: ["a.jsonl", "b.jsonl", "c.jsonl"], + }, + sink: { + scope: "machine", + path: "/home/.config/aidd/telemetry", + dayFileNames: ["2026-03-02.jsonl", "2026-03-03.jsonl"], + }, + }) + ).toEqual([ + "This would remove:", + " This project's run journal (/repo/aidd_docs/runs): 3 run file(s)", + " This machine's stored records — every project measured on this machine " + + "(/home/.config/aidd/telemetry): 2 day file(s)", + " This machine's identity (/home/.config/aidd/identity.json): nothing to remove", + NO_REPOSITORY, + ]); + }); + + it("counts a present identity as one file", () => { + expect( + printedPreview({ + journal: ONE_RUN_FILE, + identity: { + scope: "machine", + path: "/home/.config/aidd/identity.json", + present: true, + unreadable: false, + }, + })[3] + ).toBe(" This machine's identity (/home/.config/aidd/identity.json): 1 file"); + }); + + it("says a damaged identity file will still go, rather than reading as absent", () => { + expect( + printedPreview({ + journal: ONE_RUN_FILE, + identity: { + scope: "machine", + path: "/home/.config/aidd/identity.json", + present: true, + unreadable: true, + }, + })[3] + ).toBe( + " This machine's identity (/home/.config/aidd/identity.json): 1 file — present but " + + "could not be read; will still be removed" + ); + }); +}); + +describe("printTelemetryForgetPreview — what removal cannot reach", () => { + it("warns that a committed journal is certainly in history, and lists what is tracked", () => { + expect( + printedPreview({ + journal: ONE_RUN_FILE, + history: { + certainty: "committed", + files: ["aidd_docs/runs/a.jsonl", "aidd_docs/runs/b.jsonl"], + }, + }).at(-1) + ).toBe( + "Cannot be reached: this project's run journal has been committed, so git history " + + "certainly holds it. Tracked right now:\n" + + " aidd_docs/runs/a.jsonl\n aidd_docs/runs/b.jsonl\n" + + "Removing it from the working tree does not remove it from history. No command here " + + "rewrites git history." + ); + }); + + it("warns that a staged journal is not in history yet, and would come back on a commit", () => { + expect( + printedPreview({ + journal: ONE_RUN_FILE, + history: { certainty: "staged", files: ["aidd_docs/runs/a.jsonl"] }, + }).at(-1) + ).toBe( + "Cannot be reached, not yet: this project's run journal is staged (tracked by git " + + "right now) but has never been committed — history does not hold it yet:\n" + + " aidd_docs/runs/a.jsonl\n" + + "The staged copy stays in git's index after this removal deletes the working-tree " + + "file, so a later `git commit` with nothing further done would put it back. No " + + "command here touches git's index or history." + ); + }); + + it("warns that an untracked journal may still have been committed before", () => { + expect( + printedPreview({ journal: ONE_RUN_FILE, history: { certainty: "possible" } }).at(-1) + ).toBe( + "Cannot be reached: this project's run journal is not tracked by git right now, but " + + "history may still hold it if it was ever committed before — that cannot be told " + + "apart from never having been committed. No command here rewrites git history." + ); + }); + + it("puts every history reading on stderr, never on the results channel", () => { + const output = new CapturingOutput(); + + printTelemetryForgetPreview(output, preview()); + + expect(output.at("warn")).toEqual([NO_REPOSITORY]); + }); +}); + +describe("printTelemetryForgetRefused", () => { + // Looking and deciding not to is not an error, and the sentence has to name the flag that + // would have gone ahead. + it("reports nothing removed and names the flag, never a failure", () => { + const output = new CapturingOutput(); + + printTelemetryForgetRefused(output); + + expect(output.lines).toEqual([ + "Nothing removed. Pass --yes to remove exactly what is listed above.", + ]); + }); +}); + +describe("printTelemetryForgetResult", () => { + it("counts each location separately, so the three can be checked against the preview", () => { + const output = new CapturingOutput(); + + printTelemetryForgetResult(output, { + journal: { removed: 3, failed: [] }, + sink: { removed: 2, failed: [] }, + identity: { removed: 1, failed: [] }, + history: { certainty: "none" }, + }); + + expect(output.lines).toEqual([ + "AIDD telemetry: removed", + " This project's run journal: 3 removed", + " This machine's stored records: 2 removed", + " This machine's identity: 1 removed", + NO_REPOSITORY, + "The telemetry switch (.aidd/config.json) was not touched — measurement can be turned " + + "on again with `aidd telemetry on`.", + ]); + }); + + // One undeletable file must not read as everything having gone. + it("names every file it could not remove, under its own location's label", () => { + const output = new CapturingOutput(); + + printTelemetryForgetResult(output, { + journal: { removed: 1, failed: [{ path: "b.jsonl", reason: "EACCES" }] }, + sink: { removed: 0, failed: [{ path: "2026-03-02.jsonl", reason: "EPERM" }] }, + identity: { removed: 0, failed: [{ path: "identity.json", reason: "EBUSY" }] }, + history: { certainty: "none" }, + }); + + expect(output.lines.slice(1, 7)).toEqual([ + " This project's run journal: 1 removed, 1 could not be removed", + " This machine's stored records: 0 removed, 1 could not be removed", + " This machine's identity: 0 removed, 1 could not be removed", + "Could not remove journal run file b.jsonl — EACCES", + "Could not remove sink day file 2026-03-02.jsonl — EPERM", + "Could not remove identity file identity.json — EBUSY", + ]); + }); + + it("says only what was removed when nothing failed", () => { + const output = new CapturingOutput(); + + printTelemetryForgetResult(output, { + journal: { removed: 0, failed: [] }, + sink: { removed: 0, failed: [] }, + identity: { removed: 0, failed: [] }, + history: { certainty: "none" }, + }); + + expect(output.lines[1]).toBe(" This project's run journal: 0 removed"); + }); +}); diff --git a/cli/tests/presentation/display/translate-display.unit.test.ts b/cli/tests/presentation/display/translate-display.unit.test.ts new file mode 100644 index 000000000..cda023a25 --- /dev/null +++ b/cli/tests/presentation/display/translate-display.unit.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it } from "vitest"; +import { printTranslateResult } from "../../../src/presentation/display/translate-display.js"; +import { CapturingOutput } from "../../helpers/ports/capturing-output.js"; + +describe("printTranslateResult", () => { + it("says what was built and where, for a marketplace layout", () => { + const output = new CapturingOutput(false); + + printTranslateResult(output, "marketplace", { + pluginCount: 3, + totalFiles: 42, + outDir: "/tmp/out", + }); + + expect(output.at("success")).toEqual(["Built 3 plugins, 42 files written to /tmp/out"]); + }); + + it("says what was flat-installed and under where, for a flat layout", () => { + const output = new CapturingOutput(false); + + printTranslateResult(output, "flat", { + pluginCount: 3, + totalFiles: 42, + outDir: "/tmp/out", + }); + + expect(output.at("success")).toEqual([ + "Flat-installed 3 plugins, 42 files written under /tmp/out", + ]); + }); +}); diff --git a/cli/tests/presentation/display/update-display.unit.test.ts b/cli/tests/presentation/display/update-display.unit.test.ts new file mode 100644 index 000000000..d86598e0e --- /dev/null +++ b/cli/tests/presentation/display/update-display.unit.test.ts @@ -0,0 +1,88 @@ +import { describe, expect, it } from "vitest"; +import { printSelfUpdateResult } from "../../../src/presentation/display/update-display.js"; +import { CapturingOutput } from "../../helpers/ports/capturing-output.js"; + +describe("printSelfUpdateResult", () => { + it("names the version already installed when nothing newer exists", () => { + const output = new CapturingOutput(false); + + printSelfUpdateResult(output, { kind: "up-to-date", version: "1.2.3" }); + + expect(output.at("success")).toEqual(["Already up to date (1.2.3)"]); + }); + + it("answers a check that found nothing newer the same way", () => { + const output = new CapturingOutput(false); + + printSelfUpdateResult(output, { kind: "check-current", version: "1.2.3" }); + + expect(output.at("success")).toEqual(["Already up to date (1.2.3)"]); + }); + + it("puts the newer version beside the current one when a check found one", () => { + const output = new CapturingOutput(false); + + printSelfUpdateResult(output, { + kind: "check-available", + latestVersion: "2.0.0", + currentVersion: "1.2.3", + }); + + expect(output.at("info")).toEqual(["New version available: 2.0.0 (current: 1.2.3)"]); + }); + + it("names the package a dry run would install", () => { + const output = new CapturingOutput(false); + + printSelfUpdateResult(output, { kind: "dry-run", latestVersion: "2.0.0" }); + + expect(output.at("info")).toEqual(["Would install @ai-driven-dev/cli@2.0.0"]); + }); + + it("confirms the new version without a path when the update reported none", () => { + const output = new CapturingOutput(false); + + printSelfUpdateResult(output, { kind: "updated", latestVersion: "2.0.0" }); + + expect(output.captured).toEqual([ + { level: "success", message: "Successfully updated to version 2.0.0" }, + ]); + }); + + it("appends the binary path the update wrote to", () => { + const output = new CapturingOutput(false); + + printSelfUpdateResult(output, { + kind: "updated", + latestVersion: "2.0.0", + binaryPath: "/usr/local/bin/aidd", + }); + + expect(output.at("success")).toEqual([ + "Successfully updated to version 2.0.0 (/usr/local/bin/aidd)", + ]); + }); + + it("prints the changelog under its own heading after the confirmation", () => { + const output = new CapturingOutput(false); + + printSelfUpdateResult(output, { + kind: "updated", + latestVersion: "2.0.0", + changelog: "- fixed a thing", + }); + + expect(output.captured).toEqual([ + { level: "success", message: "Successfully updated to version 2.0.0" }, + { level: "info", message: "\nChangelog:\n- fixed a thing" }, + ]); + }); + + it("prints no changelog heading when the update carried an empty one", () => { + const output = new CapturingOutput(false); + + printSelfUpdateResult(output, { kind: "updated", latestVersion: "2.0.0", changelog: null }); + + expect(output.at("info")).toEqual([]); + }); +}); diff --git a/cli/tests/application/error-handler.unit.test.ts b/cli/tests/presentation/error-handler.unit.test.ts similarity index 82% rename from cli/tests/application/error-handler.unit.test.ts rename to cli/tests/presentation/error-handler.unit.test.ts index 94de4d6f0..c4f358518 100644 --- a/cli/tests/application/error-handler.unit.test.ts +++ b/cli/tests/presentation/error-handler.unit.test.ts @@ -1,8 +1,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; -import { ErrorHandler } from "../../src/application/error-handler.js"; -import { InputRequiredError } from "../../src/application/errors.js"; -import type { CLIOutput } from "../../src/application/output.js"; -import { AuthenticationError } from "../../src/domain/errors.js"; +import { AuthenticationError, InputRequiredError } from "../../src/kernel/errors.js"; +import { ErrorHandler } from "../../src/presentation/error-handler.js"; +import type { CLIOutput } from "../../src/presentation/output.js"; function createMockOutput(): CLIOutput { return { @@ -17,9 +16,8 @@ function createMockOutput(): CLIOutput { } /** - * `process.exit` returns `never`, and so does `ErrorHandler.handle`. A double that returns - * normally cannot honestly be typed as either, and it lets a test walk through code the - * real process would never reach. Throwing is what "does not return" looks like in-process. + * `process.exit` returns `never`, and so does `ErrorHandler.handle`: a double that returns + * normally lets a test walk through code the real process would never reach. */ class ProcessExited extends Error { constructor(readonly code: number | string | null | undefined) { diff --git a/cli/tests/infrastructure/adapters/logger-adapter.integration.test.ts b/cli/tests/presentation/logger-adapter.integration.test.ts similarity index 98% rename from cli/tests/infrastructure/adapters/logger-adapter.integration.test.ts rename to cli/tests/presentation/logger-adapter.integration.test.ts index c36659f40..cab7dcef8 100644 --- a/cli/tests/infrastructure/adapters/logger-adapter.integration.test.ts +++ b/cli/tests/presentation/logger-adapter.integration.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { CLIOutput } from "../../../src/application/output.js"; +import { CLIOutput } from "../../src/presentation/output.js"; describe("CLIOutput", () => { describe("debug()", () => { diff --git a/cli/tests/presentation/prompts/interactive-menu-use-case.unit.test.ts b/cli/tests/presentation/prompts/interactive-menu-use-case.unit.test.ts new file mode 100644 index 000000000..172391913 --- /dev/null +++ b/cli/tests/presentation/prompts/interactive-menu-use-case.unit.test.ts @@ -0,0 +1,549 @@ +import { describe, expect, it, vi } from "vitest"; +import type { Prompter } from "../../../src/kernel/ports/prompter.js"; +import { InteractiveMenuUseCase } from "../../../src/presentation/prompts/menu-use-case.js"; +import { buildUnitDeps, initProject } from "../../helpers/ports/build-unit-deps.js"; + +const PROJECT_ROOT = "/test-project"; + +type SelectChoice = { name: string; value: string }; + +function makeQueuedPrompter( + selectResponses: string[], + inputResponses: string[] = [] +): { + prompter: Prompter; + selectMock: ReturnType; + inputMock: ReturnType; +} { + let selectIdx = 0; + let inputIdx = 0; + const selectMock = vi.fn().mockImplementation((_msg: string, choices: SelectChoice[]) => { + const val = selectResponses[selectIdx++]; + const match = choices.find((c) => c.value === val); + if (!match) throw new Error(`No choice with value "${val}"`); + return Promise.resolve(match.value); + }); + const inputMock = vi.fn().mockImplementation(() => { + return Promise.resolve(inputResponses[inputIdx++] ?? ""); + }); + const prompter: Prompter = { + resolveConflict: vi.fn(), + resolveConflictBulk: vi.fn(), + confirm: vi.fn(), + input: inputMock, + select: selectMock, + checkbox: vi.fn(), + }; + return { prompter, selectMock, inputMock }; +} + +describe("interactive menu", () => { + describe("project without AIDD installed", () => { + it("prompts to run setup when no manifest exists and user confirms", async () => { + const deps = await buildUnitDeps(PROJECT_ROOT); + const confirmMock = vi.fn().mockResolvedValue(true); + const prompter: Prompter = { + resolveConflict: vi.fn(), + resolveConflictBulk: vi.fn(), + confirm: confirmMock, + input: vi.fn(), + select: vi.fn(), + checkbox: vi.fn(), + }; + + const result = await new InteractiveMenuUseCase(deps.manifestRepo, prompter).execute(); + + expect(result.command).toEqual(["setup"]); + expect(confirmMock).toHaveBeenCalledWith("AIDD not initialized. Run setup now?", true); + }); + + it("exits when no manifest exists and user declines setup", async () => { + const deps = await buildUnitDeps(PROJECT_ROOT); + const prompter: Prompter = { + resolveConflict: vi.fn(), + resolveConflictBulk: vi.fn(), + confirm: vi.fn().mockResolvedValue(false), + input: vi.fn(), + select: vi.fn(), + checkbox: vi.fn(), + }; + + const result = await new InteractiveMenuUseCase(deps.manifestRepo, prompter).execute(); + + expect(result.command).toEqual(["exit"]); + }); + + it("does not show the full menu before installation", async () => { + const deps = await buildUnitDeps(PROJECT_ROOT); + const selectMock = vi.fn(); + const prompter: Prompter = { + resolveConflict: vi.fn(), + resolveConflictBulk: vi.fn(), + confirm: vi.fn().mockResolvedValue(false), + input: vi.fn(), + select: selectMock, + checkbox: vi.fn(), + }; + await new InteractiveMenuUseCase(deps.manifestRepo, prompter).execute(); + expect(selectMock).not.toHaveBeenCalled(); + }); + }); + + describe("project with AIDD installed", () => { + it("groups commands by usage area", async () => { + const deps = await buildUnitDeps(PROJECT_ROOT); + await initProject(deps, PROJECT_ROOT); + const { prompter, selectMock } = makeQueuedPrompter(["exit"]); + + await new InteractiveMenuUseCase(deps.manifestRepo, prompter).execute(); + + const values = (selectMock.mock.calls[0][1] as SelectChoice[]).map((c) => c.value); + expect(values).toContain("inspect"); + expect(values).toContain("manage-tools"); + expect(values).toContain("manage-plugins"); + expect(values).toContain("marketplaces"); + expect(values).toContain("maintain"); + expect(values).toContain("system"); + expect(values).toContain("exit"); + }); + + it("each group has a description to guide the user", async () => { + const deps = await buildUnitDeps(PROJECT_ROOT); + await initProject(deps, PROJECT_ROOT); + const { prompter, selectMock } = makeQueuedPrompter(["exit"]); + + await new InteractiveMenuUseCase(deps.manifestRepo, prompter).execute(); + + const choices = selectMock.mock.calls[0][1] as Array<{ value: string; description?: string }>; + const groupsWithDescription = choices.filter((c) => c.value !== "exit" && c.description); + expect(groupsWithDescription.length).toBe(6); + }); + + it("doctor is reachable from the inspect group", async () => { + const deps = await buildUnitDeps(PROJECT_ROOT); + await initProject(deps, PROJECT_ROOT); + const { prompter } = makeQueuedPrompter(["inspect", "doctor"]); + const result = await new InteractiveMenuUseCase(deps.manifestRepo, prompter).execute(); + expect(result.command).toEqual(["doctor"]); + }); + + it("framework install is reachable from the manage-tools group", async () => { + const deps = await buildUnitDeps(PROJECT_ROOT); + await initProject(deps, PROJECT_ROOT); + const { prompter } = makeQueuedPrompter(["manage-tools", "framework-install"], ["claude"]); + const result = await new InteractiveMenuUseCase(deps.manifestRepo, prompter).execute(); + expect(result.command).toEqual(["framework", "install", "--tool", "claude"]); + }); + + it("framework update (all) is reachable from the maintain group", async () => { + const deps = await buildUnitDeps(PROJECT_ROOT); + await initProject(deps, PROJECT_ROOT); + const { prompter } = makeQueuedPrompter(["maintain", "framework-update-maintain"]); + const result = await new InteractiveMenuUseCase(deps.manifestRepo, prompter).execute(); + expect(result.command).toEqual(["framework", "update"]); + }); + + it("CLI update is reachable from the system group", async () => { + const deps = await buildUnitDeps(PROJECT_ROOT); + await initProject(deps, PROJECT_ROOT); + const { prompter } = makeQueuedPrompter(["system", "self-update"]); + const result = await new InteractiveMenuUseCase(deps.manifestRepo, prompter).execute(); + expect(result.command).toEqual(["update"]); + }); + + it("exit is available directly from a group submenu", async () => { + const deps = await buildUnitDeps(PROJECT_ROOT); + await initProject(deps, PROJECT_ROOT); + const { prompter, selectMock } = makeQueuedPrompter(["inspect", "exit"]); + const result = await new InteractiveMenuUseCase(deps.manifestRepo, prompter).execute(); + expect(result.command).toEqual(["exit"]); + expect(selectMock).toHaveBeenCalledTimes(2); + }); + + it("going back from a group returns to the main menu", async () => { + const deps = await buildUnitDeps(PROJECT_ROOT); + await initProject(deps, PROJECT_ROOT); + const { prompter, selectMock } = makeQueuedPrompter(["inspect", "back", "exit"]); + const result = await new InteractiveMenuUseCase(deps.manifestRepo, prompter).execute(); + expect(result.command).toEqual(["exit"]); + expect(selectMock).toHaveBeenCalledTimes(3); + }); + + it("internal commands adopt and init are never exposed", async () => { + const deps = await buildUnitDeps(PROJECT_ROOT); + await initProject(deps, PROJECT_ROOT); + const allValues: string[] = []; + const selectMock = vi.fn().mockImplementation((_msg: string, choices: SelectChoice[]) => { + allValues.push(...choices.map((c) => c.value)); + const first = choices.find((c) => c.value !== "exit" && c.value !== "back"); + return Promise.resolve(first?.value ?? "exit"); + }); + const prompter: Prompter = { + resolveConflict: vi.fn(), + resolveConflictBulk: vi.fn(), + confirm: vi.fn(), + input: vi.fn().mockResolvedValue(""), + select: selectMock, + checkbox: vi.fn(), + }; + await new InteractiveMenuUseCase(deps.manifestRepo, prompter).execute(); + expect(allValues).not.toContain("adopt"); + expect(allValues).not.toContain("init"); + }); + + it("always returns to root after a command (no breadcrumb saved)", async () => { + const deps = await buildUnitDeps(PROJECT_ROOT); + await initProject(deps, PROJECT_ROOT); + const { prompter } = makeQueuedPrompter(["inspect", "doctor"]); + const result = await new InteractiveMenuUseCase(deps.manifestRepo, prompter).execute(); + expect(result.command).toEqual(["doctor"]); + expect("returnTo" in result).toBe(false); + }); + }); +}); + +const BACK_AND_EXIT = [ + { name: "← Back", value: "back" }, + { name: "Exit", value: "exit" }, +]; + +async function choicesAt(path: string[]): Promise<[string, unknown]> { + const deps = await buildUnitDeps(PROJECT_ROOT); + await initProject(deps, PROJECT_ROOT); + const { prompter, selectMock } = makeQueuedPrompter([...path, "exit"]); + + await new InteractiveMenuUseCase(deps.manifestRepo, prompter).execute(); + + const call = selectMock.mock.calls[path.length]; + return [call[0] as string, call[1]]; +} + +describe("interactive menu — the rows each level offers", () => { + it("offers six groups and an exit at the root, each named and described", async () => { + expect(await choicesAt([])).toEqual([ + "What would you like to do?", + [ + { + name: "Inspect", + value: "inspect", + description: "Check status, health and installed items", + }, + { + name: "Manage tools", + value: "manage-tools", + description: "Install, remove and update AI or IDE tools", + }, + { + name: "Manage plugins", + value: "manage-plugins", + description: "Browse, install and manage AI tool plugins", + }, + { + name: "Marketplaces", + value: "marketplaces", + description: "Manage plugin marketplace registrations", + }, + { + name: "Maintain & repair", + value: "maintain", + description: "Update tools, sync tracked files, and clean everything", + }, + { name: "System", value: "system", description: "CLI update and authentication" }, + { name: "Exit", value: "exit" }, + ], + ]); + }); + + it("offers no way back from the root, only out", async () => { + const [, choices] = await choicesAt([]); + + expect((choices as { value: string }[]).map((choice) => choice.value)).not.toContain("back"); + }); + + it("offers the three inspect commands under the group's own name", async () => { + expect(await choicesAt(["inspect"])).toEqual([ + "Inspect", + [ + { + name: "Doctor", + value: "doctor", + description: "Tool inventory, drift, plugins, and structural health", + }, + { + name: "Doctor (one tool)", + value: "doctor-tool", + description: "Scope the report to a single AI or IDE tool", + }, + { + name: "Plugins", + value: "plugin-list", + description: "Show installed plugins per tool", + }, + ...BACK_AND_EXIT, + ], + ]); + }); + + it("offers the four tool commands under Manage tools", async () => { + expect(await choicesAt(["manage-tools"])).toEqual([ + "Manage tools", + [ + { name: "Install", value: "framework-install", description: "Add a tool to this project" }, + { name: "Remove", value: "framework-remove", description: "Remove an installed tool" }, + { + name: "Update all", + value: "framework-update-all", + description: "Re-install every installed tool's configs from bundled assets", + }, + { + name: "Update one", + value: "framework-update-one", + description: "Re-install one tool's configs from bundled assets", + }, + ...BACK_AND_EXIT, + ], + ]); + }); + + it("offers the six plugin commands under Manage plugins", async () => { + expect(await choicesAt(["manage-plugins"])).toEqual([ + "Manage plugins", + [ + { + name: "Install plugin", + value: "plugin-install", + description: "Install a plugin by name, local path, or interactive pick", + }, + { + name: "Search", + value: "plugin-search", + description: "Search plugins across all registered marketplaces", + }, + { + name: "Update", + value: "plugin-update", + description: "Update all installed plugins to latest version", + }, + { name: "Remove", value: "plugin-remove", description: "Remove an installed plugin" }, + { + name: "List", + value: "plugin-list-2", + description: "Show all installed plugins per tool", + }, + { + name: "Doctor", + value: "plugin-doctor", + description: "Check one plugin's installation health", + }, + ...BACK_AND_EXIT, + ], + ]); + }); + + it("offers the five marketplace commands under Marketplaces", async () => { + expect(await choicesAt(["marketplaces"])).toEqual([ + "Marketplaces", + [ + { + name: "List", + value: "marketplace-list", + description: "Show all registered marketplaces", + }, + { + name: "Add", + value: "marketplace-add", + description: "Register a new plugin marketplace", + }, + { + name: "Refresh", + value: "marketplace-refresh", + description: "Refresh all registered marketplaces", + }, + { name: "Remove", value: "marketplace-remove", description: "Unregister a marketplace" }, + { + name: "Check freshness", + value: "marketplace-check", + description: "Report stale marketplaces", + }, + ...BACK_AND_EXIT, + ], + ]); + }); + + it("offers the three repair commands under Maintain & repair", async () => { + expect(await choicesAt(["maintain"])).toEqual([ + "Maintain & repair", + [ + { + name: "Update all tools", + value: "framework-update-maintain", + description: "Re-install every installed tool's configs from bundled assets", + }, + { + name: "Sync everything", + value: "sync-all", + description: + "Regenerate tracked files across all installed tools, driven by the manifest", + }, + { + name: "Clean (nuke .aidd)", + value: "clean", + description: "Remove all AIDD-managed files from this project", + }, + ...BACK_AND_EXIT, + ], + ]); + }); + + it("offers the CLI's own update and a nested authentication branch under System", async () => { + expect(await choicesAt(["system"])).toEqual([ + "System", + [ + { + name: "Update CLI", + value: "self-update", + description: "Update the AIDD CLI binary itself (bare `update`)", + }, + { + name: "Authentication", + value: "auth", + description: "Manage authentication credentials", + }, + ...BACK_AND_EXIT, + ], + ]); + }); + + it("offers the three authentication commands two levels down", async () => { + expect(await choicesAt(["system", "auth"])).toEqual([ + "Authentication", + [ + { + name: "Status", + value: "auth-status", + description: "Show current authentication status", + }, + { name: "Login", value: "auth-login", description: "Authenticate with your credentials" }, + { name: "Logout", value: "auth-logout", description: "Remove stored credentials" }, + ...BACK_AND_EXIT, + ], + ]); + }); +}); + +describe("interactive menu — the command each pick hands over", () => { + async function commandFor(path: string[], input?: string): Promise { + const deps = await buildUnitDeps(PROJECT_ROOT); + await initProject(deps, PROJECT_ROOT); + const { prompter } = makeQueuedPrompter(path, input === undefined ? [] : [input]); + + const result = await new InteractiveMenuUseCase(deps.manifestRepo, prompter).execute(); + + return result.command; + } + + it.each([ + [["inspect", "doctor"], ["doctor"]], + [ + ["inspect", "plugin-list"], + ["plugin", "list"], + ], + [ + ["manage-tools", "framework-update-all"], + ["framework", "update"], + ], + [ + ["manage-plugins", "plugin-update"], + ["plugin", "update"], + ], + [ + ["manage-plugins", "plugin-list-2"], + ["plugin", "list"], + ], + [ + ["marketplaces", "marketplace-list"], + ["marketplace", "list"], + ], + [ + ["marketplaces", "marketplace-add"], + ["marketplace", "add"], + ], + [ + ["marketplaces", "marketplace-refresh"], + ["marketplace", "refresh"], + ], + [ + ["marketplaces", "marketplace-check"], + ["marketplace", "check"], + ], + [ + ["maintain", "framework-update-maintain"], + ["framework", "update"], + ], + [["maintain", "sync-all"], ["sync"]], + [["maintain", "clean"], ["clean"]], + [["system", "self-update"], ["update"]], + [ + ["system", "auth", "auth-status"], + ["auth", "status"], + ], + [ + ["system", "auth", "auth-login"], + ["auth", "login"], + ], + [ + ["system", "auth", "auth-logout"], + ["auth", "logout"], + ], + ])("hands %j over as %j, asking nothing", async (path, command) => { + expect(await commandFor(path)).toEqual(command); + }); + + it.each([ + [["inspect", "doctor-tool"], "claude", ["doctor", "--tool", "claude"]], + [["manage-tools", "framework-install"], "cursor", ["framework", "install", "--tool", "cursor"]], + [["manage-tools", "framework-remove"], "codex", ["framework", "remove", "--tool", "codex"]], + [ + ["manage-tools", "framework-update-one"], + "vscode", + ["framework", "update", "--tool", "vscode"], + ], + [["manage-plugins", "plugin-install"], "aidd-dev", ["plugin", "install", "aidd-dev"]], + [["manage-plugins", "plugin-search"], "review", ["plugin", "search", "review"]], + [["manage-plugins", "plugin-remove"], "aidd-dev", ["plugin", "remove", "aidd-dev"]], + [["manage-plugins", "plugin-doctor"], "aidd-dev", ["doctor", "--plugin", "aidd-dev"]], + [ + ["marketplaces", "marketplace-remove"], + "aidd-framework", + ["marketplace", "remove", "aidd-framework"], + ], + ])("hands %j over with what it asked for, as %j", async (path, answer, command) => { + expect(await commandFor(path, answer)).toEqual(command); + }); + + it.each([ + [["inspect", "doctor-tool"], "Tool (e.g. claude, cursor, copilot, codex, opencode, vscode)"], + [ + ["manage-tools", "framework-install"], + "Tool (e.g. claude, cursor, copilot, codex, opencode, vscode)", + ], + [["manage-tools", "framework-remove"], "Tool to remove"], + [["manage-tools", "framework-update-one"], "Tool to update"], + [ + ["manage-plugins", "plugin-install"], + "Plugin name, path, or leave empty for interactive pick", + ], + [["manage-plugins", "plugin-search"], "Search query"], + [["manage-plugins", "plugin-remove"], "Plugin name to remove"], + [["manage-plugins", "plugin-doctor"], "Plugin name"], + [["marketplaces", "marketplace-remove"], "Marketplace name to remove"], + ])("asks %j for its argument by name: %s", async (path, question) => { + const deps = await buildUnitDeps(PROJECT_ROOT); + await initProject(deps, PROJECT_ROOT); + const { prompter, inputMock } = makeQueuedPrompter(path, ["x"]); + + await new InteractiveMenuUseCase(deps.manifestRepo, prompter).execute(); + + expect(inputMock).toHaveBeenCalledWith(question); + }); +}); diff --git a/cli/tests/presentation/prompts/plugin-pick-use-case.unit.test.ts b/cli/tests/presentation/prompts/plugin-pick-use-case.unit.test.ts new file mode 100644 index 000000000..4ece7fb09 --- /dev/null +++ b/cli/tests/presentation/prompts/plugin-pick-use-case.unit.test.ts @@ -0,0 +1,320 @@ +import { join } from "node:path"; +import { describe, expect, it, vi } from "vitest"; +import { FetchMarketplaceSourceUseCase } from "../../../src/contexts/distribution/application/fetch-marketplace-source-use-case.js"; +import { ResolveMarketplaceUseCase } from "../../../src/contexts/distribution/application/resolve-marketplace-use-case.js"; +import { Marketplace } from "../../../src/contexts/distribution/domain/marketplace.js"; +import { PluginCatalogRepositoryAdapter } from "../../../src/contexts/distribution/infrastructure/plugin-catalog-repository-adapter.js"; +import { PluginAddUseCase } from "../../../src/contexts/framework/application/plugin/plugin-add-use-case.js"; +import { PluginDistributionReaderAdapter } from "../../../src/contexts/framework/infrastructure/plugin-distribution-reader-adapter.js"; +import { + InteractiveOnlyError, + InvalidPluginManifestError, + NoMarketplacesRegisteredError, +} from "../../../src/kernel/errors.js"; +import type { Prompter } from "../../../src/kernel/ports/prompter.js"; +import { PluginPickUseCase } from "../../../src/presentation/prompts/plugin-pick-use-case.js"; +import { buildUnitDeps, initAndInstall } from "../../helpers/ports/build-unit-deps.js"; +import { fakeEnsureBuiltMarketplace } from "../../helpers/ports/fake-ensure-built-marketplace.js"; +import type { InMemoryFileAdapter } from "../../helpers/ports/in-memory-file-adapter.js"; +import { InMemoryMarketplaceRegistry } from "../../helpers/ports/in-memory-marketplace-registry.js"; +import { KeepPrompter } from "../../helpers/ports/scripted-prompter.js"; +import { seedFromDirectory } from "../../helpers/ports/seed-from-directory.js"; + +const PLUGIN_FIXTURE = join(process.cwd(), "tests/fixtures/plugins/claude-format/sample-plugin"); +const PROJECT_ROOT = "/test-project"; +const MKT_DIR = "/mkt-source"; +const MKT_DIR_2 = "/mkt-source-2"; + +function seedMarketplaceFile( + fs: InMemoryFileAdapter, + dir: string, + plugins: Array> +): void { + fs.writeFile(join(dir, ".claude-plugin/marketplace.json"), JSON.stringify({ plugins })); +} + +function registerMarketplace( + registry: InMemoryMarketplaceRegistry, + name: string, + dir: string +): Promise { + return registry.save( + PROJECT_ROOT, + Marketplace.create({ + name, + source: { kind: "local", path: dir }, + scope: "project", + addedAt: "2026-04-29T10:00:00.000Z", + }) + ); +} + +async function buildUseCase(prompter: Prompter = new KeepPrompter()) { + const deps = await buildUnitDeps(PROJECT_ROOT); + await initAndInstall(deps, PROJECT_ROOT, "claude"); + await seedFromDirectory(deps.fs, PLUGIN_FIXTURE, { useAbsolutePaths: true }); + const registry = new InMemoryMarketplaceRegistry(); + const pluginAdd = new PluginAddUseCase( + deps.fs, + deps.manifestRepo, + deps.pluginFetcher, + new PluginDistributionReaderAdapter(deps.fs), + deps.hasher, + deps.logger, + registry, + fakeEnsureBuiltMarketplace() + ); + const fetchMarketplaceSource = new FetchMarketplaceSourceUseCase(deps.pluginFetcher); + const resolveMarketplace = new ResolveMarketplaceUseCase( + fetchMarketplaceSource, + new PluginCatalogRepositoryAdapter(deps.fs) + ); + const useCase = new PluginPickUseCase(registry, resolveMarketplace, pluginAdd, prompter); + return { useCase, deps, registry, pluginAdd }; +} + +describe("PluginPickUseCase", () => { + it("throws InteractiveOnlyError when not interactive", async () => { + const { useCase } = await buildUseCase(); + await expect( + useCase.execute({ toolIds: ["claude"], projectRoot: PROJECT_ROOT, interactive: false }) + ).rejects.toThrow(InteractiveOnlyError); + }); + + it("throws NoMarketplacesRegisteredError when registry is empty", async () => { + const { useCase } = await buildUseCase(); + await expect( + useCase.execute({ toolIds: ["claude"], projectRoot: PROJECT_ROOT, interactive: true }) + ).rejects.toThrow(NoMarketplacesRegisteredError); + }); + + it("installs the recommended plugins from the only registered marketplace", async () => { + const { useCase, deps, registry } = await buildUseCase(); + seedMarketplaceFile(deps.fs, MKT_DIR, [ + { + name: "sample-plugin", + source: { kind: "local", path: PLUGIN_FIXTURE }, + version: "1.0.0", + recommended: true, + }, + ]); + await registry.save( + PROJECT_ROOT, + Marketplace.create({ + name: "local", + source: { kind: "local", path: MKT_DIR }, + scope: "project", + addedAt: "2026-04-29T10:00:00.000Z", + }) + ); + + const result = await useCase.execute({ + toolIds: ["claude"], + projectRoot: PROJECT_ROOT, + interactive: true, + }); + + expect(result.marketplace.name).toBe("local"); + expect(result.installed).toEqual(["sample-plugin"]); + const manifest = await deps.manifestRepo.load(); + const plugins = manifest?.getPlugins("claude") ?? []; + const installed = plugins.find((p) => p.name === "sample-plugin"); + expect(installed?.marketplace).toBe("local"); + }); + + it("prompts to choose a marketplace when more than one is registered", async () => { + const { useCase, deps, registry } = await buildUseCase(); + seedMarketplaceFile(deps.fs, MKT_DIR, []); + seedMarketplaceFile(deps.fs, MKT_DIR_2, []); + await registerMarketplace(registry, "first", MKT_DIR); + await registerMarketplace(registry, "second", MKT_DIR_2); + + const result = await useCase.execute({ + toolIds: ["claude"], + projectRoot: PROJECT_ROOT, + interactive: true, + }); + + expect(result.marketplace.name).toBe("first"); + expect(result.installed).toEqual([]); + }); + + it("throws InvalidPluginManifestError when the marketplace catalog cannot be found", async () => { + const { useCase, registry } = await buildUseCase(); + await registerMarketplace(registry, "local", MKT_DIR); + + await expect( + useCase.execute({ toolIds: ["claude"], projectRoot: PROJECT_ROOT, interactive: true }) + ).rejects.toThrow(new InvalidPluginManifestError(`marketplace.json not found at "${MKT_DIR}"`)); + }); + + it("returns no installed plugins and skips the selection prompt when the catalog is empty", async () => { + const { useCase, deps, registry } = await buildUseCase(); + seedMarketplaceFile(deps.fs, MKT_DIR, []); + await registerMarketplace(registry, "local", MKT_DIR); + + const result = await useCase.execute({ + toolIds: ["claude"], + projectRoot: PROJECT_ROOT, + interactive: true, + }); + + expect(result.installed).toEqual([]); + }); + + it("installs an entry that carries a description and an explicit strict flag on the catalog", async () => { + const { useCase, deps, registry } = await buildUseCase(); + seedMarketplaceFile(deps.fs, MKT_DIR, [ + { + name: "sample-plugin", + source: { kind: "local", path: PLUGIN_FIXTURE }, + version: "1.0.0", + description: "A sample plugin used in tests", + recommended: true, + strict: true, + }, + ]); + await registerMarketplace(registry, "local", MKT_DIR); + + const result = await useCase.execute({ + toolIds: ["claude"], + projectRoot: PROJECT_ROOT, + interactive: true, + }); + + expect(result.installed).toEqual(["sample-plugin"]); + }); +}); + +describe("PluginPickUseCase — what it asks, and when it asks nothing", () => { + it("names the command a non-interactive run refused, so the message says what to rerun", async () => { + const { useCase } = await buildUseCase(); + + await expect( + useCase.execute({ toolIds: ["claude"], projectRoot: PROJECT_ROOT, interactive: false }) + ).rejects.toThrow(/plugin install/u); + }); + + it("asks nothing about a marketplace when only one is registered", async () => { + const prompter = new KeepPrompter(); + const select = vi.spyOn(prompter, "select"); + const { useCase, deps, registry } = await buildUseCase(prompter); + seedMarketplaceFile(deps.fs, MKT_DIR, []); + await registerMarketplace(registry, "local", MKT_DIR); + + await useCase.execute({ toolIds: ["claude"], projectRoot: PROJECT_ROOT, interactive: true }); + + expect(select).not.toHaveBeenCalled(); + }); + + it("offers each marketplace by name and scope when there is a choice to make", async () => { + const prompter = new KeepPrompter(); + const select = vi.spyOn(prompter, "select"); + const { useCase, deps, registry } = await buildUseCase(prompter); + seedMarketplaceFile(deps.fs, MKT_DIR, []); + seedMarketplaceFile(deps.fs, MKT_DIR_2, []); + await registerMarketplace(registry, "first", MKT_DIR); + await registerMarketplace(registry, "second", MKT_DIR_2); + + await useCase.execute({ toolIds: ["claude"], projectRoot: PROJECT_ROOT, interactive: true }); + + expect(select).toHaveBeenCalledWith("Select a marketplace:", [ + expect.objectContaining({ name: "first [project]" }), + expect.objectContaining({ name: "second [project]" }), + ]); + }); + + it("asks nothing about plugins when the catalog holds none", async () => { + const prompter = new KeepPrompter(); + const checkbox = vi.spyOn(prompter, "checkbox"); + const { useCase, deps, registry } = await buildUseCase(prompter); + seedMarketplaceFile(deps.fs, MKT_DIR, []); + await registerMarketplace(registry, "local", MKT_DIR); + + await useCase.execute({ toolIds: ["claude"], projectRoot: PROJECT_ROOT, interactive: true }); + + expect(checkbox).not.toHaveBeenCalled(); + }); + + it("offers a described plugin with its description, and a bare one by name alone", async () => { + const prompter = new KeepPrompter(); + const checkbox = vi.spyOn(prompter, "checkbox"); + const { useCase, deps, registry } = await buildUseCase(prompter); + seedMarketplaceFile(deps.fs, MKT_DIR, [ + { + name: "sample-plugin", + source: { kind: "local", path: PLUGIN_FIXTURE }, + version: "1.0.0", + description: "A sample plugin used in tests", + recommended: true, + }, + { name: "bare-plugin", source: { kind: "local", path: PLUGIN_FIXTURE }, version: "1.0.0" }, + ]); + await registerMarketplace(registry, "local", MKT_DIR); + + await useCase.execute({ toolIds: ["claude"], projectRoot: PROJECT_ROOT, interactive: true }); + + expect(checkbox).toHaveBeenCalledWith("Select plugins to install:", [ + expect.objectContaining({ + name: "sample-plugin — A sample plugin used in tests", + checked: true, + }), + expect.objectContaining({ name: "bare-plugin", checked: false }), + ]); + }); +}); + +describe("PluginPickUseCase — what it hands the installer", () => { + it("passes the entry's own source, metadata and marketplace, replacing what is there", async () => { + const prompter = new KeepPrompter(); + const { useCase, deps, registry, pluginAdd } = await buildUseCase(prompter); + const add = vi.spyOn(pluginAdd, "execute"); + seedMarketplaceFile(deps.fs, MKT_DIR, [ + { + name: "sample-plugin", + source: { kind: "local", path: PLUGIN_FIXTURE }, + version: "1.0.0", + recommended: true, + strict: true, + }, + ]); + await registerMarketplace(registry, "local", MKT_DIR); + + await useCase.execute({ toolIds: ["claude"], projectRoot: PROJECT_ROOT, interactive: true }); + + expect(add).toHaveBeenCalledWith({ + source: { kind: "local", path: PLUGIN_FIXTURE }, + toolIds: ["claude"], + projectRoot: PROJECT_ROOT, + interactive: true, + marketplace: "local", + pluginMetadata: { name: "sample-plugin", version: "1.0.0", strict: true }, + replace: true, + }); + }); + + // `strict` is optional on a catalog entry and false is what an absent one means: a plugin + // installed strict on the strength of a missing field would be refused files it may carry. + it("passes an entry that declares no strictness as not strict", async () => { + const prompter = new KeepPrompter(); + const { useCase, deps, registry, pluginAdd } = await buildUseCase(prompter); + const add = vi.spyOn(pluginAdd, "execute"); + seedMarketplaceFile(deps.fs, MKT_DIR, [ + { + name: "sample-plugin", + source: { kind: "local", path: PLUGIN_FIXTURE }, + version: "1.0.0", + recommended: true, + }, + ]); + await registerMarketplace(registry, "local", MKT_DIR); + + await useCase.execute({ toolIds: ["claude"], projectRoot: PROJECT_ROOT, interactive: true }); + + expect(add).toHaveBeenCalledWith( + expect.objectContaining({ + pluginMetadata: { name: "sample-plugin", version: "1.0.0", strict: false }, + }) + ); + }); +}); diff --git a/cli/tests/presentation/prompts/setup-plugins-prompt-use-case.unit.test.ts b/cli/tests/presentation/prompts/setup-plugins-prompt-use-case.unit.test.ts new file mode 100644 index 000000000..ad3edb9b1 --- /dev/null +++ b/cli/tests/presentation/prompts/setup-plugins-prompt-use-case.unit.test.ts @@ -0,0 +1,287 @@ +import { describe, expect, it, vi } from "vitest"; +import type { + ResolveMarketplace, + ResolveMarketplaceOptions, + ResolveMarketplaceResult, +} from "../../../src/contexts/distribution/application/resolve-marketplace-use-case.js"; +import type { PluginCatalogEntry } from "../../../src/contexts/distribution/domain/catalog.js"; +import { Marketplace } from "../../../src/contexts/distribution/domain/marketplace.js"; +import type { + PluginInstallFromMarketplace, + PluginInstallFromMarketplaceOptions, + PluginInstallFromMarketplaceResult, +} from "../../../src/contexts/framework/application/plugin/plugin-install-from-marketplace-use-case.js"; +import type { + PluginPick, + PluginPickOptions, + PluginPickResult, +} from "../../../src/presentation/prompts/plugin-pick-use-case.js"; +import { SetupPluginsPromptUseCase } from "../../../src/presentation/prompts/setup-plugins-prompt-use-case.js"; +import { InMemoryMarketplaceRegistry } from "../../helpers/ports/in-memory-marketplace-registry.js"; + +const PROJECT_ROOT = "/test-project"; + +function marketplace(name: string): Marketplace { + return Marketplace.create({ + name, + source: { kind: "local", path: `/${name}` }, + scope: "project", + addedAt: "2026-04-29T10:00:00.000Z", + }); +} + +function entry(name: string, recommended: boolean): PluginCatalogEntry { + return { + name, + source: { kind: "local", path: `/plugins/${name}` }, + version: "1.0.0", + recommended, + strict: false, + }; +} + +class RecordingPluginPick implements PluginPick { + readonly calls: PluginPickOptions[] = []; + + constructor(private readonly installed: readonly string[]) {} + + async execute(options: PluginPickOptions): Promise { + this.calls.push(options); + return { marketplace: marketplace("local"), installed: this.installed }; + } +} + +class RecordingInstaller implements PluginInstallFromMarketplace { + readonly calls: PluginInstallFromMarketplaceOptions[] = []; + + async execute( + options: PluginInstallFromMarketplaceOptions + ): Promise { + this.calls.push(options); + return { marketplace: marketplace("local"), entry: entry(options.pluginName, false) }; + } +} + +class SeededResolveMarketplace implements ResolveMarketplace { + constructor(private readonly catalogs: ReadonlyMap) {} + + async execute(options: ResolveMarketplaceOptions): Promise { + const plugins = this.catalogs.get(options.marketplace.name); + return { + marketplace: options.marketplace, + localPath: `/cache/${options.marketplace.name}`, + catalog: plugins === undefined ? null : { plugins }, + }; + } +} + +async function build( + catalogs: ReadonlyMap = new Map(), + installedByPick: readonly string[] = [] +) { + const registry = new InMemoryMarketplaceRegistry(); + for (const name of catalogs.keys()) await registry.save(PROJECT_ROOT, marketplace(name)); + const pick = new RecordingPluginPick(installedByPick); + const installer = new RecordingInstaller(); + const useCase = new SetupPluginsPromptUseCase( + pick, + installer, + registry, + new SeededResolveMarketplace(catalogs) + ); + return { useCase, pick, installer, registry }; +} + +const ONE_MARKETPLACE = new Map([ + ["local", [entry("aidd-context", true), entry("aidd-dev", true), entry("aidd-ui", false)]], +]); + +describe("SetupPluginsPromptUseCase — the mode decides what is installed", () => { + it("installs nothing and asks nothing when no plugins were wanted", async () => { + const { useCase, pick, installer } = await build(ONE_MARKETPLACE); + + const result = await useCase.execute({ + projectRoot: PROJECT_ROOT, + mode: "none", + pluginNames: ["aidd-dev"], + interactive: true, + }); + + expect(result).toEqual({ installed: [] }); + expect(pick.calls).toEqual([]); + expect(installer.calls).toEqual([]); + }); + + it("installs exactly the plugins named, without reading a catalog", async () => { + const { useCase, installer } = await build(); + + const result = await useCase.execute({ + projectRoot: PROJECT_ROOT, + mode: "named", + pluginNames: ["aidd-dev", "aidd-pm"], + interactive: false, + }); + + expect(result).toEqual({ installed: ["aidd-dev", "aidd-pm"] }); + expect(installer.calls.map((call) => call.pluginName)).toEqual(["aidd-dev", "aidd-pm"]); + }); + + it("installs only the plugins a catalog recommends", async () => { + const { useCase, installer } = await build(ONE_MARKETPLACE); + + const result = await useCase.execute({ + projectRoot: PROJECT_ROOT, + mode: "recommended", + pluginNames: [], + interactive: false, + }); + + expect(result).toEqual({ installed: ["aidd-context", "aidd-dev"] }); + expect(installer.calls.map((call) => call.pluginName)).toEqual(["aidd-context", "aidd-dev"]); + }); + + it("installs every plugin a catalog carries, recommended or not", async () => { + const { useCase } = await build(ONE_MARKETPLACE); + + const result = await useCase.execute({ + projectRoot: PROJECT_ROOT, + mode: "all", + pluginNames: [], + interactive: false, + }); + + expect(result).toEqual({ installed: ["aidd-context", "aidd-dev", "aidd-ui"] }); + }); + + it("gathers plugins from every registered marketplace, in registration order", async () => { + const { useCase } = await build( + new Map([ + ["first", [entry("aidd-context", true)]], + ["second", [entry("aidd-dev", true)]], + ]) + ); + + const result = await useCase.execute({ + projectRoot: PROJECT_ROOT, + mode: "all", + pluginNames: [], + interactive: false, + }); + + expect(result).toEqual({ installed: ["aidd-context", "aidd-dev"] }); + }); + + it("skips a marketplace whose catalog could not be read, keeping the rest", async () => { + const registry = new InMemoryMarketplaceRegistry(); + await registry.save(PROJECT_ROOT, marketplace("empty")); + await registry.save(PROJECT_ROOT, marketplace("local")); + const installer = new RecordingInstaller(); + const useCase = new SetupPluginsPromptUseCase( + new RecordingPluginPick([]), + installer, + registry, + new SeededResolveMarketplace(new Map([["local", [entry("aidd-dev", true)]]])) + ); + + const result = await useCase.execute({ + projectRoot: PROJECT_ROOT, + mode: "all", + pluginNames: [], + interactive: false, + }); + + expect(result).toEqual({ installed: ["aidd-dev"] }); + }); +}); + +describe("SetupPluginsPromptUseCase — what each install is asked for", () => { + it("installs into every tool, replacing what is there and choosing its marketplace itself", async () => { + const { useCase, installer } = await build(); + + await useCase.execute({ + projectRoot: PROJECT_ROOT, + mode: "named", + pluginNames: ["aidd-dev"], + interactive: true, + }); + + expect(installer.calls).toEqual([ + { + pluginName: "aidd-dev", + toolIds: "all", + projectRoot: PROJECT_ROOT, + interactive: true, + autoSelect: true, + replace: true, + }, + ]); + }); + + it("carries a non-interactive run's own flag into the install it drives", async () => { + const { useCase, installer } = await build(); + + await useCase.execute({ + projectRoot: PROJECT_ROOT, + mode: "named", + pluginNames: ["aidd-dev"], + interactive: false, + }); + + expect(installer.calls[0]?.interactive).toBe(false); + }); +}); + +describe("SetupPluginsPromptUseCase — the interactive pick", () => { + it("hands the pick every tool, and reports back what it installed", async () => { + const { useCase, pick } = await build(ONE_MARKETPLACE, ["aidd-dev", "aidd-pm"]); + + const result = await useCase.execute({ + projectRoot: PROJECT_ROOT, + mode: "interactive", + pluginNames: [], + interactive: true, + }); + + expect(result).toEqual({ installed: ["aidd-dev", "aidd-pm"] }); + expect(pick.calls).toEqual([{ toolIds: "all", projectRoot: PROJECT_ROOT, interactive: true }]); + }); + + // Nothing can be picked where nobody is there to pick, and a run that cannot ask must + // install nothing rather than fall through to a scripted mode nobody chose. + it("installs nothing, and never reaches the pick, on a run that cannot ask", async () => { + const { useCase, pick, installer } = await build(ONE_MARKETPLACE); + + const result = await useCase.execute({ + projectRoot: PROJECT_ROOT, + mode: "interactive", + pluginNames: [], + interactive: false, + }); + + expect(result).toEqual({ installed: [] }); + expect(pick.calls).toEqual([]); + expect(installer.calls).toEqual([]); + }); + + it("reads no catalog for an interactive pick, which reads its own", async () => { + const registry = new InMemoryMarketplaceRegistry(); + await registry.save(PROJECT_ROOT, marketplace("local")); + const resolve = new SeededResolveMarketplace(new Map([["local", [entry("aidd-dev", true)]]])); + const resolveSpy = vi.spyOn(resolve, "execute"); + const useCase = new SetupPluginsPromptUseCase( + new RecordingPluginPick([]), + new RecordingInstaller(), + registry, + resolve + ); + + await useCase.execute({ + projectRoot: PROJECT_ROOT, + mode: "interactive", + pluginNames: [], + interactive: true, + }); + + expect(resolveSpy).not.toHaveBeenCalled(); + }); +}); diff --git a/cli/tests/application/use-cases/setup/setup-tools-prompt-recommendations.unit.test.ts b/cli/tests/presentation/prompts/setup-tools-prompt-recommendations.unit.test.ts similarity index 92% rename from cli/tests/application/use-cases/setup/setup-tools-prompt-recommendations.unit.test.ts rename to cli/tests/presentation/prompts/setup-tools-prompt-recommendations.unit.test.ts index 38ff4d54d..3dcf2055c 100644 --- a/cli/tests/application/use-cases/setup/setup-tools-prompt-recommendations.unit.test.ts +++ b/cli/tests/presentation/prompts/setup-tools-prompt-recommendations.unit.test.ts @@ -1,9 +1,9 @@ import { describe, expect, it } from "vitest"; -import { ProjectContext } from "../../../../src/domain/models/project-context.js"; +import { ProjectContext } from "../../../src/contexts/framework/domain/project-context.js"; import { recommendAiTools, recommendIdeTools, -} from "../../../../src/domain/models/tool-recommendations.js"; +} from "../../../src/contexts/framework/domain/tool-recommendations.js"; function ctx(over: Partial[0]> = {}) { return new ProjectContext({ diff --git a/cli/tests/application/use-cases/setup/setup-tools-prompt-use-case.unit.test.ts b/cli/tests/presentation/prompts/setup-tools-prompt-use-case.unit.test.ts similarity index 93% rename from cli/tests/application/use-cases/setup/setup-tools-prompt-use-case.unit.test.ts rename to cli/tests/presentation/prompts/setup-tools-prompt-use-case.unit.test.ts index 2572f84fd..e62f392f7 100644 --- a/cli/tests/application/use-cases/setup/setup-tools-prompt-use-case.unit.test.ts +++ b/cli/tests/presentation/prompts/setup-tools-prompt-use-case.unit.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; -import { SetupToolsPromptUseCase } from "../../../../src/application/use-cases/setup/setup-tools-prompt-use-case.js"; -import { ScriptedPrompter } from "../../../helpers/ports/scripted-prompter.js"; +import { SetupToolsPromptUseCase } from "../../../src/presentation/prompts/setup-tools-prompt-use-case.js"; +import { ScriptedPrompter } from "../../helpers/ports/scripted-prompter.js"; describe("SetupToolsPromptUseCase", () => { describe("non-interactive mode", () => { diff --git a/cli/tests/application/use-cases/sync/sync-conflict-resolver-use-case.unit.test.ts b/cli/tests/presentation/prompts/sync-conflict-resolver-use-case.unit.test.ts similarity index 93% rename from cli/tests/application/use-cases/sync/sync-conflict-resolver-use-case.unit.test.ts rename to cli/tests/presentation/prompts/sync-conflict-resolver-use-case.unit.test.ts index 7b2089c6e..a30aee145 100644 --- a/cli/tests/application/use-cases/sync/sync-conflict-resolver-use-case.unit.test.ts +++ b/cli/tests/presentation/prompts/sync-conflict-resolver-use-case.unit.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from "vitest"; -import { SyncConflictResolverUseCase } from "../../../../src/application/use-cases/sync/sync-conflict-resolver-use-case.js"; -import { DeterministicHasher } from "../../../helpers/ports/deterministic-hasher.js"; -import { InMemoryFileAdapter } from "../../../helpers/ports/in-memory-file-adapter.js"; +import { SyncConflictResolverUseCase } from "../../../src/presentation/prompts/sync-conflict-resolver-use-case.js"; +import { DeterministicHasher } from "../../helpers/ports/deterministic-hasher.js"; +import { InMemoryFileAdapter } from "../../helpers/ports/in-memory-file-adapter.js"; const DISK_PATH = "/project/target.md"; const CONTENT_A = "content A"; diff --git a/cli/tests/infrastructure/verbose.unit.test.ts b/cli/tests/presentation/verbose.unit.test.ts similarity index 95% rename from cli/tests/infrastructure/verbose.unit.test.ts rename to cli/tests/presentation/verbose.unit.test.ts index fd58aa23c..c30a65b69 100644 --- a/cli/tests/infrastructure/verbose.unit.test.ts +++ b/cli/tests/presentation/verbose.unit.test.ts @@ -1,5 +1,5 @@ import { afterEach, describe, expect, it } from "vitest"; -import { CLIOutput } from "../../src/application/output.js"; +import { CLIOutput } from "../../src/presentation/output.js"; describe("CLIOutput AIDD_VERBOSE env var", () => { const originalEnv = process.env.AIDD_VERBOSE; diff --git a/cli/tests/infrastructure/assets/asset-loader.unit.test.ts b/cli/tests/runtime/assets/asset-loader.unit.test.ts similarity index 85% rename from cli/tests/infrastructure/assets/asset-loader.unit.test.ts rename to cli/tests/runtime/assets/asset-loader.unit.test.ts index b2c293eef..fc1457a1f 100644 --- a/cli/tests/infrastructure/assets/asset-loader.unit.test.ts +++ b/cli/tests/runtime/assets/asset-loader.unit.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; -import { parseToml } from "../../../src/domain/formats/toml.js"; -import { BundledAssetProviderAdapter } from "../../../src/infrastructure/assets/asset-loader.js"; +import { parseToml } from "../../../src/contexts/tools/domain/profiles/codex/toml.js"; +import { BundledAssetProviderAdapter } from "../../../src/runtime/assets/asset-loader.js"; const provider = new BundledAssetProviderAdapter(); @@ -28,8 +28,8 @@ describe("BundledAssetProviderAdapter.loadConfigAsset", () => { expect(typeof asset).toBe("string"); }); - // #700: a pinned "gpt-5" was rejected by ChatGPT-account Codex sessions. - // Model choice is owned by the account, not this repo — Codex's own default applies. + // Model choice is owned by the account, not this repo: a pinned "gpt-5" is rejected by + // ChatGPT-account Codex sessions, so Codex's own default applies. it("writes no model — the account decides which one it can use", () => { const asset = provider.loadConfigAsset("codex", "config.toml") as string; const parsed = parseToml(asset); @@ -78,15 +78,6 @@ describe("BundledAssetProviderAdapter.loadConfigAsset", () => { }); }); -describe("BundledAssetProviderAdapter.loadDefaultMarketplace", () => { - it("returns the aidd-framework Git source", () => { - const marketplace = provider.loadDefaultMarketplace(); - expect(marketplace.name).toBe("aidd-framework"); - expect(marketplace.type).toBe("git"); - expect(marketplace.source).toMatch(/^https:\/\/github\.com\/.+\.git$/); - }); -}); - describe("BundledAssetProviderAdapter.loadSchema — marketplace", () => { it("returns a Copilot-native schema with required fields including metadata", () => { const schema = provider.loadSchema("marketplace") as Record; diff --git a/cli/tests/application/use-cases/auth-login-use-case.unit.test.ts b/cli/tests/runtime/auth/auth-login-use-case.unit.test.ts similarity index 87% rename from cli/tests/application/use-cases/auth-login-use-case.unit.test.ts rename to cli/tests/runtime/auth/auth-login-use-case.unit.test.ts index bd83014ac..f83f71ec3 100644 --- a/cli/tests/application/use-cases/auth-login-use-case.unit.test.ts +++ b/cli/tests/runtime/auth/auth-login-use-case.unit.test.ts @@ -1,8 +1,8 @@ import { describe, expect, it, vi } from "vitest"; -import { AuthLoginUseCase } from "../../../src/application/use-cases/auth/auth-login-use-case.js"; -import { AuthenticationError } from "../../../src/domain/errors.js"; -import type { AuthCredential, AuthLevel } from "../../../src/domain/models/auth.js"; -import type { CredentialStore } from "../../../src/domain/ports/credential-store.js"; +import { AuthenticationError } from "../../../src/kernel/errors.js"; +import type { AuthCredential, AuthLevel } from "../../../src/runtime/auth/auth.js"; +import { AuthLoginUseCase } from "../../../src/runtime/auth/auth-login-use-case.js"; +import type { CredentialStore } from "../../../src/runtime/auth/ports/credential-store.js"; describe("auth login", () => { function makeCredentialStore(login: string): CredentialStore { diff --git a/cli/tests/application/use-cases/auth-logout-use-case.integration.test.ts b/cli/tests/runtime/auth/auth-logout-use-case.integration.test.ts similarity index 78% rename from cli/tests/application/use-cases/auth-logout-use-case.integration.test.ts rename to cli/tests/runtime/auth/auth-logout-use-case.integration.test.ts index 538772d86..cedff8261 100644 --- a/cli/tests/application/use-cases/auth-logout-use-case.integration.test.ts +++ b/cli/tests/runtime/auth/auth-logout-use-case.integration.test.ts @@ -1,11 +1,11 @@ import { afterEach, beforeEach, describe, expect, it } from "vitest"; -import { AuthLogoutUseCase } from "../../../src/application/use-cases/auth/auth-logout-use-case.js"; -import type { CredentialStore } from "../../../src/domain/ports/credential-store.js"; -import { AuthProviderAdapter } from "../../../src/infrastructure/adapters/auth-provider-adapter.js"; -import { GhCliAdapter } from "../../../src/infrastructure/adapters/gh-cli-adapter.js"; -import { GhTokenAdapter } from "../../../src/infrastructure/adapters/gh-token-adapter.js"; -import type { AuthStorage } from "../../../src/infrastructure/auth/auth-storage.js"; -import { HttpClient } from "../../../src/infrastructure/http/http-client.js"; +import { AuthLogoutUseCase } from "../../../src/runtime/auth/auth-logout-use-case.js"; +import { AuthProviderAdapter } from "../../../src/runtime/auth/auth-provider-adapter.js"; +import type { AuthStorage } from "../../../src/runtime/auth/auth-storage.js"; +import { GhCliAdapter } from "../../../src/runtime/auth/gh-cli-adapter.js"; +import { GhTokenAdapter } from "../../../src/runtime/auth/gh-token-adapter.js"; +import type { CredentialStore } from "../../../src/runtime/auth/ports/credential-store.js"; +import { HttpClient } from "../../../src/runtime/http/http-client.js"; import { makeAuthConfig, makeTempAuthStorage } from "../../helpers/auth.js"; describe("auth logout", () => { diff --git a/cli/tests/runtime/auth/auth-provider-adapter.unit.test.ts b/cli/tests/runtime/auth/auth-provider-adapter.unit.test.ts new file mode 100644 index 000000000..3b25c372d --- /dev/null +++ b/cli/tests/runtime/auth/auth-provider-adapter.unit.test.ts @@ -0,0 +1,200 @@ +import { describe, expect, it } from "vitest"; +import { AuthenticationError } from "../../../src/kernel/errors.js"; +import type { AuthConfig } from "../../../src/runtime/auth/auth.js"; +import { AuthProviderAdapter } from "../../../src/runtime/auth/auth-provider-adapter.js"; +import type { + CredentialFileSaveOptions, + CredentialFileStore, +} from "../../../src/runtime/auth/ports/credential-file-store.js"; +import type { + CliAuthProvider, + TokenAuthProvider, +} from "../../../src/runtime/auth/ports/oauth-provider.js"; + +const PROJECT_ROOT = "/work/project"; + +type Saved = CredentialFileSaveOptions; + +function storage(active: AuthConfig | null = null): CredentialFileStore & { saves: Saved[] } { + const saves: Saved[] = []; + return { + saves, + save: async (options) => { + saves.push(options); + }, + readActive: async () => active, + read: async () => null, + delete: async () => {}, + projectConfigPath: () => `${PROJECT_ROOT}/.aidd/auth.json`, + userConfigPath: () => "/home/user/.config/aidd/auth.json", + }; +} + +function tokenVerifier(login = "token-user"): TokenAuthProvider & { seen: string[] } { + const seen: string[] = []; + return { + seen, + verifyToken: async (token: string) => { + seen.push(token); + return login; + }, + }; +} + +function cliProvider(login: string): CliAuthProvider { + return { resolve: () => null, verify: async () => login }; +} + +describe("the credential a user hands the CLI", () => { + describe("logging in with a stored token", () => { + it("verifies the token it was given, not another", async () => { + const verifier = tokenVerifier("octocat"); + const adapter = new AuthProviderAdapter(storage(), new Map(), verifier, PROJECT_ROOT); + + const result = await adapter.login({ method: "stored", token: "ghp_secret" }, "project"); + + expect(verifier.seen).toEqual(["ghp_secret"]); + expect(result).toEqual({ login: "octocat", level: "project" }); + }); + + it("records the credential at the level asked for, against this project", async () => { + const store = storage(); + const adapter = new AuthProviderAdapter(store, new Map(), tokenVerifier(), PROJECT_ROOT); + + await adapter.login({ method: "stored", token: "ghp_secret" }, "user"); + + expect(store.saves).toEqual([ + { + credential: { method: "stored", token: "ghp_secret" }, + level: "user", + projectRoot: PROJECT_ROOT, + }, + ]); + }); + }); + + describe("logging in through an external provider", () => { + it("asks the named provider, and returns the login it reports", async () => { + const providers = new Map([ + ["gh", cliProvider("from-gh")], + ["glab", cliProvider("from-glab")], + ]); + const adapter = new AuthProviderAdapter( + storage(), + providers, + tokenVerifier("never-used"), + PROJECT_ROOT + ); + + const result = await adapter.login({ method: "external", provider: "glab" }, "user"); + + expect(result.login, "the provider named in the credential answers").toBe("from-glab"); + }); + + it("names the provider it could not find, so the user can fix the spelling", async () => { + const adapter = new AuthProviderAdapter( + storage(), + new Map([["gh", cliProvider("from-gh")]]), + tokenVerifier(), + PROJECT_ROOT + ); + + await expect(adapter.login({ method: "external", provider: "hub" }, "user")).rejects.toThrow( + /hub/ + ); + }); + }); +}); + +describe("what `auth status` reports", () => { + describe("with nothing recorded", () => { + it("says not authenticated, and verifies nothing", async () => { + const verifier = tokenVerifier(); + const adapter = new AuthProviderAdapter(storage(null), new Map(), verifier, PROJECT_ROOT); + + expect(await adapter.status()).toEqual({ authenticated: false }); + expect(verifier.seen, "no credential means no verification call").toEqual([]); + }); + }); + + describe("with a stored token recorded", () => { + const config: AuthConfig = { + version: 1, + method: "stored", + level: "project", + token: "ghp_stored", + createdAt: "2026-01-01T00:00:00.000Z", + }; + + it("verifies the recorded token and reports the recorded level", async () => { + const verifier = tokenVerifier("octocat"); + const adapter = new AuthProviderAdapter(storage(config), new Map(), verifier, PROJECT_ROOT); + + expect(await adapter.status()).toEqual({ + authenticated: true, + login: "octocat", + level: "project", + }); + expect(verifier.seen).toEqual(["ghp_stored"]); + }); + + it("refuses a record that claims a token and carries none", async () => { + const adapter = new AuthProviderAdapter( + storage({ ...config, token: undefined }), + new Map(), + tokenVerifier(), + PROJECT_ROOT + ); + + await expect(adapter.status()).rejects.toThrow(AuthenticationError); + }); + }); + + describe("with an external record", () => { + const external: AuthConfig = { + version: 1, + method: "external", + level: "user", + provider: "glab", + createdAt: "2026-01-01T00:00:00.000Z", + }; + + it("asks the provider the record names", async () => { + const adapter = new AuthProviderAdapter( + storage(external), + new Map([ + ["gh", cliProvider("from-gh")], + ["glab", cliProvider("from-glab")], + ]), + tokenVerifier(), + PROJECT_ROOT + ); + + expect(await adapter.status()).toEqual({ + authenticated: true, + login: "from-glab", + level: "user", + }); + }); + + it("falls back to gh when the record names no provider", async () => { + const adapter = new AuthProviderAdapter( + storage({ ...external, provider: undefined }), + new Map([ + ["gh", cliProvider("from-gh")], + ["glab", cliProvider("from-glab")], + ]), + tokenVerifier(), + PROJECT_ROOT + ); + + const status = await adapter.status(); + + expect(status, "a record written before providers were named still resolves").toEqual({ + authenticated: true, + login: "from-gh", + level: "user", + }); + }); + }); +}); diff --git a/cli/tests/infrastructure/auth/auth-reader.integration.test.ts b/cli/tests/runtime/auth/auth-reader.integration.test.ts similarity index 96% rename from cli/tests/infrastructure/auth/auth-reader.integration.test.ts rename to cli/tests/runtime/auth/auth-reader.integration.test.ts index bf24a2f84..64ef242a7 100644 --- a/cli/tests/infrastructure/auth/auth-reader.integration.test.ts +++ b/cli/tests/runtime/auth/auth-reader.integration.test.ts @@ -1,8 +1,8 @@ import { describe, expect, it } from "vitest"; -import type { AuthConfig } from "../../../src/domain/models/auth.js"; -import type { TokenResolver } from "../../../src/domain/ports/oauth-provider.js"; -import { AuthReaderAdapter } from "../../../src/infrastructure/adapters/auth-reader-adapter.js"; -import type { AuthStorage } from "../../../src/infrastructure/auth/auth-storage.js"; +import type { AuthConfig } from "../../../src/runtime/auth/auth.js"; +import { AuthReaderAdapter } from "../../../src/runtime/auth/auth-reader-adapter.js"; +import type { AuthStorage } from "../../../src/runtime/auth/auth-storage.js"; +import type { TokenResolver } from "../../../src/runtime/auth/ports/oauth-provider.js"; function makeStorage( overrides: Partial<{ diff --git a/cli/tests/application/use-cases/auth-status-use-case.unit.test.ts b/cli/tests/runtime/auth/auth-status-use-case.unit.test.ts similarity index 84% rename from cli/tests/application/use-cases/auth-status-use-case.unit.test.ts rename to cli/tests/runtime/auth/auth-status-use-case.unit.test.ts index 1cd98b3a0..50a5427dc 100644 --- a/cli/tests/application/use-cases/auth-status-use-case.unit.test.ts +++ b/cli/tests/runtime/auth/auth-status-use-case.unit.test.ts @@ -1,6 +1,9 @@ import { describe, expect, it } from "vitest"; -import { AuthStatusUseCase } from "../../../src/application/use-cases/auth/auth-status-use-case.js"; -import type { AuthStatus, CredentialStore } from "../../../src/domain/ports/credential-store.js"; +import { AuthStatusUseCase } from "../../../src/runtime/auth/auth-status-use-case.js"; +import type { + AuthStatus, + CredentialStore, +} from "../../../src/runtime/auth/ports/credential-store.js"; function makeCredentialStore(status: AuthStatus): CredentialStore { return { diff --git a/cli/tests/runtime/auth/auth-storage.integration.test.ts b/cli/tests/runtime/auth/auth-storage.integration.test.ts new file mode 100644 index 000000000..0f6b06aa3 --- /dev/null +++ b/cli/tests/runtime/auth/auth-storage.integration.test.ts @@ -0,0 +1,302 @@ +import { execFileSync } from "node:child_process"; +import { mkdtemp, readFile, rm, stat, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("node:child_process", async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, execFileSync: vi.fn() }; +}); + +import { AuthStorage } from "../../../src/runtime/auth/auth-storage.js"; +import { makeAuthConfig } from "../../helpers/auth.js"; + +/** + * `process.platform` is a plain value property, so it is redefined rather than spied on; the + * win32 branch alone shells out, and it needs `USERNAME`, which no POSIX session has. + */ +async function asWin32(run: () => Promise, account = "tester"): Promise { + const platform = Object.getOwnPropertyDescriptor(process, "platform"); + const username = process.env.USERNAME; + Object.defineProperty(process, "platform", { value: "win32", configurable: true }); + process.env.USERNAME = account; + try { + return await run(); + } finally { + if (platform !== undefined) Object.defineProperty(process, "platform", platform); + if (username === undefined) delete process.env.USERNAME; + else process.env.USERNAME = username; + } +} + +describe("AuthStorage", () => { + let tempDir: string; + let storage: AuthStorage; + + beforeEach(async () => { + tempDir = await mkdtemp(join(tmpdir(), "auth-storage-test-")); + storage = new AuthStorage(); + }); + + afterEach(async () => { + await rm(tempDir, { recursive: true, force: true }); + }); + + describe("read", () => { + it("returns null when file does not exist", async () => { + const result = await storage.read(join(tempDir, "nonexistent.json")); + expect(result).toBeNull(); + }); + + it("returns null when file contains invalid JSON", async () => { + const path = join(tempDir, "bad.json"); + await writeFile(path, "not json"); + const result = await storage.read(path); + expect(result).toBeNull(); + }); + + it("returns null when file contains JSON missing required fields", async () => { + const path = join(tempDir, "incomplete.json"); + await writeFile(path, JSON.stringify({ version: 1 })); + const result = await storage.read(path); + expect(result).toBeNull(); + }); + + it("returns AuthConfig when file is valid", async () => { + const config = makeAuthConfig({ token: "ghp_abc123" }); + const path = join(tempDir, "auth.json"); + await writeFile(path, JSON.stringify(config)); + const result = await storage.read(path); + expect(result).toEqual(config); + }); + }); + + describe("write", () => { + it("creates parent directories and writes the file", async () => { + const path = join(tempDir, "nested", "dir", "auth.json"); + const config = makeAuthConfig({ method: "external", level: "project", token: undefined }); + await storage.write(path, config); + const content = await readFile(path, "utf-8"); + expect(JSON.parse(content)).toEqual(config); + }); + + it("sets restrictive file permissions on non-Windows", async () => { + if (process.platform === "win32") return; + const path = join(tempDir, "auth.json"); + await storage.write(path, makeAuthConfig({ token: "ghp_secret" })); + const stats = await stat(path); + expect(stats.mode & 0o777).toBe(0o600); + }); + + it("restricts the file on win32 through an icacls argument list, never a shell command line", async () => { + const path = join(tempDir, "auth.json"); + vi.mocked(execFileSync).mockClear(); + + await asWin32(() => storage.write(path, makeAuthConfig({ token: "ghp_win" }))); + + expect(execFileSync).toHaveBeenCalledTimes(1); + const [command, args] = vi.mocked(execFileSync).mock.calls[0] ?? []; + expect(command).toBe("icacls"); + expect(args).toEqual([path, "/inheritance:r", "/grant:r", expect.stringContaining(":(R,W)")]); + }); + + it("names the account from the environment, not the %USERNAME% only a shell would expand", async () => { + vi.mocked(execFileSync).mockClear(); + + await asWin32( + () => storage.write(join(tempDir, "auth.json"), makeAuthConfig({ token: "ghp_win" })), + "Ada Lovelace" + ); + + const args = vi.mocked(execFileSync).mock.calls[0]?.[1]; + expect(args).toContain("Ada Lovelace:(R,W)"); + }); + + it("refuses to leave inheritance stripped with no grant when the session names no account", async () => { + const platform = Object.getOwnPropertyDescriptor(process, "platform"); + const username = process.env.USERNAME; + Object.defineProperty(process, "platform", { value: "win32", configurable: true }); + delete process.env.USERNAME; + try { + await expect( + storage.write(join(tempDir, "auth.json"), makeAuthConfig({ token: "ghp_win" })) + ).rejects.toThrow(/USERNAME/); + } finally { + if (platform !== undefined) Object.defineProperty(process, "platform", platform); + if (username !== undefined) process.env.USERNAME = username; + } + }); + + it("passes a path carrying shell metacharacters as one verbatim argument", async () => { + // No `"`: the file is really written to disk, and that is illegal on NTFS. `&`, the + // space, `;` and `$` are legal there and still split under a real shell. + const path = join(tempDir, "a & echo pwned; $HOME & b.json"); + vi.mocked(execFileSync).mockClear(); + + await asWin32(() => storage.write(path, makeAuthConfig({ token: "ghp_win" }))); + + const args = vi.mocked(execFileSync).mock.calls[0]?.[1]; + expect(args?.[0]).toBe(path); + }); + + it("written file can be read back", async () => { + const path = join(tempDir, "auth.json"); + const config = makeAuthConfig({ token: "ghp_roundtrip" }); + await storage.write(path, config); + const result = await storage.read(path); + expect(result).toEqual(config); + }); + }); + + describe("delete", () => { + it("removes an existing file", async () => { + const path = join(tempDir, "auth.json"); + await writeFile(path, "{}"); + await storage.delete(path); + await expect(readFile(path)).rejects.toThrow(); + }); + + it("does not throw when file does not exist", async () => { + await expect(storage.delete(join(tempDir, "missing.json"))).resolves.not.toThrow(); + }); + }); + + describe("paths", () => { + it("projectConfigPath returns .aidd/auth.json under projectRoot", () => { + const path = storage.projectConfigPath("/my/project"); + expect(path).toBe(join("/my/project", ".aidd", "auth.json")); + }); + + it("userConfigPath respects AIDD_USER_CONFIG_DIR env override", () => { + const original = process.env.AIDD_USER_CONFIG_DIR; + try { + process.env.AIDD_USER_CONFIG_DIR = "/custom/config/dir"; + const path = storage.userConfigPath(); + expect(path).toBe(join("/custom/config/dir", "auth.json")); + } finally { + if (original === undefined) { + delete process.env.AIDD_USER_CONFIG_DIR; + } else { + process.env.AIDD_USER_CONFIG_DIR = original; + } + } + }); + }); + + describe("readActive", () => { + it("returns AIDD_TOKEN env config when env var is set", async () => { + const original = process.env.AIDD_TOKEN; + try { + process.env.AIDD_TOKEN = "env-token-123"; + const result = await storage.readActive(tempDir); + expect(result).not.toBeNull(); + expect(result?.token).toBe("env-token-123"); + expect(result?.method).toBe("stored"); + } finally { + if (original === undefined) { + delete process.env.AIDD_TOKEN; + } else { + process.env.AIDD_TOKEN = original; + } + } + }); + + it("returns project config when no AIDD_TOKEN env var but project auth.json exists", async () => { + const original = process.env.AIDD_TOKEN; + delete process.env.AIDD_TOKEN; + try { + const config = makeAuthConfig({ token: "project-tok", level: "project" }); + const projectPath = storage.projectConfigPath(tempDir); + await storage.write(projectPath, config); + + const result = await storage.readActive(tempDir); + + expect(result?.token).toBe("project-tok"); + expect(result?.level).toBe("project"); + } finally { + if (original !== undefined) process.env.AIDD_TOKEN = original; + } + }); + + it("returns user config when no AIDD_TOKEN and no project auth.json", async () => { + const original = process.env.AIDD_TOKEN; + const userConfigDirOriginal = process.env.AIDD_USER_CONFIG_DIR; + delete process.env.AIDD_TOKEN; + try { + process.env.AIDD_USER_CONFIG_DIR = tempDir; + const config = makeAuthConfig({ token: "user-tok", level: "user" }); + const userPath = storage.userConfigPath(); + await storage.write(userPath, config); + + const result = await storage.readActive("/some/other/project"); + + expect(result?.token).toBe("user-tok"); + } finally { + if (original !== undefined) process.env.AIDD_TOKEN = original; + if (userConfigDirOriginal === undefined) { + delete process.env.AIDD_USER_CONFIG_DIR; + } else { + process.env.AIDD_USER_CONFIG_DIR = userConfigDirOriginal; + } + } + }); + + it("returns null when no token source is available", async () => { + const tokenOriginal = process.env.AIDD_TOKEN; + const userConfigDirOriginal = process.env.AIDD_USER_CONFIG_DIR; + delete process.env.AIDD_TOKEN; + process.env.AIDD_USER_CONFIG_DIR = join(tempDir, "no-such-dir"); + try { + const result = await storage.readActive(join(tempDir, "no-project")); + expect(result).toBeNull(); + } finally { + if (tokenOriginal !== undefined) process.env.AIDD_TOKEN = tokenOriginal; + if (userConfigDirOriginal === undefined) { + delete process.env.AIDD_USER_CONFIG_DIR; + } else { + process.env.AIDD_USER_CONFIG_DIR = userConfigDirOriginal; + } + } + }); + }); + + describe("save", () => { + it("saves project-level credential to .aidd/auth.json", async () => { + const credential = { method: "stored" as const, token: "ghp_save_project" }; + await storage.save({ credential, level: "project", projectRoot: tempDir }); + + const saved = await storage.read(storage.projectConfigPath(tempDir)); + expect(saved?.token).toBe("ghp_save_project"); + expect(saved?.level).toBe("project"); + }); + + it("saves user-level credential to user config path", async () => { + const userConfigDirOriginal = process.env.AIDD_USER_CONFIG_DIR; + process.env.AIDD_USER_CONFIG_DIR = tempDir; + try { + const credential = { method: "stored" as const, token: "ghp_save_user" }; + await storage.save({ credential, level: "user", projectRoot: tempDir }); + + const saved = await storage.read(storage.userConfigPath()); + expect(saved?.token).toBe("ghp_save_user"); + expect(saved?.level).toBe("user"); + } finally { + if (userConfigDirOriginal === undefined) { + delete process.env.AIDD_USER_CONFIG_DIR; + } else { + process.env.AIDD_USER_CONFIG_DIR = userConfigDirOriginal; + } + } + }); + + it("saves external credential without token field", async () => { + const credential = { method: "external" as const, provider: "gh" }; + await storage.save({ credential, level: "project", projectRoot: tempDir }); + + const saved = await storage.read(storage.projectConfigPath(tempDir)); + expect(saved?.method).toBe("external"); + expect("token" in (saved ?? {})).toBe(false); + }); + }); +}); diff --git a/cli/tests/infrastructure/adapters/gh-cli-adapter.integration.test.ts b/cli/tests/runtime/auth/gh-cli-adapter.integration.test.ts similarity index 96% rename from cli/tests/infrastructure/adapters/gh-cli-adapter.integration.test.ts rename to cli/tests/runtime/auth/gh-cli-adapter.integration.test.ts index b2641d3bb..9f5527f11 100644 --- a/cli/tests/infrastructure/adapters/gh-cli-adapter.integration.test.ts +++ b/cli/tests/runtime/auth/gh-cli-adapter.integration.test.ts @@ -1,6 +1,6 @@ import { spawnSync } from "node:child_process"; import { describe, expect, it, vi } from "vitest"; -import { GhCliAdapter } from "../../../src/infrastructure/adapters/gh-cli-adapter.js"; +import { GhCliAdapter } from "../../../src/runtime/auth/gh-cli-adapter.js"; vi.mock("node:child_process", () => ({ spawnSync: vi.fn(), diff --git a/cli/tests/infrastructure/adapters/file-adapter.integration.test.ts b/cli/tests/runtime/filesystem/file-adapter.integration.test.ts similarity index 89% rename from cli/tests/infrastructure/adapters/file-adapter.integration.test.ts rename to cli/tests/runtime/filesystem/file-adapter.integration.test.ts index 5513b2df1..de541b853 100644 --- a/cli/tests/infrastructure/adapters/file-adapter.integration.test.ts +++ b/cli/tests/runtime/filesystem/file-adapter.integration.test.ts @@ -1,17 +1,22 @@ -import { mkdir, readFile, rm, symlink, writeFile } from "node:fs/promises"; +import { mkdir, mkdtemp, readFile, rename, rm, symlink, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { afterEach, beforeEach, describe, expect, it } from "vitest"; -import { FileAdapter } from "../../../src/infrastructure/adapters/file-adapter.js"; -import { HasherAdapter } from "../../../src/infrastructure/adapters/hasher-adapter.js"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("node:fs/promises", async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, rename: vi.fn(actual.rename) }; +}); + +import { FileAdapter } from "../../../src/runtime/filesystem/file-adapter.js"; +import { HasherAdapter } from "../../../src/runtime/filesystem/hasher-adapter.js"; describe("FileAdapter", () => { let tempDir: string; let fs: FileAdapter; beforeEach(async () => { - tempDir = join(tmpdir(), `fs-adapter-test-${Date.now()}`); - await mkdir(tempDir, { recursive: true }); + tempDir = await mkdtemp(join(tmpdir(), "aidd-fs-adapter-")); fs = new FileAdapter(new HasherAdapter()); }); @@ -33,6 +38,22 @@ describe("FileAdapter", () => { const content = await readFile(path, "utf-8"); expect(content).toBe("nested"); }); + + // A crash mid-write must never leave a truncated file at `path`, and two concurrent `aidd + // setup` runs both write `references.json`: `rename` is the one POSIX-atomic step. + it("writes through a temporary file and renames it into place", async () => { + const path = join(tempDir, "atomic.txt"); + await writeFile(path, "old content", "utf-8"); + vi.mocked(rename).mockClear(); + + await fs.writeFile(path, "new content"); + + expect(rename).toHaveBeenCalledTimes(1); + const [renamedFrom, renamedTo] = vi.mocked(rename).mock.calls[0] as [string, string]; + expect(renamedTo).toBe(path); + expect(renamedFrom).not.toBe(path); + expect(await readFile(path, "utf-8")).toBe("new content"); + }); }); describe("readFile()", () => { @@ -102,8 +123,10 @@ describe("FileAdapter", () => { const files = await fs.listDirectory(tempDir); expect(files).toContain("a.txt"); - expect(files).toContain(join("sub", "b.txt")); - expect(files).toContain(join("sub", "deep", "c.txt")); + // `listDirectory()` normalizes every relative path to "/" — what a manifest read on every + // platform stores — so the expectation is the POSIX literal, never `join()`. + expect(files).toContain("sub/b.txt"); + expect(files).toContain("sub/deep/c.txt"); }); }); @@ -132,45 +155,6 @@ describe("FileAdapter", () => { }); }); - describe("backup()", () => { - it("creates a copy with .bak.YYYYMMDDTHHMMSS suffix", async () => { - const path = join(tempDir, "original.txt"); - await writeFile(path, "original content", "utf-8"); - - const backupPath = await fs.backup(path); - - expect(backupPath).toMatch(/original\.txt\.bak\.\d{8}T\d{6}$/); - }); - - it("returns the absolute backup path", async () => { - const path = join(tempDir, "file.txt"); - await writeFile(path, "data", "utf-8"); - - const backupPath = await fs.backup(path); - - expect(backupPath.startsWith(tempDir)).toBe(true); - }); - - it("backup file contains original content", async () => { - const path = join(tempDir, "source.txt"); - await writeFile(path, "backup me", "utf-8"); - - const backupPath = await fs.backup(path); - const backupContent = await readFile(backupPath, "utf-8"); - - expect(backupContent).toBe("backup me"); - }); - - it("original file still exists after backup", async () => { - const path = join(tempDir, "keep.txt"); - await writeFile(path, "keep this", "utf-8"); - - await fs.backup(path); - - expect(await fs.fileExists(path)).toBe(true); - }); - }); - describe("mergeJsonFile()", () => { it("creates file if it does not exist", async () => { const path = join(tempDir, "new.json"); @@ -402,11 +386,9 @@ describe("FileAdapter", () => { const files = await fs.listDirectory(tempDir); - // symlink must NOT appear const posixFiles = files.map((f) => f.split("\\").join("/")); expect(posixFiles).not.toContain("skills/secret-link.txt"); - // regular file inside skills/ must still appear expect(posixFiles.some((f) => f.includes("normal"))).toBe(true); }); }); diff --git a/cli/tests/infrastructure/adapters/hasher-adapter.integration.test.ts b/cli/tests/runtime/filesystem/hasher-adapter.integration.test.ts similarity index 87% rename from cli/tests/infrastructure/adapters/hasher-adapter.integration.test.ts rename to cli/tests/runtime/filesystem/hasher-adapter.integration.test.ts index 2306ec899..3ab4f271d 100644 --- a/cli/tests/infrastructure/adapters/hasher-adapter.integration.test.ts +++ b/cli/tests/runtime/filesystem/hasher-adapter.integration.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { HasherAdapter } from "../../../src/infrastructure/adapters/hasher-adapter.js"; +import { HasherAdapter } from "../../../src/runtime/filesystem/hasher-adapter.js"; describe("HasherAdapter", () => { const hasher = new HasherAdapter(); @@ -22,7 +22,6 @@ describe("HasherAdapter", () => { }); it("matches known MD5 value for a fixed input", () => { - // MD5("test") = 098f6bcd4621d373cade4e832627b4f6 const result = hasher.hash("test"); expect(result.value).toBe("098f6bcd4621d373cade4e832627b4f6"); }); diff --git a/cli/tests/runtime/git/inject-token.unit.test.ts b/cli/tests/runtime/git/inject-token.unit.test.ts new file mode 100644 index 000000000..05e75d102 --- /dev/null +++ b/cli/tests/runtime/git/inject-token.unit.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, it } from "vitest"; +import { injectTokenIntoUrl } from "../../../src/runtime/git/inject-token.js"; + +describe("injectTokenIntoUrl", () => { + it("returns the URL unchanged when token is undefined", () => { + const url = "https://github.com/owner/repo.git"; + expect(injectTokenIntoUrl(url, undefined)).toBe(url); + }); + + it("does not modify ssh URLs", () => { + const ssh = "git@github.com:owner/repo.git"; + expect(injectTokenIntoUrl(ssh, "tk")).toBe(ssh); + }); + + it("uses x-access-token for github", () => { + expect(injectTokenIntoUrl("https://github.com/owner/repo.git", "tk")).toBe( + "https://x-access-token:tk@github.com/owner/repo.git" + ); + }); + + it("uses oauth2 for gitlab", () => { + expect(injectTokenIntoUrl("https://gitlab.com/owner/repo.git", "tk")).toBe( + "https://oauth2:tk@gitlab.com/owner/repo.git" + ); + }); + + it("uses x-token-auth for bitbucket", () => { + expect(injectTokenIntoUrl("https://bitbucket.org/owner/repo.git", "tk")).toBe( + "https://x-token-auth:tk@bitbucket.org/owner/repo.git" + ); + }); + + it("falls back to bare-token form for unknown hosts", () => { + expect(injectTokenIntoUrl("https://example.com/owner/repo.git", "tk")).toBe( + "https://tk@example.com/owner/repo.git" + ); + }); + + it("matches a known forge on a subdomain of it", () => { + expect(injectTokenIntoUrl("https://gist.github.com/owner/repo.git", "tk")).toBe( + "https://x-access-token:tk@gist.github.com/owner/repo.git" + ); + }); + + it("does not treat a known host appearing in the path as that host", () => { + expect(injectTokenIntoUrl("https://evil.example/github.com/owner/repo.git", "tk")).toBe( + "https://tk@evil.example/github.com/owner/repo.git" + ); + }); + + it("does not treat a host merely ending in a known host's name as that host", () => { + expect(injectTokenIntoUrl("https://notgithub.com/owner/repo.git", "tk")).toBe( + "https://tk@notgithub.com/owner/repo.git" + ); + }); + + it("does not treat a known host in the query string as that host", () => { + expect(injectTokenIntoUrl("https://example.com/repo.git?from=gitlab.com", "tk")).toBe( + "https://tk@example.com/repo.git?from=gitlab.com" + ); + }); + + it("leaves a string that is not a parseable URL untouched", () => { + expect(injectTokenIntoUrl("https://", "tk")).toBe("https://"); + }); +}); diff --git a/cli/tests/runtime/home-dir.unit.test.ts b/cli/tests/runtime/home-dir.unit.test.ts new file mode 100644 index 000000000..c6456ef39 --- /dev/null +++ b/cli/tests/runtime/home-dir.unit.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it } from "vitest"; +import { resolveHomeDir } from "../../src/kernel/reading/home-dir.js"; + +describe("resolveHomeDir", () => { + // `os.homedir()` reads `USERPROFILE` on Windows, never `HOME`, so a bare `homedir()` drops + // a sandboxed `HOME`. No platform branch here, so a regression fails on every platform. + it("prefers HOME over the OS-reported home directory", () => { + const env = { HOME: "C:\\sandbox\\home" } as NodeJS.ProcessEnv; + const osHomedir = () => "C:\\Users\\runneradmin"; + + expect(resolveHomeDir(env, osHomedir)).toBe("C:\\sandbox\\home"); + }); + + it("falls back to the OS-reported home directory when HOME is unset", () => { + const env = {} as NodeJS.ProcessEnv; + const osHomedir = () => "C:\\Users\\runneradmin"; + + expect(resolveHomeDir(env, osHomedir)).toBe("C:\\Users\\runneradmin"); + }); + + it("falls back when HOME is set but empty", () => { + const env = { HOME: "" } as NodeJS.ProcessEnv; + const osHomedir = () => "C:\\Users\\runneradmin"; + + expect(resolveHomeDir(env, osHomedir)).toBe("C:\\Users\\runneradmin"); + }); +}); diff --git a/cli/tests/infrastructure/http/http-client.integration.test.ts b/cli/tests/runtime/http/http-client.integration.test.ts similarity index 97% rename from cli/tests/infrastructure/http/http-client.integration.test.ts rename to cli/tests/runtime/http/http-client.integration.test.ts index 3a18fa9db..95778ff51 100644 --- a/cli/tests/infrastructure/http/http-client.integration.test.ts +++ b/cli/tests/runtime/http/http-client.integration.test.ts @@ -1,8 +1,8 @@ import { createServer } from "node:http"; import type { AddressInfo } from "node:net"; import { beforeEach, describe, expect, it } from "vitest"; -import { AuthenticationError } from "../../../src/domain/errors.js"; -import { HttpClient } from "../../../src/infrastructure/http/http-client.js"; +import { AuthenticationError } from "../../../src/kernel/errors.js"; +import { HttpClient } from "../../../src/runtime/http/http-client.js"; function startServer( handler: ( diff --git a/cli/tests/infrastructure/adapters/prompter-adapter.integration.test.ts b/cli/tests/runtime/prompter/prompter-adapter.integration.test.ts similarity index 98% rename from cli/tests/infrastructure/adapters/prompter-adapter.integration.test.ts rename to cli/tests/runtime/prompter/prompter-adapter.integration.test.ts index a06aa6cb0..8a9aa3568 100644 --- a/cli/tests/infrastructure/adapters/prompter-adapter.integration.test.ts +++ b/cli/tests/runtime/prompter/prompter-adapter.integration.test.ts @@ -3,9 +3,8 @@ import { describe, expect, it } from "vitest"; import { InquirerPrompterAdapter, SilentPrompterAdapter, -} from "../../../src/infrastructure/adapters/prompter-adapter.js"; +} from "../../../src/runtime/prompter/prompter-adapter.js"; -// Key sequences for @inquirer/prompts const ENTER = "\n"; const ARROW_DOWN = "\x1b[B"; const SPACE = " "; diff --git a/cli/tests/application/use-cases/check-update-use-case.unit.test.ts b/cli/tests/runtime/self-update/check-update-use-case.unit.test.ts similarity index 75% rename from cli/tests/application/use-cases/check-update-use-case.unit.test.ts rename to cli/tests/runtime/self-update/check-update-use-case.unit.test.ts index 25fe5254d..53284dba4 100644 --- a/cli/tests/application/use-cases/check-update-use-case.unit.test.ts +++ b/cli/tests/runtime/self-update/check-update-use-case.unit.test.ts @@ -1,13 +1,13 @@ -import { homedir } from "node:os"; import { join } from "node:path"; import { describe, expect, it, vi } from "vitest"; -import { CheckUpdateUseCase } from "../../../src/application/use-cases/check-update-use-case.js"; -import { FileHash } from "../../../src/domain/models/file.js"; -import type { FileReader } from "../../../src/domain/ports/file-reader.js"; -import type { FileWriter } from "../../../src/domain/ports/file-writer.js"; -import type { Logger } from "../../../src/domain/ports/logger.js"; -import type { SelfUpdater } from "../../../src/domain/ports/self-updater.js"; -import type { VersionReader } from "../../../src/domain/ports/version-reader.js"; +import { FileHash } from "../../../src/kernel/file.js"; +import type { FileReader } from "../../../src/kernel/ports/file-reader.js"; +import type { FileWriter } from "../../../src/kernel/ports/file-writer.js"; +import type { Logger } from "../../../src/kernel/ports/logger.js"; +import type { VersionReader } from "../../../src/kernel/ports/version-reader.js"; +import { CheckUpdateUseCase } from "../../../src/runtime/self-update/check-update-use-case.js"; +import type { SelfUpdater } from "../../../src/runtime/self-update/self-updater.js"; +import { userConfigDir } from "../../../src/runtime/user-config-dir.js"; const TTL_24H = 24 * 60 * 60 * 1000; @@ -53,21 +53,20 @@ function makeFsStub(store: Map = new Map()): FileReader & FileWr createDirectory: async () => {}, deleteEmptyDirectories: async () => {}, deleteDirectory: async () => {}, - chmodExecutable: async () => {}, isExecutable: async () => false, + realpath: async (path: string) => path, + chmodExecutable: async () => {}, }; } function seedCache(store: Map, latest: string, ageMs = 0): void { - const dir = process.env.AIDD_USER_CONFIG_DIR ?? join(homedir(), ".config", "aidd"); + const dir = userConfigDir(); store.set( join(dir, "update-check.json"), JSON.stringify({ checkedAt: Date.now() - ageMs, latest }) ); } -// ---- SWR matrix ---- - describe("SWR matrix: printFromCacheOnly", () => { it("fresh cache (<24h) → shows notice, no network call", async () => { const { logger, warns } = makeLogger(); @@ -149,3 +148,28 @@ describe("refresh (online piggyback path)", () => { expect(JSON.parse(written as string).latest).toBe("2.0.0"); }); }); + +describe("where the cache lands", () => { + it("follows XDG_CONFIG_HOME when set, the way every other machine-local file does", async () => { + const saved = { xdg: process.env.XDG_CONFIG_HOME, aidd: process.env.AIDD_USER_CONFIG_DIR }; + process.env.XDG_CONFIG_HOME = "/xdg-config"; + delete process.env.AIDD_USER_CONFIG_DIR; + try { + const store = new Map(); + const { logger } = makeLogger(); + await new CheckUpdateUseCase( + makeSelfUpdater("2.0.0"), + makeVersionReader("1.0.0"), + logger, + makeFsStub(store) + ).refresh(); + expect([...store.keys()]).toContain( + join("/xdg-config", "aidd", "cache", "update-check.json") + ); + } finally { + if (saved.xdg === undefined) delete process.env.XDG_CONFIG_HOME; + else process.env.XDG_CONFIG_HOME = saved.xdg; + if (saved.aidd !== undefined) process.env.AIDD_USER_CONFIG_DIR = saved.aidd; + } + }); +}); diff --git a/cli/tests/application/check-update.unit.test.ts b/cli/tests/runtime/self-update/check-update.unit.test.ts similarity index 87% rename from cli/tests/application/check-update.unit.test.ts rename to cli/tests/runtime/self-update/check-update.unit.test.ts index f0e7758de..28b71a15d 100644 --- a/cli/tests/application/check-update.unit.test.ts +++ b/cli/tests/runtime/self-update/check-update.unit.test.ts @@ -1,13 +1,13 @@ import { homedir } from "node:os"; import { join } from "node:path"; import { describe, expect, it, vi } from "vitest"; -import { CheckUpdateUseCase } from "../../src/application/use-cases/check-update-use-case.js"; -import { FileHash } from "../../src/domain/models/file.js"; -import type { FileReader } from "../../src/domain/ports/file-reader.js"; -import type { FileWriter } from "../../src/domain/ports/file-writer.js"; -import type { Logger } from "../../src/domain/ports/logger.js"; -import type { SelfUpdater } from "../../src/domain/ports/self-updater.js"; -import type { VersionReader } from "../../src/domain/ports/version-reader.js"; +import { FileHash } from "../../../src/kernel/file.js"; +import type { FileReader } from "../../../src/kernel/ports/file-reader.js"; +import type { FileWriter } from "../../../src/kernel/ports/file-writer.js"; +import type { Logger } from "../../../src/kernel/ports/logger.js"; +import type { VersionReader } from "../../../src/kernel/ports/version-reader.js"; +import { CheckUpdateUseCase } from "../../../src/runtime/self-update/check-update-use-case.js"; +import type { SelfUpdater } from "../../../src/runtime/self-update/self-updater.js"; const CACHE_PATH_SUFFIX = "update-check.json"; @@ -52,8 +52,9 @@ function makeFsStub(store: Map = new Map()): FileReader & FileWr createDirectory: async () => {}, deleteEmptyDirectories: async () => {}, deleteDirectory: async () => {}, - chmodExecutable: async () => {}, isExecutable: async () => false, + realpath: async (path: string) => path, + chmodExecutable: async () => {}, }; } @@ -81,7 +82,7 @@ describe("CheckUpdateUseCase", () => { makeFsStub(store) ).printFromCacheOnly(); expect(logs.some((l) => l.includes("CLI update available"))).toBe(true); - expect(logs.some((l) => l.includes("aidd self-update"))).toBe(true); + expect(logs.some((l) => l.includes("aidd update"))).toBe(true); }); it("stays silent when CLI version matches latest in cache", async () => { diff --git a/cli/tests/runtime/self-update/current-version-adapter.integration.test.ts b/cli/tests/runtime/self-update/current-version-adapter.integration.test.ts new file mode 100644 index 000000000..f876d25f5 --- /dev/null +++ b/cli/tests/runtime/self-update/current-version-adapter.integration.test.ts @@ -0,0 +1,9 @@ +import { describe, expect, it } from "vitest"; +import { CurrentVersionAdapter } from "../../../src/runtime/self-update/current-version-adapter.js"; + +describe("CurrentVersionAdapter", () => { + it("returns the bundled package version", () => { + const adapter = new CurrentVersionAdapter(); + expect(adapter.get()).toMatch(/^\d+\.\d+\.\d+/); + }); +}); diff --git a/cli/tests/infrastructure/adapters/github-release-resolver-adapter.integration.test.ts b/cli/tests/runtime/self-update/github-release-resolver-adapter.integration.test.ts similarity index 88% rename from cli/tests/infrastructure/adapters/github-release-resolver-adapter.integration.test.ts rename to cli/tests/runtime/self-update/github-release-resolver-adapter.integration.test.ts index a18f7f981..0a2aafc15 100644 --- a/cli/tests/infrastructure/adapters/github-release-resolver-adapter.integration.test.ts +++ b/cli/tests/runtime/self-update/github-release-resolver-adapter.integration.test.ts @@ -3,24 +3,23 @@ import { AuthenticationError, CatalogFetchAuthError, CatalogFetchError, -} from "../../../src/domain/errors.js"; -import { GitHubReleaseResolverAdapter } from "../../../src/infrastructure/adapters/github-release-resolver-adapter.js"; -import { HttpNotFoundError } from "../../../src/infrastructure/errors.js"; -import type { HttpGet } from "../../../src/infrastructure/http/http-client.js"; - -/** A real HttpGet whose one method is a spy, so a call can be both made and asserted. */ -type SpyingHttp = HttpGet & { get: ReturnType }; + HttpNotFoundError, +} from "../../../src/kernel/errors.js"; +import type { HttpGet } from "../../../src/runtime/http/http-client.js"; +import { GitHubReleaseResolverAdapter } from "../../../src/runtime/self-update/github-release-resolver-adapter.js"; const REPO = "owner/repo"; -function makeHttp(body: unknown, statusCode = 200): SpyingHttp { - return { - get: vi.fn().mockResolvedValue({ body, statusCode, contentType: "application/json" }), - }; +function makeHttp(body: unknown, statusCode = 200) { + const get = vi + .fn() + .mockResolvedValue({ body, statusCode, contentType: "application/json" }); + return { get } satisfies HttpGet; } -function makeHttpThrowing(err: Error): SpyingHttp { - return { get: vi.fn().mockRejectedValue(err) }; +function makeHttpThrowing(err: Error) { + const get = vi.fn().mockRejectedValue(err); + return { get } satisfies HttpGet; } describe("GitHubReleaseResolverAdapter", () => { diff --git a/cli/tests/application/use-cases/self-update-use-case.unit.test.ts b/cli/tests/runtime/self-update/self-update-use-case.unit.test.ts similarity index 93% rename from cli/tests/application/use-cases/self-update-use-case.unit.test.ts rename to cli/tests/runtime/self-update/self-update-use-case.unit.test.ts index 53106a012..f1430faaa 100644 --- a/cli/tests/application/use-cases/self-update-use-case.unit.test.ts +++ b/cli/tests/runtime/self-update/self-update-use-case.unit.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it, vi } from "vitest"; -import { SelfUpdateUseCase } from "../../../src/application/use-cases/self-update-use-case.js"; -import type { SelfUpdater } from "../../../src/domain/ports/self-updater.js"; -import type { VersionReader } from "../../../src/domain/ports/version-reader.js"; +import type { VersionReader } from "../../../src/kernel/ports/version-reader.js"; +import { SelfUpdateUseCase } from "../../../src/runtime/self-update/self-update-use-case.js"; +import type { SelfUpdater } from "../../../src/runtime/self-update/self-updater.js"; function makeUseCase( currentVersion: string, diff --git a/cli/tests/infrastructure/adapters/self-updater-adapter.integration.test.ts b/cli/tests/runtime/self-update/self-updater-adapter.integration.test.ts similarity index 93% rename from cli/tests/infrastructure/adapters/self-updater-adapter.integration.test.ts rename to cli/tests/runtime/self-update/self-updater-adapter.integration.test.ts index b9ece785a..24ef5a2c9 100644 --- a/cli/tests/infrastructure/adapters/self-updater-adapter.integration.test.ts +++ b/cli/tests/runtime/self-update/self-updater-adapter.integration.test.ts @@ -1,14 +1,13 @@ import { execSync } from "node:child_process"; import { platform } from "node:os"; import { beforeEach, describe, expect, it, vi } from "vitest"; -import { FrameworkResolutionError } from "../../../src/domain/errors.js"; -import { SelfUpdaterAdapter } from "../../../src/infrastructure/adapters/self-updater-adapter.js"; -import { HttpNotFoundError } from "../../../src/infrastructure/errors.js"; +import { FrameworkResolutionError, HttpNotFoundError } from "../../../src/kernel/errors.js"; import { HttpClient, type HttpGet, type HttpResponse, -} from "../../../src/infrastructure/http/http-client.js"; +} from "../../../src/runtime/http/http-client.js"; +import { SelfUpdaterAdapter } from "../../../src/runtime/self-update/self-updater-adapter.js"; interface GetCall { url: string; @@ -35,7 +34,9 @@ function jsonResponse(body: unknown): HttpResponse { } const NPM_DIST_TAGS_URL = "https://registry.npmjs.org/-/package/@ai-driven-dev/cli/dist-tags"; -const GH_TAG_URL = "https://api.github.com/repos/ai-driven-dev/aidd-cli/releases/tags/v5.1.2"; +// The repository this package lives in, and the tag release-please gives it: +// `include-component-in-tag` makes it `cli-v`, never a bare `v`. +const GH_TAG_URL = "https://api.github.com/repos/ai-driven-dev/framework/releases/tags/cli-v5.1.2"; vi.mock("node:child_process", () => ({ execSync: vi.fn() })); vi.mock("node:os", () => ({ platform: vi.fn() })); @@ -49,8 +50,8 @@ function makeAdapter(): SelfUpdaterAdapter { function mockInstall(whichOutput: string, os: "win32" | "linux" | "darwin" = "linux"): void { mockPlatform.mockReturnValue(os); - // which/where is read with `encoding: "utf8"` (a string); the install call runs with - // piped stdio and yields a Buffer. Both are what execSync really returns. + // which/where is read with `encoding: "utf8"` and answers a string; the install call runs + // piped and answers a Buffer. Both are what execSync really returns. mockExecSync.mockReturnValueOnce(whichOutput).mockReturnValue(Buffer.alloc(0)); } diff --git a/cli/tests/runtime/smoke-harness-isolation.unit.test.ts b/cli/tests/runtime/smoke-harness-isolation.unit.test.ts new file mode 100644 index 000000000..c6f54adec --- /dev/null +++ b/cli/tests/runtime/smoke-harness-isolation.unit.test.ts @@ -0,0 +1,75 @@ +import { readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { describe, expect, it } from "vitest"; + +const harness = readFileSync( + fileURLToPath(new URL("../../scripts/smoke-tools.sh", import.meta.url)), + "utf8" +); + +/** Three of the tools the smoke harness drives activate plugins through their own CLI, + * which writes into the *user's* home, never the project directory. */ +describe("the smoke harness never runs against the real user home", () => { + it("gives every case a home under its own temporary root", () => { + expect(harness).toMatch(/export HOME="\$TMPROOT\/[^"]+"/u); + }); + + // `HOME` does not isolate Codex: it reads `CODEX_HOME`, and falls back to the real + // `~/.codex` when that is unset. + it("gives Codex its own home too, which HOME alone does not move", () => { + expect(harness).toMatch(/export CODEX_HOME="\$TMPROOT\/[^"]+"/u); + }); + + // `find` returns directory order, which is neither sorted nor stable across filesystems, + // so `find … | head -1` on a tree of several files runs a different case on every machine. + it("picks the file a case damages in a fixed order, never whatever find returns first", () => { + const unsorted = [...harness.matchAll(/find [^\n|]*\|[ \t]*head\b/gu)].map((m) => m[0]); + + expect(unsorted).toEqual([]); + + // The fixed order comes from `tracked_file`'s own `.sort()`. Neither line above calls + // `find`, so its removal would fall back to manifest order silently. + expect(harness).toMatch(/relativePath\)\)\.sort\(\)/u); + + // Two functions reading the manifest to pick a file is two sources of truth for the + // same question. `tracked_file` must be the only one left. + expect(harness.match(/manifest\.json/gu)?.length ?? 0).toBe(1); + }); + + // The token is resolved through `gh`, which reads the real home: the sandbox must be + // exported after that line and before the first case that runs. + it("resolves the token before moving home, and moves it before the first case", () => { + const token = harness.indexOf("gh auth token"); + const home = harness.search(/export HOME="\$TMPROOT/u); + const firstCase = harness.indexOf("section "); + + expect(token).toBeGreaterThan(-1); + expect(home).toBeGreaterThan(token); + expect(home).toBeLessThan(firstCase); + }); +}); + +/** A `restore --force` that returns 0 having restored nothing is the failure this covers, + * and an exit code alone is not a repair. */ +describe("a smoke case that damages a file checks the damage was undone", () => { + it("marks the drift it writes, so the check can name what it is looking for", () => { + expect(harness).toContain("SMOKE_DRIFT"); + }); + + it("looks for that mark again after every run that follows a planted drift", () => { + // Pairing the check to the run that follows the planting, rather than to a verb, is what + // survives the verb being renamed. + const lines = harness.split("\n"); + const planted = lines + .map((line, index) => (/"\$DRIFT_MARK" >> /u.test(line) ? index : -1)) + .filter((index) => index >= 0); + const runsAfterPlanting = planted.map((index) => { + const next = lines.slice(index + 1).find((line) => /^\s*run "/u.test(line)); + return /run "([^"]+)"/u.exec(next ?? "")?.[1]; + }); + const checks = [...harness.matchAll(/repaired "([^"]+)"/gu)].map((match) => match[1]); + + expect(runsAfterPlanting.length).toBeGreaterThan(0); + expect(checks.sort()).toEqual([...runsAfterPlanting].sort()); + }); +}); diff --git a/cli/tests/runtime/user-config-dir.unit.test.ts b/cli/tests/runtime/user-config-dir.unit.test.ts new file mode 100644 index 000000000..9356693b8 --- /dev/null +++ b/cli/tests/runtime/user-config-dir.unit.test.ts @@ -0,0 +1,46 @@ +import { homedir } from "node:os"; +import { join } from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { userConfigDir } from "../../src/runtime/user-config-dir.js"; + +const ENV_KEYS = ["AIDD_USER_CONFIG_DIR", "XDG_CONFIG_HOME"] as const; + +function saveEnv(): Record<(typeof ENV_KEYS)[number], string | undefined> { + return Object.fromEntries(ENV_KEYS.map((k) => [k, process.env[k]])) as Record< + (typeof ENV_KEYS)[number], + string | undefined + >; +} + +function restoreEnv(saved: Record<(typeof ENV_KEYS)[number], string | undefined>): void { + for (const key of ENV_KEYS) { + if (saved[key] === undefined) delete process.env[key]; + else process.env[key] = saved[key]; + } +} + +describe("userConfigDir", () => { + const saved = saveEnv(); + afterEach(() => restoreEnv(saved)); + + it("honors XDG_CONFIG_HOME when AIDD_USER_CONFIG_DIR is unset", () => { + delete process.env.AIDD_USER_CONFIG_DIR; + process.env.XDG_CONFIG_HOME = "/xdg/config"; + + expect(userConfigDir()).toBe(join("/xdg/config", "aidd")); + }); + + it("prefers AIDD_USER_CONFIG_DIR over XDG_CONFIG_HOME", () => { + process.env.AIDD_USER_CONFIG_DIR = "/custom/aidd"; + process.env.XDG_CONFIG_HOME = "/xdg/config"; + + expect(userConfigDir()).toBe("/custom/aidd"); + }); + + it("falls back to ~/.config/aidd when neither is set", () => { + delete process.env.AIDD_USER_CONFIG_DIR; + delete process.env.XDG_CONFIG_HOME; + + expect(userConfigDir()).toBe(join(homedir(), ".config", "aidd")); + }); +}); diff --git a/cli/tests/infrastructure/framework-build-force.integration.test.ts b/cli/tests/runtime/wiring/framework-build-force.integration.test.ts similarity index 85% rename from cli/tests/infrastructure/framework-build-force.integration.test.ts rename to cli/tests/runtime/wiring/framework-build-force.integration.test.ts index b3437c07f..e55656f40 100644 --- a/cli/tests/infrastructure/framework-build-force.integration.test.ts +++ b/cli/tests/runtime/wiring/framework-build-force.integration.test.ts @@ -2,15 +2,15 @@ import { mkdtemp, rm } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join, resolve } from "node:path"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; -import { FlatTargetExistsError } from "../../src/domain/errors.js"; -import { BundledAssetProviderAdapter } from "../../src/infrastructure/assets/asset-loader.js"; +import { FlatTargetExistsError } from "../../../src/kernel/errors.js"; +import { BundledAssetProviderAdapter } from "../../../src/runtime/assets/asset-loader.js"; import { createFrameworkBuildUseCase, type FrameworkBuildDeps, -} from "../../src/infrastructure/deps.js"; -import { CapturingLogger } from "../helpers/ports/capturing-logger.js"; -import { InMemoryFileAdapter } from "../helpers/ports/in-memory-file-adapter.js"; -import { seedFromDirectory } from "../helpers/ports/seed-from-directory.js"; +} from "../../../src/runtime/wiring/translate.js"; +import { CapturingLogger } from "../../helpers/ports/capturing-logger.js"; +import { InMemoryFileAdapter } from "../../helpers/ports/in-memory-file-adapter.js"; +import { seedFromDirectory } from "../../helpers/ports/seed-from-directory.js"; const FIXTURE_DIR = resolve(process.cwd(), "tests/fixtures/framework"); // Canonical flat destination for the fixture's "aidd-test" plugin agent, under --target copilot. diff --git a/cli/tests/runtime/wiring/framework-build-registry.unit.test.ts b/cli/tests/runtime/wiring/framework-build-registry.unit.test.ts new file mode 100644 index 000000000..a3e480845 --- /dev/null +++ b/cli/tests/runtime/wiring/framework-build-registry.unit.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, it } from "vitest"; +import type { FrameworkBuildMode } from "../../../src/contexts/tools/domain/registry.js"; +import { + type FrameworkBuildTarget, + frameworkBuildTargetModes, +} from "../../../src/contexts/translate/domain/build-target.js"; +import { AI_TOOL_IDS } from "../../../src/kernel/tool.js"; +import { BundledAssetProviderAdapter } from "../../../src/runtime/assets/asset-loader.js"; +import { createFrameworkBuildUseCase } from "../../../src/runtime/wiring/translate.js"; +import { CapturingLogger } from "../../helpers/ports/capturing-logger.js"; +import { InMemoryFileAdapter } from "../../helpers/ports/in-memory-file-adapter.js"; + +const ALL_TARGETS: readonly FrameworkBuildTarget[] = AI_TOOL_IDS; +const ALL_MODES: readonly FrameworkBuildMode[] = ["marketplace", "flat"]; + +function makeDeps() { + return { + fs: new InMemoryFileAdapter(), + assetProvider: new BundledAssetProviderAdapter(), + logger: new CapturingLogger(), + }; +} + +function isSupported(target: FrameworkBuildTarget, mode: FrameworkBuildMode): boolean { + return frameworkBuildTargetModes().some((e) => e.target === target && e.mode === mode); +} + +/** + * Both the wiring and the domain read the pairs off the profiles and cannot disagree on which + * exist; what is worth running is that each resolves to a use case the wiring can construct. + */ +describe("every announced build target/mode resolves to a wired use case", () => { + for (const target of ALL_TARGETS) { + for (const mode of ALL_MODES) { + const label = `${target}:${mode}`; + const expected = isSupported(target, mode); + + it(`${label} is ${expected ? "" : "NOT "}wired in the registry, matching the domain list`, () => { + const useCase = createFrameworkBuildUseCase(makeDeps(), { + target, + mode, + outDir: "/out", + force: false, + }); + expect(useCase !== undefined).toBe(expected); + }); + } + } +}); diff --git a/cli/tsconfig.json b/cli/tsconfig.json index e74348b9f..9fdd19ab2 100644 --- a/cli/tsconfig.json +++ b/cli/tsconfig.json @@ -21,6 +21,6 @@ "@/*": ["./src/*"] } }, - "include": ["src/**/*", "tests/**/*", "../kanban/src/**/*"], + "include": ["src/**/*", "tests/**/*"], "exclude": ["node_modules", "dist"] } diff --git a/cli/tsup.config.ts b/cli/tsup.config.ts index a0e71afc1..2639b2acf 100644 --- a/cli/tsup.config.ts +++ b/cli/tsup.config.ts @@ -1,17 +1,57 @@ import { copyFileSync } from "node:fs"; +import { join, resolve, sep } from "node:path"; +import { fileURLToPath } from "node:url"; import { defineConfig } from "tsup"; +/** + * Where the build lands. `dist` normally; each e2e run passes its own directory under + * `.e2e-build/` (`tests/e2e/global-setup.ts`) so two concurrent vitest invocations never + * share, and race to rewrite, one `dist/cli.js`. + * + * Those two are the whole legitimate set, and anything else is refused rather than + * trusted. `clean: true` empties the target before building, so an out dir pointed at a + * directory holding anything else destroys its contents — silently, exiting 0. And a + * directory outside this package could not produce a working binary anyway: + * `skipNodeModulesBundle` leaves every dependency an external import that Node resolves + * by walking up from the built file, so only somewhere under `cli/` finds + * `cli/node_modules`. Refusing here turns both into an error that says so. + */ +const PACKAGE_ROOT = fileURLToPath(new URL(".", import.meta.url)); +const E2E_BUILD_ROOT = resolve(PACKAGE_ROOT, ".e2e-build"); + +function resolveOutDir(): string { + const requested = process.env.AIDD_BUILD_OUT_DIR; + if (requested === undefined) return "dist"; + + const absolute = resolve(PACKAGE_ROOT, requested); + if (absolute === resolve(PACKAGE_ROOT, "dist")) return requested; + if (absolute.startsWith(`${E2E_BUILD_ROOT}${sep}`)) return requested; + + throw new Error( + `AIDD_BUILD_OUT_DIR must be this package's "dist" or a directory under ".e2e-build/", ` + + `and was "${requested}". The build empties its target before writing, and a binary ` + + `built outside this package cannot resolve its dependencies.` + ); +} + +const outDir = resolveOutDir(); + export default defineConfig({ entry: { cli: "src/cli.ts" }, format: ["esm"], target: "node20", - outDir: "dist", + outDir, clean: true, banner: { js: "#!/usr/bin/env node", }, sourcemap: false, dts: false, + // Off: nothing in the bundle defers a heavy import any more. It was on for kanban's two + // views, which loaded their text interface — ink, react, cli-table3 — only when the + // command ran; with splitting off esbuild folds such an import back into a static one and + // the deferral is lost in silence. That command is gone, and the build produces one file + // either way. Turn this back on before adding a dynamic import worth deferring. splitting: false, shims: false, skipNodeModulesBundle: true, @@ -27,20 +67,23 @@ export default defineConfig({ async onSuccess() { copyFileSync( "assets/schemas/claude-code-plugin-manifest.json", - "dist/claude-code-plugin-manifest.json" + join(outDir, "claude-code-plugin-manifest.json") ); copyFileSync( "assets/schemas/copilot-plugin-marketplace.json", - "dist/copilot-plugin-marketplace.json" + join(outDir, "copilot-plugin-marketplace.json") ); copyFileSync( "assets/schemas/claude-marketplace-manifest.json", - "dist/claude-marketplace-manifest.json" + join(outDir, "claude-marketplace-manifest.json") + ); + copyFileSync( + "assets/schemas/codex-plugin-manifest.json", + join(outDir, "codex-plugin-manifest.json") ); - copyFileSync("assets/schemas/codex-plugin-manifest.json", "dist/codex-plugin-manifest.json"); copyFileSync( "assets/schemas/codex-marketplace-manifest.json", - "dist/codex-marketplace-manifest.json" + join(outDir, "codex-marketplace-manifest.json") ); }, }); diff --git a/cli/vitest.config.ts b/cli/vitest.config.ts index 0e60edd4a..e62ddcea6 100644 --- a/cli/vitest.config.ts +++ b/cli/vitest.config.ts @@ -13,17 +13,34 @@ export default defineConfig({ provider: "v8", reporter: ["text", "json-summary"], include: ["src/**/*.ts"], + // Excluded because measuring them here would report a false zero, not because + // they are untested. `cli.ts` and `presentation/commands/` are exercised by 104 + // e2e tests and 98 smoke checks, but both spawn a built `cli.js` as a subprocess + // and v8 coverage does not cross a process boundary: including them reports 0%. + // Ports are interfaces with no runtime body; `runtime/wiring/` is composition. + // Their real net is the e2e suite and scripts/smoke-tools.sh, counted there. + // + // Three of these globs still named their pre-refactor locations, under the layer + // directories the contexts replaced. They excluded nothing, so the files this comment + // argues must not be counted were counted, and the thresholds below sat about a point + // from failing for a reason nobody intended. `referenced-paths.arch.test.ts` now reads + // this file, so a glob cannot outlive the directory it points at again. exclude: [ "src/cli.ts", - "src/application/commands/**", - "src/domain/ports/**", - "src/infrastructure/deps.ts", + "src/presentation/commands/**", + "src/kernel/ports/**", + "src/contexts/*/domain/ports/**", + "src/runtime/wiring/**", ], + // Measured 93.76 / 89.31 / 94.49 / 93.76 once the stale globs above were repointed. + // Set a little under that: a threshold with no headroom fails on an honest change, + // and one far below what is measured is not a gate. `pnpm test:coverage` runs them, + // and CI runs that — until this commit the numbers were configured and never executed. thresholds: { - statements: 85, - branches: 80, - functions: 90, - lines: 85, + statements: 92, + branches: 87, + functions: 93, + lines: 92, }, }, }, diff --git a/cli/vitest.mutation.config.ts b/cli/vitest.mutation.config.ts new file mode 100644 index 000000000..7d914d51f --- /dev/null +++ b/cli/vitest.mutation.config.ts @@ -0,0 +1,44 @@ +import { defineConfig } from "vitest/config"; +import { textLoader } from "./tests/helpers/vitest-text-loader.js"; + +const TEXT_EXTENSIONS = [".md", ".toml"] as const; + +/** + * The projects a mutation run may use: the two that measure behaviour. + * + * The architecture ratchets read the source tree as text — folder sizes, cited paths, + * the import graph. Stryker works on a copy of that tree with a mutant injected, so those + * tests answer a question about the sandbox rather than about the code, and they fail the + * initial run before a single mutant is tried. + * + * The e2e project is left out for the opposite reason: it spawns the built binary, which + * no mutant reaches, so every mutant would survive it and dilute the score with noise. + * + * A plain `test.exclude` does not do this. The workspace file defines the projects, and + * it wins over a config passed with `--config`; only another workspace replaces it. + */ +export default defineConfig({ + test: { + projects: [ + { + plugins: [textLoader(TEXT_EXTENSIONS)], + test: { + name: "unit", + include: ["tests/**/*.unit.test.ts"], + globals: false, + environment: "node", + }, + }, + { + plugins: [textLoader(TEXT_EXTENSIONS)], + test: { + name: "integration", + include: ["tests/**/*.integration.test.ts"], + globals: false, + environment: "node", + testTimeout: 60000, + }, + }, + ], + }, +}); diff --git a/cli/vitest.workspace.ts b/cli/vitest.workspace.ts index 9a42abcbd..0236f30ca 100644 --- a/cli/vitest.workspace.ts +++ b/cli/vitest.workspace.ts @@ -14,6 +14,15 @@ export default defineWorkspace([ globalSetup: ["./tests/helpers/sweep-stale-temp-dirs.ts"], }, }, + { + plugins: [textLoader(TEXT_EXTENSIONS)], + test: { + name: "architecture", + include: ["tests/architecture/**/*.arch.test.ts"], + globals: false, + environment: "node", + }, + }, { plugins: [textLoader(TEXT_EXTENSIONS)], test: { @@ -33,7 +42,7 @@ export default defineWorkspace([ globals: false, environment: "node", testTimeout: 60000, - globalSetup: ["./tests/helpers/sweep-stale-temp-dirs.ts"], + globalSetup: ["./tests/e2e/global-setup.ts", "./tests/helpers/sweep-stale-temp-dirs.ts"], }, }, ]); diff --git a/commitlint.config.cjs b/commitlint.config.cjs index 9db61b011..2a6a85071 100644 --- a/commitlint.config.cjs +++ b/commitlint.config.cjs @@ -1,15 +1,12 @@ module.exports = { extends: ["@commitlint/config-conventional"], rules: { - // Type validated by config-conventional (feat, fix, chore, docs, refactor, perf, test, build, ci, revert, style). - // Scope is optional. When provided, must be kebab-case. Known scopes below are encouraged but not required - // (the rule level is `warning` = 1, so a non-listed scope is reported but does NOT block the commit). + // scope-enum sits at level 1: an unlisted scope warns, it never blocks the commit. "scope-case": [2, "always", "kebab-case"], "scope-enum": [ 1, "always", [ - // Plugin scopes (long + short forms) "aidd-context", "aidd-dev", "aidd-vcs", @@ -24,19 +21,22 @@ module.exports = { "refine", "orchestrator", "ui", - // CLI scope + "aidd-telemetry", + "telemetry", "cli", - // Root scopes (touching marketplace.json or framework-wide config) + "kanban", "framework", "marketplace", - // Tooling & infra scopes "release-please", + "release", "ci", "deps", + "deps-dev", "lefthook", "commitlint", "contributing", "docs", + "readme", "security", "test", ], diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index c12aa3feb..1b0f28551 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -50,7 +50,7 @@ A hook is authored once, with `${CLAUDE_PLUGIN_ROOT}`, and the installer rewrite | Codex | yes | `${PLUGIN_ROOT}` | Measured: it expands `${CLAUDE_PLUGIN_ROOT}` too, and will not run a hook it has not been asked to trust | | GitHub Copilot| yes | `${PLUGIN_ROOT}` | Declared, never observed against a running hook | | Cursor | declared | `./` | Its own hook format: the converter rewrites the root to a path relative to the plugin before the declared token is ever substituted. Two headless probes fired no plugin hook at all, and what registers a plugin sitting in Cursor's own plugin directory was not identified | -| OpenCode | no, by a second route | — | A declarative `hooks.json` means nothing to it — its plugin runtime is JS modules, so it joins through one instead: `plugins/aidd-telemetry/hooks/opencode-plugin.js` maps `session.created` to session-start, `session.idle` to turn-end, and (2026-08-31) a completed tool part on `message.part.updated` to tool-used. The column above is about the declarative axis alone; a tool answering `no` there is not a tool that cannot journal | +| OpenCode | no, by a second route | — | A declarative `hooks.json` means nothing to it — its plugin runtime is JS modules, so every other plugin's `hooks.json` is translated into one at build time (`opencode-hooks-bridge.ts`, one generated `-hooks.js` per plugin, `SessionStart`/`Stop`/`PostToolUse` only). `aidd-telemetry` ships its own hand-written entry instead (`plugins/aidd-telemetry/hooks/opencode-plugin.js`, no generated bridge for it) because its journal needs a stdin dialect the generated one does not speak: `session.created` maps to session-start, `session.idle` to turn-end, and (2026-08-31) a completed tool part on `message.part.updated` to tool-used. The column above is about the declarative axis alone; a tool answering `no` there is not a tool that cannot journal | A tool that runs no hook says why, and an install that carries one tells whoever ran it what was skipped. @@ -99,7 +99,7 @@ Every capability lives in exactly one plugin, chosen by **concern**. This taxono `aidd-ui` is alpha: smoke-test only, off the curated install path. -`aidd-telemetry` is beta, off the curated install path: opt-in only — a repository must commit `.aidd/config.json` with `telemetry.enabled: true`. Each session appends observations, one JSON object per line, to its own `aidd_docs/runs/__.jsonl`, created on demand and git-ignored; that directory's presence is a location, not a permission. A line is never rewritten, only appended — `session_start`, `turn_end`, and `file_written` (a repository-relative path, never a task_id: task identity is a derivation, and belongs to whatever reads the log). Never a measurement; tokens and cost are joined afterwards from the provider's telemetry. +`aidd-telemetry` is beta, off the curated install path: opt-in only — a repository must commit `.aidd/config.json` with `telemetry.enabled: true`. Each session appends observations, one JSON object per line, to its own `aidd_docs/runs/__.jsonl`, created on demand and git-ignored; that directory's presence is a location, not a permission. A line is never rewritten, only appended — `session_start`, `turn_end`, `file_written`, `step_start`, `step_end`, `task_declared` and `unrecognised_payload` (a path is repository-relative, never a task_id: task identity is a derivation, and belongs to whatever reads the log). Never a measurement; tokens and cost are joined afterwards from the provider's telemetry. **Observation** writes only *about* the other layers, never the artifact it describes, and nothing may depend on it. diff --git a/docs/CREATE_PLUGIN.md b/docs/CREATE_PLUGIN.md index d2d1670eb..8d3e6ceb5 100644 --- a/docs/CREATE_PLUGIN.md +++ b/docs/CREATE_PLUGIN.md @@ -11,7 +11,7 @@ flowchart LR ## 🏗️ Scaffold -Pick a name (lowercase, `aidd-`). For the directory shape, `plugin.json`, and `SKILL.md`/action format, follow [Anatomy of a plugin](ARCHITECTURE.md#-anatomy-of-a-plugin). Minimum: `.claude-plugin/plugin.json` + `skills/-/` (with `SKILL.md` + `actions/`). +Pick a name (lowercase, `aidd-`). For the directory shape, `plugin.json`, and `SKILL.md`/action format, follow [Anatomy of a plugin](ARCHITECTURE.md#-anatomy-of-a-plugin). Minimum: `/.claude-plugin/plugin.json` + `skills/-/` (with `SKILL.md` + `actions/`). ## 📝 Register diff --git a/docs/FAQ.md b/docs/FAQ.md index 4f3db5f07..9b03ff651 100644 --- a/docs/FAQ.md +++ b/docs/FAQ.md @@ -65,8 +65,9 @@ installed. **No prompt, no code, no diff** — the stored shape is an allowlist, field by field, in [`metrics-contract.md`](../aidd_docs/product/metrics-contract.md). -Turning it off stops the recording and keeps what you already measured; both directories are -ordinary files you can delete. Coverage differs per AI tool, and a tool that cannot be +Turning it off stops the recording and keeps what you already measured. `aidd telemetry off` +removes the switch and the commit trailer, `aidd telemetry forget` the records; both +directories, and the `.aidd/config.json` that opted you in, are ordinary files you can delete. Coverage differs per AI tool, and a tool that cannot be measured is named rather than shown as a zero → [the plugin's README](../plugins/aidd-telemetry/README.md). diff --git a/docs/MAINTAINERS.md b/docs/MAINTAINERS.md index f7cf44786..d57d64a3f 100644 --- a/docs/MAINTAINERS.md +++ b/docs/MAINTAINERS.md @@ -13,18 +13,18 @@ How to operate this repository day to day. This file is the **Maintainer** playb | Live backlog & roadmap | [Project board #8](https://github.com/orgs/ai-driven-dev/projects/8) | single source of truth | | Roles → access | GitHub teams `trusted-partners` / `certified-members` / `core-team` | mapped to the role ladder | | Branch protection | ruleset "main protection" + `.github/rulesets/main.json` | `main` is PR-only | -| Releases | release-please (`ci.yml`) + `release-please-config.json` | 8 packages (root + 7 plugins), auto | +| Releases | release-please (`ci.yml`) + `release-please-config.json` | 10 packages (root + 8 plugins + `cli`), auto | | Pre-commit checks | `lefthook.yml` + `scripts/` | json/yaml/schema/frontmatter/catalogs/counts | ## 📅 Daily - **Triage issues.** New issues auto-add to board #8. The form already stamped the type. You give the issue a **milestone** or a **priority**, never both. - **Roadmap.** Priority = the community vote (mechanism in `GOVERNANCE.md`). Accepted items live on board #8 — keep `ROADMAP.md` a pointer, don't maintain a second list. -- **Review PRs.** Approve as CODEOWNERS, then squash-merge (merge policy → [`GOVERNANCE.md`](../GOVERNANCE.md#-code-decisions-merging)). +- **Review PRs.** Approve as CODEOWNERS, then squash-merge (merge policy → [`GOVERNANCE.md`](../GOVERNANCE.md#-code-decisions)). ## 📋 Four axes, one board -Each axis answers one question, and only `Status` lives on the board. Routing (`next`/`main`) is not an axis — it derives from the branch prefix ([routing table](../aidd_docs/memory/vcs.md#types)). +Each axis answers one question, and only `Status` lives on the board. Routing (`next`/`main`) is not an axis — it derives from the branch prefix ([routing table](../aidd_docs/memory/vcs.md#branches)). | Axis | Lives in | Answers | Values | | --- | --- | --- | --- | @@ -68,7 +68,7 @@ release-please opens/updates a `chore: release main` PR on each push to `main`. 3. CI tags each bumped package, creates the GitHub Releases, and attaches the bundles: - `aidd-framework-marketplace-X.Y.Z.zip` (`.claude-plugin/` + `plugins/`) - `-vX.Y.Z.zip` - - `aidd-framework---X.Y.Z.zip` - per-tool distributions (9 archives: 4 marketplace claude/cursor/copilot/codex + 5 flat incl. opencode), produced by the `build-per-tool` matrix job in `ci.yml` via `aidd-cli framework build`. **Pinned** to a specific `@ai-driven-dev/cli` version - bump it deliberately when adopting CLI build changes. + - `aidd-framework---X.Y.Z.zip` - per-tool distributions (9 archives: 4 marketplace claude/cursor/copilot/codex + 5 flat incl. opencode), produced by the `build-per-tool` matrix job in `ci.yml`. It builds the CLI from this run's own checkout and runs `translate --to --out --as ` — no published-version pin to bump. Versions live in `.release-please-manifest.json`. Forcing a version / pre-release: `release-as` in `release-please-config.json` (remove it after the release ships). @@ -76,7 +76,7 @@ Versions live in `.release-please-manifest.json`. Forcing a version / pre-releas The weekly `next` → `main` promotion **must be a merge commit, never a squash and never a rebase**: -- A squash collapses the batch's conventional commits into one subject from the PR title. If that title isn't a valid conventional type, `Commitlint` fails on `main` and **release-please is skipped** — no release. release-please also reads each commit's type/scope to bump the right package, which a squash hides. +- A squash collapses the batch's conventional commits into one subject from the PR title. If that title isn't a valid conventional type, `Commitlint` fails on `main` and **release-please is skipped** — no release. `ci.yml` now lints the PR title itself on every pull request, the promote PR included, so a bad subject fails the promote PR's own `Commitlint` check before merge; the recovery below is the fallback, not the expected path. release-please attributes each commit to a package by the **path it touched** (`release-please-config.json`'s `packages` map), not by its scope, and a squash hides which paths the batch actually touched. - A rebase keeps the commits but recopies them under new hashes, so git never records that the branches were reconciled. The merge base between `main` and `next` then goes stale, and every later back-merge conflicts on the release metadata release-please rewrites each time — a conflict with no real content behind it. - A merge commit does both jobs: the commits land verbatim, and its second parent keeps a shared merge base so the back-merge stays clean. - Use the **Promote next to main** workflow (it merges); merging by hand, pick **Create a merge commit** and give it a conventional subject. @@ -96,11 +96,11 @@ The weekly `next` → `main` promotion **must be a merge commit, never a squash ## 🔒 Branch protection & the bot bypass -The protection policy (PR-only, CODEOWNERS review, required checks) is defined in [`GOVERNANCE.md`](../GOVERNANCE.md#-code-decisions-merging); this is the ops. +The protection policy (PR-only, CODEOWNERS review, required checks) is defined in [`GOVERNANCE.md`](../GOVERNANCE.md#-code-decisions); this is the ops. Two bypass actors (both `pull_request` mode, so neither can push directly to `main`): - the **aidd-bot GitHub App** (`Integration`) - release-please and the Dependabot auto-merge mint a token from it (`actions/create-github-app-token`), so their PRs trigger the required checks *and* the App merges them past the human-review rule. -- the **`admin` team** - lead maintainers can merge their own PR without a second review. Everyone else needs a code-owner review. +- the org's **`admin` team** (team id `11783938`, the actor the rulesets record) - lead maintainers can merge their own PR without a second review. Everyone else needs a code-owner review. It is a separate team from the three `GOVERNANCE.md` maps roles to, and confers no role of its own. The App: ID in secret `AIDD_BOT_APP_ID`, key in `AIDD_BOT_PRIVATE_KEY`. If the App is broken/uninstalled, release and Dependabot PRs stop merging - fix the App rather than re-adding an admin bypass. @@ -110,11 +110,14 @@ Head branches are **not** auto-deleted on merge (`delete_branch_on_merge: false` - The back-merge runs unattended (bot App `always` bypass on the `next` ruleset). If it can't push, it opens a tracking issue — resync with a `main` → `next` PR. - If `next` is ever missing, recreate it: `git push origin main:next`. -To change protection, edit `.github/rulesets/main.json` (or `next.json`), then apply it live: +To change protection, edit `.github/rulesets/main.json` (or `next.json`) first — it is the +source, the live ruleset is applied from it, never the other way round. An admin then applies +it, either through `gh api` or the UI: ```bash gh api -X PUT repos/ai-driven-dev/framework/rulesets/ --input .github/rulesets/main.json ``` -Keep the file and the live ruleset in sync. +or Settings → Rules → Rulesets → edit the ruleset by hand to match the file. Keep the file and +the live ruleset in sync either way. ## 👥 People @@ -134,6 +137,7 @@ Roles, promotion, and inactivity rules → [`GOVERNANCE.md`](../GOVERNANCE.md#-r - **README counts** — the hero `N plugins · N skills · N agents` block (between the `counts:start`/`counts:end` markers) and each per-plugin `N skills` span — auto-generated by `scripts/sync-readme-counts.mjs` via lefthook. - **Per-plugin `CATALOG.md`** — auto-generated by `scripts/summarize-markdown.js` via lefthook. - **README contributors mosaic** — the contrib.rocks image updates itself. +- **`docs/prompts-documentation.md`** — auto-generated by `scripts/summarize-markdown.js` via lefthook. ## 🛠️ Multi-tool diff --git a/docs/MARKETPLACE.md b/docs/MARKETPLACE.md index 560fab5e5..ffe728c67 100644 --- a/docs/MARKETPLACE.md +++ b/docs/MARKETPLACE.md @@ -35,7 +35,7 @@ Set scope at install time via the `/plugin` UI, or edit `enabledPlugins` directl ## 🔖 Versioning & updates -- Each plugin and the root marketplace version independently via `release-please` (tags `-vX.Y.Z`, root `vX.Y.Z`). Tooling → [`vcs.md`](../aidd_docs/memory/vcs.md#release-management). +- Each plugin and the root marketplace version independently via `release-please` (tags `-vX.Y.Z`, root `vX.Y.Z`). Tooling → [`deployment.md`](../aidd_docs/memory/deployment.md). - Pull updates inside Claude Code: `/plugin marketplace update aidd-framework`. - Full history → [`CHANGELOG.md`](../CHANGELOG.md). diff --git a/lefthook.yml b/lefthook.yml index f48509960..483fb4f90 100644 --- a/lefthook.yml +++ b/lefthook.yml @@ -22,17 +22,23 @@ pre-commit: fi node scripts/validate-yaml.mjs {files} scripts-tests: - glob: "{scripts,plugins/aidd-context/hooks}/**" + glob: "{scripts,plugins,cli/src}/**" run: | if ! command -v node >/dev/null 2>&1; then echo "ℹ️ node not available; skipping scripts-tests" exit 0 fi - # Wrapped, because a suite that writes into this repository's own .git/hooks passes - # every assertion and destroys an install nothing can restore — `.git` is in no - # history. The wrapper passes the suite's own exit code through untouched. + # `node --test ` fails on a directory, so a glob is required — and a glob + # matching nothing exits 0 in silence, so a renamed folder must be caught by a count. + found=$(ls scripts/__tests__/*.test.js 2>/dev/null | wc -l | tr -d ' ') + if [ "$found" -eq 0 ]; then + echo "❌ no test files under scripts/__tests__/; the runner is not wired" + exit 1 + fi + # Wrapped: a suite writing into this repository's own .git/hooks passes every + # assertion and destroys an install nothing can restore — `.git` is in no history. node scripts/check-tests-leave-git-alone.js -- \ - node --test 'scripts/__tests__/*.test.js' + node --test 'scripts/__tests__/**/*.test.js' skill-frontmatter: glob: "plugins/*/skills/*/SKILL.md" run: | @@ -56,8 +62,7 @@ pre-commit: fi node scripts/check-skill-argument-hints.mjs context-imports: - # Two patterns, because "**/" needs a literal slash and so never matches - # a file at the repository root. + # Two patterns: "**/" needs a literal slash, so it never matches a repository-root file. glob: - "{CLAUDE,AGENTS,copilot-instructions}.md" - "**/{CLAUDE,AGENTS,copilot-instructions}.md" @@ -68,9 +73,8 @@ pre-commit: fi node scripts/check-context-imports.js context-reference-form: - # The whole repository's answer, not the staged files': the hook rewrites - # every target at once, so a partial view could call one file clean while - # its sibling drifted in the same session. + # The whole repository, not the staged files: the hook rewrites every target at once, + # so a partial view could call one file clean while its sibling drifted. glob: - "{CLAUDE,AGENTS,copilot-instructions}.md" - "**/{CLAUDE,AGENTS,copilot-instructions}.md" @@ -81,6 +85,24 @@ pre-commit: exit 0 fi node scripts/check-context-reference-form.js + referenced-paths: + glob: "{*.md,docs/**,aidd_docs/memory/**}" + run: | + if ! command -v node >/dev/null 2>&1; then + echo "ℹ️ node not available; skipping referenced-paths" + exit 0 + fi + node scripts/check-referenced-paths.js + + doc-duplication: + glob: "**/*.md" + run: | + if ! command -v node >/dev/null 2>&1; then + echo "ℹ️ node not available; skipping doc-duplication" + exit 0 + fi + node scripts/check-doc-duplication.js + markdown-links: glob: "**/*.md" run: | @@ -88,13 +110,8 @@ pre-commit: echo "ℹ️ node not available; skipping markdown-links" exit 0 fi - # TEMPORARY (aidd-cli -> framework migration, phase 1): cli's own - # historical archives were never link-checked before landing here — - # 704 pre-existing, unrelated broken "links" (test fixtures with - # intentionally fake paths, old task docs using @src/foo.ts as an - # agent-mention shorthand, not a markdown link). See the migration - # plan's final phase for the follow-up that fixes the real links and - # removes these two --ignore flags. + # Both ignored trees hold deliberately unresolvable targets — fixture paths and + # `@src/foo.ts` agent mentions — not links a reader is meant to follow. node scripts/check-markdown-links.js --ignore cli/tests/fixtures --ignore cli/aidd_docs/tasks summarize-plugin-catalogs: run: | @@ -132,55 +149,33 @@ pre-commit: fi node scripts/sync-readme-counts.mjs >/dev/null git add README.md 2>/dev/null || true - scripts-test: - glob: "{scripts,plugins}/**" - run: | - if ! command -v node >/dev/null 2>&1; then - echo "ℹ️ node not available; skipping scripts-test" - exit 0 - fi - # `node --test ` treats the directory as a module path and fails, so - # the glob is required. A glob that matches nothing exits 0 in silence, - # which would make a renamed folder read as a green run — count first. - found=$(ls scripts/__tests__/*.test.js 2>/dev/null | wc -l | tr -d ' ') - if [ "$found" -eq 0 ]; then - echo "❌ no test files under scripts/__tests__/; the runner is not wired" - exit 1 - fi - node --test "scripts/__tests__/**/*.test.js" cli-biome: glob: "cli/**" run: cd cli && pnpm lint - cli-layering: - # The two invariants biome cannot express: dependencies point inward, and no type is - # widened through `unknown` or `never`. Measured, not assumed — a noRestrictedImports - # rule written against "../../infrastructure" never fired on a violation planted in - # src/domain. The glob covers `cli/**`, not `cli/src/**`: the cast half of the check - # reads `cli/tests` too, and a test-only commit must not slip past it. - glob: "cli/**" - run: node scripts/check-cli-layering.mjs + cli-architecture: + # The suite reads source as text only, and reads well beyond cli/src: narrower globs + # kept going stale, so this one is the whole surface plus the automation files. + glob: "{cli/**,lefthook.yml,.github/workflows/**}" + run: cd cli && pnpm test:arch cli-typecheck: - # The CLI type-checks `kanban/` too, so that folder's dependencies must be - # resolvable. Install them only when they are missing, to keep the hook fast. - # - # `--ignore-workspace` was here so pnpm would not resolve kanban to the - # repository root, find it lists no members, report "Already up to date" and - # install nothing. `kanban/pnpm-workspace.yaml` now stops that upward search on - # its own — that is the whole reason the file exists — and the flag had turned - # harmful: it discards that same file, and with it the `allowBuilds` entry pnpm - # needs to run esbuild's install script. Measured in the real layout under - # pnpm 12.3.4, a root workspace above and kanban's own file present: without the - # flag the install exits 0, with it, ERR_PNPM_IGNORED_BUILDS. - glob: "{cli,kanban}/**" + glob: "cli/**" + run: cd cli && pnpm typecheck + cli-type-honesty: + # Type honesty only; dependency direction between layers belongs to biome + # (cli/biome.json's noRestrictedImports overrides), not here. + glob: "{cli/src/**,cli/tests/**}" run: | - [ -d kanban/node_modules ] || (cd kanban && pnpm install --frozen-lockfile) - cd cli && pnpm typecheck + if ! command -v node >/dev/null 2>&1; then + echo "ℹ️ node not available; skipping cli-type-honesty" + exit 0 + fi + node scripts/check-cli-type-honesty.mjs pre-push: commands: cli-knip: glob: "cli/**" - run: cd cli && pnpm knip:production + run: cd cli && pnpm knip cli-test: glob: "cli/**" run: cd cli && pnpm test @@ -189,3 +184,12 @@ commit-msg: commands: commitlint: run: pnpm exec commitlint --edit {1} + +# lefthook owns prepare-commit-msg here and regenerates it on install, so `aidd telemetry on`'s +# delegate is called from a job instead. Absent delegate, nothing runs — opt-in per machine. +prepare-commit-msg: + commands: + aidd-session-trailer: + run: | + delegate="$(git rev-parse --git-common-dir)/hooks/aidd-session-trailer.sh" + if [ -f "$delegate" ]; then sh "$delegate" {1} {2}; fi diff --git a/plugins/aidd-context/hooks/update_memory.js b/plugins/aidd-context/hooks/update_memory.js index 11a9cd767..4cb856ec1 100644 --- a/plugins/aidd-context/hooks/update_memory.js +++ b/plugins/aidd-context/hooks/update_memory.js @@ -1,56 +1,31 @@ #!/usr/bin/env node /** - * update_memory.js - Syncs the project memory block in AI context files. + * Syncs the project memory block in a project's AI context files: root memory files as + * always-loaded references, `internal/` and `external/` as a read-on-demand list. * - * Scans aidd_docs/memory/ and updates the block delimited by - * / in each - * context file with two tiers: - * - Root memory files -> always loaded, via a tool-appropriate reference. - * - internal/ and external/ -> listed (plain paths, no @), read on demand. + * Only Claude Code resolves the `@` import form, so AGENTS.md and the copilot instructions + * take a markdown link, where an `@` line would be inert text loading nothing. * - * Reference syntax for the always-loaded tier: - * CLAUDE.md -> @aidd_docs/memory/file.md - * AGENTS.md -> [aidd_docs/memory/file.md](aidd_docs/memory/file.md) - * .github/copilot-instructions -> [aidd_docs/memory/file.md](../aidd_docs/memory/file.md) - * - * Only Claude Code resolves the @ import form. The tools reading AGENTS.md - * (codex, cursor, opencode) do not, so an @ line there was inert text; a link - * is at least navigable. - * - * Usage: - * node update_memory.js every context file already present - * node update_memory.js claude codex only those tools' context files - * - * The auto hook calls it with no argument. The project-memory skill passes the - * tools the user picked, so a context file the user did not choose is left - * alone even when it exists. - * - * It only ever fills a block that is already there. Creating the file, or the - * block inside it, is the skill's job. + * With no argument it fills every context file present; named tools narrow it, so a file the + * user never picked keeps its block untouched. It only ever fills a block already there. */ -// ── Constants ───────────────────────────────────────────────────── - const DOCS_DIR = "aidd_docs"; const MEMORY_SUBDIR = "memory"; const ON_DEMAND_DIRS = ["internal", "external"]; -// HTML comment markers, not a bare tag. A line opening a -// bare tag starts an HTML block that runs to the next blank line, and an @import -// inside one is skipped by the context loader exactly like a fenced code block, -// so the memory silently never loads. A comment closes on its own line. +// Comment markers, not a bare tag: a bare tag opens an HTML block running to the next blank +// line, and a context loader skips an @import inside one, so the memory never loads. const BLOCK_OPEN = ""; const BLOCK_CLOSE = ""; -// The shape written before the markers above. Blocks already in a user's context -// file are rewritten to the new markers on the next run; without that they stop -// matching, the file is skipped with no output, and the memory stays unloaded. +// A block written with these is rewritten on the next run: unmigrated, it stops matching and +// the file is skipped with no output at all. const LEGACY_BLOCK_OPEN = ""; const LEGACY_BLOCK_CLOSE = ""; const ON_DEMAND_NOTE = ""; const EXCLUDED_FILES = new Set([".gitkeep", "README.md"]); -// Human-facing index of the memory bank. The hook refreshes the list between -// these markers; everything else in the file is hand-written and preserved. +// The list between these markers is refreshed; the rest of the file is hand-written. const MEMORY_README = "README.md"; const TOC_OPEN = ""; const TOC_CLOSE = ""; @@ -61,7 +36,7 @@ const TARGET_FILES = [ { path: ".github/copilot-instructions.md", syntax: "link" }, ]; -// Which context file each tool reads. Mirrors the skill's references/tools.md. +// Mirrors the skill's references/tools.md. const TOOL_FILES = { claude: "CLAUDE.md", codex: "AGENTS.md", @@ -70,15 +45,12 @@ const TOOL_FILES = { copilot: ".github/copilot-instructions.md", }; -// ── Helpers ─────────────────────────────────────────────────────── - function memoryPath(path, ...parts) { return path.join(DOCS_DIR, MEMORY_SUBDIR, ...parts); } -// Read a file's text, or null if it does not exist. Opening directly (instead -// of an existsSync check first) avoids a time-of-check/time-of-use race: the -// file is touched exactly once. Real errors (permissions, etc.) still throw. +// Opening directly rather than checking existence first touches the file once, so no +// time-of-check/time-of-use race. A real error still throws. function readTextOrNull(fs, filePath) { try { return fs.readFileSync(filePath, "utf8"); @@ -88,8 +60,7 @@ function readTextOrNull(fs, filePath) { } } -// List a directory, or [] if it does not exist. Same single-touch rationale as -// readTextOrNull: no separate existence check before reading. +// Single-touch, like readTextOrNull: no existence check before reading. function readDirOrEmpty(fs, dir) { try { return fs.readdirSync(dir, { withFileTypes: true }); @@ -99,7 +70,6 @@ function readDirOrEmpty(fs, dir) { } } -// Top-level .md at the root of memory/ (always-loaded tier). function scanRootFiles(fs, path) { return readDirOrEmpty(fs, memoryPath(path)) .filter((e) => e.isFile() && e.name.endsWith(".md") && !EXCLUDED_FILES.has(e.name)) @@ -107,7 +77,6 @@ function scanRootFiles(fs, path) { .sort(); } -// .md under memory// recursively (on-demand tier). function scanSubdir(fs, path, sub) { const out = []; const walk = (dir) => { @@ -121,11 +90,9 @@ function scanSubdir(fs, path, sub) { return out.sort(); } -// A markdown link resolves against the file holding it, so it has to climb back -// out of whatever directory that file sits in. Derived from the target's own -// depth rather than hardcoded: a root-level AGENTS.md needs no prefix, while -// .github/copilot-instructions.md needs one "../". Hardcoding one level made -// every root-level link point outside the repository. +// A markdown link resolves against the file holding it, so it climbs out of that file's own +// directory. Derived from the target's depth: hardcoding one level made every root-level +// link point outside the repository. function relativePrefix(targetPath) { return "../".repeat(targetPath.split("/").length - 1); } @@ -143,17 +110,13 @@ function buildBlockContent(rootFiles, onDemandFiles, syntax, prefix = "") { for (const f of onDemandFiles) lines.push(`- ${f.replace(/\\/g, "/")}`); } if (lines.length === 0) return "\n"; - // Blank line on each side of the content. The markers are comments, which - // close on their own line, but a context loader that treats any line opening - // with `<` as an HTML block running to the next blank line would otherwise - // swallow the imports. The blank lines hold under either reading. + // A blank line on each side: a context loader treating any `<` line as an HTML block + // running to the next blank line would otherwise swallow the imports. return `\n\n${lines.join("\n")}\n\n`; } -// The real block is the one whose markers each own their line, outside any code -// fence. Substring search is not enough: these markers are the strings every -// upgrade note and migration doc quotes, and cutting on a quoted one would -// mangle that prose while leaving the real block untouched and unloaded. +// Markers that each own their line, outside any code fence. Substring search would cut on +// the quoted marker every upgrade note carries, mangling prose and missing the real block. function findBlockLines(lines, open, close) { let fence = null; let openLine = -1; @@ -179,7 +142,6 @@ function findBlockLines(lines, open, close) { return null; } -// Replace the text between an open and close marker, leaving the rest intact. function updateMarkers(content, open, close, innerContent) { const lines = content.split("\n"); const found = findBlockLines(lines, open, close); @@ -192,7 +154,6 @@ function updateMarkers(content, open, close, innerContent) { ); } -// Rewrite a legacy block to the comment markers. function migrateLegacyMarkers(content) { const lines = content.split("\n"); const found = findBlockLines(lines, LEGACY_BLOCK_OPEN, LEGACY_BLOCK_CLOSE); @@ -203,8 +164,8 @@ function migrateLegacyMarkers(content) { return lines.join("\n"); } -// A file carrying one marker without its pair can never be filled again. Say so: -// silence about a block that does not sync is what kept the memory unloaded. +// One marker without its pair can never be filled again, and silence about a block that +// does not sync is what keeps memory unloaded. function reportUnpairedMarkers(filePath, content) { const has = (marker) => content.includes(marker); const unpaired = @@ -226,7 +187,6 @@ function memoryRelative(path, filePath) { return filePath.replace(/\\/g, "/").replace(`${memoryPath(path)}/`, ""); } -// Human-facing TOC of the memory bank, grouped by load tier. function buildToc(rootFiles, onDemandFiles, path) { const link = (f) => { const rel = memoryRelative(path, f); @@ -240,9 +200,8 @@ function buildToc(rootFiles, onDemandFiles, path) { return `\n${lines.join("\n")}\n`; } -// The context files to fill. No tool named: every target already present, which -// is what the auto hook wants. Tools named: only theirs, so an AGENTS.md the -// user never picked keeps its block untouched. +// No tool named means every target present, which is what the auto hook wants; tools named +// means only theirs. function resolveTargets(tools) { if (tools.length === 0) return TARGET_FILES; @@ -267,20 +226,16 @@ function gitAdd(childProcess, files) { } } -// ── Main ────────────────────────────────────────────────────────── - -// Runs as a script, never imported: no require, no module.exports, and every -// dependency pulled in with dynamic import(). The file is copied into the user's -// project by the CLI, so a project declaring "type": "module" decides how it is -// parsed. CommonJS syntax here would crash the hook on load in any such project. +// Runs as a script, never imported, with every dependency pulled in through dynamic +// import(): the file is copied into a user's project, so a project declaring +// "type": "module" decides how it is parsed and CommonJS syntax would crash it on load. (async () => { const fs = await import("node:fs"); const path = await import("node:path"); const childProcess = await import("node:child_process"); - // Every path below is project-relative, so anchor on the project root when - // Claude Code names it. Without this a run started elsewhere finds no bank - // and exits 0, which reads as success. + // Every path below is project-relative: without this anchor a run started elsewhere finds + // no bank and exits 0, which reads as success. const root = process.env.CLAUDE_PROJECT_DIR; if (root && fs.existsSync(root)) process.chdir(root); @@ -304,9 +259,8 @@ function gitAdd(childProcess, files) { target.syntax, relativePrefix(target.path), ); - // Compare against what is on disk, not against the migrated text: a file - // whose memory list is unchanged would otherwise look identical and skip - // the write, leaving the legacy markers in place forever. + // Compared against what is on disk, not against the migrated text: an unchanged memory + // list would otherwise skip the write and leave the old markers in place forever. const updated = updateBlock(migrateLegacyMarkers(original), innerContent); if (updated === null) { @@ -319,7 +273,7 @@ function gitAdd(childProcess, files) { changed.push(target.path); } - // Refresh the human-facing TOC in memory/README.md, only if it opts in with markers. + // Only if the README opts in with its own markers. const readmePath = memoryPath(path, MEMORY_README); const readmeOriginal = readTextOrNull(fs, readmePath); if (readmeOriginal !== null) { @@ -331,14 +285,11 @@ function gitAdd(childProcess, files) { } } - // Stage only when running as the auto hook, which owns no other change. Called - // by the skill, generate has just written files this script knows nothing about, - // so staging its own two would leave a partial index that reads like the whole - // change. The skill reports instead, and the user stages what they mean to commit. + // Only as the auto hook, which owns no other change: called by the skill, staging its own + // two files would leave a partial index that reads like the whole change. if (changed.length > 0 && tools.length === 0) gitAdd(childProcess, changed); - // Only when tools were named, which means the skill called us and its sync - // action stops on a non-zero exit. The auto hook must never fail a session - // start over a file the user has yet to repair. + // Only when tools were named, so the skill's sync action can stop: the auto hook must + // never fail a session start over a file the user has yet to repair. if (unpaired && tools.length > 0) process.exit(1); })(); diff --git a/plugins/aidd-telemetry/README.md b/plugins/aidd-telemetry/README.md index bfbc51235..d6bb6f0c5 100644 --- a/plugins/aidd-telemetry/README.md +++ b/plugins/aidd-telemetry/README.md @@ -2,66 +2,19 @@ # aidd-telemetry -Know what a piece of work cost — tokens, models, and which skill spent them. +Know what a piece of work cost: which skill, which step and which task spent the tokens. -> Status: beta — usable, and being proven. Proven end to end on Claude Code; see -> [Coverage](#coverage) for what each of the other four answers, and what none of them does. -> It stays off the curated install path until it has run on other people's machines, not -> until that table is all green: two of its rows are limits of the tool, not of this plugin. +> Status: beta. Proven end to end on Claude Code; the other four tools are covered to the +> extent their own files allow, see [Coverage](#coverage). Off the curated install path +> until it has run on other people's machines. -Providers can tell you a developer burned four million tokens on Tuesday. None can tell you -that `aidd-dev:02-implement` spent 78,188 of them. The difference is the task, the step and -the skill — what the framework knows and a provider does not. +## What it does -**Nothing is measured until you say so.** No command, server, or route in this plugin sends -anything anywhere — the export writer this plugin used to ship is deleted from the code. If -you ran `aidd telemetry endpoint` on an older version, that is a fact about a settings file -this plugin can no longer see or touch: `aidd telemetry check` and `aidd telemetry off` both -detect a settings file still carrying what it wrote, and name exactly what to remove by -hand. +Your provider can tell you a developer burned four million tokens on Tuesday. This plugin +tells you that `aidd-dev:02-implement` spent 78,188 of them, on task `2026_09_01_the-upward-link`, +inside one orchestrated flow. It attributes consumption to the framework's own units of work. -## Install and use - -Install it with the CLI: - -```bash -npm install -g @ai-driven-dev/cli -aidd plugin install aidd-telemetry -``` - -Your tool's own plugin mechanism works too, and the plugin records identically either way. -The CLI is recommended for one reason: **nothing here can be read without it.** Allowing -measurement and answering what it cost both go through `aidd`, so a person installing the -other way still ends up needing it — and until they run it, the install has recorded no -version of this plugin anywhere, which `aidd telemetry check` will tell them. - -Then ask your AI tool for a skill. You never type a command yourself: - -- **`00-init`** — allows measurement for this project, and verifies the switch took. -- **`01-cost`** — answers what a period or one task consumed. -- **`02-check`** — answers whether the chain is actually recording. - -All three reach the CLI, and each checks that `aidd` answers before doing anything — -stopping with the reason when it does not, rather than reporting an empty figure in place -of a missing tool. - -**Three acts, and only the middle one needs nothing.** - -| | Needs | -| --- | --- | -| **Allowing** it, once per project | the `aidd` CLI | -| **Recording**, every session after that | nothing — the hooks run under plain `node` | -| **Answering** what it cost | the `aidd` CLI | - -Recording is the act that must never depend on anything: it runs on every tool call, and a -hook that needs an installed binary records nothing, silently, when that binary is missing. -Allowing and answering can afford the CLI, and answering in particular belongs there — the -report is computed once, in one place, so the figure cannot differ depending on who asked. - -Your sessions are measured from the moment you allow it, whether or not `aidd` is present. -Without it you cannot ask what they cost — recording keeps going, and the answer waits. - -``` +```text period 2026-08-21 to 2026-08-21 sessions 1 @@ -74,171 +27,183 @@ period 2026-08-21 to 2026-08-21 aidd-ui:01-hello 33% 38,490 tokens from a journal interval ``` -`report --json` prints the same figures as one object a program can parse — -[the contract](../../aidd_docs/product/cost-report-contract.md). +Three things it never does: it never sends anything anywhere, it never stores a prompt, a +diff or a line of code, and it never records until you turn it on. ## How it works -Three parts, and the third is the only one that joins anything. +Two sources exist already. The plugin adds the one thing that joins them. + +```mermaid +flowchart LR + Tool["Your AI tool
(Claude Code, Codex, Copilot, OpenCode, Cursor)"] + Hooks["Plugin hooks
node, no dependency"] + Journal["Run journal
aidd_docs/runs/*.jsonl
which skill ran, when, which task folder"] + Transcript["The tool's own transcript
tokens and model, no AIDD knowledge"] + CLI["aidd telemetry report"] + Store["Figures
~/.config/aidd/telemetry/"] + Answer["tokens per step, task, flow, model, person"] + + Tool -->|"SessionStart, PostToolUse, Stop"| Hooks -->|append one line| Journal + Tool -->|writes itself| Transcript + Journal --> CLI + Transcript --> CLI + CLI -->|joins by session, keeps a record| Store --> Answer +``` -**The hooks journal.** While measuring is on, they append one line per observation to -`aidd_docs/runs/__.jsonl` — git-ignored, one file per session, never -rewritten. Which session, which skill was running when, which files inside a task folder -changed. **No token, no cost, no model ever lands there.** +- **The hooks journal.** While measurement is on, every session appends one line per + observation to `aidd_docs/runs/__.jsonl`, git-ignored, never rewritten. + No token, no cost, no model lands there. +- **Your tool writes its own transcript**, in its own place and format. It holds the tokens + and knows nothing about skills. +- **`aidd telemetry report` joins the two.** It reads the transcript, normalises it into one + shape whatever tool produced it, matches each record against the journal and keeps the + result under `~/.config/aidd/telemetry/`. The join cannot happen live: when a hook fires, + the tokens for that turn are not written yet. -**Your AI tool writes its own transcript**, in its own place, in its own format. It holds -the tokens and knows nothing about AIDD skills. +Recording is the one act that depends on nothing: the hooks run under plain `node`, so a +session is measured whether or not `aidd` is installed. Allowing and answering go through +the CLI, so the figure is computed once, in one place, whatever asked for it. -**`read` joins the two.** It opens the transcript, normalises it into one shape whatever -tool produced it, matches each record against the journal, and stores the result under -`~/.config/aidd/telemetry/`. `report` reads that store — and, before it answers, joins -any session the journal names that the store has not caught up with, so **you never -have to run `read` to get a figure**. `read` stays the command for asking what each -tool answered, one line per tool, rather than a total. +## Getting started -The join cannot happen live: when a hook fires, the tokens for that turn are not written -yet. +```sh +npm install -g @ai-driven-dev/cli +aidd plugin install aidd-telemetry +``` + +Then talk to your AI tool. Each skill reaches the CLI and stops with the reason if `aidd` +does not answer, rather than reporting an empty figure. + +| Ask your tool for | It runs | You get | +| --- | --- | --- | +| `00-init` | `aidd telemetry on`, then `check` | measurement allowed for this project, and proof the switch took | +| `01-cost` | `aidd telemetry report` | what a period or one task consumed, by step, model, task, flow, tool or person | +| `02-check` | `aidd telemetry check` | whether the chain is actually recording, and what to fix if not | + +Your tool's own plugin mechanism installs the plugin just as well. The CLI stays required +for one reason: nothing recorded can be read without it. + +```mermaid +sequenceDiagram + participant You + participant Tool as AI tool + participant Hook as journal.cjs + participant CLI as aidd telemetry + You->>Tool: ask for 00-init + Tool->>CLI: telemetry on + Note over CLI: .aidd/config.json telemetry.enabled = true
aidd_docs/runs/ git-ignored + Tool->>Hook: SessionStart + Hook->>Hook: session_start line + loop every turn + Tool->>Hook: PostToolUse + Hook->>Hook: step_start, task_declared, file_written + Tool->>Hook: Stop + Hook->>Hook: turn_end line + end + You->>Tool: ask for 01-cost + Tool->>CLI: telemetry report + CLI->>CLI: read transcript + journal, join by session + CLI-->>Tool: figures per step and task +``` ## What a figure tells you about itself -Every attributed figure says **how** it was attributed, because the two ways are not the -same claim: +Every attributed figure says how it was attributed, because the two ways are not the same +claim. | Reads | Means | | --- | --- | -| stated by the tool | the tool named the running skill itself, on the line with the counters — exact | -| from a journal interval | derived from the interval between two boundaries the framework recorded — an inference | -| unattributed | neither source could say | +| stated by the tool | the tool named the running skill itself, on the line with the counters: exact | +| from a journal interval | derived from the interval between two boundaries the journal recorded: an inference | +| unattributed | neither source could say. It never means "no step ran" | -**`unattributed` never means "no step ran".** On at least one measured tool the two are -indistinguishable, so the stronger reading would be a fact nobody measured. - -The same rule runs through everything here: **an absent figure is named, never shown as a -zero.** A tool that cannot be read, one that carries no amount, one that measured nothing, -and one whose reader failed are four different answers. +An absent figure is named, never shown as a zero. A tool that cannot be read, one that +carries no amount, one that measured nothing and one whose reader failed are four +different answers. No tool read locally writes a figure in currency: reports give tokens, +and turning tokens into money is a separate service's job. ## Coverage | Tool | Tokens | Step | Task | | --- | --- | --- | --- | -| **Claude Code** | ✅ proven on live sessions | ✅ stated by the tool, and by interval | ✅ observed | -| **Codex** | ✅ on captured rollouts | ✅ by interval | ✅ observed | -| **OpenCode** | ✅ | ✅ through its own plugin API, not a declarative hook | ✅ observed | -| **Copilot** | ⚠️ session total only, no per-request figure — one cumulative total at `session.shutdown`, never a sum of requests | ✅ by interval ([#663](https://github.com/ai-driven-dev/framework/issues/663)) | ✅ observed | -| **Cursor** | ❌ no token count in any file it writes | ✅ headless fires `sessionEnd` where interactive fires `stop`; both are mapped | ✅ observed | - -**No amount, anywhere.** No tool read locally writes a figure in currency. Reports give -tokens; turning tokens into money is a separate service's job. - -### What the numbers do not say - -- **Codex needs one interactive approval.** Its hook trust is per entry, and a headless run - never sees the prompt — so a Codex session journals nothing until someone approves once, - in an interactive session, and says nothing while it does not. -- **OpenCode never announces a session, so the plugin opens it.** Measured, not asserted: - one live `opencode 1.14.20` run (2026-08-31, started with `--print-logs`) shows the - plugin's own event hook firing for roughly 38 events of other types, and its debug log - shows `session.created` genuinely published on the bus after the plugin loaded — yet it - never reached the hook. Two further runs neither confirm nor refute this: one without - debug logging, one that captured no plugin events at all — see - `scripts/__tests__/fixtures/README.md`, "OpenCode's plugin events" for exactly what each - run shows. `session.idle` (the turn-end signal) is unaffected and reaches every session. - Since a session nobody announced would otherwise leave the journal with no run file — and - so drop the turn-end and every task declaration after it, for every `opencode run` there - has ever been — the first call a session produces opens it, carrying the directory that - call was already going to use. What is lost is only what `session.created` alone could - have said: on a server serving more than one directory, a session it never announced is - journalled under the plugin's own init-time directory rather than its own. -- **A task is declared from a tool call's own arguments, on every host now.** Claude Code, - Codex, Copilot and Cursor each hand their hook a tool call whose own arguments can name a - file under a task folder — a `Read`, a `Bash` command line, an object keyed `path` — and - the journal reads that text rather than asking the host to cooperate. **OpenCode joined - them 2026-08-31**, settled by a bounded measurement rather than assumed either way: a - completed tool part's own arguments do reach the plugin's `event` hook - (`message.part.updated`, `part.type: "tool"`, `part.state.status: "completed"`), and - `hooks/opencode-plugin.js` reads them the same way. An earlier reading had found no tool - part across three sessions; that was a model choosing not to call a tool, not a limit of - the plugin surface — see `scripts/__tests__/fixtures/README.md`, "OpenCode's tool part" - for what changed the answer and "The task-declaration payloads" for one real capture per - host, taken live 2026-08-31 for four of the five, and for OpenCode the call the plugin - builds from one such captured event. -- **These are raw counters, not your tool's usage screen.** A vendor's own page weights a +| **Claude Code** | ✅ proven on live sessions | ✅ stated by the tool, and by interval | ✅ | +| **Codex** | ✅ on captured rollouts | ✅ by interval | ✅ | +| **OpenCode** | ✅ | ✅ through its own plugin API | ✅ | +| **Copilot** | ⚠️ session total only, no per-request figure (one cumulative total at shutdown) | ✅ by interval ([#663](https://github.com/ai-driven-dev/framework/issues/663)) | ✅ | +| **Cursor** | ❌ no token count in any file it writes | ✅ | ✅ | + +
+Measured limits, per tool + +- **Codex needs one interactive approval.** Its hook trust is per entry and a headless run + never sees the prompt, so a Codex session journals nothing until someone approves once, + in an interactive session. +- **OpenCode never announces a session, so the plugin opens it.** Measured on one live + `opencode 1.14.20` run (2026-08-31, `--print-logs`): `session.created` is published on + the bus but never reaches the hook, while `session.idle` reaches every session. The first + call a session produces therefore opens it, under the directory that call was going to + use. What is lost: on a server serving several directories, a session it never announced + is journalled under the plugin's own init-time directory. Details in + `scripts/__tests__/fixtures/README.md`, "OpenCode's plugin events". +- **A task is declared from a tool call's own arguments, on every host.** A `Read`, a `Bash` + command line or an object keyed `path` naming a file under a task folder is enough; the + journal reads that text rather than asking the host to cooperate. OpenCode joined on + 2026-08-31 once a completed tool part's arguments were measured to reach its `event` + hook. One real capture per host is kept in `scripts/__tests__/fixtures/README.md`. +- **These are raw counters, not your tool's usage screen.** A vendor's page weights a cached token by what it charges for it; these figures are the counts the tool wrote down. The two disagree on cache lines by construction, and neither is wrong. - **A period means when the work ran**, not when it was billed. - **A sweep reaches a session only where the journal was installed.** Work done before you turned measurement on is not there, and nothing reconstructs it. +
+ ## Privacy -- **The switch lives in a file you commit or do not**, per project — which means it is - git-tracked the moment someone commits it on, and applies to everyone who clones from - then on, not only to whoever ran `aidd telemetry on`. Refuse it for yourself alone with - `AIDD_TELEMETRY=0`, which overrides the file unconditionally. +- **Nothing leaves the machine.** Every code path that once could, the export writer, its + endpoint, the server it talked to, is deleted. Everything measured is read back from + where it was written. On a machine where an older version once configured an export + endpoint, `aidd telemetry check` and `aidd telemetry off` both detect the settings file + it left and name what to remove by hand. - **No prompt, no code, no diff.** The stored shape is an allowlist, field by field, in [the record contract](../../aidd_docs/product/metrics-contract.md). -- **Nothing this plugin runs sends a record anywhere else.** Every code path that could — - the export writer, its endpoint, the server it once talked to — is deleted; everything - measured is read back from where it was written, on the same machine that wrote it. On a - machine that ran an older version's `aidd telemetry endpoint`, that settings file is a - fact this plugin cannot see or undo any more — `aidd telemetry check` and `aidd telemetry - off` both detect it and name what to remove by hand. -- **`off` keeps what you measured.** It stops the recording, not the record — nothing - already written is deleted. **`aidd telemetry forget` removes it**: this project's run - journal, this machine's stored records (spanning every project ever measured on this - machine, not only this one), and this machine's identity file. It shows exactly what - would go, and what git history keeps regardless, before anything happens, and removes - nothing without `--yes`. The telemetry switch itself is never touched — measurement can - be turned back on afterwards. +- **The switch is a file you commit or do not**, per project (`.aidd/config.json`). Once + committed on, it applies to everyone who clones. Refuse it for yourself alone with + `AIDD_TELEMETRY=0`, which overrides the file unconditionally. +- **`off` keeps what you measured.** It stops the recording, not the record. + **`aidd telemetry forget` removes it**: this project's journal, this machine's stored + records (every project ever measured on this machine) and this machine's identity file. + It shows what would go before anything happens and removes nothing without `--yes`. +- **Your identity is yours to attach.** `aidd telemetry identity` opts a person in or out + of naming themselves on their own records; nothing about a colleague's identity arrives on + its own. ## Where things live -**The journal** stays in the repository it describes — `aidd_docs/runs/`, git-ignored the -moment measurement is turned on, through `aidd setup`, `aidd plugin add`, or -`aidd telemetry on`. It is a property of that repository: every line names a -repository-relative path or a task folder, and moving it out would leave a file about one -repository with no way to say which. It records the repository, the task folders written -into, the skills run, and their timings — nothing else, and no person's name or identity. - -**The figures** stay with the person — `AIDD_TELEMETRY_DIR`, or `~/.config/aidd/telemetry/` -when that variable is unset. A session's consumption belongs to whoever ran it, not to -whichever checkout was open at the time. Point `AIDD_TELEMETRY_DIR` at a directory a team -shares, or a CI's own per repository, and every figure this plugin writes follows it — at -the cost that anything outside the default is not swept together with the rest of a -person's figures by a reader that assumes it. The default stays the default. On Windows -that default is `%APPDATA%\aidd\telemetry\` instead — `.config` is not where a Windows -application puts this — unless a machine already journalled under the old `.config` path, -which it keeps using rather than losing access to what was already written there. - -**Share `AIDD_TELEMETRY_DIR`, never `AIDD_USER_CONFIG_DIR`.** This document used to name -the second one here, and that was a mistake worth stating plainly: `AIDD_USER_CONFIG_DIR` -relocates a machine's whole aidd config, `auth.json` included — a GitHub token. Following -the advice as written put a credential in the directory a team was told to share. `0600` -holds on a local POSIX filesystem, but a network share or a synced folder is usually what -"a directory a team shares" means, and neither guarantees it; and whatever the mode, two -people pointing at one directory overwrite each other's token file. The figures are the one -thing here meant to leave a machine, so they have a name of their own and nothing else -follows it. `AIDD_USER_CONFIG_DIR` still moves the figures too, so a setup made before this -split keeps working — but it moves the token with them, and it should be changed. - -**The identity file never follows either variable.** It is read from the OS -profile only, on every platform, by design — see `aidd telemetry identity` above. So on a -shared sink, a colleague's records stay `unresolved` in every -reader's report until that reader deliberately runs `aidd telemetry identity link` on the -colleague's identifier — nothing about a colleague's own identity arrives on its own, and -nothing stops a reader typing an identifier they never opted into. Linking one folds that -spend into the reader's own row from then on: it declares "this identifier is me", and the -CLI cannot check the claim against anything the colleague wrote. +| What | Where | Why there | +| --- | --- | --- | +| The switch | `.aidd/config.json` in the project | a property of the repository, shared by committing it | +| The journal | `aidd_docs/runs/` in the project, git-ignored | every line names a repository-relative path or a task folder | +| The figures | `~/.config/aidd/telemetry/` (`%APPDATA%\aidd\telemetry\` on Windows), or `AIDD_TELEMETRY_DIR` | a session's consumption belongs to the person who ran it | +| The identity | the OS profile, on every platform | it never follows a shared directory | -## The backlog link +**Share `AIDD_TELEMETRY_DIR`, never `AIDD_USER_CONFIG_DIR`.** Point the first at a directory +a team shares and every figure follows it. The second relocates a machine's whole AIDD +configuration, `auth.json` and its GitHub token included, and two people pointing at one +directory would overwrite each other's token file. A setup made before this split still +works through `AIDD_USER_CONFIG_DIR`, and should be changed. + +On a shared sink, a colleague's records stay `unresolved` in your report until you run +`aidd telemetry identity link` on their identifier yourself. Linking declares "this +identifier is me", and the CLI cannot check that claim against anything the colleague wrote. -**A task folder can say which backlog item it delivers.** `aidd-pm:04-spec` and -`aidd-dev:01-plan` write `backlog-link.json` at the folder's own level — beside `spec.md` -and `plan.md`, never inside either — the moment the request they are building from names -one. A folder with no `backlog-link.json` is a normal state, never an error: most tasks -never declare one, and the report reads that exactly as it reads any other silence. +## The backlog link -The file carries one meaningful field: +A task folder can say which backlog item it delivers. `aidd-pm:04-spec` and +`aidd-dev:01-plan` write `backlog-link.json` beside `spec.md` and `plan.md` when the request +they build from names one. No file is the normal state, never an error. ```json { @@ -248,36 +213,19 @@ The file carries one meaningful field: } ``` -`backlog` names the item on whatever support it lives — a forge reference -(`"owner/repo#123"`) where the backlog lives with a ticket provider, or a -project-relative Markdown path (`"aidd_docs/backlog/tasks/x.md"`) where it lives as a file -— one field for both, never two, per -[`persistence.md`](../aidd-pm/skills/10-task/references/persistence.md)'s own rule of -keeping one authority across supports. `written_at` and `written_by` are provenance, not status: they say when -the declaration was made and by what, so a wrong one can be traced to the act that -produced it, not what the task itself is doing. - -**It is a plain file, correctable by hand.** Open it, edit `backlog`, save it — the next -`aidd telemetry report` reads the file as it stands, never a cache, and never something it -derived and kept. Nothing here re-derives or overwrites a declaration that already exists: -the skills that write this file leave one already there untouched, exactly as a person's -own edit would expect. - -**What it deliberately does not carry.** No steps, no produced-file list: `step_start` and -`file_written` already carry both, timestamped, in the run journal, and deriving which step -produced which file from that is the same interval mechanism task attribution already -uses — a second, hand-maintained copy here could disagree with the journal, and a copy that -can disagree is worse than none. No `branch`, no `pull_request`: git and the forge already -know both. No status: the artefacts' own frontmatter owns that. No second task identity: -the folder path already is one. +`backlog` names the item wherever it lives, a forge reference or a project-relative +Markdown path, one field for both per +[`persistence.md`](../aidd-pm/skills/10-task/references/persistence.md). `written_at` and +`written_by` are provenance, not status. It is a plain file: edit `backlog` and the next +report reads it as it stands. Nothing here re-derives or overwrites a declaration that +exists. It carries no steps, no file list, no branch and no status: the journal, git and +the artefacts' own frontmatter already own those. ## Where things are written down -- [`aidd_docs/runs/README.md`](../../aidd_docs/runs/README.md) — what the journal records, +- [`aidd_docs/runs/README.md`](../../aidd_docs/runs/README.md): what the journal records, and what it deliberately does not. -- [`cost-report-contract.md`](../../aidd_docs/product/cost-report-contract.md) — the object - a skill consumes. -- [`metrics-contract.md`](../../aidd_docs/product/metrics-contract.md) — one stored line, +- [`cost-report-contract.md`](../../aidd_docs/product/cost-report-contract.md): the object + `report --json` prints, for a skill or a program to consume. +- [`metrics-contract.md`](../../aidd_docs/product/metrics-contract.md): one stored line, for a service that prices them. -- [The backlog link](#the-backlog-link) above — the one file a task folder writes to say - which backlog item it delivers. diff --git a/plugins/aidd-telemetry/hooks/journal.cjs b/plugins/aidd-telemetry/hooks/journal.cjs index a5bebcb32..7167b184a 100644 --- a/plugins/aidd-telemetry/hooks/journal.cjs +++ b/plugins/aidd-telemetry/hooks/journal.cjs @@ -38,11 +38,9 @@ function resolveEventName(argvEvent, payload) { return HOOK_EVENT_NAME_TO_CANONICAL[payload && payload.hook_event_name] || null; } -// Only on session-start or turn-end - never tool-used, which fires on every tool call and -// would otherwise pay handleUnrecognisedPayload's git shellout once per call for the life -// of the session. Every declared host fires session-start at least once (see hooks.json), -// so an undeclared one wired the same way is caught at parity with a declared host's own -// per-session cost, not worse; one that fires tool-used alone would go untraced. +// Never tool-used, which fires on every tool call and would pay handleUnrecognisedPayload's +// git shellout once per call. Every declared host fires session-start at least once, so an +// undeclared one wired the same way costs no more than a declared one. function maybeRecordUnrecognisedPayload(payload, event) { if (!payload || typeof payload !== "object") return; const resolvedEvent = resolveEventName(event, payload); @@ -72,9 +70,8 @@ function processPayload(payload, event) { fileWrites.handleTaskFilesObserved(payload, host, sessionId); record.handleTurnEnd(payload, host, sessionId); } else if (resolvedEvent === "tool-used") { - // One event, three readings of it, sharing nothing else: handleFileWritten returns early - // unless the path looks like a task folder, a skill call has no task path, and a task - // declaration reads the call's own arguments rather than a named field. + // One event, three readings of it, sharing nothing else: each returns early on the + // shapes the others are looking for. fileWrites.handleFileWritten(payload, host, sessionId); stepStarts.handleStepStart(payload, host, sessionId); stepEnds.handleStepEnd(payload, host, sessionId); diff --git a/plugins/aidd-telemetry/hooks/lib/file-writes.cjs b/plugins/aidd-telemetry/hooks/lib/file-writes.cjs index 35c96ccda..40bf62fcf 100644 --- a/plugins/aidd-telemetry/hooks/lib/file-writes.cjs +++ b/plugins/aidd-telemetry/hooks/lib/file-writes.cjs @@ -11,8 +11,7 @@ const path = require("node:path"); const { findRunFileByVendorId, appendLine, buildFileWrittenLine, nowIso } = require("./record.cjs"); // Unanchored pre-filter, tested before any git shellout. A task is a folder of files or a -// single .md file - both shapes exist side by side, so matching only the folder would -// leave real tasks unattachable. +// single .md file, and both shapes exist side by side. const TASK_SEGMENT_PATTERN = /aidd_docs\/tasks\/\d{4}_\d{2}\/[^/]+(\/|\.md$)/u; function looksLikeTaskPath(rawPath) { @@ -33,10 +32,9 @@ function taskFolderRelativePath(repoRoot, rawPath) { return TASK_PATH_ANCHOR_PATTERN.test(relative) ? relative : null; } -// The written-path field differs per tool, and Codex has no path field at all - it is -// inside an apply_patch command string. Each host's own hooks/lib/tools/.cjs states -// its extractor, or null; gathered here for a caller that wants every host covered rather -// than one at a time (see cli/tests/helpers/telemetry-journal-hook.ts). +// The written-path field differs per tool, and Codex has no path field at all - it is inside +// an apply_patch command string. Each host's own tools file states its extractor, or null; +// gathered here for a caller that wants every host at once. const WRITTEN_PATH_EXTRACTOR_BY_HOST = Object.freeze( Object.fromEntries( Object.entries(TOOLS_BY_HOST) @@ -46,32 +44,20 @@ const WRITTEN_PATH_EXTRACTOR_BY_HOST = Object.freeze( ); const TASKS_DIR = "aidd_docs/tasks"; -// A task folder holds documents. A scan that walked node_modules would cost more than the -// git shellout this hook already pays on every event. -// -// Measured 2026-08-22 against a synthetic tree: reaching 2000 entries costs ~13.5ms p95, -// well inside the 200ms p95 the whole turn-end handler already budgets for -// (aidd-telemetry-journal.test.js) while spending ~9ms of it on git shellouts and -// everything else - see measurements.md (aidd_docs/tasks/2026_08/2026_08_21_telemetry-v1-close/) -// for the full numbers, including this repository's own tree, which uses 172 of the 2000. +// A task folder holds documents, and a scan that walked node_modules would cost more than +// the git shellout this hook already pays on every event. Reaching this cap measures ~13.5ms +// p95, well inside the 200ms p95 the turn-end handler is held to. const MAX_SCAN_ENTRIES = 2000; /** * Every file under the task tree modified since `sinceMs`, repository-relative and * "/"-separated. This is what makes a task attributable on a tool that never says what it - * wrote: a write made through a shell command, an apply_patch, or an editor leaves the - * same trace on disk as one made through a file tool, and the disk is the one thing every - * host shares. Scoped to the task tree rather than the repository, so a build touching a - * thousand files is never walked. + * wrote: the disk is the one thing every host shares. Scoped to the task tree, so a build + * touching a thousand files is never walked. * - * `truncated` is true whenever the budget ran out before the tree was fully read - either a - * directory's own listing was cut short (a wide directory, still processing when the cap - * hit) or a directory was queued but never opened at all (`pending` non-empty at the end). - * Checking `pending` alone misses the first case: a listing cut off mid-read can still - * leave `pending` empty, and reading that as "nothing left" is exactly the silent - * truncation this exists to catch. `scanned` is exactly how many entries were looked at, - * never one more: the entry that would have crossed the cap is left unopened rather than - * counted and dropped. + * `truncated` covers both ways the budget can run out - a listing cut short mid-read, or a + * directory queued and never opened. Checking `pending` alone misses the first, which is + * exactly the silent truncation this exists to catch. */ function taskFilesModifiedSince(repoRoot, sinceMs) { const root = path.join(repoRoot, ...TASKS_DIR.split("/")); @@ -112,9 +98,8 @@ function modifiedSince(filePath, sinceMs) { } } -// Guards ordered cheapest-first: the tool-name whitelist and the unanchored path regex -// both run with zero git shellouts. This fires on every tool call, so a tool that wrote -// nothing must be rejected before anything is spawned or walked. +// Cheapest-first: this fires on every tool call, so a tool that wrote nothing must be +// rejected before anything is spawned or walked. function handleFileWritten(payload, host, sessionId) { const stated = statedRawPath(payload, host); if (!stated) return; @@ -125,25 +110,20 @@ function handleFileWritten(payload, host, sessionId) { const relativePath = taskFolderRelativePath(target.repoRoot, realPathOf(stated)); if (!relativePath) return; - // The session id arrives already read behind the host's own declaration (journal.cjs), the - // same one the session_start line was named with. Reading payload.session_id here instead - // would be one host's spelling promoted to a rule - and on Codex it is the spelling that + // The session id arrives already read behind the host's own declaration. Reading + // payload.session_id here would be one host's spelling promoted to a rule, and on Codex it // names the parent of a resumed session, so the lookup would find another session's file. const filePath = findRunFileByVendorId(target.dir, sessionId); if (filePath) appendFileWritten(filePath, relativePath, "tool-stated"); } /** - * Everything in the task tree that changed during this turn, whoever wrote it. + * Everything in the task tree that changed during this turn, whoever wrote it. At turn end, + * not at every tool call: a turn ends once per prompt while tools fire dozens of times, and + * this is what catches a write no payload names. * - * At turn end, not at every tool call. A turn ends once per prompt while tools fire dozens - * of times, so this costs one walk per turn rather than one per call - and it catches a - * write made through a shell command, an apply_patch, or anything else no payload names, - * which is what makes a task attributable on a host that never says what it wrote. - * - * The moment recorded is the end of the turn rather than the write itself. That is honest: - * nothing here observed *when* the file changed, only that it had, and a task is derived - * from the path rather than from the moment. + * The moment recorded is the end of the turn rather than the write itself, because nothing + * here observed when the file changed, only that it had. */ function handleTaskFilesObserved(payload, host, sessionId) { const target = resolveRunsDir(readCwd(host, payload)); @@ -152,9 +132,8 @@ function handleTaskFilesObserved(payload, host, sessionId) { const filePath = findRunFileByVendorId(target.dir, sessionId); if (!filePath) return; - // The run file's own mtime is the moment this session last wrote a line, so anything in - // the task tree newer than it changed since. No state to keep, and appending moves the - // mark forward on its own. + // The run file's own mtime is the moment this session last wrote a line, so no state has + // to be kept: appending moves the mark forward on its own. const since = lastWriteMs(filePath); const { found, truncated, scanned } = taskFilesModifiedSince(target.repoRoot, since); const alreadyStated = new Set(); @@ -163,9 +142,8 @@ function handleTaskFilesObserved(payload, host, sessionId) { alreadyStated.add(observed); appendFileWritten(filePath, observed, "observed"); } - // Silent truncation would read as complete coverage - the one failure mode this layer - // exists to remove. A reader of the run file must be able to tell "nothing else changed" - // from "the walk gave up before it could tell." + // Silent truncation would read as complete coverage: a reader must be able to tell + // "nothing else changed" from "the walk gave up before it could tell". if (truncated) { appendLine(filePath, buildScanTruncatedLine({ at: nowIso(), cap: MAX_SCAN_ENTRIES, scanned })); } @@ -175,9 +153,8 @@ function appendFileWritten(filePath, relativePath, source) { appendLine(filePath, buildFileWrittenLine({ at: nowIso(), path: relativePath, source })); } -// Not a record.cjs builder: record.cjs owns session_start/turn_end/file_written/step_start -// alone, and readJournalFile there already ignores any type it does not name, so this new -// one is inert to every existing reader rather than breaking one. +// Not a record.cjs builder: a reader ignores any type it does not name, so this one is inert +// to every existing reader rather than breaking one. function buildScanTruncatedLine({ at, cap, scanned }) { return { type: "scan_truncated", at, cap, scanned }; } @@ -190,14 +167,10 @@ function lastWriteMs(filePath) { } } -// git resolves symlinks in --show-toplevel; the tool's file_path may not have (macOS's -// /tmp -> /private/tmp). `.native` rather than the plain JS realpath: on Windows the JS -// implementation only walks symlinks and leaves an 8.3 short-name alias (what the CI -// runner's own temp dir resolves through) untouched, so it never matches getRepoRoot's -// git-derived, already-canonical path - `.native` calls GetFinalPathNameByHandle, which -// resolves both symlinks and short-name aliases the same way git itself does. -// Falls back to the raw path so a file deleted between write and hook does not silently -// drop a real observation. +// git resolves symlinks in --show-toplevel and the tool's own path may not have. `.native` +// rather than the plain JS realpath: on Windows the JS one leaves an 8.3 short-name alias +// untouched, so it never matches git's already-canonical answer. Falls back to the raw path, +// so a file deleted between write and hook does not silently drop a real observation. function realPathOf(rawPath) { try { return fs.realpathSync.native(rawPath); diff --git a/plugins/aidd-telemetry/hooks/lib/host.cjs b/plugins/aidd-telemetry/hooks/lib/host.cjs index 479979f72..9081d975b 100644 --- a/plugins/aidd-telemetry/hooks/lib/host.cjs +++ b/plugins/aidd-telemetry/hooks/lib/host.cjs @@ -1,8 +1,6 @@ -// Claude Code and Codex hand a SessionStart hook the same five keys, so the -// host is read from transcript_path's shape, never from field names. It also -// cannot be read from the environment: a Codex session launched from inside -// a Claude Code session inherits CLAUDECODE and CLAUDE_CODE_SESSION_ID from -// its parent. +// Claude Code and Codex hand a SessionStart hook the same five keys, so the host is read +// from transcript_path's shape. Not from the environment either: a Codex session launched +// inside a Claude Code session inherits that session's own variables. const CODEX_TRANSCRIPT_PATTERN = /\/sessions\/\d{4}\/\d{2}\/\d{2}\/rollout-/u; const CLAUDE_CODE_TRANSCRIPT_PATTERN = /\/projects\/.*\.jsonl$/u; @@ -11,9 +9,8 @@ function normalizeSeparators(value) { return value.replace(/\\/gu, "/"); } -// Every string reachable inside a payload value, walked because which field carries a -// path differs by host and by tool - a skill's SKILL.md path, a declared task path. Neutral -// like normalizeSeparators above: what it walks, not which host's payload it is walking. +// Which field carries a path differs by host and by tool, so every string is walked. Neutral +// about whose payload it is walking. function* stringsWithin(value) { if (typeof value === "string") { yield value; @@ -23,10 +20,8 @@ function* stringsWithin(value) { for (const nested of Object.values(value)) yield* stringsWithin(nested); } -// The complete set of hosts journal.cjs will write for. A fifth host becomes one more -// entry here, never a branch in the dispatcher - detectHost above stays the only place -// that decides which host a payload came from; this only decides whether that host is -// one the journal acts on yet. +// A further host becomes one more entry here, never a branch in the dispatcher: detectHost +// stays the only place deciding which host a payload came from. const DECLARED_HOSTS = new Set(["claude-code", "codex", "copilot", "cursor", "opencode"]); function detectHost(payload) { @@ -51,14 +46,9 @@ function detectHost(payload) { if (CLAUDE_CODE_TRANSCRIPT_PATTERN.test(transcriptPath)) return "claude-code"; } - // Copilot's other payload shape: its plugin loader stamps a PascalCase-named - // hook `_vsCodeCompat` and switches to a second builder that reuses Claude - // Code's own event spelling verbatim (session_id, hook_event_name) instead of - // sessionId. Told apart from Claude Code and Codex by `timestamp`, a field - // neither of those ever carries, and checked only after their transcript_path - // patterns above so a host that does carry one is claimed by its own shape - // first. Measured 2026-08-21 against a real @github/copilot@1.0.80 session - - // see scripts/__tests__/fixtures/copilot-compat-*.json. + // Copilot's other payload shape, which reuses Claude Code's own event spelling verbatim. + // Told apart by `timestamp`, a field neither Claude Code nor Codex carries, and checked + // after their transcript_path patterns so a host carrying one is claimed by its own shape. if ( Object.prototype.hasOwnProperty.call(payload, "timestamp") && Object.prototype.hasOwnProperty.call(payload, "hook_event_name") && @@ -67,12 +57,9 @@ function detectHost(payload) { return "copilot"; } - // OpenCode alone names itself, checked last: every shape above was reverse-engineered - // from a captured payload nobody here controls, so a vendor host claims a payload it - // matches first, and a self-declared "tool" field only wins once none of them did. - // OpenCode has no hook payload at all - hooks/opencode-plugin.js builds this one itself, - // from inside a JS plugin OpenCode loads in-process (see measurements.md, phase 5) - and - // no captured fixture from any other host has ever carried a top-level "tool" key. + // OpenCode alone names itself, checked last: every shape above was reverse-engineered from + // a payload nobody here controls, so a self-declared "tool" field only wins once none of + // them matched. OpenCode has no hook payload at all - opencode-plugin.js builds this one. if (payload.tool === "opencode") return "opencode"; return null; diff --git a/plugins/aidd-telemetry/hooks/lib/plugin-version.cjs b/plugins/aidd-telemetry/hooks/lib/plugin-version.cjs index c19633a4f..3f849b315 100644 --- a/plugins/aidd-telemetry/hooks/lib/plugin-version.cjs +++ b/plugins/aidd-telemetry/hooks/lib/plugin-version.cjs @@ -1,32 +1,21 @@ // The plugin's own version, so a person comparing figures across an upgrade can tell which -// build of *this plugin* wrote a journal line. Never the framework's version, and never the -// CLI's: this hook is the one producer that runs as part of this plugin at all. +// build of *this plugin* wrote a journal line. Never the framework's version and never the +// CLI's. // -// Two places to look, because there are two ways this plugin reaches a machine and neither -// route can see the other's: +// Two routes, because there are two ways this plugin reaches a machine and neither can see +// the other's: a tool's own install lays down the plugin tree whole, so `plugin.json` sits +// two directories up under a per-target directory name; `aidd setup` copies the hooks alone +// with no manifest at any offset, and records the version in `.aidd/manifest.json` instead. // -// 1. Beside these hooks. A tool's own install mechanism — the route the README documents — -// lays down the built plugin tree whole, so `plugin.json` sits two directories up. The -// build renames that directory per target (`.claude-plugin`, `.cursor-plugin`, -// `.codex-plugin`, `.plugin`), which is the only thing that differs; looking for one -// name found the version on Claude and nowhere else. -// 2. What the `aidd` CLI recorded when it installed. `aidd setup --ai cursor` copies the -// hooks alone into `.cursor/hooks/aidd-telemetry/`, with no manifest at any offset, so -// (1) can never answer there. It writes `.aidd/manifest.json` in the same act, which -// names this plugin and its version per tool. -// -// Each route's precondition is exactly the case the other cannot serve, so the pair covers -// every way this file can arrive on a machine. Neither answering is `null` — an unknown -// version, never a default and never a guess. A hook that fires on every tool call must not -// fail because a version is unavailable. +// Neither answering is `null` — an unknown version, never a default and never a guess: a hook +// firing on every tool call must not fail because a version is unavailable. const fs = require("node:fs"); const path = require("node:path"); -// Mirrors `manifestDir` in cli/src/application/use-cases/framework/strategies/tool-contracts.ts, -// name for name. Not a shared import: this plugin is copied verbatim into user projects and -// can require nothing from `cli/`, the same reason `sanitizePathSegment` is duplicated in -// `repo.cjs`. `aidd-telemetry-plugin-version.test.js` pins the two lists to each other. +// Mirrors the manifest directory each tool's own profile declares. Not a shared import: this +// plugin is copied verbatim into user projects and can require nothing from `cli/`. +// `aidd-telemetry-plugin-version.test.js` pins this list to what those profiles declare. const MANIFEST_DIRS = Object.freeze([ ".claude-plugin", ".cursor-plugin", @@ -34,16 +23,13 @@ const MANIFEST_DIRS = Object.freeze([ ".plugin", ]); -// This plugin's own name, as `.claude-plugin/plugin.json` states it. A constant rather than -// a derivation: after a flat install `__dirname` is `.cursor/hooks/aidd-telemetry/lib`, so -// no fixed number of parent hops names the plugin on every route. Pinned by a test against -// the real manifest, so it cannot drift from the name it stands for. +// A constant rather than a derivation: after a flat install no fixed number of parent hops +// names the plugin on every route. Pinned by a test against the real manifest. const PLUGIN_NAME = "aidd-telemetry"; -// Mirrors `telemetryJournalHost` across cli/src/domain/tools/ai/*.ts — the journal's own host -// names on the left, the tool ids `.aidd/manifest.json` keys on the right. Only Claude Code -// spells them differently; the other four are the same word twice, and are listed anyway so -// a fifth host is a line here rather than a silent `undefined`. +// The journal's own host names on the left, the tool ids `.aidd/manifest.json` keys on the +// right. Only Claude Code spells them differently; the rest are listed anyway, so a further +// host is a line here rather than a silent `undefined`. const AIDD_TOOL_ID_BY_HOST = Object.freeze({ "claude-code": "claude", codex: "codex", @@ -52,9 +38,8 @@ const AIDD_TOOL_ID_BY_HOST = Object.freeze({ opencode: "opencode", }); -// Reads `version` off one manifest file — never throws. A missing manifest, one this process -// cannot read, one that is not valid JSON, or one whose `version` is absent or not a -// non-empty string are all the same fact from a hook's point of view: no version here. +// Never throws: a missing manifest, an unreadable one, invalid JSON and an absent `version` +// are all the same fact from a hook's point of view. function readManifestVersion(manifestPath) { try { const parsed = JSON.parse(fs.readFileSync(manifestPath, "utf8")); @@ -73,10 +58,8 @@ function versionBesideTheHooks() { return null; } -// `.aidd/manifest.json`'s `tools..plugins[]`, read for this host's own tool. Never for -// whichever tool happens to list this plugin first: `aidd plugin update --tool cursor` -// updates one tool's copy alone, so two entries can legitimately disagree, and answering -// with the wrong one would be worse than answering with nothing. +// Read for this host's own tool, never for whichever tool lists this plugin first: an update +// touches one tool's copy alone, so two entries can legitimately disagree. function versionFromAiddManifest(repoRoot, host) { const toolId = AIDD_TOOL_ID_BY_HOST[host]; if (typeof repoRoot !== "string" || !repoRoot || toolId === undefined) return null; @@ -95,12 +78,8 @@ function versionFromAiddManifest(repoRoot, host) { } /** - * This plugin's version, or `null` when neither route can name one. - * - * Not memoised any more, and the removal is the point: the answer now depends on where the - * question is asked from, so a cache keyed on nothing would hand one repository's answer to - * the next. `journal.cjs` runs as a brand-new process per hook invocation and asks once, so - * there was never a second call to save in production. + * This plugin's version, or `null` when neither route can name one. Not memoised: the answer + * depends on where the question is asked from, and the hook is a new process each time. */ function pluginVersion(repoRoot, host) { return versionBesideTheHooks() ?? versionFromAiddManifest(repoRoot, host); diff --git a/plugins/aidd-telemetry/hooks/lib/record.cjs b/plugins/aidd-telemetry/hooks/lib/record.cjs index d25c95faa..f6ad8726c 100644 --- a/plugins/aidd-telemetry/hooks/lib/record.cjs +++ b/plugins/aidd-telemetry/hooks/lib/record.cjs @@ -97,19 +97,13 @@ function findRunFileByVendorId(dir, vendorId) { // on session_start, so a reader can tell a file's shape without scanning it. const SCHEMA_VERSION = 2; -// Which export-side attribute vendor_id can be joined against, per host - each host's own -// hooks/lib/tools/.cjs states it, measured or explicitly null; this is that fact -// gathered into the one shape buildSessionStartLine below already expects. +// Which export-side attribute vendor_id joins against, per host: each host's own tools file +// states it, measured or explicitly null, and this gathers them into one shape. const VENDOR_FIELD_BY_HOST = Object.freeze( - Object.fromEntries( - Object.entries(TOOLS_BY_HOST).map(([host, tool]) => [host, tool.vendorField]) - ) + Object.fromEntries(Object.entries(TOOLS_BY_HOST).map(([host, tool]) => [host, tool.vendorField])) ); -// codexSessionIdFromTranscriptPath and readSessionId(host, payload) live in -// hooks/lib/tools/ now (codex.cjs and tools/index.cjs respectively) and are re-exported -// below unchanged - CLI tests reach them by exactly these names (see -// cli/tests/helpers/telemetry-journal-hook.ts). +// Re-exported below under exactly these names: the CLI's test helpers reach them that way. const { codexSessionIdFromTranscriptPath } = require("./tools/codex.cjs"); const PRIVATE_FILE_MODE = 0o600; @@ -120,23 +114,14 @@ function appendLine(filePath, line) { fs.appendFileSync(filePath, `${JSON.stringify(line)}\n`, { mode: PRIVATE_FILE_MODE }); } -// worktree_id / worktree_repo_id are omitted entirely, never written as null or "", for a -// session that is not in a linked worktree - the common case, a plain checkout. See -// `worktreeFields` in hooks/lib/repo.cjs for why absence is the only honest answer there: -// an empty string would gather every plain checkout into one group as though they were the -// same worktree. They are appended after the fields that were already on this line, so a -// reader keyed on order sees no existing key move. No schema_version bump: an optional -// field a reader may not know about is exactly what an optional field is for. +// worktree_id, worktree_repo_id and plugin_version are omitted entirely, never null and +// never "", when nothing can name one - see `worktreeFields` in repo.cjs for why absence is +// the only honest answer. They are appended after the fields already on this line, so a +// reader keyed on order sees no existing key move, and an optional field a reader may not +// know about needs no schema_version bump. // -// plugin_version is the same shape, appended after them for the same reason: this plugin's -// own version, from `plugin-version.cjs`'s two routes - the manifest beside these hooks -// after a tool's own install, or what the `aidd` CLI recorded when it did the install - and -// omitted entirely, never `null` and never a guessed or inherited value, when neither can -// name one. Stamped once, on the one line that names the session, never repeated -// on every later line: it is a fact about which build of this plugin observed the whole -// session, not a per-event fact. Never the framework's own version, and never the CLI's - -// this hook is the one producer that can honestly say which build of *this plugin* wrote -// this line. +// plugin_version is stamped once, on the line that names the session: it is a fact about +// which build of this plugin observed the session, never the framework's or the CLI's. function buildSessionStartLine({ at, runId, @@ -172,42 +157,33 @@ function buildTurnEndLine({ at, promptId }) { return line; } -// path is repository-relative and "/"-separated on every platform. Never a task_id: that -// derivation belongs to the reader, not the writer. -// `source` says how the path came to be known, for the same reason step_attribution does: -// "tool-stated" is the path the host handed us, exact and with no false positive. -// "observed" is a file that changed inside a task folder while this session was running, -// which is how a write made through a shell command or an apply_patch becomes visible at -// all - and which can, in principle, catch a file something else on the machine wrote in -// the same window. A consumer that must not risk that filters on this field. +// path is repository-relative and "/"-separated on every platform, never a task_id: that +// derivation belongs to the reader. `source` says how the path came to be known - +// "tool-stated" is exact, "observed" is a file that changed inside a task folder while the +// session ran, which can in principle catch a write something else on the machine made. function buildFileWrittenLine({ at, path: writtenPath, source }) { return { type: "file_written", at, path: writtenPath, source }; } -// A start, and nothing else. No end, no duration, no parent: no tool exposes when a -// skill's work finishes, so all three would be a conclusion stored as a fact. The skill -// name is sanitised as a value, never as a path segment - it is a name here, not a -// location. turn_id is omitted, never written as null, when the host carries none. +// A start and nothing else: no tool exposes when a skill's work finishes, so an end, a +// duration or a parent would be a conclusion stored as a fact. The skill name is sanitised +// as a value, never as a path segment. function buildStepStartLine({ at, skill, turnId }) { const line = { type: "step_start", at, skill: sanitizeSkillName(skill) }; if (typeof turnId === "string" && turnId !== "") line.turn_id = turnId; return line; } -// The end of the step `skill` names, told rather than inferred - see step-ends.cjs for why no -// hook can observe it. Carries the skill, never only the moment: a bare end would have to -// close whatever step is open, which closes the wrong one the moment a skill invokes another. +// Told rather than inferred - see step-ends.cjs for why no hook can observe it. Carries the +// skill, never only the moment: a bare end closes whatever step is open, which is the wrong +// one as soon as a skill invokes another. function buildStepEndLine({ at, skill }) { return { type: "step_end", at, skill: sanitizeSkillName(skill) }; } -// A start, told rather than inferred, the same way step_start is: a tool call named a task -// path, so this session is on that task from here. No end on this line either, and for the -// same reason step_start carries none - closing is the reader's derivation, from whichever -// turn_end or later task_declared comes next (see buildTaskIntervals in the CLI's own -// cli/src/domain/models/task-attribution.ts, which is where that walk lives now). path is -// repository-relative like file_written's, never a task_id: the derivation belongs to the -// reader. +// A tool call named a task path, so this session is on that task from here. No end on this +// line, for the reason step_start carries none: closing is the reader's derivation, from +// whichever turn_end or later task_declared comes next. function buildTaskDeclaredLine({ at, path: declaredPath }) { return { type: "task_declared", at, path: declaredPath }; } @@ -219,26 +195,21 @@ function sanitizeSkillName(skill) { return cleaned === "" || cleaned === "." || cleaned === ".." ? "-" : cleaned; } -// sessionId arrives already read behind the host declaration (see readSessionId above) - -// this function never assumes payload.session_id is that host's own spelling. The working -// directory is read the same way, behind readCwd - Cursor names it workspace_roots, never -// cwd (see hooks/lib/repo.cjs). +// sessionId and the working directory both arrive behind the host's own declaration: Cursor +// names its directory workspace_roots, never cwd. function handleSessionStart(payload, host, sessionId) { const target = resolveWriteTarget(readCwd(host, payload)); if (!target) return; const { projectId, projectRemote, dir, repoRoot, worktreeId, worktreeRepoId } = target; - // Before the run file, and outside the guard below: the commit trailer's call site can go - // missing in a repository whose `prepare-commit-msg` another tool regenerates, and a - // session that already has a run file is exactly the one whose second SessionStart should - // still put it back. Here rather than in `journal.cjs` because this is the only place that - // already holds an *enabled* repository's hooks directory - `resolveWriteTarget` gated on - // that and paid the `rev-parse` for it - and buying a module boundary with a second - // process on every session start is not a trade this hook makes. - repairCommitTrailerHook(target.hooksDir, target.gitDir); - - // SessionStart is not documented to fire once per session_id - `source` takes values - // beyond `startup` - so this guard prevents a second file for one vendor_id. + // Before the run file and outside the guard below: the trailer's call site can go missing + // where another tool regenerates `prepare-commit-msg`, and the session that already has a + // run file is exactly the one whose next SessionStart should put it back. Here because + // this is the only place already holding an enabled repository's hooks directory. + repairCommitTrailerHook(target.hooksDir, target.gitDir, repoRoot); + + // SessionStart is not documented to fire once per session_id, so this guard prevents a + // second file for one vendor_id. if (findRunFileByVendorId(dir, sessionId)) return; const runId = generateUlid(); @@ -274,25 +245,19 @@ function handleTurnEnd(payload, host, sessionId) { appendLine(filePath, buildTurnEndLine({ at: nowIso(), promptId: payload.prompt_id })); } -// A payload's session is only readable behind a known host's own spelling -// (readSessionId above), so an unrecognised one has no session and therefore no run file -// to append to; it lands in one file shared by the whole repo instead, named so it can -// never collide with `__.jsonl`. +// An unrecognised payload has no readable session and so no run file to append to: it lands +// in one file shared by the repository, named so it can never collide with a run file. const UNRECOGNISED_FILE_NAME = "_unrecognised.jsonl"; -// Overwritten, not appended: journal.cjs already keeps this off the tool-used path (the -// git-shellout gate), so this only ever runs once per session-start or turn-end - but it -// still stays at exactly one line however many of those arrive, and `at` is always the -// most recent one rather than freezing on the first. A marker that never moved forward -// would recreate the stale-forever diagnosis this whole change removed from `hook fired`. +// Overwritten, not appended: the file stays at exactly one line however many events arrive, +// and `at` is always the most recent, since a marker frozen on the first would read as stale +// forever. function handleUnrecognisedPayload(payload) { - // An unrecognised payload's own shape is, by definition, unknown - it may spell its - // working directory differently, or carry none at all (Cursor already does this among - // declared hosts), so payload.cwd is used only when it looks usable. process.cwd() - // falls back: a hook always runs inside the project it measures, which is a fact about - // where this process runs, not a guess about the payload. Which of the two produced a - // given marker is not recorded, since it does not change the answer. - const cwd = payload && typeof payload.cwd === "string" && payload.cwd ? payload.cwd : process.cwd(); + // An unrecognised payload's shape is by definition unknown - it may spell its working + // directory differently or carry none, as Cursor already does - so payload.cwd is used + // only when usable, and process.cwd() falls back on where this hook is running. + const cwd = + payload && typeof payload.cwd === "string" && payload.cwd ? payload.cwd : process.cwd(); const target = resolveRunsDir(cwd); if (!target) return; diff --git a/plugins/aidd-telemetry/hooks/lib/repo.cjs b/plugins/aidd-telemetry/hooks/lib/repo.cjs index a5b178b50..1951fcf48 100644 --- a/plugins/aidd-telemetry/hooks/lib/repo.cjs +++ b/plugins/aidd-telemetry/hooks/lib/repo.cjs @@ -19,7 +19,11 @@ function gitEnv() { function getRepoRoot(cwd) { if (typeof cwd !== "string" || !cwd) return null; try { - const result = spawnSync("git", ["rev-parse", "--show-toplevel"], { cwd, encoding: "utf8", env: gitEnv() }); + const result = spawnSync("git", ["rev-parse", "--show-toplevel"], { + cwd, + encoding: "utf8", + env: gitEnv(), + }); if (result.status !== 0) return null; const root = result.stdout.trim(); return root || null; @@ -32,30 +36,21 @@ function getRepoRoot(cwd) { const WORKTREES_SEGMENT = "worktrees"; const GIT_DIR_NAME = ".git"; -// The worktree a session ran in, named so two worktrees of one repository can be -// told apart in a journal - taken from git, never from an agent runner's own variable, -// which names that runner's concept rather than the repository's. +// Taken from git, never from an agent runner's own variable, which names that runner's +// concept rather than the repository's. // -// A plain checkout gets NEITHER field: absent, never null and never "". An absent value is -// the only one a reader cannot mistake for a worktree that happens to be called something; -// an empty string would gather every plain checkout on earth into one group as though they -// were the same worktree. That is the error `cost-report.ts`'s `NO_KNOWN_PROJECT` symbol -// exists to prevent for a record naming no project, and it is prevented here the same way - -// by the field not being there at all. +// A plain checkout gets NEITHER field: absent, never null and never "" - an empty string +// would gather every plain checkout into one group as though they were the same worktree. // // The layout is the test, never a comparison of the two directories: two spellings of one -// path (a Windows drive letter cased differently in each) would compare unequal and write -// a worktree field on a plain checkout. `path.basename` reads "/" and "\" alike on Windows, -// and `path.resolve` is applied first because `git rev-parse` prints these two relative to -// the cwd it ran in for a plain checkout and absolute for a linked worktree. +// path would compare unequal and write a worktree field on a plain checkout. `path.resolve` +// comes first because `git rev-parse` prints these relative for a plain checkout. function worktreeFields(cwd, commonDir, gitDir) { if (!commonDir || !gitDir) return {}; const resolvedGitDir = path.resolve(cwd, gitDir); - // A plain checkout's git directory is always the literal `.git` inside the working tree, - // whatever that tree is called - so a repository that happens to live in a directory - // named `worktrees` would otherwise pass the layout test below and be recorded as a - // worktree named ".git". The linked worktree's git directory is named for the worktree - // and is never `.git`. + // A plain checkout's git directory is the literal `.git`, so a repository living in a + // directory named `worktrees` would otherwise pass the layout test below. A linked + // worktree's git directory is named for the worktree and is never `.git`. if (path.basename(resolvedGitDir) === GIT_DIR_NAME) return {}; if (path.basename(path.dirname(resolvedGitDir)) !== WORKTREES_SEGMENT) return {}; const repoName = repositoryNameFromCommonDir(path.resolve(cwd, commonDir)); @@ -65,12 +60,9 @@ function worktreeFields(cwd, commonDir, gitDir) { }; } -// The repository every worktree of one clone shares, named from the directory that holds -// it: `/.git` and a bare `.git` both answer ``. Recorded beside the -// worktree rather than left to `project_id`, which falls back to the *worktree's* own -// directory name when a clone has no remote - so two worktrees of a remote-less clone -// carry two different `project_id` values and nothing else on the line would say they -// belong together. +// `/.git` and a bare `.git` both answer ``. Recorded beside the worktree +// rather than left to `project_id`, which falls back to the worktree's own directory name +// when a clone has no remote - so two worktrees of one clone would look unrelated. function repositoryNameFromCommonDir(commonDir) { const base = path.basename(commonDir); const name = @@ -78,19 +70,11 @@ function repositoryNameFromCommonDir(commonDir) { return name === "" || name === "." || name === ".." ? null : sanitizePathSegment(name); } -// One `git rev-parse` in the ordinary case, and never more than two. -// -// The fourth option, `--git-path hooks`, is what finds the directory git will actually run a -// hook from: it honours `core.hooksPath`, which joining `.git/hooks` by hand does not, and -// from a linked worktree it answers the common hooks directory, which is where git looks. -// -// It cannot simply be added to the list, and that is a measured constraint rather than a -// stylistic one. `rev-parse` fails atomically — a git that does not understand one option -// answers non-zero for all of them — and this call returning null makes `resolveWriteTarget` -// return null, which makes the journal write nothing at all. Against a git stubbed to reject -// `--git-path`, the hook exited 0 and recorded no session. So the four-option form is asked -// first and the three-option form is the fallback: every git pays one call, and only one too -// old to answer the fourth pays a second and simply goes without the hooks directory. +// One `git rev-parse` in the ordinary case, and never more than two. `--git-path hooks` +// honours `core.hooksPath`, which joining `.git/hooks` by hand does not, but `rev-parse` +// fails atomically: a git that does not understand one option answers non-zero for all of +// them, and a null here makes the journal write nothing at all. So the four-option form is +// asked first, and an older git pays a second call and goes without the hooks directory. function getRepoLocation(cwd) { if (typeof cwd !== "string" || !cwd) return null; try { @@ -102,15 +86,12 @@ function getRepoLocation(cwd) { return { repoRoot: root, // Carried so the trailer repair can tell a hooks directory inside the git directory - // from one `core.hooksPath` points at inside the working tree - the second being - // version-controlled content nothing here writes to unasked. + // from one inside the working tree, which is version-controlled content. ...(commonDir ? { gitDir: path.resolve(cwd, commonDir) } : {}), ...worktreeFields(cwd, commonDir, gitDir), - // Resolved against `cwd`, never against the repository root: `git rev-parse` prints - // this relative to the directory it ran in, exactly as `worktreeFields` above says of - // its own two outputs. Resolving against the root instead sent a session started in - // `sub/deep` two levels ABOVE the repository - measured, `../../.git/hooks` became a - // path outside the checkout entirely, which is where the repair then wrote. + // Against `cwd`, never against the repository root: `git rev-parse` prints this + // relative to the directory it ran in, so resolving against the root sends a session + // started in `sub/deep` two levels above the checkout, which is where repair writes. ...(hooksDir ? { hooksDir: path.resolve(cwd, hooksDir) } : {}), }; } catch { @@ -120,9 +101,8 @@ function getRepoLocation(cwd) { const LOCATION_OPTIONS = ["--show-toplevel", "--git-common-dir", "--git-dir"]; -/** The trimmed words `rev-parse` printed, or `null` when it refused. A git too old for one - * of the options refuses all of them, which is why the caller has a shorter form to fall - * back to rather than a shorter reading of this one. */ +/** `null` when `rev-parse` refused. A git too old for one option refuses all of them, which + * is why the caller falls back to a shorter form rather than a shorter reading of this. */ function revParse(cwd, options) { const result = spawnSync("git", ["rev-parse", ...options], { cwd, @@ -130,11 +110,12 @@ function revParse(cwd, options) { env: gitEnv(), }); if (result.status !== 0) return null; - return String(result.stdout).split("\n").map((part) => part.trim()); + return String(result.stdout) + .split("\n") + .map((part) => part.trim()); } -// `aidd framework build` copies hooks/ verbatim with no install step, so JSON.parse is -// the only parser available. +// hooks/ is copied verbatim with no install step, so JSON.parse is the only parser here. function readTelemetryConfig(repoRoot) { try { return JSON.parse(fs.readFileSync(path.join(repoRoot, ".aidd", "config.json"), "utf8")); @@ -143,26 +124,20 @@ function readTelemetryConfig(repoRoot) { } } -// The only refusal available at a person's own scope. Not a second config file: state for -// "is this measured" already lives in .aidd/config.json (the project's tracked decision), -// and a file at the person's scope would be a third place the same fact could live, in a -// change whose point is that there are too many already. An environment variable is -// refusable per shell, per session and per machine, and needs nothing to be created. +// The only refusal at a person's own scope, and a variable rather than a second config file: +// it is refusable per shell, per session and per machine, and needs nothing created. // -// Only the literal string "0" counts as a refusal. Unset or empty is not a choice this -// variable can express - it never turns measurement on by itself, and it never overrides an -// enabled project. `cli/src/domain/models/telemetry-switch.ts`'s `personRefusesTelemetry` -// mirrors this exactly, so the hook and the CLI can never disagree about whether a person -// has refused. +// Only the literal "0" is a refusal - unset or empty is not a choice this can express, and it +// never turns measurement on by itself. The CLI's own `personRefusesTelemetry` mirrors this +// exactly, so the two can never disagree about whether a person has refused. const TELEMETRY_REFUSAL_VARIABLE = "AIDD_TELEMETRY"; function personRefusesTelemetry() { return process.env[TELEMETRY_REFUSAL_VARIABLE] === "0"; } -// Strict `=== true`, not truthy: a half-written config must read as off, not on. The -// person's own refusal is read before the project's file, and wins over it unconditionally - -// a project that turns measurement on can never out-rank the person running it. +// Strict `=== true`, not truthy: a half-written config must read as off. The person's refusal +// is read first and wins unconditionally - no project out-ranks the person running it. function telemetryEnabled(repoRoot) { if (personRefusesTelemetry()) return false; const config = readTelemetryConfig(repoRoot); @@ -209,11 +184,7 @@ function sanitizePathSegment(segment) { } function sanitizeProjectId(projectId) { - return projectId - .split("/") - .filter(Boolean) - .map(sanitizePathSegment) - .join("/"); + return projectId.split("/").filter(Boolean).map(sanitizePathSegment).join("/"); } // Split from deriveProjectId so a caller holding remoteUrl pays one git shellout, not two. @@ -234,18 +205,14 @@ function runsDir(repoRoot) { return process.env.AIDD_RUNS_DIR || path.join(repoRoot, "aidd_docs", "runs"); } -// What this hook writes is who-worked-on-what-for-how-long, so it is not left -// world-readable. Windows accepts this mode on mkdirSync/appendFileSync/chmodSync -// without error, but does nothing with it - the directory and every file in it land at -// 0666 regardless (measured on a real windows-latest runner). Privacy there comes -// from restrictToCurrentUser below, not from this constant. +// What this hook writes is who-worked-on-what-for-how-long, so it is not left world-readable. +// Windows accepts this mode without error and does nothing with it - everything lands at 0666 +// regardless - so privacy there comes from restrictToCurrentUser below instead. const PRIVATE_DIR_MODE = 0o700; -// `mkdirSync`'s `mode` applies only to a directory it creates, so a checked-out -// `aidd_docs/runs/` needs this chmod - and, on Windows, needs it reset again on every -// write, since anything (a checkout, an admin, another tool) could have widened it since -// the last one. Never applied to a user-named AIDD_RUNS_DIR - a user who names their own -// runs directory keeps responsibility for its permissions. +// `mkdirSync`'s `mode` applies only to a directory it creates, so a checked-out runs +// directory needs this chmod - and on Windows needs it reset on every write, since anything +// could have widened it since. Never on a user-named AIDD_RUNS_DIR: that is theirs to set. function tightenOwnedDir(dir) { if (process.env.AIDD_RUNS_DIR) return; if (process.platform === "win32") return restrictToCurrentUser(dir, { inheritable: true }); @@ -256,28 +223,19 @@ function tightenOwnedDir(dir) { } } -// POSIX needs no second pass: `appendFileSync`'s own `mode` already set 0600 at the -// moment it created the file. On Windows this is the direct, non-recursive reset every -// file this code writes gets on its own. +// POSIX needs no second pass: `appendFileSync`'s own `mode` set 0600 as it created the file. function tightenOwnedFile(filePath) { if (process.env.AIDD_RUNS_DIR) return; if (process.platform === "win32") restrictToCurrentUser(filePath); } // The real mechanism on Windows: reset the target's NTFS ACL to inherit nothing and grant -// Full Control to the current user alone. `inheritable` adds the container-inherit flags -// `(OI)(CI)` so a directory's own future children pick up the same grant; a file gets -// neither, since a file has no children to inherit anything. Never `/T`: measured on a -// real windows-latest runner, `/T` walked into files this code does not own - a -// checked-out `.gitkeep` among them - and left at least one with no usable ACE of its -// own, so an ordinary `git add -A` right after got "Permission denied" opening it. It -// bought nothing here anyway: a file this code creates gets its own tightenOwnedFile -// pass, and `(OI)(CI)` alone makes the directory's own grant apply to anything created -// in it afterward - so this now only ever touches the target's own ACL entry, never a -// file already sitting inside a directory it is applied to. `icacls` shelled out to the -// same way `git` already is above; `/C` keeps it going past one bad entry instead of -// aborting the whole reset, and its own exit code is not trusted as proof of anything - -// only a caller reading the ACL back can say whether it worked. +// Full Control to the current user alone. `inheritable` adds `(OI)(CI)` so a directory's +// future children pick up the grant; a file has no children to inherit anything. +// +// Never `/T`: it walks into files this code does not own and can leave one with no usable +// ACE, so an ordinary `git add -A` then fails with "Permission denied". `/C` keeps icacls +// going past one bad entry, and its exit code is not trusted as proof of anything. function restrictToCurrentUser(target, { inheritable = false } = {}) { try { const owner = process.env.USERDOMAIN @@ -292,30 +250,21 @@ function restrictToCurrentUser(target, { inheritable = false } = {}) { } } -// Decision, not an inherited default: a worktree keeps its own journal. -// `getRepoRoot` resolves `--show-toplevel`, the worktree's own root - never -// `--git-common-dir`'s shared repository, which this deliberately does not read. -// -// Two reasons hold it there. First, the layout an agent runner actually gives each agent -// - Orca sets ORCA_WORKTREE_ID and does exactly this - is a bare clone plus worktrees, -// which has no main working tree to write into at all: `--git-common-dir` there names the -// bare `.git`, whose parent is not a checkout. Second, even where a main worktree does -// exist, writing into it from worktree B would dirty a checkout on a different branch, -// possibly with uncommitted work of its own, whose `.gitignore` was never asked to carry -// the entry `aidd telemetry on` added when B turned measurement on. +// A decision, not an inherited default: a worktree keeps its own journal, at the worktree's +// own root and never at `--git-common-dir`'s shared repository. // -// Cross-worktree joining - so a report can still see every worktree's sessions together - -// `worktreeFields` above names the worktree on `session_start`, and the write -// target stays exactly where this function has always put it. +// A bare clone plus worktrees has no main working tree to write into at all, and even where +// one exists, writing into it from another worktree dirties a checkout on a different branch +// whose `.gitignore` was never asked to carry the entry. Cross-worktree joining is served by +// `worktreeFields` naming the worktree on `session_start` instead. function resolveRunsDir(cwd) { const location = getRepoLocation(cwd); if (!location || !telemetryEnabled(location.repoRoot)) return null; return { ...location, dir: runsDir(location.repoRoot) }; } -// A token-authenticated clone leaves a live credential in the remote's userinfo -// (`https://ghp_xxx@host/o/r`), and the journal is meant to be read and shipped. Only -// scheme-bearing URLs have userinfo; scp-style `git@host:owner/repo` is left whole. +// A token-authenticated clone leaves a live credential in the remote's userinfo, and the +// journal is meant to be read and shipped. Only a scheme-bearing URL has userinfo. function remoteWithoutCredentials(remoteUrl) { if (typeof remoteUrl !== "string") return null; return remoteUrl.replace(/^([a-zA-Z][a-zA-Z0-9+.-]*:\/\/)[^/]*@/u, "$1"); diff --git a/plugins/aidd-telemetry/hooks/lib/step-ends.cjs b/plugins/aidd-telemetry/hooks/lib/step-ends.cjs index 2e86df3fc..20ba8ae35 100644 --- a/plugins/aidd-telemetry/hooks/lib/step-ends.cjs +++ b/plugins/aidd-telemetry/hooks/lib/step-ends.cjs @@ -1,14 +1,10 @@ -// When a skill's work finished, told rather than inferred - the one thing about a step that -// no host emits. Measured: the `tool_result` for a `Skill` call comes back about a tenth of a -// second after the call, which is the dispatch and not the completion, so `PostToolUse` never -// sees an end. `step-starts.cjs` says the same in its own header. The only party that knows -// the work is over is the skill, and the only channel it has is a tool call it makes. +// When a skill's work finished, told rather than inferred: no host emits it. A `Skill` call's +// `tool_result` comes back a tenth of a second later, which is the dispatch and not the +// completion, so the only party that knows the work is over is the skill itself. // -// Read out of the call's own free-form arguments, exactly as `task-declared.cjs` reads a task -// path, and for the same reason: it asks nothing of the host, so every host that forwards -// tool arguments is covered rather than one. The hook stays the writer - a script a skill -// invoked directly would carry no payload, therefore no session id and no cwd, and could not -// find the run file to append to. +// Read out of the call's own free-form arguments, which asks nothing of the host, so every +// host forwarding tool arguments is covered. The hook stays the writer: a script a skill +// invoked directly would carry no session id and no cwd, and could find no run file. const fs = require("node:fs"); @@ -17,21 +13,15 @@ const { resolveRunsDir } = require("./repo.cjs"); const { readCwd } = require("./tools/index.cjs"); const { findRunFileByVendorId, appendLine, buildStepEndLine, nowIso } = require("./record.cjs"); -// The marker, then the skill it closes. Naming the skill is not decoration: closing "whatever -// step is open" closes the wrong one the moment a skill invokes another. -// -// The name must begin with a letter, which is what refuses a path fragment - `../../etc/passwd` -// offers `.` at the first position and matches nothing. `[\w.-]` after that covers every skill -// name this repository ships (`artifact-design`) and the plugin-qualified form -// (`aidd-dev:01-plan`), and nothing else: no slash, no space, no shell metacharacter, so a -// later reader never has to defend against one. +// The marker, then the skill it closes: closing "whatever step is open" closes the wrong one +// the moment a skill invokes another. The name must begin with a letter, which refuses a path +// fragment, and the rest admits no slash, space or shell metacharacter, so a later reader +// never has to defend against one. const STEP_END_PATTERN = /aidd:step-end[ \t]+([A-Za-z][\w.-]*(?::[\w.-]+)?)/u; -// A tool call that merely *mentions* the marker - reading this file, grepping for it - closes -// a step it never ran. The same false positive `task-declared.cjs` accepts for a task path -// mentioned in passing, and bounded the same way: a step end can only ever close an interval -// its own session already opened for that exact skill, so a mention in another session, or of -// a skill that never started, writes a line the reader ignores. +// A call merely *mentioning* the marker closes a step it never ran - the same false positive +// `task-declared.cjs` accepts, bounded the same way: an end can only close an interval its own +// session opened for that exact skill, so any other mention writes a line the reader ignores. function firstStepEndIn(value) { for (const candidate of stringsWithin(value)) { const match = STEP_END_PATTERN.exec(normalizeSeparators(candidate)); @@ -40,19 +30,15 @@ function firstStepEndIn(value) { return null; } -// tool_input is every declared host's own shape for a tool call's arguments; only Copilot's -// canonical builder spells them toolArgs, as a JSON string - which a plain string scan reads -// exactly as well as a parsed object would, so it is read the same way rather than parsed -// first. Identical to `declaredTaskPath`'s own two-field read, deliberately. +// tool_input is every declared host's shape for a call's arguments; only Copilot's canonical +// builder spells them toolArgs, as a JSON string a plain scan reads just as well. function declaredStepEnd(payload) { return firstStepEndIn(payload.tool_input) ?? firstStepEndIn(payload.toolArgs); } -// The same watermark `task-declared.cjs` protects, for the same reason: file-writes.cjs reads -// the run file's own mtime as "the moment this session last wrote a line" to know what changed -// since, and an end landing between a shell write and the turn end that observes it would push -// that mark past the write, silently dropping it. Restoring the mtime leaves the line itself -// the only trace on disk. +// The watermark `task-declared.cjs` protects, for the same reason: file-writes.cjs reads the +// run file's mtime as "the moment this session last wrote a line", and an end landing between +// a shell write and the turn end that observes it would push that mark past the write. function mtimeOf(filePath) { try { return fs.statSync(filePath).mtime; diff --git a/plugins/aidd-telemetry/hooks/lib/step-starts.cjs b/plugins/aidd-telemetry/hooks/lib/step-starts.cjs index 56df86b9e..de9f151c0 100644 --- a/plugins/aidd-telemetry/hooks/lib/step-starts.cjs +++ b/plugins/aidd-telemetry/hooks/lib/step-starts.cjs @@ -8,11 +8,8 @@ const { readCwd, toolFor, TOOLS_BY_HOST } = require("./tools/index.cjs"); const { findRunFileByVendorId, appendLine, buildStepStartLine, nowIso } = require("./record.cjs"); const { SKILL_FILE_PATTERN } = require("./tools/skill-detection.cjs"); -// Gathered from the per-host declarations for a caller that wants every host covered -// rather than one at a time (see cli/tests/helpers/telemetry-journal-hook.ts and -// scripts/__tests__/aidd-telemetry-journal.test.js). A host with no stepStart - OpenCode, -// whose plugin forwards no tool call at all - is simply absent, the same shape a -// hand-maintained table gave before this moved. +// Gathered from the per-host declarations, for a caller that wants every host at once. A host +// with no stepStart is simply absent. const STEP_START_BY_HOST = Object.freeze( Object.fromEntries( Object.entries(TOOLS_BY_HOST) diff --git a/plugins/aidd-telemetry/hooks/lib/task-declared.cjs b/plugins/aidd-telemetry/hooks/lib/task-declared.cjs index 956484660..2c88d03be 100644 --- a/plugins/aidd-telemetry/hooks/lib/task-declared.cjs +++ b/plugins/aidd-telemetry/hooks/lib/task-declared.cjs @@ -1,9 +1,7 @@ -// Which ticket a session is on, told rather than inferred - the same move step-starts.cjs -// already made for which skill is running. A task is inferred today from a written path, and -// only Claude Code's payload hands one over in readable form. A declaration asks nothing of -// the host: any tool call whose own arguments name a file under a task folder is evidence the -// flow is on that task, and naming the file you are about to read (or edit, or grep) is what -// calling the tool already requires - Read, Edit, and a Bash command line all carry it. +// Which ticket a session is on, told rather than inferred. Inferring it from a written path +// works only where the host hands one over in readable form; a declaration asks nothing of the +// host, since any call whose arguments name a file under a task folder is evidence the flow is +// on that task, and naming the file is what calling the tool already requires. const fs = require("node:fs"); @@ -12,12 +10,10 @@ const { resolveRunsDir } = require("./repo.cjs"); const { readCwd, toolFor } = require("./tools/index.cjs"); const { findRunFileByVendorId, appendLine, buildTaskDeclaredLine, nowIso } = require("./record.cjs"); -// Unanchored, and tolerant of sitting inside a larger string - a quote or whitespace closes -// it, the same tolerance SKILL_FILE_PATTERN gives a Codex shell command line. Two shapes, -// matching file-writes.cjs's own TASK_SEGMENT_PATTERN exactly: a folder task's path continues -// past a "/" into its own file, a single-file task's ends the segment itself in ".md" - a -// bare segment with neither (a task folder mentioned with no file, a random ".txt") is not a -// task path either place. +// Unanchored and tolerant of sitting inside a larger string, closed by a quote or whitespace. +// Two shapes, matching file-writes.cjs's own TASK_SEGMENT_PATTERN exactly: a folder task +// continues past a "/" into its file, a single-file task ends the segment in ".md", and a bare +// segment with neither is not a task path in either place. const TASK_PATH_PATTERN = /aidd_docs\/tasks\/\d{4}_\d{2}\/[^/"'\s]+\/[^"'\s]*|aidd_docs\/tasks\/\d{4}_\d{2}\/[^/"'\s]+\.md/u; @@ -29,33 +25,25 @@ function firstTaskPathIn(value) { return null; } -// tool_input is every declared host's own shape for a tool call's arguments, Copilot's -// _vsCodeCompat builder included (see step-starts.cjs). Only Copilot's canonical builder -// carries none, spelling its arguments toolArgs instead - a JSON string, but a plain string -// scan finds a path inside it exactly as well as a parsed object would, so it is read the -// same way rather than parsed first. +// tool_input is every declared host's shape for a call's arguments; only Copilot's canonical +// builder spells them toolArgs, as a JSON string a plain scan reads just as well. function declaredTaskPath(payload) { return firstTaskPathIn(payload.tool_input) ?? firstTaskPathIn(payload.toolArgs); } -// A write into a task folder that this host's own extractor can already name is -// handleFileWritten's claim, not this one's: that reading is exact (a field the host itself -// populated), where a declaration is only ever an inference from arguments text, and the two -// firing on the same event would be two claims about one write. Restricted to hosts and tools -// whose own hooks/lib/tools/.cjs names a writtenPath extractor - a Bash write, which no -// extractor reads on any host, is not excluded here and reaches the declaration below on its -// own arguments text. +// A write this host's own extractor can name is handleFileWritten's claim: that reading is +// exact, where a declaration is an inference from arguments text, and both firing on one event +// would be two claims about one write. A Bash write, which no extractor reads on any host, is +// not excluded and reaches the declaration below on its arguments text. function statedAsWrittenAlready(payload, host) { const tool = toolFor(host); const extractWrittenPath = tool && tool.writtenPath; return typeof extractWrittenPath === "function" && typeof extractWrittenPath(payload) === "string"; } -// A declaration must never move the run file's own mtime forward: file-writes.cjs's observed -// pass reads that mtime as "the moment this session last wrote a line" to know what changed -// since. A task mention landing between a shell write and the turn end that observes it would -// push the watermark past that write, silently dropping it - so the append below restores the -// mtime it found, leaving the line itself the only trace on disk. +// A declaration must never move the run file's mtime forward: the observed pass reads it as +// "the moment this session last wrote a line", and a mention landing between a shell write and +// the turn end that observes it would push the watermark past that write. function mtimeOf(filePath) { try { return fs.statSync(filePath).mtime; @@ -73,9 +61,8 @@ function restoreMtime(filePath, mtime) { } } -// Its own guard chain, deliberately not handleFileWritten's: that one only fires for a write -// tool on the one host whose field names are known, while this fires for any tool call on any -// host, because the evidence it reads is the arguments themselves rather than a named field. +// Its own guard chain, not handleFileWritten's: this fires for any call on any host, because +// the evidence it reads is the arguments themselves rather than a named field. function handleTaskDeclared(payload, host, sessionId) { if (statedAsWrittenAlready(payload, host)) return; diff --git a/plugins/aidd-telemetry/hooks/lib/tools/claude-code.cjs b/plugins/aidd-telemetry/hooks/lib/tools/claude-code.cjs index f007ee5dd..8040b54fd 100644 --- a/plugins/aidd-telemetry/hooks/lib/tools/claude-code.cjs +++ b/plugins/aidd-telemetry/hooks/lib/tools/claude-code.cjs @@ -20,7 +20,7 @@ function writtenPath(payload) { module.exports = { readSessionId: (payload) => payload.session_id, readCwd: (payload) => payload.cwd, - // CLAUDE_TELEMETRY_IDENTITY_ATTRIBUTE, measured 2026-08-13. + // CLAUDE_TELEMETRY_IDENTITY_ATTRIBUTE, measured on its own export. vendorField: "session.id", stepStart: { skillName: skillNameFromArgument({ @@ -31,10 +31,8 @@ module.exports = { }), turnIdField: "prompt_id", }, - // Claude Code alone, and that is a coverage fact rather than an oversight: Copilot and - // Cursor were never captured handing a path to a hook, and Codex writes through an - // apply_patch command string. A host with no writtenPath here is not blind to tasks - - // file-writes.cjs's observed pass covers it - but a stated path is exact where an - // observed one is inferred, so it is preferred wherever it exists. + // Claude Code alone: no other host was captured handing a path to a hook. A host without + // one is not blind to tasks - file-writes.cjs's observed pass covers it - but a stated path + // is exact where an observed one is inferred. writtenPath, }; diff --git a/plugins/aidd-telemetry/hooks/lib/tools/codex.cjs b/plugins/aidd-telemetry/hooks/lib/tools/codex.cjs index 324de2ab4..5fb6b633e 100644 --- a/plugins/aidd-telemetry/hooks/lib/tools/codex.cjs +++ b/plugins/aidd-telemetry/hooks/lib/tools/codex.cjs @@ -6,22 +6,14 @@ const { skillNameFromSkillFileRead } = require("./skill-detection.cjs"); -// A Codex rollout is named `rollout--.jsonl`, and that trailing uuid is -// the identity both sides of this system join on: the hook writes it as `vendor_id` (see -// `readSessionId` below) and the reader resolves a session by it (CODEX_ROLLOUT_LOCATION in -// cli/src/domain/formats/codex-rollout.ts, whose `matches` compares `-.jsonl`). The -// join is filename to filename, and holds by construction. +// A Codex rollout is named `rollout--.jsonl`, and that trailing uuid is the +// identity both sides join on: the hook writes it as `vendor_id`, the reader resolves a +// session by it. The join is filename to filename, and holds by construction. It is never +// read as `session_meta.id`, which a rollout can carry differently from its own filename. // -// It is NOT read as `session_meta.id`, and an earlier version of this comment said it was - -// "measured across every rollout on disk". Re-measured 2026-09-01 over 418 rollouts, that -// claim is false: two of them, both `thread_source: "realtime_voice"`, carry a -// `session_meta.id` that is not the uuid in their own filename. Nothing broke, because no -// code here ever reads that field; what broke was the sentence explaining why this works. -// -// The two parses live apart because hooks/ is copied verbatim by the framework build and -// can import nothing from cli/ - the same reason sanitizePathSegment is duplicated - so -// tests/domain/formats/codex-rollout.unit.test.ts pins them to each other and turns red if -// either moves. +// The two parses live apart because hooks/ is copied verbatim and can import nothing from +// cli/ - the same reason sanitizePathSegment is duplicated - so a test pins them to each +// other and turns red if either moves. const CODEX_ROLLOUT_PREFIX = "rollout-"; const CODEX_ROLLOUT_EXTENSION = ".jsonl"; const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/iu; @@ -38,11 +30,9 @@ function codexSessionIdFromTranscriptPath(transcriptPath) { } // The filename first, `session_id` only as the fallback for a payload carrying no transcript -// path - because the two disagree often. Measured 2026-09-01 over 418 rollouts on the -// machine this was written on: 158 carry a `session_meta.session_id` that is not their own -// `session_meta.id`, and it is `thread_source` that explains which - 89 `subagent`, 11 -// `user`, 58 with the field unset. A vendor_id written from `session_id` on any of those -// names another rollout, and joins to nothing while the journal still looks healthy. +// path: the two disagree on well over a third of rollouts, subagent threads most of all, and +// a vendor_id written from `session_id` there names another rollout and joins to nothing +// while the journal still looks healthy. function readSessionId(payload) { return codexSessionIdFromTranscriptPath(payload.transcript_path) ?? payload.session_id; } @@ -51,7 +41,7 @@ module.exports = { readSessionId, codexSessionIdFromTranscriptPath, readCwd: (payload) => payload.cwd, - // Measured 2026-08-13, on codex.sse_event. + // Measured on codex.sse_event. vendorField: "conversation.id", stepStart: { skillName: skillNameFromSkillFileRead, turnIdField: "turn_id" }, writtenPath: null, diff --git a/plugins/aidd-telemetry/hooks/lib/tools/copilot.cjs b/plugins/aidd-telemetry/hooks/lib/tools/copilot.cjs index f458d77d4..4c15407d5 100644 --- a/plugins/aidd-telemetry/hooks/lib/tools/copilot.cjs +++ b/plugins/aidd-telemetry/hooks/lib/tools/copilot.cjs @@ -11,14 +11,12 @@ module.exports = { // builder's - both are Copilot's own, never a fallback guess. readSessionId: (payload) => payload.sessionId ?? payload.session_id, readCwd: (payload) => payload.cwd, - // Measured 2026-08-13, on the invoke_agent span. + // Measured on the invoke_agent span. vendorField: "gen_ai.conversation.id", stepStart: { - // Canonical builder: toolName/toolArgs, toolArgs a JSON string. _vsCodeCompat builder, - // captured 2026-08-22 against a real @github/copilot@1.0.80 skill call: tool_name - // stays the canonical "skill" spelling, but tool_input arrives as an object keyed - // like Claude Code's own tool_input.skill, not like the canonical builder's - // JSON-string toolArgs. Neither was guessed; both came from a captured payload. + // The canonical builder spells them toolName/toolArgs, toolArgs a JSON string. The + // _vsCodeCompat one keeps the "skill" tool name but delivers tool_input as an object, + // keyed like Claude Code's. Neither was guessed; both came from a captured payload. skillName: skillNameFromAnyArgument([ skillNameFromArgument({ toolField: "toolName", diff --git a/plugins/aidd-telemetry/hooks/lib/tools/opencode.cjs b/plugins/aidd-telemetry/hooks/lib/tools/opencode.cjs index bf6dcdb60..bd6aec7e8 100644 --- a/plugins/aidd-telemetry/hooks/lib/tools/opencode.cjs +++ b/plugins/aidd-telemetry/hooks/lib/tools/opencode.cjs @@ -1,13 +1,8 @@ -// Everything the journal knows about OpenCode. It is not a stdin hook - its own plugin -// module (hooks/opencode-plugin.js) builds this payload itself, from the session's own -// `directory` (session_start) or the plugin's own init-time directory (turn_end) - but -// reads through the same session_id/cwd keys every stdin host uses, so the shape below -// stays one shape. It forwards only session.created and session.idle, never a tool call, -// so there is no payload here for a step or a written path to be read from at all. -// telemetryExport is itself unmeasured (session.id is documented on the ai.streamText span -// behind experimental.openTelemetry, but no export has been captured to confirm it - that -// is the export route's probe, not this one), so vendorField names nothing, the same fact Cursor's -// entry states for the same reason. +// Everything the journal knows about OpenCode. It is not a stdin hook: opencode-plugin.js +// builds this payload itself, through the same session_id/cwd keys every stdin host uses, so +// the shape below stays one shape. It forwards no tool call, so there is no payload here for +// a step or a written path to be read from. Its own export is unmeasured, so vendorField +// names nothing, the same fact Cursor's entry states for the same reason. module.exports = { readSessionId: (payload) => payload.session_id, diff --git a/plugins/aidd-telemetry/hooks/lib/tools/skill-detection.cjs b/plugins/aidd-telemetry/hooks/lib/tools/skill-detection.cjs index daf0f9e03..8fff989b1 100644 --- a/plugins/aidd-telemetry/hooks/lib/tools/skill-detection.cjs +++ b/plugins/aidd-telemetry/hooks/lib/tools/skill-detection.cjs @@ -1,6 +1,5 @@ -// How a step's skill name is found, in general - reused by more than one tool's own -// declaration, so it lives here rather than in any single one of them. Neither shape below -// names a host; each tool file passes in the field names its own payload actually uses. +// How a step's skill name is found, in general: neither shape below names a host, and each +// tool file passes in the field names its own payload uses. const { normalizeSeparators, stringsWithin } = require("../host.cjs"); @@ -43,11 +42,10 @@ function skillNameFromAnyArgument(readers) { }; } -// The path family: the host names no skill, and the only evidence is that it read a -// SKILL.md. Every string in the tool's arguments is scanned rather than one named field, -// because Cursor puts the path in `file_path` while Codex buries it in a shell command - -// and because Codex's hook calls that tool `Bash` while its own transcripts call it -// `exec_command`, so keying on a tool name would have matched nothing, silently. +// The path family: the host names no skill, and the only evidence is that it read a SKILL.md. +// Every string in the arguments is scanned rather than one named field, because Cursor puts +// the path in `file_path` while Codex buries it in a shell command - and Codex spells that +// tool differently in its hook and its transcript, so a tool name would match nothing. function skillNameFromSkillFileRead(payload) { for (const value of stringsWithin(payload.tool_input)) { const match = SKILL_FILE_PATTERN.exec(normalizeSeparators(value)); diff --git a/plugins/aidd-telemetry/hooks/lib/trailer-repair.cjs b/plugins/aidd-telemetry/hooks/lib/trailer-repair.cjs index b1a33d591..fed7f3d4c 100644 --- a/plugins/aidd-telemetry/hooks/lib/trailer-repair.cjs +++ b/plugins/aidd-telemetry/hooks/lib/trailer-repair.cjs @@ -6,33 +6,23 @@ const path = require("node:path"); /** * Puts back the one line that makes a commit carry its session, when something removed it. * - * `aidd telemetry on` installs two things: a delegate script, which is ours outright and - * which nobody regenerates, and a single line in `prepare-commit-msg` that calls it. Only - * the second is fragile — in a repository where lefthook or husky owns that file, it is - * generated, and a generated file is rewritten. Measured on the repository this was built - * in: lefthook rewrote three hooks on 2026-09-02 and spared `prepare-commit-msg` only - * because its config declared no job for that event. + * The delegate script is ours outright and nobody regenerates it; the line calling it from + * `prepare-commit-msg` is the fragile half. This declines wherever the CLI itself would have + * declined — where a hook manager owns that file — or the delegate gains a second caller per + * commit, the manager's job and a direct call nobody installed. * - * So this does not defend the call site, it re-establishes it. **It never asks why the line - * is gone**, and that is what lets one path cover every cause — a regenerated hook, an - * overwrite by hand, a `core.hooksPath` that moved, a hook that never existed. No - * third-party tool is named anywhere here, so nothing rots when one of them changes format. + * It never asks why the line is gone, which is what lets one path cover every cause. The + * managers are named in one place only, as root marker filenames, so nothing here rots when + * either changes the format of what it generates. * - * The opt-out is the one a person already knows. `aidd telemetry off` deletes the delegate, - * and this only ever runs when the delegate is present, so nothing is ever resurrected after - * `off`. Removing the line by hand while keeping the file is not an opt-out and was never - * documented as one; `aidd telemetry check` reports what is in place either way. + * `aidd telemetry off` deletes the delegate and this only runs while the delegate is present, + * so nothing is ever resurrected after `off`. */ -/** The delegate's filename and the line that calls it. Both are the CLI's - * (`cli/src/domain/formats/commit-session-trailer.ts`), and both are spelled again here - * because this file is zero-dependency CommonJS shipped into a person's repository and - * cannot import from a TypeScript program that may not be installed. - * - * The duplication is real and it is guarded the way this plugin's other cross-language - * literal is: a test spawns this hook, reads the file it repaired, and compares it against - * what the CLI's own function produces — never against a second copy of the string typed - * into a fixture. Two sides that each assert their own spelling agree with themselves. */ +/** Both are also the CLI's, spelled again here because this file is zero-dependency + * CommonJS shipped into a person's repository and cannot import from a TypeScript program + * that may not be installed. A test compares what this repairs against what the CLI's own + * function produces, never against a second copy typed into a fixture. */ const DELEGATE_FILE = "aidd-session-trailer.sh"; const HOOK_FILE = "prepare-commit-msg"; const HOOK_HEADER = "#!/bin/sh"; @@ -45,12 +35,35 @@ function hookLine(delegatePath) { } /** - * Answers what it did, in a word. Nothing in the hook reads it — a session start has no - * business reporting on a repair — and it exists for the tests, which are the only reader - * that needs to tell "declined" from "nothing to do" without inspecting the filesystem - * twice: + * Every spelling lefthook accepts for its own config, in the order it looks for them, then + * husky's directory. Mirrors the CLI's own list, spelled again for the reason `DELEGATE_FILE` + * is, and pinned to that list by scripts/__tests__/aidd-telemetry-trailer-repair.test.js. + */ +const HOOK_MANAGER_MARKERS = Object.freeze([ + "lefthook.yml", + "lefthook.yaml", + ".lefthook.yml", + ".lefthook.yaml", + ".husky", +]); + +/** + * From the root's marker files alone, never from the hook's contents: a manager regenerates + * that file, so by the time anything reads it the append is gone and the marker is all that + * is left. The root is the worktree's own, not the git directory — in a linked worktree only + * the first is where a manager's config sits. + */ +function hookManagerOwns(repoRoot) { + if (typeof repoRoot !== "string" || repoRoot === "") return false; + return HOOK_MANAGER_MARKERS.some((marker) => fs.existsSync(path.join(repoRoot, marker))); +} + +/** + * Answers what it did, in a word, for the tests: they are the only reader that needs to tell + * "declined" from "nothing to do" without inspecting the filesystem twice. * * `"no-delegate"` nothing to call, so nothing to repair — the state `off` leaves + * `"manager-owned"` a hook manager owns the file and already reaches the delegate itself * `"not-ours-to-write"` the hook is version-controlled or a symlink; see `isOursToWrite` * `"present"` the hook already calls it * `"repaired"` the line was missing and has been put back @@ -60,14 +73,14 @@ function hookLine(delegatePath) { * Never throws. This runs inside a hook, and a hook that throws is a session that reports an * error for something no session did. */ -function repairCommitTrailerHook(hooksDir, gitDir) { +function repairCommitTrailerHook(hooksDir, gitDir, repoRoot) { if (typeof hooksDir !== "string" || hooksDir === "") return "no-delegate"; if (!fs.existsSync(path.join(hooksDir, DELEGATE_FILE))) return "no-delegate"; - // The physical directory, and everything below is built from it: the guard compares - // realpaths, so a line built from the unresolved spelling would name a path the guard - // never approved. On macOS, where `/tmp` is a link to `/private/tmp`, that put two call - // sites in one hook — the delegate ran twice per commit, and `check` then called the file - // "somebody else's too" about two lines this project wrote. + + if (hookManagerOwns(repoRoot)) return "manager-owned"; + // The physical directory, since the guard compares realpaths: a line built from the + // unresolved spelling names a path the guard never approved, which on macOS put two call + // sites in one hook and ran the delegate twice per commit. const resolved = realPath(hooksDir); if (resolved === null || !isOursToWrite(resolved, gitDir)) return "not-ours-to-write"; @@ -77,9 +90,8 @@ function repairCommitTrailerHook(hooksDir, gitDir) { // which would edit whatever it points at, most usefully a file the team shares. if (isSymbolicLink(hookPath)) return "not-ours-to-write"; - // `rename` needs the directory, not the file — so without this a `0444` hook is silently - // replaced and left reading `0444`, its content changed while its permissions say it - // cannot be. Asked before anything is written, so the answer is "declined", not "done". + // `rename` needs the directory, not the file, so without this a `0444` hook is silently + // replaced and left reading `0444`. Asked before anything is written. if (fs.existsSync(hookPath) && !isWritable(hookPath)) return "unwritable"; const line = hookLine(delegatePath); @@ -95,42 +107,30 @@ function repairCommitTrailerHook(hooksDir, gitDir) { } /** - * Beside the target and renamed over it where that is possible, directly where it is not. + * Staged beside the target and renamed over it where possible, written directly where not. + * Renaming stops a session starting at the same moment from reading a half-written hook, but + * staging needs write permission on the directory: a `0555` hooks directory holding a + * writable hook takes the direct write and refuses the staged one. * - * Renaming is what stops a session starting at the same moment from reading a half-written - * hook and appending to the fragment. But staging needs write permission on the *directory*, - * where a direct write needs it only on the file — measured, a `0555` hooks directory - * holding a writable hook takes the direct write and refuses the staged one. Repairing that - * repository mattered before this function existed, so the fallback keeps it. - * - * The mode is carried across deliberately: `rename` replaces the inode, so without this a - * `0700` hook would come back `0755` and a non-executable one would come back executable — - * widening a third party's file on a path meant to be conservative. + * The mode is carried across deliberately: `rename` replaces the inode, so a `0700` hook + * would otherwise come back `0755`, widening a third party's file. */ function write(hookPath, content) { const mode = modeOf(hookPath); const staging = `${hookPath}.aidd-${process.pid}`; try { - // The mode on this call has no test that can fail for it: `chmod` below sets the same - // bits a moment later, so removing it leaves every case green. It stays because it - // narrows the window in which the staging file exists wider than the hook it replaces, - // and that window is not something a test here can observe. + // No test can fail for this mode — `chmod` below sets the same bits — but it narrows the + // window in which the staging file is wider than the hook it replaces. fs.writeFileSync(staging, content, { mode }); - // `open(2)` applies the umask to the mode it is given, so a `0770` hook came back - // `0750` — narrowed rather than widened, and just as much somebody else's file to have - // changed. `chmod` is not filtered, and is what actually carries the mode across. + // `open(2)` applies the umask to the mode it is given, so a `0770` hook comes back + // `0750`. `chmod` is not filtered, and is what actually carries the mode across. fs.chmodSync(staging, mode); fs.renameSync(staging, hookPath); return "repaired"; } catch { // Never leave the staging file behind: one per session, each under a different pid, in - // the directory a person opens when something is wrong. - // - // Untested, and said rather than dressed up: no input reachable on POSIX gets past the - // write and fails the rename. Both files sit in one directory, so there is no cross-device - // case; a target that is a directory throws on the read long before here. The case this - // exists for is Windows, where renaming over a file another process holds open fails - // EPERM — and no test on this branch runs there. + // the directory a person opens when something is wrong. Reachable only on Windows, where + // renaming over a file another process holds open fails EPERM. try { fs.unlinkSync(staging); } catch { @@ -145,8 +145,7 @@ function write(hookPath, content) { } } -/** The hook's own permission bits, or the mode git needs to run one when there is no hook - * yet. Never widened: a file that was not executable stays that way, and `check` reports it +/** Never widened: a file that was not executable stays that way, and `check` reports it * rather than this quietly fixing it. */ function modeOf(hookPath) { try { @@ -157,22 +156,15 @@ function modeOf(hookPath) { } /** - * Only a hooks directory physically inside the repository's own git directory. - * - * `core.hooksPath` may point at a directory in the working tree — a checked-in `.githooks/` - * is a common way to share hooks with a team. Appending there dirties a tracked file, on - * every session start, with a machine-absolute path that cannot be committed; and a - * `git checkout` restoring the file brings it straight back. + * Only a hooks directory physically inside the repository's own git directory. `core.hooksPath` + * may point into the working tree — a checked-in `.githooks/` shared with a team — where + * appending dirties a tracked file with a machine-absolute path on every session start. * - * **Physically, through `realpath`, not lexically through `resolve`.** An independent check - * reproduced the difference: `ln -s ../.githooks .git/hooks` makes git answer - * `/.git/hooks`, which is inside the git directory by string and inside the working - * tree on disk. `path.resolve` never resolves a link, so the containment test passed and - * `lstat` on the hook file — reached through the linked directory — saw a real file rather - * than a link. Both guards were defeated at once, and a tracked file was modified. + * Physically, through `realpath`, never lexically: `ln -s ../.githooks .git/hooks` is inside + * the git directory by string and inside the working tree on disk, and it defeats both a + * `resolve` containment test and an `lstat` on the hook reached through the link. * - * A path that cannot be resolved declines: this is the guard that decides whether to write - * into somebody's repository, and the failure direction it takes is "do not". + * A path that cannot be resolved declines: this guard's failure direction is "do not". */ function isOursToWrite(hooksDir, gitDir) { if (typeof gitDir !== "string" || gitDir === "") return false; @@ -207,4 +199,11 @@ function isSymbolicLink(target) { } } -module.exports = { repairCommitTrailerHook, hookLine, DELEGATE_FILE, HOOK_FILE, HOOK_HEADER }; +module.exports = { + repairCommitTrailerHook, + hookLine, + DELEGATE_FILE, + HOOK_FILE, + HOOK_HEADER, + HOOK_MANAGER_MARKERS, +}; diff --git a/plugins/aidd-telemetry/hooks/opencode-plugin.js b/plugins/aidd-telemetry/hooks/opencode-plugin.js index 860fb0a1f..2b9c51679 100644 --- a/plugins/aidd-telemetry/hooks/opencode-plugin.js +++ b/plugins/aidd-telemetry/hooks/opencode-plugin.js @@ -1,26 +1,18 @@ // OpenCode's own extension surface: a JS module it loads in-process through its -// `{plugin,plugins}/*.{ts,js}` auto-discovery convention - never a hook it spawns per event, -// which is why every other file in this directory (a command journal.cjs runs, reading stdin) -// has no counterpart here. +// `{plugin,plugins}/*.{ts,js}` auto-discovery convention, never a hook it spawns per event. // -// The export shape below is load-bearing, not style: OpenCode's loader only recognises a -// genuine ESM export. Measured across three real sessions, a CommonJS `module.exports` file -// sat in the auto-discovery path, was logged as found, and never ran a single line of its own -// code - no error, no output. An `export const` file loaded and ran on the very next attempt. +// The export shape is load-bearing, not style: OpenCode's loader only recognises a genuine +// ESM export. A CommonJS `module.exports` file sits in the auto-discovery path, is logged as +// found, and never runs a line of its own code - no error, no output. // -// A second, separate limit rules out reusing journal.cjs's own functions in-process: OpenCode's -// loader cannot see a local CommonJS file's exports at all - `await import("./lib/record.cjs")` -// resolves to a namespace with none, even for a trivial one-line `module.exports = {...}` file, -// while a genuine ESM sibling imports fine. So this file spawns `journal.cjs` as the child -// process every other host's own hook already runs, over the same stdin-JSON contract, naming -// itself so `detectHost` (lib/host.cjs) recognises it without guessing at a fifth vendor shape. -// See scripts/__tests__/fixtures/opencode-session-idle.json and its README entry for the -// captured evidence behind this shape. +// A second limit rules out reusing journal.cjs's functions in-process: OpenCode's loader +// cannot see a local CommonJS file's exports at all, so `await import("./lib/record.cjs")` +// resolves to an empty namespace while a genuine ESM sibling imports fine. So this spawns +// `journal.cjs` as a child over the same stdin-JSON contract every other host's hook uses, +// naming itself so `detectHost` recognises it without guessing at a fifth vendor shape. // -// A third gap, found only by running a real session: `node ` is not a valid -// invocation - Node's CLI treats the string as a module specifier and resolves it relative -// to its own cwd, not as an absolute script path, so the spawned process died with -// MODULE_NOT_FOUND every time and journal.cjs never ran. fileURLToPath fixes it. +// `node ` is not a valid invocation - Node treats the string as a module +// specifier resolved against its own cwd - so fileURLToPath is what makes the spawn work. import { spawnSync } from "node:child_process"; import { fileURLToPath } from "node:url"; @@ -28,52 +20,39 @@ const JOURNAL_SCRIPT = fileURLToPath(new URL("./journal.cjs", import.meta.url)); // Never `process.execPath`: OpenCode ships as its own standalone binary, so that path names // `opencode` itself, not a Node runtime that can run journal.cjs. +// +// This runs in-process, not as a host-spawned hook OpenCode expects to wait on, so `timeout` +// bounds the block: a `journal.cjs` that hangs must not freeze OpenCode's event loop, and a +// killed run is one missed measurement. function runJournal(event, payload) { spawnSync("node", [JOURNAL_SCRIPT, event], { input: JSON.stringify(payload), encoding: "utf8", + timeout: 5000, }); } -// `session.created` carries the session's own `info.directory`, set by OpenCode itself; -// `session.idle` and `message.part.updated` carry only `sessionID`. A single server can -// outlive many sessions and serve more than one directory (`opencode run --dir`, -// `--attach`), so `input.directory` - this plugin instance's own init-time directory, fixed -// once - is not a safe stand-in: a turn-end or a declaration written to the wrong project's -// journal finds no run file and silently no-ops. Cached per session id instead, from the -// one event that actually carries it. -// -// `session.created` was never observed reaching this hook in a live `opencode 1.14.20` -// measurement (2026-08-31) that discriminates "never published" from "published but not -// delivered" - see the fixtures' own README entry for that run's log and two further -// attempts that could not distinguish the two. +// Only `session.created` carries the session's own `info.directory`; the other events carry +// `sessionID` alone. A single server can outlive many sessions and serve more than one +// directory, so this plugin's fixed init-time directory is not a safe stand-in - a turn-end +// written to the wrong project's journal finds no run file and silently no-ops. // -// Every `opencode run` invocation is a session OpenCode never announced, so nothing fills -// this cache from `session.created` there, and `session.idle`/`message.part.updated` fall -// back to `input.directory` below - this plugin's own init-time directory, which is -// correct for the single-directory case `opencode run` is. `journalCallsFor` writes that -// same directory back into this cache when it opens such a session, so the session is -// opened once and every later event reads the directory its own opening used. +// `session.created` was never observed reaching this hook, and every `opencode run` is a +// session OpenCode never announced, so the later events fall back to the init-time directory, +// which is correct for the single-directory case `opencode run` is. `journalCallsFor` writes +// it back here, so the session is opened once and every later event reads the same directory. const directoryBySessionId = new Map(); -// Mirrors `lib/task-declared.cjs`'s own `TASK_PATH_PATTERN` and `lib/host.cjs`'s -// `stringsWithin`, duplicated rather than imported: this file's own top-of-file comment -// already measured that OpenCode's loader cannot see a local CommonJS file's exports at -// all, which is the reason this plugin spawns `journal.cjs` as a child process in the -// first place rather than calling its functions in-process. That constraint applies here -// too - the same import that never worked for `record.cjs` would not work for -// `task-declared.cjs` either. +// Mirrors `lib/task-declared.cjs`'s own `TASK_PATH_PATTERN`, duplicated rather than +// imported: OpenCode's loader cannot see a local CommonJS file's exports at all, which is the +// same constraint that makes this plugin spawn `journal.cjs` rather than call into it. const TASK_PATH_PATTERN = /aidd_docs\/tasks\/\d{4}_\d{2}\/[^/"'\s]+\/[^"'\s]*|aidd_docs\/tasks\/\d{4}_\d{2}\/[^/"'\s]+\.md/u; -// Every completed tool part reaches this hook - most naming no task at all - and, unlike -// every other host's own hook runner, this one runs inside OpenCode's own in-process event -// handler: a `spawnSync` per call here blocks the agent's own event loop, not a -// short-lived hook process the host expects to pay for anyway. Cheap and conservative: -// `true` only when a string reachable inside `toolInput` could possibly be a declared -// path, exactly what `declaredTaskPath` would itself test after the spawn - so this can -// never skip a call `handleTaskDeclared` would have acted on, only calls it would have -// read and discarded. +// Every completed tool part reaches this hook, most naming no task, and a `spawnSync` per +// call blocks OpenCode's own event loop. Cheap and conservative: `true` only when a string +// inside `toolInput` could possibly be a declared path, which is what `declaredTaskPath` +// tests after the spawn, so this can never skip a call that would have been acted on. function mightDeclareATask(toolInput) { if (typeof toolInput === "string") { return TASK_PATH_PATTERN.test(toolInput.replace(/\\/gu, "/")); @@ -82,26 +61,14 @@ function mightDeclareATask(toolInput) { return Object.values(toolInput).some(mightDeclareATask); } -// A tool call's own arguments, once it has any: `pending` carries `input: {}`, empty and -// unsearchable, before OpenCode has resolved what the call is even for - only `completed` -// is read, the moment every argument, and the tool's own output, are settled, the same -// moment every other host's own PostToolUse-style hook fires at. Reading `running` too -// would call `handleTaskDeclared` a second time for the one real call already caught at -// `completed` - never wrong, since a duplicate declaration is a duplicate closed interval -// pointing at the same path, but a needless one. +// Only `completed` is read: `pending` carries an empty, unsearchable `input`, and reading +// `running` too would declare the same task twice for one call. // -// Measured live, `opencode 1.14.20`, 2026-08-31: a tool part's own `event.properties.part` -// carries no task identity of any kind - only `tool` (a name: `"read"`, `"bash"`, …) and -// `state.input`, the call's own arguments, exactly the shape `declaredTaskPath` already -// reads on every other host as `tool_input`. `state.input.filePath` (a `read` call) and -// `state.input.command` (a `bash` call) were both observed carrying an absolute path or a -// shell command line - the same two shapes Claude Code's `Read` and Codex's `Bash` already -// give this reader, never a new field this reader has to learn. +// A tool part carries no task identity of any kind - only the tool's name and `state.input`, +// the call's own arguments, which is exactly the shape `declaredTaskPath` already reads on +// every other host as `tool_input`. // -// `mightDeclareATask` is read before this ever produces a call worth spawning for: on -// OpenCode, `tool-used` does exactly one thing downstream (`journal.cjs`'s own -// `handleFileWritten` and `handleStepStart` both no-op for a host with no `writtenPath` / -// `stepStart` extractor - see `lib/tools/opencode.cjs`) - task declaration - so a call this +// On OpenCode `tool-used` does exactly one thing downstream, task declaration, so a call this // pre-filter refuses would have done nothing after the spawn either. function declaredTaskCallFor(event, sessionDirectories, fallbackDirectory) { const part = event.properties.part; @@ -115,22 +82,16 @@ function declaredTaskCallFor(event, sessionDirectories, fallbackDirectory) { }; } -/** One OpenCode event in, the journal call it produces out - or `null` for an event this - * plugin does not act on. Pure but for the one map mutation `session.created` makes on - * its way through: kept separate from `runJournal`'s spawn so a captured event can be - * asserted against without running node as a child process. +/** Kept separate from `runJournal`'s spawn so a captured event can be asserted against + * without running node as a child process. * - * Reached as a property of the plugin below, never as a second named export. OpenCode's - * loader calls every function-valued export of a file in `plugin/` as a plugin factory of - * its own: measured against opencode 1.14.20 in a freshly installed project, this function - * exported beside the plugin was called with one argument, returned `null`, and `opencode - * run` died reading `.auth` off it before any session started - the tool this plugin - * measures, unusable in every project the framework had installed. A property is invisible - * to that loader, and a non-function export would have been ignored by it too. */ + * Reached as a property of the plugin below, never as a second named export: OpenCode's + * loader calls every function-valued export of a file in `plugin/` as a plugin factory of its + * own, and doing that to this function left `opencode run` dead before any session started. */ function journalCallFor(event, sessionDirectories, fallbackDirectory) { if (event.type === "session.created") { - const sessionId = event.properties.info.id; - const cwd = event.properties.info.directory; + const sessionId = event.properties?.info?.id; + const cwd = event.properties?.info?.directory; sessionDirectories.set(sessionId, cwd); return { script: "session-start", payload: { tool: "opencode", session_id: sessionId, cwd } }; } @@ -145,30 +106,25 @@ function journalCallFor(event, sessionDirectories, fallbackDirectory) { return null; } -/** The session id an event names, whichever field its own type carries it in. */ +/** Nothing on OpenCode's bus guarantees `properties` is set, and an event this plugin does + * not act on is read here before any per-type dispatch would skip it, so a missing field must + * resolve to `undefined` rather than throw. */ function sessionIdOf(event) { - if (event.type === "session.created") return event.properties.info.id; - return event.properties.sessionID; + if (event.type === "session.created") return event.properties?.info?.id; + return event.properties?.sessionID; } /** Every journal call one OpenCode event produces, in the order the journal must receive - * them - empty for an event this plugin does not act on. + * them. * - * `journalCallFor` alone leaves `opencode run` measuring nothing. OpenCode publishes - * `session.created` on its own bus and never delivers it to a plugin's event hook - * (measured, 2026-08-31 - see plugins/aidd-telemetry/README.md, "OpenCode never announces a - * session"), and `opencode run` is always such a session. So the journal - * never receives a `session_start`, never creates the run file the rest of the session - * appends to, and drops the `turn-end` and every task declaration that follows - while - * `telemetryLocalRead` declares the tool covered and `aidd telemetry read`, which reads - * only sessions the run journal knows, can never find one. A declaration with nothing - * behind it, the same fault this plugin's own second export was. + * OpenCode publishes `session.created` on its own bus and never delivers it to a plugin's + * event hook, and `opencode run` is always such a session — so `journalCallFor` alone leaves + * the journal with no `session_start`, no run file, and every later line dropped, while the + * tool still reads as covered. * - * So the first call for a session nobody announced opens it. The `session-start` carries - * the directory that following call was already going to use - never a new guess: for a - * session no `session.created` named, that is this plugin's own init-time directory, - * exactly what `journalCallFor` already hands `turn-end` and `tool-used`. An announced - * session is untouched, and no session is opened twice. */ + * So the first call for a session nobody announced opens it, carrying the directory that + * call was already going to use rather than a new guess. An announced session is untouched, + * and no session is opened twice. */ function journalCallsFor(event, sessionDirectories, fallbackDirectory) { const sessionId = sessionIdOf(event); const announced = sessionId !== undefined && sessionDirectories.has(sessionId); @@ -182,13 +138,20 @@ function journalCallsFor(event, sessionDirectories, fallbackDirectory) { export const AiddTelemetry = async (input) => ({ event: async ({ event }) => { - for (const call of journalCallsFor(event, directoryBySessionId, input.directory)) { - runJournal(call.script, call.payload); + // The rule journal.cjs's own main() states, applied where OpenCode calls this in-process + // instead of spawning it: a measurement layer that breaks a session is worse than one + // that misses one, and whatever throws here is this plugin's fault, never the person's. + try { + for (const call of journalCallsFor(event, directoryBySessionId, input.directory)) { + runJournal(call.script, call.payload); + } + } catch { + // Silent on purpose - see above. } }, }); -// The spawn-free test seams, hung off the one export rather than standing beside it - see -// journalCallFor's own comment for the measurement that rules out a second export. +// The spawn-free test seams, hung off the one export rather than standing beside it — see +// journalCallFor's own comment for why a second export is ruled out. AiddTelemetry.journalCallFor = journalCallFor; AiddTelemetry.journalCallsFor = journalCallsFor; diff --git a/scripts/__tests__/a-backlog-link-the-reader-can-read.test.js b/scripts/__tests__/a-backlog-link-the-reader-can-read.test.js index aa18649b8..a883c622b 100644 --- a/scripts/__tests__/a-backlog-link-the-reader-can-read.test.js +++ b/scripts/__tests__/a-backlog-link-the-reader-can-read.test.js @@ -8,18 +8,13 @@ const ROOT = path.resolve(__dirname, "../.."); /** * A task folder declares the backlog item it delivers in `backlog-link.json`, and the whole - * `by_backlog` axis rests on that one file being readable. + * `by_backlog` axis rests on that one file being readable. A writer spelling the fields in + * camelCase while the reader looks for snake_case makes every declaration unreadable, in + * silence. * - * Nothing checked that it was. Of the three declarations this repository held, two carried - * `writtenAt` and `writtenBy` while `task-backlog-adapter.ts` reads `written_at` and - * `written_by`, so the report answered `declaration: unreadable` for 130 records and named - * the item for 4. Both were written by `aidd-orchestrator:01-sdlc`, which is told to let - * Spec or Plan declare the item and instead wrote the file itself, taking the field names - * from the TypeScript interface rather than from what either skill teaches. - * - * The reader is a `cli/` module and this is a repository script test, so the rule is - * restated here rather than imported across that boundary — and the second case below is - * what keeps the restatement honest. + * The reader is a `cli/` module and this is a repository script test, so the rule is restated + * here rather than imported across that boundary — and the second case below is what keeps + * the restatement honest. */ const REQUIRED_FIELDS = ["backlog", "written_at", "written_by"]; @@ -47,7 +42,9 @@ describe("every backlog declaration in this repository is one the report can rea (field) => typeof parsed[field] !== "string" || parsed[field] === "" ); if (missing.length > 0) { - unreadable.push(`${file} is missing ${missing.join(", ")} (has ${Object.keys(parsed).join(", ")})`); + unreadable.push( + `${file} is missing ${missing.join(", ")} (has ${Object.keys(parsed).join(", ")})` + ); } } @@ -77,4 +74,19 @@ describe("every backlog declaration in this repository is one the report can rea } } }); + + /** The check above pins the writer's side alone, which would stay green through a + * writer/reader disagreement. This reads `TaskBacklogAdapter.read`'s own field access, in + * `cli/` across the boundary this file's header explains it does not import across. */ + it("is the shape the reader in cli/ actually reads, not just the shape skills teach", () => { + const READER_FILE = "cli/src/contexts/telemetry/infrastructure/task-backlog-adapter.ts"; + const text = fs.readFileSync(path.join(ROOT, READER_FILE), "utf8"); + + for (const field of REQUIRED_FIELDS) { + assert.ok( + text.includes(`parsed.${field}`), + `${READER_FILE} must read \`parsed.${field}\`, the same field the skills teach` + ); + } + }); }); diff --git a/scripts/__tests__/a-skill-links-only-inside-itself.test.js b/scripts/__tests__/a-skill-links-only-inside-itself.test.js index 668f1807a..78ce3209f 100644 --- a/scripts/__tests__/a-skill-links-only-inside-itself.test.js +++ b/scripts/__tests__/a-skill-links-only-inside-itself.test.js @@ -6,22 +6,14 @@ const { describe, it } = require("node:test"); const ROOT = path.resolve(__dirname, "../.."); /** - * A skill ships two ways and a relative path survives only one of them. + * A skill ships two ways and a relative path survives only one of them: the tree ships flat + * and as a marketplace. `check-markdown-links.js` resolves every link against this + * repository, where the target does exist, so a link reaching out of a skill passes there + * and is dead in every installed copy. * - * `architecture.md` has stated the rule since the per-tool distributions landed — "a skill - * never links outside itself: the tree ships both flat and as a marketplace, so no relative - * path survives both" — and nothing enforced it. `check-markdown-links.js` resolves every - * link against this repository, where the target does exist, so a link reaching out of a - * skill passes there and is dead in every installed copy. - * - * Five had accumulated, three of them into `aidd_docs/product/cost-report-contract.md`, - * which no plugin ships at all. - * - * The skill's own directory is the boundary, not the plugin's: `plugins//skills/ - * /`. A link to a sibling skill, to the plugin's README, or to anything in the - * repository is equally unreachable once a tool has installed the skill somewhere of its - * own choosing. Name the file in prose instead — a reader can search for it, and a name - * cannot rot into a broken link. + * The skill's own directory is the boundary, not the plugin's. A link to a sibling skill, to + * the plugin's README, or to anything in the repository is equally unreachable once a tool + * has installed the skill somewhere of its own choosing; name the file in prose instead. */ const SKILL_ROOT = /^plugins\/[^/]+\/skills\/[^/]+$/u; diff --git a/scripts/__tests__/aidd-orchestrator-no-host-reports.test.js b/scripts/__tests__/aidd-orchestrator-no-host-reports.test.js new file mode 100644 index 000000000..9be03b2c2 --- /dev/null +++ b/scripts/__tests__/aidd-orchestrator-no-host-reports.test.js @@ -0,0 +1,42 @@ +const assert = require("node:assert/strict"); +const fs = require("node:fs"); +const path = require("node:path"); +const test = require("node:test"); + +const ROOT = path.resolve(__dirname, "../.."); + +/** + * A skill never links outside itself, so the explanation of why a skill must announce its own + * end is written out in full in every orchestrating skill rather than linked once. This pins + * the copies to each other: one could otherwise drift and a flow would stop being measured + * with nothing going red. + */ +const ORCHESTRATING_SKILLS = [ + "plugins/aidd-orchestrator/skills/00-async-dev/SKILL.md", + "plugins/aidd-orchestrator/skills/01-sdlc/SKILL.md", + "plugins/aidd-orchestrator/skills/02-backlog/SKILL.md", +]; + +const PARAGRAPH_START = "No host reports when an orchestration finished."; + +function sharedExplanation(file) { + // A Windows checkout may carry CRLF; the comparison is about words, never line endings. + const text = fs.readFileSync(path.join(ROOT, file), "utf8").replace(/\r\n/g, "\n"); + const start = text.indexOf(PARAGRAPH_START); + assert.ok(start !== -1, `${file} no longer carries the shared "No host reports" explanation`); + const end = text.indexOf("\n\n", start); + return text.slice(start, end === -1 ? undefined : end).trim(); +} + +test('every orchestrating skill\'s "No host reports" explanation agrees with the others, word for word', () => { + const [canonicalFile, ...rest] = ORCHESTRATING_SKILLS; + const canonical = sharedExplanation(canonicalFile); + + for (const file of rest) { + assert.equal( + sharedExplanation(file), + canonical, + `${file}'s explanation drifted from ${canonicalFile}'s - one of the two now says something different` + ); + } +}); diff --git a/scripts/__tests__/aidd-telemetry-cost-skill.test.js b/scripts/__tests__/aidd-telemetry-cost-skill.test.js index fb098c14a..5e9f646fc 100644 --- a/scripts/__tests__/aidd-telemetry-cost-skill.test.js +++ b/scripts/__tests__/aidd-telemetry-cost-skill.test.js @@ -6,12 +6,9 @@ const test = require("node:test"); const pluginDir = path.resolve(__dirname, "../../plugins/aidd-telemetry"); const skillDir = path.join(pluginDir, "skills/01-cost"); -// Real source, not a re-description of it: closure tests below check the skill's own text -// against what these two modules actually accept and actually emit. // Normalized, because these tests match multi-line shapes against the file's own text and -// git hands a Windows checkout the same content with CRLF endings - where `\n\n` matches -// nothing. Measured: the axis table regex below returns true on the POSIX checkout and false -// on the identical file with CRLF, which is how a green suite failed on Windows alone. +// git hands a Windows checkout the same content with CRLF endings, where `\n\n` matches +// nothing - which is how a green suite failed on Windows alone. const read = (file) => fs.readFileSync(file, "utf8").replace(/\r\n/gu, "\n"); const skill = read(path.join(skillDir, "SKILL.md")); // A router skill's rules live in its actions; reading only the router would test a @@ -28,7 +25,7 @@ test("the cost skill reads the object, never the text meant for a person", () => assert.ok(everything.includes("--json"), "must ask for the machine-readable output"); assert.ok( everything.includes("cost_report_version"), - "must refuse a version it does not know, which means naming the field", + "must refuse a version it does not know, which means naming the field" ); }); @@ -45,75 +42,48 @@ test("the cost skill says when a total is partial", () => { test("the cost skill prefers an absolute period for a figure that will be kept", () => { assert.ok(everything.includes("--from"), "must know the absolute flags"); - assert.ok( - everything.includes("resolves against today"), - "must say why --days cannot be cited", - ); + assert.ok(everything.includes("resolves against today"), "must say why --days cannot be cited"); }); -// The previous guard here was a list of six forbidden substrings ("reduce(", "sum(", -// "* 0.", "rate per", "per 1M", "per 1K"). A section appended to 03-report.md telling an -// agent to scrape the aligned human table, add its column up by hand, and multiply by "the -// price of a million tokens" left every one of the 21 tests in this file green, including -// this one - the six-token list is a guess about how a mistake will be spelled, and -// rewording "per 1M tokens" walks straight past it without touching a single listed token. -// It is dropped rather than extended: a longer blacklist is the same defect with more -// words, and it was already false of the clean file, whose own step 4 legitimately -// computes a share of a total the script printed. -// -// What is actually checkable is narrower: the only rendering an agent could scrape and add -// up by hand is the padded, column-aligned one built for a person, and `emitReport` -// (scripts/telemetry-report.cjs:268-273) only reaches it when a `report` call carries -// neither `--json` nor `--axis`. So every command this skill instructs is checked against -// the script's own interface, and every `report` call is required to end on one of the two -// paths that hand back a value the script already computed - closure tests, not a wordlist, -// so a differently-worded reintroduction of the same defect still has to name an invalid -// command or an invalid flag to survive, not just avoid six phrases. +// Closure tests, never a wordlist of forbidden phrases: a blacklist is a guess about how a +// mistake will be spelled, and rewording walks straight past it. The only rendering an agent +// could scrape and add up by hand is the column-aligned one built for a person, so every +// command the skill instructs is checked against the binary's own interface and every +// `report` call must end on a path that hands back a value already computed. // -// "Prefer your own arithmetic over `cost_report_version`" and "ignore `undated_records` -// because a partial total reads badly" are not covered by anything below. Both leave every -// correct instruction already in the file untouched and only add a contradiction of it - -// there is no file content whose absence or presence proves an agent will follow the newer, -// wrong sentence over the older, right one. That is a claim about behaviour, checkable only -// by running the skill, not by asserting over its text. - -/** Every `` `aidd telemetry report ` `` the skill's actions write, flags only - the - * subcommand itself is fixed in the pattern, not captured, so a bare `` `aidd telemetry - * report` `` naming the command in prose (SKILL.md's own transversal rules) never matches: - * that requires a space then at least one more character, which a bare mention has none of. - * Phase 1 moved this from the plugin's own `telemetry-report.cjs` to the CLI; the pattern - * moved with it, the same way `telemetry-cost-skill-commands.e2e.test.ts` extracts `` `aidd - * telemetry …` `` commands to run against the real CLI. */ +// A sentence contradicting a correct instruction elsewhere in the file is not covered here: +// nothing about the text proves which of the two an agent follows. + +/** Every `` `aidd telemetry report ` `` the skill's actions write, flags only: the + * subcommand is fixed in the pattern and not captured, so a bare mention of the command in + * prose never matches, since that requires a space and at least one more character. */ function reportCommands(text) { return [...text.matchAll(/`aidd telemetry report ([^`]+)`/gu)].map((m) => m[1].trim()); } test("every report invocation asks for the object or a derived artefact, never the bare human table", () => { const commands = reportCommands(everything); - // A closure test over an empty extraction passes vacuously - exactly what silently - // happened here once the pattern still named the deleted `telemetry-report.cjs`. + // A closure test over an empty extraction passes vacuously, which is what happened here + // while the pattern still named the deleted `telemetry-report.cjs`. assert.ok(commands.length > 0, "the extraction must not be vacuous"); for (const flags of commands) { const tokens = flags.split(/\s+/u); assert.ok( tokens.includes("--json") || tokens.includes("--axis"), - `"report ${flags}" names neither --json nor --axis, so it would print the human table`, + `"report ${flags}" names neither --json nor --axis, so it would print the human table` ); } }); -/** A real envelope, built from the same `build` + `toEnvelope` pipeline the script itself - * calls - never hand-typed - with a task and every capability populated, so a path this - * fixture doesn't exercise never gets counted as unreachable-by-accident. */ /** The envelope the CLI is pinned against, read from the committed fixture rather than built - * here: the builder used to live in this plugin, and the point of the move is that it now - * lives in one place. See cli/tests/e2e/telemetry-cost-skill-commands.e2e.test.ts. */ + * here, so the builder lives in one place. Every capability is populated, so a path the + * fixture does not exercise is never counted as unreachable by accident. */ function realEnvelope() { return JSON.parse( fs.readFileSync( path.resolve(__dirname, "../../cli/tests/fixtures/cli-owns-read/expected-envelope.json"), - "utf8", - ), + "utf8" + ) ); } @@ -151,21 +121,8 @@ test("every field the cost skill names by name resolves on the object the script const paths = envelopePaths(realEnvelope(), "", new Set()); const claims = fieldClaims(everything); // Pinned for the reason the command count above is: an extractor matching nothing would - // make this pass on an empty set of claims. - // 13 -> 14 when the skill's own step 8 (03-report.md) named `measurement_enabled`, added - // alongside the CLI envelope gaining that field (review.md, "one route, and every - // sentence about it true", findings 2 and 3). 14 -> 15 when 03-report.md named `by_task`, - // the new top-level breakdown added alongside the six-questions task axis. 15 -> 16 when - // 03-report.md named `by_backlog`, the upward link's own top-level breakdown regrouping - // `by_task`'s rows by what each task's own folder declares. 16 -> 17 when 03-report.md - // named `by_flow`, the flow-and-versions top-level breakdown grouping by the orchestrated - // run the journal's own step sequence already names, nothing newly captured for it. - // 17 -> 18 when 03-report.md named `by_agent`, the breakdown by the agent that ran, added - // while bringing the skill's own version paragraph up from `8` to `11` - it had gone three - // bumps stale, so the paragraph named neither `by_agent` nor `prompt-matched` nor the - // fourth no-task reason a row can now carry. 18 -> 19 when 03-report.md named `by_prompt`, - // the breakdown by the prompt that caused the work - the one axis no host limit can empty, - // since the reader resolves the turn for itself rather than waiting on a capture. + // make this pass on an empty set of claims. The number moves whenever the skill names a + // field it did not before, which is a decision, not a drift. assert.equal(claims.size, 19, "expected exactly nineteen field references in the cost skill"); // Fields the envelope carries only under some condition, so a fixture cannot show them all @@ -193,7 +150,7 @@ test("the cost skill refuses to invent a figure when its script is absent", () = assert.ok(everything.includes("show no figure"), "must state that no figure is shown"); assert.ok( everything.includes("cannot be found"), - "must name the unresolvable script as the reason", + "must name the unresolvable script as the reason" ); }); @@ -237,15 +194,8 @@ test("the plugin README gives every partly-measurable tool its reason, not just }); test("no skill searches plugin directories for its own script any more", () => { - // Measured on Codex: `env | grep -i plugin_root` in the shell a skill spawns matches - // nothing, which is exactly why this search used to exist — a search that only knew - // Claude Code's directory found nothing there, on a tool where the script was actually - // installed. 00-init, 01-cost and 02-check have all since moved to `aidd`: none of the - // three ships a script to find any more, pinned instead by - // cli/tests/e2e/telemetry-init-skill-commands.e2e.test.ts, - // cli/tests/e2e/telemetry-cost-skill-commands.e2e.test.ts and - // cli/tests/e2e/telemetry-check-skill-commands.e2e.test.ts respectively. This just pins that no - // locate/action file resurrects the search. + // None of the three skills ships a script to find any more - each calls `aidd` instead - + // so this pins that no locate or action file resurrects the plugin-root search. for (const skill of ["00-init", "01-cost", "02-check"]) { const dir = path.join(pluginDir, "skills", skill, "actions"); const text = fs @@ -263,8 +213,7 @@ test("the init skill owns turning measurement on, and asks first", () => { .map((name) => fs.readFileSync(path.join(initDir, "actions", name), "utf8")) .join("\n"); - // Phase 3 moved this from a script beside the skill to the CLI — `aidd telemetry on` is - // now the place that turns it on, and the skill names no `.cjs` path any more. + // `aidd telemetry on` is the place that turns it on, and the skill names no `.cjs` path. assert.ok(init.includes("aidd telemetry on"), "must be the place that turns it on"); assert.ok(/[Aa]sk/u.test(init), "must ask before measuring someone's project"); assert.ok(!/\.cjs\b/u.test(init), "must not name a script beside itself any more"); @@ -273,7 +222,7 @@ test("the init skill owns turning measurement on, and asks first", () => { test("the cost skill defers enabling to init rather than doing it itself", () => { assert.ok( !/telemetry-switch/u.test(everything), - "reporting must not turn measurement on behind the user's back", + "reporting must not turn measurement on behind the user's back" ); }); @@ -281,7 +230,9 @@ test("the cost skill defers enabling to init rather than doing it itself", () => // skill breaks the day a host installs one of them and not the other. test("no skill reaches into another skill's directory", () => { const skills = ["00-init", "01-cost", "02-check"]; - const pairs = skills.flatMap((own) => skills.filter((other) => other !== own).map((other) => [own, other])); + const pairs = skills.flatMap((own) => + skills.filter((other) => other !== own).map((other) => [own, other]) + ); for (const [own, other] of pairs) { const dir = path.join(pluginDir, "skills", own); const text = fs @@ -295,8 +246,7 @@ test("no skill reaches into another skill's directory", () => { }); test("the check skill calls the CLI, never a script of its own", () => { - // Phase 5 moved this from a script beside the skill to the CLI — `aidd telemetry check` - // is now the place that judges every claim, and the skill names no `.cjs` path any more. + // `aidd telemetry check` judges every claim, and the skill names no `.cjs` path. const checkDir = path.join(pluginDir, "skills/02-check"); const check = fs .readdirSync(path.join(checkDir, "actions")) @@ -315,8 +265,8 @@ test("the check skill calls the CLI, never a script of its own", () => { // the number is written down. test("the cost skill names the envelope version the CLI actually emits", () => { const envelopeSource = fs.readFileSync( - path.resolve(__dirname, "../../cli/src/domain/models/cost-report-envelope.ts"), - "utf8", + path.resolve(__dirname, "../../cli/src/contexts/telemetry/domain/cost-report-envelope.ts"), + "utf8" ); const emitted = /COST_REPORT_ENVELOPE_VERSION = (\d+)/u.exec(envelopeSource)?.[1]; const named = /`cost_report_version` is `(\d+)` today/u.exec(everything)?.[1]; @@ -334,7 +284,7 @@ test("the cost skill states the shape of its answer", () => { assert.ok(report.includes("| Sessions |"), "a headline table"); assert.ok( report.includes("never a table of zeroes"), - "must say an empty breakdown is left out rather than filled with zeroes", + "must say an empty breakdown is left out rather than filled with zeroes" ); }); @@ -361,14 +311,15 @@ test("the cost skill offers its axes in the language of a question", () => { // so the next one cannot ship unoffered. test("the cost skill offers every axis the binary accepts, in both places it names them", () => { const artefactSource = read( - path.resolve(__dirname, "../../cli/src/application/display/cost-report-artefact.ts"), + path.resolve(__dirname, "../../cli/src/presentation/display/cost-report-artefact.ts") ); const declared = /export const ARTEFACT_AXES = \[([^\]]+)\]/u.exec(artefactSource)?.[1] ?? ""; const axes = [...declared.matchAll(/"([a-z]+)"/gu)].map((match) => match[1]); assert.ok(axes.length > 1, "must have read the axis list from the artefact module itself"); const axisFlagEnum = /--axis <([^>]+)>/u.exec(everything)?.[1].split("|") ?? []; - const questionTable = /\| The question sounds like \| Axis \| Artefact \|[\s\S]*?\n\n/u.exec(skill)?.[0] ?? ""; + const questionTable = + /\| The question sounds like \| Axis \| Artefact \|[\s\S]*?\n\n/u.exec(skill)?.[0] ?? ""; // Both directions: an axis the binary accepts must be offered, and one the skill offers // must exist - the second is not covered elsewhere, since the e2e that runs every command // the skill names expands this enumeration to its first alternative alone. @@ -377,7 +328,7 @@ test("the cost skill offers every axis the binary accepts, in both places it nam assert.ok(axisFlagEnum.includes(axis), `must list "${axis}" among the --axis choices`); assert.ok( new RegExp(`\\|[^|\\n]*\\b${axis}\\b[^|\\n]*\\|`, "u").test(questionTable), - `must map a question to the "${axis}" axis in SKILL.md's own table`, + `must map a question to the "${axis}" axis in SKILL.md's own table` ); } }); @@ -394,17 +345,15 @@ test("reads a Windows checkout the same way it reads a POSIX one", () => { assert.equal(asRead, skill, "must hand back the same text a POSIX checkout would"); assert.ok( /\| The question sounds like \| Axis \| Artefact \|[\s\S]*?\n\n/u.test(asRead), - "the axis table must still be found in a file checked out with CRLF endings", + "the axis table must still be found in a file checked out with CRLF endings" ); } finally { fs.rmSync(scratch, { recursive: true, force: true }); } }); -// Per person used to be the one axis nothing could answer, back when no record carried an -// identity - #661 resolved one identity across tools and machines and gave the report a -// `person` axis, so the skill must offer it rather than still claiming the question is -// structurally unanswerable. +// A record now carries an identity resolved across tools and machines, so the skill must +// offer the `person` axis rather than still calling the question structurally unanswerable. test("the cost skill answers per person through the person axis, not as unanswerable", () => { assert.ok(/per.person/iu.test(everything), "must still speak in the language of the question"); assert.ok(everything.includes("|person>"), "must list person among the --axis choices"); @@ -413,31 +362,23 @@ test("the cost skill answers per person through the person axis, not as unanswer // old "none - unanswerable" row, so a substring match on the phrase alone would still // pass on fully reverted docs. everything.includes("| per person, who spent, which teammate | person |"), - "must map the per-person question to the person axis in SKILL.md's own table", + "must map the per-person question to the person axis in SKILL.md's own table" ); assert.ok( !/unanswerable/iu.test(everything), - "must not still claim per-person cannot be answered", + "must not still claim per-person cannot be answered" ); }); -// The version bug this pins against: render.cjs bumped `ENVELOPE_VERSION` to 2 when -// `by_day` and `by_project` landed, and the skill kept telling itself to refuse anything -// but version 1 - which would have made it stop on every object the script now prints. -// Read off the live constant rather than a hardcoded number, so the same drift cannot -// recur silently the next time `ENVELOPE_VERSION` bumps. // A total to quote and a table to paste are different things - a rendering suited to the // axis, written to a file when a file is what was asked for. test("the cost skill writes an artefact to a file when a file is what was asked for", () => { assert.ok(everything.includes("Write it to a file"), "must say when it writes rather than shows"); assert.ok(everything.includes("Show it inline"), "must say when it shows rather than writes"); assert.ok( - /states its period and (its )?axis/u.test(everything) || everything.includes("period and its axis"), - "an artefact must name the period and axis it came from", + /states its period and (its )?axis/u.test(everything) || + everything.includes("period and its axis"), + "an artefact must name the period and axis it came from" ); }); -// Mirrors cli/tests/domain/models/cost-report.unit.test.ts's "a still-open local-read -// turn is superseded, never doubled" — the plugin's own `build()` must answer the same -// way the CLI's `buildCostReport` does, since both read the same day files (phase-1, -// "A turn read while it runs is not the last word"). \ No newline at end of file diff --git a/scripts/__tests__/aidd-telemetry-journal-perf-harness.js b/scripts/__tests__/aidd-telemetry-journal-perf-harness.js index 3c0b320ee..3eccd83ab 100644 --- a/scripts/__tests__/aidd-telemetry-journal-perf-harness.js +++ b/scripts/__tests__/aidd-telemetry-journal-perf-harness.js @@ -1,13 +1,8 @@ #!/usr/bin/env node -// aidd-telemetry-journal-perf-harness.js - measures journal.cjs's in-process -// turn-end and file-written latency; run as a child process of the p95 tests -// in aidd-telemetry-journal.test.js. Not itself a *.test.js file, so node -// --test does not pick it up. -// -// Spawned as a separate process so the parent test can enforce a hard -// wall-clock timeout with a real kill: node:test's own per-test timeout does -// not interrupt a blocking synchronous call (confirmed empirically - a -// `while (true) {}` test body with `{ timeout: 1000 }` does not stop at 1s). +// Measures journal.cjs's in-process turn-end and file-written latency, run as a child of the +// p95 tests. Not a *.test.js file, so node --test does not pick it up, and a separate process +// so the parent can enforce a wall-clock timeout with a real kill: node:test's own timeout +// does not interrupt a blocking synchronous call. const fs = require("node:fs"); const os = require("node:os"); diff --git a/scripts/__tests__/aidd-telemetry-journal.test.js b/scripts/__tests__/aidd-telemetry-journal.test.js index e67aeb138..67b6cbfc7 100644 --- a/scripts/__tests__/aidd-telemetry-journal.test.js +++ b/scripts/__tests__/aidd-telemetry-journal.test.js @@ -1,12 +1,9 @@ const assert = require("node:assert/strict"); const childProcess = require("node:child_process"); -// Patched before repo.cjs is required below, since repo.cjs destructures spawnSync at -// require time - a monkeypatch of child_process's own export applied after that point -// would never reach repo.cjs's already-captured reference. This is what lets -// countGitInvocations() count real git calls in-process, with no PATH shim: a shim -// script needs a POSIX shebang and the execute bit to run, neither of which Windows -// honours (it resolves an executable by PATHEXT/extension instead). +// Patched before repo.cjs is required, which destructures spawnSync at require time. This +// is what lets countGitInvocations() count real git calls in-process, with no PATH shim: a +// shim needs a shebang and the execute bit, neither of which Windows honours. let gitCallCounter = null; const realSpawnSync = childProcess.spawnSync; childProcess.spawnSync = (command, ...rest) => { @@ -57,14 +54,9 @@ const { const { readCwd } = require("../../plugins/aidd-telemetry/hooks/lib/tools/index.cjs"); -// One exact key set per line type (see phase-1.md) - the replacement for the -// old THE_TEN_KEYS whitelist, which guarded a single mutable record that no -// longer exists. -// -// plugin_version is here, not optional in this list, because every test below runs -// against this repository's own real, readable `.claude-plugin/plugin.json` - the -// unreadable-manifest case (plugin_version genuinely absent) is asserted on its own, -// against a temporary copy whose manifest this suite deletes. +// One exact key set per line type. plugin_version is not optional here, because every test +// below runs against this repository's own readable manifest; the absent case is asserted +// on its own, against a temporary copy whose manifest this suite deletes. const SESSION_START_KEYS = [ "type", "at", @@ -149,22 +141,17 @@ test("detectHost recognises Copilot's compat shape - session_id and hook_event_n assert.equal(detectHost(loadFixture("copilot-compat-turn-end.json")), "copilot"); }); -// Captured 2026-08-28 from a real `@github/copilot` 1.0.80 session's own hook stdin, in a -// project declaring this plugin's own PascalCase events. Two shapes nothing had ever -// captured, and the reason they matter is what the same session settled about the other -// three: `copilot-compat-{session-start,post-tool-use-skill,turn-end}.json` were compared -// key-for-key against that stdin and match exactly. The compat shape is what this plugin's -// hooks actually receive - not a variant they might. +// Captured from a real Copilot session's own hook stdin, in a project declaring this +// plugin's PascalCase events. The compat shape is what these hooks actually receive, not a +// variant they might: every compat fixture was compared key-for-key against that stdin. test("detectHost recognises the two compat shapes captured last, so all five of Copilot's events are pinned rather than three", () => { assert.equal(detectHost(loadFixture("copilot-compat-user-prompt-submitted.json")), "copilot"); assert.equal(detectHost(loadFixture("copilot-compat-pre-tool-use-skill.json")), "copilot"); }); -// Copilot's compat builder renames a built-in tool to Claude Code's own PascalCase spelling -// - the same session captured `tool_name: "Read"` for a file read - but leaves `skill` -// lowercase. `STEP_START_BY_HOST.copilot`'s compat branch keys on that exact lowercase -// spelling, so the asymmetry is load-bearing: were `skill` renamed the way `Read` is, no -// step would ever open on this shape and a whole session would read as unattributed. +// Copilot's compat builder renames a built-in tool to Claude Code's PascalCase spelling but +// leaves `skill` lowercase, and the compat branch keys on that exact lowercase spelling: were +// `skill` renamed the way `Read` is, no step would open and a session would read unattributed. test("a compat skill call names the tool in lowercase, unlike the PascalCase every built-in tool gets", () => { const preToolUse = loadFixture("copilot-compat-pre-tool-use-skill.json"); const postToolUse = loadFixture("copilot-compat-post-tool-use-skill.json"); @@ -415,12 +402,9 @@ for (const name of [ }); } -// Codex has no `Stop` event. Its own vocabulary, read out of the 0.149.0 binary and -// confirmed by a live `codex exec`, is SessionStart / SessionEnd / PostToolUse and friends - -// SessionStart and SessionEnd fired, Stop never did, so every Codex session journalled a -// session_start with nothing after it. Both fixtures below are real captures from -// that run with the home path redacted; this test replays the SessionEnd one as the -// turn-end hook receives it, and fails if a Codex turn ever stops closing. +// Codex has no `Stop` event: its own vocabulary is SessionStart / SessionEnd / PostToolUse, +// so a journal waiting for Stop records a session_start with nothing after it. Both fixtures +// are real captures with the home path redacted. test("Codex's own SessionEnd payload closes the turn, since Codex never sends a Stop", () => { const repo = makeTempRepo({ remote: "git@github.com:acme/codex-session-end.git" }); try { @@ -518,10 +502,6 @@ test("a file-written replay with hook_event_name stripped still appends a file_w } }); -// The two hardcoded-list versions of these checks (one per redacted concern) were replaced -// by a single directory-scanning test, further down, per phase-1.md task 3.3: "run it over -// every fixture in the directory... which were never checked" - a hardcoded FIXTURE_NAMES -// array is exactly the thing a fixture added later would silently escape. test("the Cursor fixture's user_email is the redaction placeholder", () => { const cursor = loadFixture("cursor-session-start.json"); @@ -604,9 +584,8 @@ test("AIDD_RUNS_DIR overrides the in-repo default outright", () => { }); }); -// a worktree gets its own journal by decision, not by accident. This is the test -// that decision asked for - it also proves --show-toplevel behaves as resolveRunsDir -// assumes, rather than merely asserting the assumption. +// A worktree gets its own journal by decision, and this also proves --show-toplevel behaves +// the way resolveRunsDir assumes rather than merely asserting the assumption. function addWorktree(main, dir) { execFileSync("git", ["add", "-A"], { cwd: main, env: CLEAN_ENV }); execFileSync("git", ["commit", "-q", "-m", "init", "--allow-empty"], { @@ -616,12 +595,10 @@ function addWorktree(main, dir) { execFileSync("git", ["worktree", "add", "-b", "feature", dir], { cwd: main, env: CLEAN_ENV }); } -// git's --show-toplevel resolves symlinks (macOS's /var, /tmp among them) and, on -// Windows, always answers with a forward-slash long-filename path - which can differ -// in spelling from fs.realpathSync() of the same directory when %TEMP% itself -// resolves through an 8.3 short alias. fs.realpathSync.native asks the OS for the -// canonical form on every platform, which collapses both differences; lowercased on -// win32 since NTFS paths are case-insensitive. +// git's --show-toplevel resolves symlinks and, on Windows, answers with a long-filename +// path that can differ from fs.realpathSync()'s when %TEMP% resolves through an 8.3 alias. +// `.native` asks the OS for the canonical form; lowercased on win32, where paths are +// case-insensitive. function canonicalPath(target) { const resolved = fs.realpathSync.native(target); return process.platform === "win32" ? resolved.toLowerCase() : resolved; @@ -657,10 +634,8 @@ function makeTempDir(prefix) { return fs.mkdtempSync(path.join(os.tmpdir(), prefix)); } -// `withConfig` is independent of `withRunsDir`: the switch and the location -// it writes to no longer have to move together, which is the whole point of -// phase 1. Defaults to a switched-on repo, matching every test written -// before the config file existed. +// `withConfig` is independent of `withRunsDir`: the switch and the location it writes to do +// not have to move together. Defaults to a switched-on repository. function makeTempRepo({ remote, withRunsDir = true, withConfig = true } = {}) { const dir = makeTempDir("aidd-telemetry-repo-"); execFileSync("git", ["init", "-q"], { cwd: dir, env: CLEAN_ENV }); @@ -699,10 +674,9 @@ function makePayload({ cwd, sessionId, event }) { }; } -// `event` defaults from the payload's own hook_event_name, and is overridable -// for tests that exercise a disagreement or an absent hook_event_name. -// AIDD_RUNS_DIR is set to "" (which runsDir treats as unset, being falsy) so -// an ambient override in this process's real environment can never leak in. +// `event` defaults from the payload's own hook_event_name, overridable for a test exercising +// a disagreement. AIDD_RUNS_DIR is set to "", which runsDir treats as unset, so an ambient +// override in this process's real environment can never leak in. function replayIn(payload, event = ARGV_EVENT_BY_HOOK_EVENT_NAME[payload.hook_event_name]) { const args = event ? [script, event] : [script]; return spawnSync(process.execPath, args, { @@ -763,8 +737,8 @@ function replayInWithGitDir(payload, gitDir) { }); } -// Run files are `.jsonl` - one line per observation, appended, never -// rewritten (see plan.md). Recurses because AIDD_RUNS_DIR can point anywhere. +// Run files are `.jsonl` - one line per observation, appended, never rewritten. Recurses +// because AIDD_RUNS_DIR can point anywhere. function readRunFiles(dir) { const files = []; let entries; @@ -1223,11 +1197,8 @@ test("ten turns in one session produce one file, not ten, and its lines record r assert.equal(afterStart.length, 1); const startLine = readLines(afterStart[0])[0]; - // nowIso() truncates to whole seconds, so a Stop replayed within the - // same wall-clock second as SessionStart would not visibly move `at` - // even if handleTurnEnd ran correctly. Crossing a second boundary for - // real is what makes "time advances" a fact about handleTurnEnd, not a - // fact about clock resolution. + // nowIso() truncates to whole seconds, so a Stop replayed inside the same second would + // not visibly move `at` even when handleTurnEnd ran correctly. execFileSync("sleep", ["1.1"]); for (let i = 0; i < 9; i++) { @@ -1413,9 +1384,8 @@ test("a Stop still appends its line even when the run file's existing content is }); test("a session that never produces a git commit still yields a complete session_start line", () => { - // makeTempRepo runs `git init` and configures identity but never commits - - // every test in this file already exercises that shape. This test states - // the acceptance criterion explicitly rather than leaving it implicit. + // makeTempRepo runs `git init` and configures identity but never commits, so a repository + // with no HEAD is the shape under test here. const repo = makeTempRepo({ remote: "git@github.com:acme/no-commit.git" }); try { const log = spawnSync("git", ["log"], { cwd: repo, encoding: "utf8", env: CLEAN_ENV }); @@ -1437,11 +1407,6 @@ test("a session that never produces a git commit still yields a complete session } }); -// `parent_run_id is present and null` is gone: the field itself left -// the written form. Measured reason, from plan.md - a subagent shares its -// parent's session_id, and SubagentStart/SubagentStop carry an agent_id, so -// nesting is inside a run, not between runs; there is nothing left for the -// field to model. test("vendor_field names the export-side attribute, and vendor_id is the same session.id value a live export would carry", () => { // vendor_id is exactly the payload's session_id, the same value Claude @@ -1556,10 +1521,8 @@ test("findRunFileByVendorId ignores leftover pre-event-log .json files (both the } }); -// Restores process.env exactly, including "unset" when a key didn't exist. -// Used below to drive processPayload in-process rather than through a child -// process, since counting git invocations needs to observe *this* process's -// PATH. +// Restores process.env exactly, including "unset" when a key did not exist. Drives +// processPayload in-process, since counting git invocations observes this process's PATH. function withEnv(overrides, fn) { const original = {}; for (const key of Object.keys(overrides)) { @@ -1822,14 +1785,6 @@ test("taskFolderRelativePath returns null for non-string or empty input", () => assert.equal(taskFolderRelativePath(undefined, "/repo/aidd_docs/tasks/2026_08/alpha/x.md"), null); }); -// advanceTasks (the tasks[]-interval state machine) is gone outright, along -// with every pure-unit test of it: file_written no longer computes or stores -// an interval, or a task_id - it records the path, and nothing derives from -// it in this hook. The six advanceTasks tests that stood here (open/leave- -// open/resume/close-and-open/null-switch/placeholder-replace) have no -// replacement, because there is no longer a state machine for them to prove -// correct - the assertions they made are about a shape this plan removes, -// not evidence this plan still needs in another form. test("a session with no file-written at all produces only the session_start line, never a file_written line", () => { const repo = makeTempRepo({ remote: "git@github.com:acme/no-write.git" }); @@ -2203,11 +2158,9 @@ test("a truncated final line leaves every earlier line readable", () => { }); test("no source file in hooks/lib/ reads a run file's contents back - record.cjs and file-writes.cjs never read a file at all", () => { - // Scoped to record.cjs and file-writes.cjs, not repo.cjs: repo.cjs legitimately - // reads .aidd/config.json, which is not a run file. This is the static - // half of the hard constraint (append never reads); the dynamic half is - // exercised above by the corrupted-content and byte-identity tests, which - // would fail immediately if a read-modify-write crept back in. + // Scoped to record.cjs and file-writes.cjs, not repo.cjs, which legitimately reads + // .aidd/config.json. The static half of "append never reads"; the dynamic half is the + // corrupted-content and byte-identity tests above. const recordSrc = fs.readFileSync( path.join(root, "plugins/aidd-telemetry/hooks/lib/record.cjs"), "utf8", @@ -2449,10 +2402,8 @@ test("a leaked GIT_DIR never redirects a session into another repository", () => } }); -// --------------------------------------------------------------------------- -// Phase 1: the journal serves four hosts (see phase-1.md). Each host's own -// SessionStart shape, mirroring scripts/__tests__/fixtures/*-session-start.json - -// same field names, synthetic ids so each test owns its own session. +// Each host's own SessionStart shape, mirroring fixtures/*-session-start.json: same field +// names, synthetic ids so each test owns its own session. function makeCodexPayload({ cwd, sessionId, event, turnId }) { return { @@ -2481,11 +2432,9 @@ function makeCopilotPayload({ cwd, sessionId }) { }; } -// Copilot's other builder, _vsCodeCompat (see lib/host.cjs): Claude Code's own event -// spelling reused verbatim (session_id, hook_event_name) instead of sessionId, plus a -// timestamp field neither Codex nor Claude Code ever carries. Mirrors the shape measured -// 2026-08-21 against a real @github/copilot@1.0.80 session - see -// fixtures/copilot-compat-*.json for the untouched capture this builder is shaped from. +// Copilot's other builder, _vsCodeCompat: Claude Code's own event spelling reused verbatim, +// plus a timestamp field neither Codex nor Claude Code carries. Shaped from the untouched +// capture in fixtures/copilot-compat-*.json. function makeCopilotCompatPayload({ cwd, sessionId, event }) { return { hook_event_name: event, @@ -2495,13 +2444,9 @@ function makeCopilotCompatPayload({ cwd, sessionId, event }) { }; } -// Cursor's own captured payload (fixtures/cursor-session-start.json - the exact shape the -// probe measured, per plan.md) carries no top-level cwd at all, only workspace_roots. -// repo.cjs's resolveWriteTarget/resolveRunsDir read payload.cwd unconditionally, and -// repo.cjs is outside phase-1's architecture projection - translating workspace_roots into a -// usable cwd is not this phase's work to invent. So this builder mirrors the real shape -// exactly; it must NOT grow a cwd field just to make a happy-path test pass, or the test -// would assert a capability the code does not have. +// Cursor's captured payload carries no top-level cwd at all, only workspace_roots. This +// builder mirrors that shape exactly and must NOT grow a cwd field to make a happy-path test +// pass, or the test would assert a capability the code does not have. function makeCursorPayload({ cwd, sessionId, event }) { return { conversation_id: sessionId, @@ -2733,9 +2678,8 @@ test("a Copilot compat turn-end appends a turn_end line to the file its own sess } }); -// Renamed from a title claiming "the two are no longer indistinguishable from outside" - -// this pair (a run file vs none) is exactly what the criterion says is NOT enough; the real -// comparison (unrecognised payload vs no payload at all) is asserted separately below. +// A run file against none is not the decisive comparison; unrecognised payload against no +// payload at all is, and it is asserted separately below. test("a Copilot compat session-start writes a run file; an unrecognised payload of the same event writes the unrecognised marker instead, never a run file of its own", () => { const repo = makeTempRepo({ remote: "git@github.com:acme/copilot-compat-vs-unrecognised.git" }); try { @@ -2901,7 +2845,6 @@ test("every fixture in the directory is free of a real email address, a real hom } }); -// ── Phase 2: a started step is a fact ───────────────────────────────────────── const { SKILL_FILE_PATTERN, @@ -2909,10 +2852,9 @@ const { } = require("../../plugins/aidd-telemetry/hooks/lib/step-starts.cjs"); const { buildStepStartLine } = require("../../plugins/aidd-telemetry/hooks/lib/record.cjs"); -// Each entry is a real captured payload, edited only where a test needs its own repo, -// session or skill name. The shapes themselves are never hand-written: Copilot delivering -// its arguments as a JSON string and Codex naming the tool `Bash` are exactly the details -// a plausible invention would get wrong. +// Each entry is a real captured payload, edited only where a test needs its own repo, session +// or skill name: Copilot delivering its arguments as a JSON string and Codex naming the tool +// `Bash` are exactly the details an invented shape would get wrong. const STEP_FIXTURE_BY_HOST = { "claude-code": "claude-code-post-tool-use-skill.json", copilot: "copilot-post-tool-use-skill.json", @@ -2920,10 +2862,9 @@ const STEP_FIXTURE_BY_HOST = { cursor: "cursor-post-tool-use-skill-read.json", }; -// Codex's session identity is the rollout it writes, read off transcript_path, not the -// session_id the payload also carries - a resumed session's session_id names its parent. -// A test that renamed only session_id would leave the two events pointing at two different -// sessions, which is precisely the bug the derivation exists to prevent. +// Codex's session identity is the rollout it writes, not the session_id the payload also +// carries, which on a resumed session names its parent. Renaming only session_id would point +// the two events at two different sessions. function retargetCodexTranscript(payload, sessionId) { // The last 36 characters before the extension, exactly as the hook's own parse takes // them - matching a UUID-ish run of characters instead could cross the timestamp @@ -2987,10 +2928,9 @@ function endPayload(cwd, sessionId, skill) { return payload; } -// A skill's end is the one thing about a step no host emits - measured, a `Skill` call's own -// `tool_result` returns in about a tenth of a second, which is the dispatch and not the -// completion. So the skill declares it, through a tool call it makes, and the hook - which -// alone holds the session id and the working directory - writes the line. +// A skill's end is the one thing about a step no host emits: a `Skill` call's `tool_result` +// returns in a tenth of a second, which is the dispatch and not the completion. So the skill +// declares it, and the hook, which alone holds the session id and cwd, writes the line. test("a skill declaring its own end leaves a step_end naming it", () => { const repo = makeTempRepo({ remote: "git@github.com:acme/step-end.git" }); try { @@ -3010,10 +2950,9 @@ test("a skill declaring its own end leaves a step_end naming it", () => { } }); -// The same watermark `task-declared.cjs` protects, and for the same reason: file-writes.cjs -// reads the run file's own mtime as "the moment this session last wrote a line" to know what -// changed since. A step end landing between a shell write and the turn end that observes it -// would push that mark past the write, silently dropping it. +// The watermark `task-declared.cjs` protects: file-writes.cjs reads the run file's mtime as +// "the moment this session last wrote a line", so a step end landing between a shell write +// and the turn end that observes it would push that mark past the write. test("a step end never costs the turn a write it should have observed", () => { const repo = makeTempRepo({ remote: "git@github.com:acme/step-end-mtime.git" }); const sessionId = "00000000-0000-4000-8000-00000000e0df"; @@ -3043,10 +2982,9 @@ function stepLinesIn(repo) { } // Claude Code and Copilot name the skill in a tool argument; Codex and Cursor leave only a -// SKILL.md path. Four hosts, one assertion, because the point of the table is that the -// caller cannot tell which family ran. -// Hex throughout, so Codex's identity really is derived from its transcript path rather -// than quietly falling back to session_id because the synthetic id is not a UUID. +// SKILL.md path. One assertion for four hosts, because the caller cannot tell which family +// ran. Hex throughout, so Codex's identity is really derived from its transcript path rather +// than falling back to session_id because a synthetic id is not a UUID. const STEP_SESSION_SUFFIX_BY_HOST = { "claude-code": "aaa", copilot: "bbb", @@ -3209,9 +3147,8 @@ test("a file whose name ends in SKILL.md but sits outside a skills tree opens no assert.equal(SKILL_FILE_PATTERN.test("sed -n '1,120p' .agents/skills/alpha/SKILL.md"), true); }); -// The decisive shape is a call the argument family REJECTS that still carries a SKILL.md -// path: on an argument-family host, merely reading a skill file is not opening a step. -// A fallback chain would mint a phantom step here, and a payload the argument family +// The decisive shape is a call the argument family rejects that still carries a SKILL.md +// path: a fallback chain would mint a phantom step here, and a payload the argument family // accepts could never show it, since the first family would answer and the second never run. test("reading a SKILL.md on an argument-family host opens no step - the table names one family, it is not a fallback chain", () => { const repo = makeTempRepo({ remote: "git@github.com:acme/step-two-candidates.git" }); @@ -3294,10 +3231,9 @@ test("adding a fifth host is a table entry, not an edit to the handler", () => { } }); -// The word hooks.json ships and the word journal.cjs accepts are two halves of one -// contract, and nothing else checks they agree. The journal was already dead on every -// real installation once, for a mismatch of exactly this shape that 2250 tests missed -// because they all ran from the source tree. +// The word hooks.json ships and the word journal.cjs accepts are two halves of one contract, +// and nothing else checks they agree. A mismatch leaves the journal dead on every real +// installation while a suite running from the source tree stays green. test("every argv word hooks.json ships is one journal.cjs recognises", () => { const declared = JSON.parse( fs.readFileSync(path.join(root, "plugins/aidd-telemetry/hooks/hooks.json"), "utf8") @@ -3395,7 +3331,6 @@ test("a turn that wrote nothing into a task folder records nothing", () => { } }); -// ── Phase 3: a declared task ────────────────────────────────────────────────── const { TASK_PATH_PATTERN, @@ -3418,12 +3353,9 @@ const TASK_DECLARED_FIXTURE_BY_HOST = { copilot: "copilot-task-declared.json", }; -// Each entry is a real captured payload (see fixtures/README.md, "The task-declaration -// payloads"), edited only where a test needs its own repo or session - never a hand-written -// shape. declaredTaskPath finds the task path by pattern match, not by cwd, so the captured -// tool_input/toolArgs value is left exactly as captured; only the fields the run-file lookup -// and session identity depend on are retargeted, the same way stepPayload/sessionStartPayload -// above retarget the step-opening captures. +// Each entry is a real captured payload, edited only where a test needs its own repo or +// session. declaredTaskPath matches by pattern, not by cwd, so the captured arguments are +// left exactly as captured and only the fields the run-file lookup depends on are retargeted. function taskPayload(host, { cwd, sessionId }) { const payload = loadFixture(TASK_DECLARED_FIXTURE_BY_HOST[host]); if (host === "cursor") { @@ -3558,7 +3490,6 @@ test("declaredTaskPath reads tool_input first and Copilot's toolArgs string only assert.equal(declaredTaskPath({ tool_input: { command: "echo hi" } }), null); }); -// a session says which worktree it ran in ------------------------------------ // A linked worktree of `repo`, switched on, with its own runs directory. The seed commit // is what `git worktree add` needs a HEAD for; makeTempRepo leaves the repo empty. @@ -3687,14 +3618,10 @@ test("a plain checkout that happens to live in a directory named 'worktrees' is } }); -// OpenCode is the one host with no hook payload of its own: `hooks/opencode-plugin.js` -// builds one and spawns this same journal, so `lib/tools/opencode.cjs` is only ever reached -// through that synthesized shape. Nothing drove it here — a coverage run over this suite -// showed `opencode.cjs` at 0% of its own functions while the other four hosts were between -// 96% and 100% — so the fifth host's write path was the one never exercised in process. -// The payload below is exactly what `opencode-plugin.js` builds (`{ tool, session_id, cwd }`, -// and `tool_input` on a tool call); it carries no `hook_event_name`, so the event can only -// come from argv, which is the second thing this covers. +// OpenCode is the one host with no hook payload of its own: `opencode-plugin.js` builds one +// and spawns this same journal, so `lib/tools/opencode.cjs` is only ever reached through that +// synthesized shape. The payload below is exactly what the plugin builds, and it carries no +// `hook_event_name`, so the event can only come from argv. function openCodePayload({ cwd, sessionId, toolInput }) { return { tool: "opencode", diff --git a/scripts/__tests__/aidd-telemetry-opencode-payloads.test.js b/scripts/__tests__/aidd-telemetry-opencode-payloads.test.js index 79c3b6a7b..1d3653b03 100644 --- a/scripts/__tests__/aidd-telemetry-opencode-payloads.test.js +++ b/scripts/__tests__/aidd-telemetry-opencode-payloads.test.js @@ -1,10 +1,6 @@ -// Every other tool has between three and eight captures behind its reader. OpenCode had -// none - its coverage was asserted from a doc comment (`plugin README.md`'s "OpenCode -// misses a server process's first session"), never from a payload. This file replaces that -// with real captures of `session.idle` and a completed tool part, plus `session.created` -// reconstructed from a verified SDK type declaration plus a genuinely captured sibling -// event - see fixtures/README.md's "OpenCode's plugin events" for exactly what each one -// rests on and does not. +// Real captures of `session.idle` and a completed tool part, plus `session.created` +// reconstructed from a verified SDK type declaration and a genuinely captured sibling event - +// see fixtures/README.md for exactly what each one rests on and what it does not. const assert = require("node:assert/strict"); const fs = require("node:fs"); const os = require("node:os"); @@ -16,7 +12,7 @@ const { detectHost } = require("../../plugins/aidd-telemetry/hooks/lib/host.cjs" const PLUGIN_SOURCE = path.resolve( __dirname, - "../../plugins/aidd-telemetry/hooks/opencode-plugin.js", + "../../plugins/aidd-telemetry/hooks/opencode-plugin.js" ); const fixturesDir = path.join(__dirname, "fixtures"); @@ -24,16 +20,17 @@ function loadFixture(name) { return JSON.parse(fs.readFileSync(path.join(fixturesDir, name), "utf8")); } -// A byte-identical `.mjs` twin, for this file alone - the same reason -// opencode-plugin.test.js's own `makeInstalledRepo` keeps one: this repository declares no -// `"type": "module"` anywhere up the tree, so plain Node's `import()` would read -// `opencode-plugin.js` as CommonJS and choke on its `export` syntax. OpenCode's own loader -// does not consult that field at all - the extension is the only thing that differs from -// what ships. +// A byte-identical `.mjs` twin: this repository declares no `"type": "module"` anywhere up +// the tree, so plain Node's `import()` would read the plugin as CommonJS and choke on its +// `export` syntax. OpenCode's own loader consults no such field, and the extension is the +// only thing that differs from what ships. let pluginModulePromise; async function pluginModule() { if (!pluginModulePromise) { - const twin = path.join(fs.mkdtempSync(path.join(os.tmpdir(), "aidd-opencode-payloads-")), "opencode-plugin.mjs"); + const twin = path.join( + fs.mkdtempSync(path.join(os.tmpdir(), "aidd-opencode-payloads-")), + "opencode-plugin.mjs" + ); fs.copyFileSync(PLUGIN_SOURCE, twin); pluginModulePromise = import(pathToFileURL(twin).href); } @@ -49,23 +46,19 @@ async function journalCallsFor() { } // OpenCode loads every function-valued named export of a file in `plugin/` as a plugin -// factory of its own. Measured live against opencode 1.14.20 in a freshly installed -// project: a second such export returning `null` killed `opencode run` with -// `TypeError: null is not an object (evaluating 'S.auth')` before any session started, so -// installing this framework made the tool it measures unusable. A non-function export is -// ignored by that same loader, which is why the spawn-free seam this file needs rides on -// the plugin function as a property instead of standing beside it as a second export. +// factory of its own, and a second such export returning `null` kills `opencode run` before +// any session starts. A non-function export is ignored by that same loader, which is why the +// spawn-free seam rides on the plugin function as a property. test("the plugin file exports one plugin factory, never a second one OpenCode would call", async () => { const exported = await pluginModule(); const factories = Object.keys(exported).filter((name) => typeof exported[name] === "function"); assert.deepEqual(factories, ["AiddTelemetry"]); }); -// `opencode run` is always a session OpenCode never announced: measured, `session.created` -// is published on its own bus and never reaches a plugin's event hook (plugins/aidd-telemetry -// /README.md, "OpenCode misses a server process's first session"). Without these four cases the -// journal receives a turn-end for a run file that was never created, drops it, and every -// OpenCode session reads back as nothing at all - while the tool is declared covered. +// `opencode run` is always a session OpenCode never announced: `session.created` is published +// on its own bus and never reaches a plugin's event hook. Without these four cases the journal +// receives a turn-end for a run file that was never created, drops it, and every OpenCode +// session reads back as nothing at all while the tool is declared covered. test("session.idle for a session nobody announced opens it first, so the journal has a run file to write into", async () => { const calls = await journalCallsFor(); const idle = loadFixture("opencode-session-idle.json"); @@ -74,11 +67,11 @@ test("session.idle for a session nobody announced opens it first, so the journal assert.deepEqual( produced.map((call) => call.script), - ["session-start", "turn-end"], + ["session-start", "turn-end"] ); assert.deepEqual( produced.map((call) => call.payload.cwd), - ["/home/user/fallback", "/home/user/fallback"], + ["/home/user/fallback", "/home/user/fallback"] ); assert.equal(detectHost(produced[0].payload), "opencode"); }); @@ -91,7 +84,7 @@ test("a task declaration in a session nobody announced opens it first too, never assert.deepEqual( produced.map((call) => call.script), - ["session-start", "tool-used"], + ["session-start", "tool-used"] ); assert.equal(produced[0].payload.session_id, produced[1].payload.session_id); }); @@ -104,12 +97,12 @@ test("a session already announced is never opened a second time", async () => { const produced = calls( loadFixture("opencode-session-idle.json"), sessionDirectories, - "/home/user/fallback", + "/home/user/fallback" ); assert.deepEqual( produced.map((call) => call.script), - ["turn-end"], + ["turn-end"] ); }); @@ -119,12 +112,12 @@ test("a session OpenCode did announce produces its one session-start, never a do const produced = calls( loadFixture("opencode-session-created.json"), new Map(), - "/home/user/fallback", + "/home/user/fallback" ); assert.deepEqual( produced.map((call) => call.script), - ["session-start"], + ["session-start"] ); }); @@ -138,7 +131,7 @@ test("a second session.idle on one already-opened session adds no second session assert.deepEqual( produced.map((call) => call.script), - ["turn-end"], + ["turn-end"] ); }); @@ -148,6 +141,28 @@ test("an event this plugin does not act on opens no session either", async () => assert.deepEqual(calls({ type: "message.updated", properties: {} }, new Map(), "/x"), []); }); +// OpenCode's bus carries event types this plugin never dispatches on, none of them guaranteed +// to carry a `properties` object. Session id resolution runs before the per-type dispatch that +// would ignore such an event, so it alone decides whether one crashes OpenCode's event loop. +test("an event with no properties at all does not crash resolving its session id", async () => { + const calls = await journalCallsFor(); + + assert.deepEqual(calls({ type: "server.connected" }, new Map(), "/x"), []); +}); + +// The field this reads is real, but nothing guarantees OpenCode fills `properties.info` +// before firing: an empty `properties` object must resolve to an undefined id, not throw. +test("session.created with an empty properties object does not crash, it opens an unnamed session", async () => { + const calls = await journalCallsFor(); + + const produced = calls({ type: "session.created", properties: {} }, new Map(), "/x"); + + assert.deepEqual( + produced.map((call) => call.script), + ["session-start"] + ); +}); + test("session.idle, captured live, turns into a turn-end call the journal recognises as opencode", async () => { const builder = await journalCallFor(); const event = loadFixture("opencode-session-idle.json"); @@ -224,12 +239,12 @@ test("a second session.created on one server keeps its own directory, never over const firstIdle = builder( { type: "session.idle", properties: { sessionID: first.properties.info.id } }, sessionDirectories, - "/home/user/fallback", + "/home/user/fallback" ); const secondIdle = builder( { type: "session.idle", properties: { sessionID: second.properties.info.id } }, sessionDirectories, - "/home/user/fallback", + "/home/user/fallback" ); assert.equal(firstCall.payload.cwd, first.properties.info.directory); @@ -244,10 +259,8 @@ test("an event this plugin does not act on produces no journal call", async () = assert.equal(builder({ type: "message.updated", properties: {} }, new Map(), "/x"), null); }); -// A completed tool part, captured live (opencode 1.14.20, 2026-08-31, model -// opencode/ling-3.0-flash-fin-free): the model called its own `read` tool, and the plugin's -// `event` hook received the part carrying that call's own arguments - see fixtures/README.md's -// "OpenCode's tool part" for what was measured and what was not. Turns into a tool-used call +// A completed tool part, captured live: the model called its own `read` tool and the plugin's +// `event` hook received the part carrying that call's arguments. Turns into a tool-used call // the same shape Claude Code's Read and Codex's Bash already give the declaration reader. test("a completed tool part, captured live, turns into a tool-used call the journal recognises as opencode", async () => { const builder = await journalCallFor(); @@ -295,13 +308,9 @@ test("a tool part still pending or running produces no journal call - only a com assert.equal(builder(running, new Map(), "/x"), null); }); -// The negative, captured live in the same run as the positive above: a completed `bash` -// tool call, `ls -la`, naming no task folder anywhere in its own arguments. Produces no -// journal call at all: a pre-filter over the call's own arguments, mirroring -// `task-declared.cjs`'s own `TASK_PATH_PATTERN`, refuses it before a call is ever built - -// on OpenCode, `tool-used` does nothing else downstream for a call that names no task (see -// `opencode-plugin.js`'s own comment on `declaredTaskCallFor`), so nothing is lost by never -// spawning `journal.cjs` for it. +// The negative, captured live in the same run as the positive above: a completed `bash` call +// naming no task folder anywhere in its arguments. The pre-filter refuses it before a call is +// ever built, and on OpenCode `tool-used` does nothing else downstream for such a call. test("a completed tool part naming no task folder produces no journal call at all - nothing downstream would have acted on it", async () => { const builder = await journalCallFor(); const event = loadFixture("opencode-tool-part-no-task.json"); diff --git a/scripts/__tests__/aidd-telemetry-plugin-version.test.js b/scripts/__tests__/aidd-telemetry-plugin-version.test.js index 303beb790..567384d62 100644 --- a/scripts/__tests__/aidd-telemetry-plugin-version.test.js +++ b/scripts/__tests__/aidd-telemetry-plugin-version.test.js @@ -12,9 +12,7 @@ const CLEAN_ENV = Object.fromEntries( ); const { - pluginVersion, readManifestVersion, - versionBesideTheHooks, versionFromAiddManifest, MANIFEST_DIRS, PLUGIN_NAME, @@ -40,14 +38,18 @@ function makeTempDir(prefix) { test("MANIFEST_DIRS names every directory the build renames this plugin's manifest into", () => { // Duplicated from the CLI on purpose - this plugin is copied verbatim into user projects - // and can import nothing from `cli/`. Pinned here so the copy cannot drift from the list - // that decides where the manifest is actually written. Looking for one name found the - // version on Claude and nowhere else, which is the defect this list exists to close. - const contracts = fs.readFileSync( - path.resolve(__dirname, "../../cli/src/application/use-cases/framework/strategies/tool-contracts.ts"), - "utf8", - ); - const declared = [...contracts.matchAll(/manifestDir:\s*"([^"]+)"/gu)].map((m) => m[1]); + // and can import nothing from `cli/` - and pinned here so the copy cannot drift. Read off + // each profile's own declared manifest path, since no single file lists them. + const profilesDir = path.resolve(__dirname, "../../cli/src/contexts/tools/domain/profiles"); + const declared = []; + for (const tool of fs.readdirSync(profilesDir, { withFileTypes: true })) { + if (!tool.isDirectory()) continue; + for (const file of fs.readdirSync(path.join(profilesDir, tool.name))) { + if (!file.endsWith(".ts")) continue; + const source = fs.readFileSync(path.join(profilesDir, tool.name, file), "utf8"); + for (const m of source.matchAll(/"(\.[A-Za-z0-9_.-]+)\/plugin\.json"/gu)) declared.push(m[1]); + } + } assert.ok(declared.length > 0, "the CLI must still declare manifest directories"); assert.deepEqual([...MANIFEST_DIRS].sort(), [...new Set(declared)].sort()); @@ -61,12 +63,14 @@ test("AIDD_TOOL_ID_BY_HOST maps every journal host the CLI declares, onto that t // The journal's host names and `.aidd/manifest.json`'s tool ids are the same set spelled // twice; only Claude Code differs. A host missing here answers `undefined` and silently // costs the version, so the map has to be complete rather than merely correct. - const toolsDir = path.resolve(__dirname, "../../cli/src/domain/tools/ai"); + const profilesDir = path.resolve(__dirname, "../../cli/src/contexts/tools/domain/profiles"); const declared = {}; - for (const file of fs.readdirSync(toolsDir).filter((f) => f.endsWith(".ts"))) { - const source = fs.readFileSync(path.join(toolsDir, file), "utf8"); - const host = /telemetryJournalHost:\s*"([^"]+)"/u.exec(source); - if (host) declared[host[1]] = path.basename(file, ".ts"); + for (const tool of fs.readdirSync(profilesDir, { withFileTypes: true })) { + if (!tool.isDirectory()) continue; + const profile = path.join(profilesDir, tool.name, "profile.ts"); + if (!fs.existsSync(profile)) continue; + const host = /telemetryJournalHost:\s*"([^"]+)"/u.exec(fs.readFileSync(profile, "utf8")); + if (host) declared[host[1]] = tool.name; } assert.ok(Object.keys(declared).length > 0, "the CLI must still declare journal hosts"); @@ -106,9 +110,8 @@ test("readManifestVersion reads null for valid JSON that names no usable version }); test("pluginVersion answers per repository, never handing one project's version to the next", () => { - // There used to be a process-wide memo here, keyed on nothing. It was harmless while the - // answer came from one fixed path; it stopped being harmless the moment the second route - // made the answer depend on which repository is asking. + // No process-wide memo: the second route makes the answer depend on which repository asks, + // so a cache keyed on nothing would hand one repository's answer to the next. const repoWithout = makeTempDir("aidd-plugin-version-norepo-"); const repoWith = makeTempDir("aidd-plugin-version-withrepo-"); fs.mkdirSync(path.join(repoWith, ".aidd"), { recursive: true }); @@ -144,10 +147,8 @@ test("the aidd manifest answers for the host's own tool, never for whichever lis }); // The integration half: journal.cjs run from a temporary copy of this plugin's own hooks/ -// tree, so a missing manifest can be exercised without ever touching this repository's own -// real, committed plugin.json - the same copy-and-run technique -// plugin-install-shape.test.js and opencode-plugin.test.js already use for a structural -// concern neither this repo's real tree nor a fixture file alone can vary. +// tree, so a missing manifest is exercised without touching this repository's own committed +// plugin.json - a structural concern neither the real tree nor a fixture alone can vary. const HOOKS_SRC = path.resolve(__dirname, "../../plugins/aidd-telemetry/hooks"); const REAL_CLAUDE_PLUGIN_DIR = path.resolve(__dirname, "../../plugins/aidd-telemetry/.claude-plugin"); @@ -258,10 +259,9 @@ test("a plugin copy whose manifest is present but not valid JSON reads the same }); test("finds the manifest under every name the build renames it to, not only Claude's", () => { - // The defect this closes: the lookup named `.claude-plugin` alone, so a session on - // cursor, codex or copilot wrote a journal line with no version at all — indistinguishable - // from a line written before the field existed. Driven through the real hook, one built - // layout at a time. + // A lookup naming one manifest directory leaves every other tool writing a line with no + // version at all, indistinguishable from one written before the field existed. Driven + // through the real hook, one built layout at a time. for (const manifestDir of MANIFEST_DIRS) { const journalScript = makePluginCopy("valid", manifestDir); const repo = makeTempRepo(); @@ -275,9 +275,8 @@ test("finds the manifest under every name the build renames it to, not only Clau }); test("falls back to what the aidd CLI recorded when the hooks were installed away from any manifest", () => { - // `aidd setup --ai cursor` copies `hooks/` alone into `.cursor/hooks/aidd-telemetry/`, - // with no manifest at any offset — measured, not assumed. The CLI writes - // `.aidd/manifest.json` in the same act, and that is the only thing left that knows. + // `aidd setup` copies `hooks/` alone, with no manifest at any offset, and writes + // `.aidd/manifest.json` in the same act - the only thing left that knows the version. const journalScript = makePluginCopy("absent"); const repo = makeTempRepo(); fs.writeFileSync( diff --git a/scripts/__tests__/aidd-telemetry-step-end.test.js b/scripts/__tests__/aidd-telemetry-step-end.test.js index e489d2a75..b49089c0c 100644 --- a/scripts/__tests__/aidd-telemetry-step-end.test.js +++ b/scripts/__tests__/aidd-telemetry-step-end.test.js @@ -1,8 +1,7 @@ -// A skill's own end, declared the way a task already is: read out of a tool call's own -// free-form arguments, never from a field a host populates. Nothing any host emits says when -// a skill's work finishes - measured, the `tool_result` for a `Skill` call comes back in -// about a tenth of a second, which is the dispatch and not the completion - so the only party -// that can say it is the skill itself, and the only channel it has is a tool call it makes. +// A skill's own end, read out of a tool call's free-form arguments and never from a field a +// host populates. Nothing any host emits says when a skill's work finishes: a `Skill` call's +// `tool_result` comes back in a tenth of a second, which is the dispatch and not the +// completion, so the only party that can say it is the skill itself. const assert = require("node:assert/strict"); const fs = require("node:fs"); const path = require("node:path"); @@ -99,7 +98,7 @@ test("every skill declaring its own end declares it in the form the hook reads", // lives; a copy kept here would be the second, and the two would disagree first. function orchestratingSkillsTheReaderKnows() { const source = fs.readFileSync( - path.join(__dirname, "..", "..", "cli", "src", "domain", "models", "flow-attribution.ts"), + path.join(__dirname, "..", "..", "cli", "src", "contexts", "telemetry", "domain", "flow-attribution.ts"), "utf8" ); const declared = /ORCHESTRATING_SKILLS: ReadonlySet = new Set\(\[([^\]]*)\]\)/u.exec( @@ -110,11 +109,9 @@ function orchestratingSkillsTheReaderKnows() { } test("every skill that opens a flow also says when that flow is over", () => { - // Without the marker a flow closes on the next orchestrating step_start or, failing that, - // on the journal's own last witnessed moment - so an orchestration that never says it is - // done goes on owning everything the session did afterwards. The reader stopped closing a - // flow at a `turn_end` on 2026-09-04, which is what makes this declaration load-bearing - // rather than a refinement. + // The reader does not close a flow at a `turn_end`, so without the marker a flow closes on + // the next orchestrating step_start or on the journal's last witnessed moment - and an + // orchestration that never says it is done owns everything the session did afterwards. const qualified = orchestratingSkillsTheReaderKnows().filter((skill) => skill.includes(":")); assert.ok(qualified.length > 0, "no orchestrating skill is named in the plugin-qualified form"); diff --git a/scripts/__tests__/aidd-telemetry-task-declaration.test.js b/scripts/__tests__/aidd-telemetry-task-declaration.test.js index a9c236646..c5600d055 100644 --- a/scripts/__tests__/aidd-telemetry-task-declaration.test.js +++ b/scripts/__tests__/aidd-telemetry-task-declaration.test.js @@ -1,15 +1,7 @@ -// The task-declaration reader (declaredTaskPath, in hooks/lib/task-declared.cjs) was tested -// only against hand-written payloads until now (aidd-telemetry-journal.test.js's own -// readTaskPayload()), which proves the reader agrees with itself, not with anything a host -// actually sends - the weakest cell the six-questions audit named. This file replaces that -// with one real, live capture per host that can declare, for four of the five - Codex -// included, now that codex-cli is runnable in this environment - and, for OpenCode, the -// call `hooks/opencode-plugin.js` builds from a genuinely captured event, now that a -// genuine `opencode 1.14.20` capture (2026-08-31) settled the question a bounded -// measurement was run to answer: a completed tool part's own arguments do reach the -// plugin's `event` hook, and that hook joins one into a declaration the same way every -// other host's hook already does. See fixtures/README.md's "The task-declaration payloads" -// for exactly what each fixture rests on. +// One real, live capture per host that can declare a task, rather than a hand-written payload +// - which would only prove the reader agrees with itself, never with anything a host sends. +// For OpenCode it is the call `opencode-plugin.js` builds from a genuinely captured event. +// See fixtures/README.md for exactly what each fixture rests on. const assert = require("node:assert/strict"); const fs = require("node:fs"); const path = require("node:path"); @@ -61,21 +53,21 @@ for (const [host, fixtureName] of Object.entries(SKILL_FIXTURE_BY_HOST)) { }); } -// Mutation proof, per host: declaredTaskPath reads only `payload.tool_input`, falling back -// to `payload.toolArgs` - renaming a key *inside* that object proves nothing, since -// firstTaskPathIn walks every string value regardless of its key. The shape drift that -// actually matters is the wrapper itself: if a host ever renamed tool_input (or Copilot's -// canonical toolArgs), the reader would stop finding anything in it - so that is the key -// this proof renames. All four captures here carry the path inside `tool_input`; none uses -// Copilot's canonical `toolArgs` string (see fixtures/README.md on why a live capture -// against this plugin's own hooks.json cannot land on that shape). +// Mutation proof, per host: renaming a key *inside* the arguments proves nothing, since +// firstTaskPathIn walks every string value regardless of its key. The drift that matters is +// the wrapper itself - a host renaming `tool_input` would leave the reader finding nothing - +// so that is the key this proof renames. const WRAPPER_KEY = "tool_input"; const RENAMED_WRAPPER_CASE_BY_HOST = Object.keys(TASK_DECLARED_FIXTURE_BY_HOST); for (const host of RENAMED_WRAPPER_CASE_BY_HOST) { test(`${host}: renaming the ${WRAPPER_KEY} wrapper the path lives in turns the declaration reader red`, () => { const payload = loadFixture(TASK_DECLARED_FIXTURE_BY_HOST[host]); - assert.equal(declaredTaskPath(payload), TASK_RELATIVE_PATH, "sanity: the un-mutated fixture still declares"); + assert.equal( + declaredTaskPath(payload), + TASK_RELATIVE_PATH, + "sanity: the un-mutated fixture still declares" + ); payload.arguments = payload[WRAPPER_KEY]; delete payload[WRAPPER_KEY]; @@ -84,12 +76,3 @@ for (const host of RENAMED_WRAPPER_CASE_BY_HOST) { }); } -// OpenCode declares a task now: a bounded, three-further-session measurement (opencode -// 1.14.20, 2026-08-31) found a completed tool part's own arguments do reach the plugin's -// `event` hook - see fixtures/README.md's "OpenCode's tool part" for what was run, what -// arrived, and what did not. hooks/opencode-plugin.js's `declaredTaskCallFor` joins one the -// same way every other host's hook already does, asserted above through -// TASK_DECLARED_FIXTURE_BY_HOST like every other host. cli/src/domain/tools/ai/opencode.ts's -// telemetryTaskAttributable flips to true for the same reason, and -// registry-conformance.unit.test.ts keeps it tied to the journal hook's own tool-used -// dispatch rather than typed in twice by hand. diff --git a/scripts/__tests__/aidd-telemetry-task-path-pattern-parity.test.js b/scripts/__tests__/aidd-telemetry-task-path-pattern-parity.test.js new file mode 100644 index 000000000..5e0a71570 --- /dev/null +++ b/scripts/__tests__/aidd-telemetry-task-path-pattern-parity.test.js @@ -0,0 +1,35 @@ +const assert = require("node:assert/strict"); +const fs = require("node:fs"); +const path = require("node:path"); +const { describe, it } = require("node:test"); + +const ROOT = path.resolve(__dirname, "../.."); + +/** + * `TASK_PATH_PATTERN` is duplicated on purpose - opencode-plugin.js's own doc comment + * explains why it cannot import `hooks/lib/task-declared.cjs` - but nothing pinned the two + * literals to each other, so one could drift and a task declaration would stop matching on + * OpenCode alone, silently, with no red anywhere. This reads both source files and compares + * the literal text of the assignment, not its behaviour: a regex that matches the same + * strings today but is spelled differently would still be a drift worth catching. + */ + +function taskPathPatternLiteral(file) { + const text = fs.readFileSync(path.join(ROOT, file), "utf8"); + const match = text.match(/const TASK_PATH_PATTERN =\s*([\s\S]*?);/u); + assert.ok(match, `${file} no longer declares TASK_PATH_PATTERN the way this guard expects`); + return match[1].replace(/\s+/gu, " ").trim(); +} + +describe("TASK_PATH_PATTERN stays identical between its two copies", () => { + it("opencode-plugin.js's own copy matches lib/task-declared.cjs's, character for character", () => { + const canonical = taskPathPatternLiteral("plugins/aidd-telemetry/hooks/lib/task-declared.cjs"); + const duplicate = taskPathPatternLiteral("plugins/aidd-telemetry/hooks/opencode-plugin.js"); + + assert.equal( + duplicate, + canonical, + "opencode-plugin.js's TASK_PATH_PATTERN drifted from lib/task-declared.cjs's - fix the copy here to match" + ); + }); +}); diff --git a/scripts/__tests__/aidd-telemetry-trailer-repair.test.js b/scripts/__tests__/aidd-telemetry-trailer-repair.test.js index 82f54ecbb..bffdd1633 100644 --- a/scripts/__tests__/aidd-telemetry-trailer-repair.test.js +++ b/scripts/__tests__/aidd-telemetry-trailer-repair.test.js @@ -22,10 +22,10 @@ const CLEAN_ENV = Object.fromEntries( /** * The trailer's call site, put back after something removed it. * - * Every case here reproduces the failure by its **shape** — a `prepare-commit-msg` replaced - * between two commits — and never by its brand. Nothing installs lefthook or husky, and - * nothing here names them: the repair asks only whether the line is there, so a test that - * needed a particular tool would be testing something narrower than the code. + * Every repair case reproduces the failure by its shape — a `prepare-commit-msg` replaced + * between two commits — never by its brand, since the repair asks only whether the line is + * there. The two cases that do name lefthook and husky assert the opposite, that a marker + * file makes the repair decline, and a marker file at the root is the entire fixture. */ function withRepo(run, nested = "") { const root = fs.mkdtempSync(path.join(os.tmpdir(), "aidd-trailer-repair-")); @@ -38,12 +38,9 @@ function withRepo(run, nested = "") { path.join(root, ".aidd", "config.json"), JSON.stringify({ telemetry: { enabled: true } }) ); - // Resolved against the cwd the hook is given, which is what the hook itself does — git - // prints `--git-path` relative to the directory it ran in. Pinning it here is the point: - // an earlier version resolved against the repository root instead, and a session started - // in a subdirectory then repaired a hooks directory two levels ABOVE the checkout. Every - // case below runs from `root`, where both bases coincide, so `withRepo` takes the cwd the - // session will use and the nested case supplies a different one. + // Resolved against the cwd the hook is given, which is what the hook does: git prints + // `--git-path` relative to the directory it ran in. Every case below runs from `root`, + // where both bases coincide, so the nested case supplies a different cwd. const hooks = String( spawnSync("git", ["rev-parse", "--git-path", "hooks"], { cwd: sessionCwd, @@ -186,9 +183,8 @@ test("nothing is written when measurement is off for the project", () => { /** * A file the filesystem says cannot be written is left exactly as it is, and the session * survives. `rename` needs the *directory*, not the file, so without an explicit check a - * `0444` hook was silently replaced and left reading `0444` — its content changed while its - * permissions said it could not be. The old test asserted only that the session exited 0, - * which was true either way. + * `0444` hook is silently replaced and left reading `0444` — its content changed while its + * permissions say it could not be. */ test("an unwritable hook file is left alone, and costs the session nothing", () => { withRepo(({ root, hooksDir }) => { @@ -206,13 +202,11 @@ test("an unwritable hook file is left alone, and costs the session nothing", () }); }); -// `open(2)` applies the umask to the mode it is handed, so a staged write narrowed a `0770` -// hook to `0750`. Narrowing is as much a change to somebody else's file as widening. +// `open(2)` applies the umask to the mode it is handed, so a staged write narrows a `0770` +// hook to `0750`, and narrowing is as much a change to somebody else's file as widening. // // Asserted as "the mode the repair found is the mode it left", never against a literal: -// Windows has no POSIX permission bits, and reports `0o666` for every writable file -// whatever it was chmod'ed to. The literal made this fail there for a reason that was -// about the platform and not about the repair. +// Windows has no POSIX permission bits and reports `0o666` for every writable file. test("a repair does not narrow a hook's group permissions", () => { withRepo(({ root, hooksDir }) => { installDelegate(hooksDir); @@ -229,12 +223,9 @@ test("a repair does not narrow a hook's group permissions", () => { /** * The atomicity criterion, asserted on the one thing only `rename` produces: a different - * inode. A direct `writeFileSync` truncates and refills the file it already has, so the - * inode survives; staging beside the target and renaming over it replaces it. - * - * This exists because the criterion had no test that could fail — removing the - * stage-and-rename branch entirely left every other case green, and a test asserting only - * that no staging file remains is equally true when nothing is ever staged. + * inode. A direct `writeFileSync` truncates and refills the file it already has, so the inode + * survives, and a test asserting only that no staging file remains is equally true when + * nothing is ever staged. */ test("a repair replaces the hook rather than truncating it, so no reader sees it half-written", () => { withRepo(({ root, hooksDir }) => { @@ -307,21 +298,19 @@ test("a read-only hooks directory holding a writable hook is still repaired", () }); /** - * The defect an independent check reproduced, and the one no case above could see: every - * one of them starts the session at the repository root, where resolving against the root - * and against the cwd give the same answer. + * The one case no other can see: every other starts the session at the repository root, where + * resolving against the root and against the cwd give the same answer. * - * `git rev-parse --git-path hooks` prints relative to the directory it ran in. Resolved - * against the repository root instead, a session started in `sub/deep` produced - * `/../../.git/hooks` — a path two levels ABOVE the checkout, which is where the - * repair then wrote, while the session's own repository stayed broken. + * `git rev-parse --git-path hooks` prints relative to the directory it ran in, so resolving + * against the repository root sends a session started in `sub/deep` two levels ABOVE the + * checkout, which is where the repair then writes. */ test("a session started in a subdirectory repairs its own repository, and nothing above it", () => { withRepo(({ root, sessionCwd, hooksDir }) => { const line = installDelegate(hooksDir); regenerateHook(hooksDir); - // Two levels up, which is where the defect actually wrote: it resolved - // `../../.git/hooks` against the repository root. One level up would assert nothing. + // Two levels up, which is where resolving `../../.git/hooks` against the repository root + // lands. One level up would assert nothing. const outside = path.resolve(root, "..", "..", ".git", "hooks", "prepare-commit-msg"); assert.equal(sessionStart(sessionCwd).status, 0); @@ -332,10 +321,10 @@ test("a session started in a subdirectory repairs its own repository, and nothin }); /** - * `core.hooksPath` may point into the working tree — a checked-in `.githooks/` is a common - * way to share hooks with a team. `aidd telemetry on` writing the line once was a write a - * person asked for; this one is not, it recurs on every session, and it would dirty a - * tracked file with a machine-absolute path nobody can commit. + * `core.hooksPath` may point into the working tree — a checked-in `.githooks/` is a common way + * to share hooks with a team. `aidd telemetry on` writing the line once is a write a person + * asked for; this one is not, it recurs on every session, and it would dirty a tracked file + * with a machine-absolute path nobody can commit. */ test("a hooks directory inside the working tree is never written to", () => { withRepo(({ root, hooksDir }) => { @@ -373,12 +362,10 @@ test("a prepare-commit-msg that is a symlink is left alone, target and all", () }); /** - * The fallback that keeps the journal alive on an older git, which a systematic mutation - * pass found guarded by nothing — the blocker it fixes could have come straight back. - * - * `rev-parse` fails atomically, so a git that does not understand `--git-path` answers - * non-zero for every option asked with it. Without the three-option retry, the whole - * location read returns null and the session records nothing at all. + * The fallback that keeps the journal alive on an older git. `rev-parse` fails atomically, so + * a git that does not understand `--git-path` answers non-zero for every option asked with + * it, and without the three-option retry the whole location read returns null and the session + * records nothing at all. */ test("a git that rejects --git-path still journals the session", () => { withRepo(({ root }) => { @@ -417,3 +404,56 @@ test("a git that rejects --git-path still journals the session", () => { ); }); }); + +/** + * Where a hook manager owns `prepare-commit-msg`, the repair adds nothing: the fault here is + * not a missing line but a line that must never be added. `aidd telemetry on` installs no + * call site in such a repository, so a repair appending one would give the delegate two + * callers per commit. The marker file is the only fact that survives a regeneration, which is + * why the decision is taken from it and not from the hook's contents. + */ +const MANAGER_MARKERS = [ + ["lefthook", (root) => fs.writeFileSync(path.join(root, "lefthook.yml"), "pre-commit:\n")], + ["husky", (root) => fs.mkdirSync(path.join(root, ".husky"))], +]; + +for (const [manager, plant] of MANAGER_MARKERS) { + test(`nothing is written where ${manager} owns the hook`, () => { + withRepo(({ root, hooksDir }) => { + plant(root); + const line = installDelegate(hooksDir); + regenerateHook(hooksDir); + const before = hookText(hooksDir); + + assert.equal(sessionStart(root).status, 0); + + assert.equal(hookText(hooksDir), before, "the manager's own file is left untouched"); + assert.equal(hookText(hooksDir).includes(line), false, "and no second caller was added"); + }); + }); +} + +/** + * The marker list is spelled twice — here and in the CLI's `detectHookManager` — because this + * hook is zero-dependency CommonJS copied into a person's repository and can import nothing + * from `cli/`. Pinned against the CLI's own declaration read as text, never a third copy + * typed into this file, so a spelling added on one side fails here. + */ +test("the manager markers are the ones the CLI decides by", () => { + const declaration = fs.readFileSync( + path.join(repo, "cli", "src", "contexts", "telemetry", "domain", "telemetry-setup.ts"), + "utf8" + ); + const lefthook = declaration + .split("export const LEFTHOOK_MARKER_NAMES = [")[1] + .split("]")[0] + .match(/"([^"]+)"/gu) + .map((quoted) => quoted.slice(1, -1)); + const husky = declaration.match(/HUSKY_MARKER_NAME = "([^"]+)"/u)[1]; + + const { HOOK_MANAGER_MARKERS } = require( + path.join(repo, "plugins", "aidd-telemetry", "hooks", "lib", "trailer-repair.cjs") + ); + + assert.deepEqual([...HOOK_MANAGER_MARKERS], [...lefthook, husky]); +}); diff --git a/scripts/__tests__/check-claude-accepts-build.test.js b/scripts/__tests__/check-claude-accepts-build.test.js new file mode 100644 index 000000000..8e720d885 --- /dev/null +++ b/scripts/__tests__/check-claude-accepts-build.test.js @@ -0,0 +1,64 @@ +const assert = require("node:assert/strict"); +const fs = require("node:fs"); +const os = require("node:os"); +const path = require("node:path"); +const { describe, it } = require("node:test"); + +const { check, verdict } = require("../check-claude-accepts-build.cjs"); + +describe("the verdict is read from the host's text, never its exit code", () => { + it("reads a refusal", () => { + assert.equal(verdict(" ❯ name: Expected string\n\n✘ Validation failed\n"), "failed"); + }); + + it("reads an acceptance, warnings included", () => { + assert.equal(verdict(" ❯ plugins[7].recommended: Unknown field\n\n✔ Validation passed with warnings\n"), "passed"); + }); + + it("says unknown when the host said neither", () => { + assert.equal(verdict("command not found: claude\n"), "unknown"); + }); +}); + +/** A `claude` stand-in that prints `answer` whatever it is asked, plus a CLI stand-in that + * writes one file under `--out` so the translation step has something to hand over. Both are + * node scripts, so the same fakes run on Windows. */ +function fakes(answer) { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "claude-accepts-")); + const claude = path.join(dir, "claude.js"); + fs.writeFileSync(claude, `process.stdout.write(${JSON.stringify(`${answer}\n`)});\n`); + const host = [process.execPath, claude]; + const cli = path.join(dir, "cli.js"); + fs.writeFileSync( + cli, + 'const fs = require("node:fs"); const out = process.argv[process.argv.indexOf("--out") + 1];\n' + + 'fs.mkdirSync(out, { recursive: true }); fs.writeFileSync(`${out}/built`, "");\n', + ); + const lines = []; + return { dir, host, cli, log: (text) => lines.push(text), lines }; +} + +describe("the exit code follows the verdict", () => { + it("exits 0 when the host accepts the build", () => { + const f = fakes("✔ Validation passed"); + assert.equal(check({ root: f.dir, cli: f.cli, host: f.host, log: f.log }), 0); + }); + + it("exits 1 when the host refuses it", () => { + const f = fakes("✘ Validation failed"); + assert.equal(check({ root: f.dir, cli: f.cli, host: f.host, log: f.log }), 1); + }); + + it("exits 2 when the host says neither, and says so", () => { + const f = fakes("something else entirely"); + assert.equal(check({ root: f.dir, cli: f.cli, host: f.host, log: f.log }), 2); + assert.match(f.lines.join(""), /NO VERDICT: .* said neither/); + }); + + it("exits 2 when the CLI is not built, without asking the host", () => { + const f = fakes("✔ Validation passed"); + const code = check({ root: f.dir, cli: path.join(f.dir, "missing.js"), host: f.host, log: f.log }); + assert.equal(code, 2); + assert.match(f.lines.join(""), /not built/); + }); +}); diff --git a/scripts/__tests__/check-context-reference-form.test.js b/scripts/__tests__/check-context-reference-form.test.js index 7ef6f244a..abe7509c6 100644 --- a/scripts/__tests__/check-context-reference-form.test.js +++ b/scripts/__tests__/check-context-reference-form.test.js @@ -8,14 +8,10 @@ const { } = require("../check-context-reference-form.js"); /** - * The memory block's reference form, checked against the hook that writes it. - * - * `CLAUDE.md` takes `@aidd_docs/…` because Claude Code resolves that import. - * `AGENTS.md` takes a markdown link because the tools reading it — codex, cursor, - * opencode — do not: an `@` line there is inert text that loads nothing and says - * nothing. The hook has known this since #732; what was missing is anything that - * notices when a file drifts back, which is how the wrong form reached `next` - * inside an unrelated commit. + * The memory block's reference form, checked against the hook that writes it. `CLAUDE.md` + * takes `@aidd_docs/…` because Claude Code resolves that import; `AGENTS.md` takes a markdown + * link because the tools reading it do not, and an `@` line there is inert text that loads + * nothing and reports nothing. What this adds is noticing a file that drifts back. */ const HOOK = ` @@ -42,7 +38,6 @@ function block(...lines) { const AT_LINE = "@aidd_docs/memory/architecture.md"; const LINK_LINE = "[aidd_docs/memory/architecture.md](aidd_docs/memory/architecture.md)"; -// ── the hook's table is the source of truth ──────────────────────────────── test("the declared form of each context file is read from the hook itself", () => { assert.deepEqual(readDeclaredTargets(HOOK), [ @@ -58,7 +53,6 @@ test("a hook whose table cannot be read is an error, never an empty pass", () => assert.throws(() => readDeclaredTargets("const TARGET_FILES = whatever;"), /TARGET_FILES/u); }); -// ── the form each file carries ───────────────────────────────────────────── test("a block written in the form its file declares raises nothing", () => { assert.deepEqual(referenceFormProblems(block(AT_LINE), "at"), []); @@ -89,7 +83,6 @@ test("every reference in the block is checked, not only the first", () => { assert.equal(referenceFormProblems(mixed, "link").length, 2); }); -// ── what is deliberately not a problem ───────────────────────────────────── test("a context file with no memory block is not a problem", () => { assert.deepEqual(referenceFormProblems("# Title\n\nprose only\n", "link"), []); @@ -116,7 +109,6 @@ test("an unclosed block is left to the hook to report", () => { assert.deepEqual(referenceFormProblems(unclosed, "link"), []); }); -// ── the files on disk ────────────────────────────────────────────────────── test("a file the hook declares but the repository does not have is skipped", () => { const problems = checkFiles([{ path: "does-not-exist.md", syntax: "link" }], { diff --git a/scripts/__tests__/check-markdown-links.test.js b/scripts/__tests__/check-markdown-links.test.js index d13b39fcd..32425fbec 100644 --- a/scripts/__tests__/check-markdown-links.test.js +++ b/scripts/__tests__/check-markdown-links.test.js @@ -6,6 +6,7 @@ const test = require("node:test"); const { formatIssue, + problemForTarget, reportProblems, } = require("../check-markdown-links.js"); @@ -202,3 +203,70 @@ test("repository scan ignores interrupted test temp directories", () => { fs.rmSync(tempDir, { recursive: true, force: true }); } }); + +test("repository scan ignores a mutation sandbox and an e2e build, which copy the tree", () => { + const sandboxes = [ + path.join(root, "cli", ".stryker-tmp", "sandbox-probe"), + path.join(root, "cli", ".e2e-build", "run-probe"), + ]; + + try { + for (const dir of sandboxes) { + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync(path.join(dir, "copied.md"), "[Missing](./missing.md)\n", "utf8"); + } + + const result = spawnSync(process.execPath, [script], { cwd: root, encoding: "utf8" }); + + assert.equal(result.status, 0, result.stdout); + assert.match(result.stdout, /✅ Links: 0 broken in \d+ files/u); + } finally { + for (const dir of sandboxes) fs.rmSync(dir, { recursive: true, force: true }); + } +}); + +test("problemForTarget reports a fragment no heading in the target file answers", () => { + const tempDir = fs.mkdtempSync(path.join(root, "scripts/__tests__/.tmp-check-markdown-links-")); + + try { + const target = path.join(tempDir, "target.md"); + fs.writeFileSync(target, ["# Target", "", "## Commits", "", "## ✅ Code decisions", ""].join("\n")); + const source = path.join(tempDir, "source.md"); + + // The five anchors this repository shipped dead for months all had this shape: a + // target file that resolves, a fragment nothing in it answers. + assert.deepEqual(problemForTarget("./target.md#types", source), { + raw: "./target.md#types", + reason: "anchor-not-found", + }); + + // Slugging is GitHub's: punctuation dropped, every remaining space a hyphen — so a + // leading emoji leaves the hyphen it was separated by, and `&` leaves two. + assert.equal(problemForTarget("./target.md#commits", source), null); + assert.equal(problemForTarget("./target.md#-code-decisions", source), null); + + // A target that does not resolve is already reported as a missing path; naming the + // fragment too would be two findings for one fix. + assert.deepEqual(problemForTarget("./missing.md#commits", source), { + raw: "./missing.md#commits", + reason: "local-path-not-found", + }); + + // GitHub's line fragments resolve against the file, not a heading. + assert.equal(problemForTarget("./target.md#L119", source), null); + assert.equal(problemForTarget("./target.md#L119-L130", source), null); + + // Only markdown carries headings. A fragment on anything else is the reader's business. + const asset = path.relative(tempDir, path.join(root, "docs/assets/logo.png")).replaceAll(path.sep, "/"); + assert.equal(problemForTarget(`${asset}#anything`, source), null); + } finally { + fs.rmSync(tempDir, { recursive: true, force: true }); + } +}); + +test("formatIssue explains a dead fragment", () => { + assert.equal( + formatIssue({ raw: "aidd_docs/memory/vcs.md#types", reason: "anchor-not-found" }), + "aidd_docs/memory/vcs.md#types (no heading in the target file answers that fragment)", + ); +}); diff --git a/scripts/__tests__/check-referenced-paths.test.js b/scripts/__tests__/check-referenced-paths.test.js new file mode 100644 index 000000000..07cc65ca6 --- /dev/null +++ b/scripts/__tests__/check-referenced-paths.test.js @@ -0,0 +1,68 @@ +const assert = require("node:assert/strict"); +const fs = require("node:fs"); +const path = require("node:path"); +const test = require("node:test"); + +const { + deadReferences, + referencedPaths, +} = require("../check-referenced-paths.js"); + +const root = path.resolve(__dirname, "../.."); + +test("referencedPaths reads a backticked path anchored on a real top-level entry", () => { + const found = referencedPaths( + [ + "The manifest is `.claude-plugin/marketplace.json`, and `scripts/check-markdown-links.js` reads it.", + "Config: `release-please-config.json`.", + ].join("\n") + ); + + assert.deepEqual(found.map((r) => r.target), [ + ".claude-plugin/marketplace.json", + "scripts/check-markdown-links.js", + "release-please-config.json", + ]); +}); + +test("referencedPaths ignores what only looks like a path", () => { + const content = [ + "Accept `application/json` and `application/vnd.github+json`.", + "A port lives under `domain/ports`, a use case under `application/flows`.", + "Run `pnpm test:changed`, then `cd cli && pnpm test`.", + "The tag is `-v` and the file `/.aidd/auth.json`.", + "Node `>=22.12`.", + "A tool reads `.claude/skills/` and `.claude/agents/` in the user's project.", + ].join("\n"); + + assert.deepEqual(referencedPaths(content), []); +}); + +test("deadReferences names the file, the line and the path", () => { + const tempDir = fs.mkdtempSync(path.join(root, "scripts/__tests__/.tmp-check-referenced-paths-")); + + try { + // Assembled, never written out: a repository guard reads every source file for a + // literal path and fails on one nothing holds, which is exactly what this fixture is. + const dead = ["scripts", "a-file-this-repository-does-not-hold.js"].join("/"); + const page = path.join(tempDir, "page.md"); + fs.writeFileSync( + page, + ["# Page", "", "Alive: `scripts/check-markdown-links.js`.", "", `Dead: \`${dead}\`.`, ""].join("\n") + ); + + assert.deepEqual(deadReferences([page]), [{ file: page, line: 5, target: dead }]); + } finally { + fs.rmSync(tempDir, { recursive: true, force: true }); + } +}); + +test("the repository's own prose names no path that does not exist", () => { + const { scannedFiles, dead } = require("../check-referenced-paths.js").scanRepository(); + + assert.ok(scannedFiles > 20, `expected the scan to reach the bank and the docs, got ${scannedFiles}`); + assert.deepEqual( + dead.map((d) => `${path.relative(root, d.file)}:${d.line} ${d.target}`), + [] + ); +}); diff --git a/scripts/__tests__/cli-ci-gate-covers-every-job.test.js b/scripts/__tests__/cli-ci-gate-covers-every-job.test.js new file mode 100644 index 000000000..eac6ce08f --- /dev/null +++ b/scripts/__tests__/cli-ci-gate-covers-every-job.test.js @@ -0,0 +1,61 @@ +const assert = require("node:assert/strict"); +const fs = require("node:fs"); +const path = require("node:path"); +const test = require("node:test"); +const yaml = require("js-yaml"); + +const root = path.resolve(__dirname, "../.."); + +const cliCiWorkflow = () => + yaml.load(fs.readFileSync(path.join(root, ".github/workflows/cli-ci.yml"), "utf8")); + +// A pull request merges with `cli CI`'s real jobs red when no required check names one of +// them. `gate` fans every job in, so a job silently missing from its `needs` is that bug +// again: this reads the job list live off the workflow rather than duplicating it by hand. +test("cli-ci.yml's gate job needs every other job in the workflow", () => { + const jobs = cliCiWorkflow().jobs; + const everyOtherJob = Object.keys(jobs) + .filter((name) => name !== "gate") + .sort(); + const gateNeeds = [...jobs.gate.needs].sort(); + + assert.deepEqual(gateNeeds, everyOtherJob); +}); + +test("gate reports even when a needed job fails or is skipped (if: always())", () => { + assert.equal(cliCiWorkflow().jobs.gate.if, "always()"); +}); + +// The ruleset JSON is the one GitHub actually enforces once an admin applies it +// (`docs/MAINTAINERS.md`); this only pins that the file names the right check. +test("both branch rulesets require the cli/gate check by its exact job name", () => { + const gateName = cliCiWorkflow().jobs.gate.name; + + for (const rulesetFile of ["main.json", "next.json"]) { + const ruleset = JSON.parse( + fs.readFileSync(path.join(root, ".github/rulesets", rulesetFile), "utf8") + ); + const statusCheckRule = ruleset.rules.find((rule) => rule.type === "required_status_checks"); + const contexts = statusCheckRule.parameters.required_status_checks.map((c) => c.context); + + assert.ok( + contexts.includes(gateName), + `${rulesetFile} does not require "${gateName}" in its required_status_checks` + ); + } +}); + +// The filter decides whether the suite runs at all. A change to the workflow itself is a +// change nothing else in the filter matches, so unless the filter names its own file, the +// commit that changes how the suite runs is the one commit the suite never runs on. +test("the changes filter names the workflow file itself as relevant", () => { + const changes = cliCiWorkflow().jobs.changes; + const script = changes.steps.map((step) => step.run ?? "").join("\n"); + const ownPath = ".github/workflows/cli-ci.yml"; + + assert.match( + script, + new RegExp(`^\\s*${ownPath.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\)\\s*$`, "m"), + `${ownPath} must be a case of the relevance filter, on its own line` + ); +}); diff --git a/scripts/__tests__/cli-opens-no-server.test.js b/scripts/__tests__/cli-opens-no-server.test.js index 02dd19246..ad8bf99f8 100644 --- a/scripts/__tests__/cli-opens-no-server.test.js +++ b/scripts/__tests__/cli-opens-no-server.test.js @@ -8,28 +8,23 @@ const ROOT = path.resolve(__dirname, "../.."); const CLI_SRC_PREFIX = "cli/src/"; /** - * "One route, and every sentence about it true" - * (aidd_docs/tasks/2026_08/2026_08_28_one-route-that-is-true/) deleted the one thing this - * codebase ever ran that opened a network listener: `aidd telemetry receive`, an OTLP/HTTP - * server bound to a local port so a tool's own export could be captured. That route is now - * read-only history - the writer, its adapter, and the port it bound are gone - and the - * spec's own hard constraint is that this stays true of the *code*, not a promise in a - * document: "Nothing this system runs opens a network listener, and nothing it runs sends - * anything anywhere." + * Nothing this system runs opens a network listener, and the constraint has to stay true of + * the code rather than of a document. This walks every tracked source file under `cli/src` + * and greps for the literal patterns a listener is built from, so a future listener has to be + * a deliberate decision — this assertion updated, with a reason — never an accident. * - * This is the same kind of check `source-stays-text.test.js` runs: walk every tracked - * source file under `cli/src`, grep for the literal patterns a listener is built from, and - * fail loudly if one reappears - a future listener then has to be a deliberate decision - * (this test's assertion updated, with a reason), never an accident nobody noticed. - * - * Deliberately narrow to *server* construction, not merely importing `node:http` or - * `node:net` - this codebase makes plenty of outbound HTTP calls (checking for updates, - * fetching a marketplace, downloading a release), and an outbound client is not the thing - * this test exists to forbid. + * Deliberately narrow to server construction, not merely importing `node:http` or `node:net`: + * this codebase makes plenty of outbound calls, and a client is not what this forbids. */ const FORBIDDEN_PATTERNS = [ - { pattern: /\.createServer\s*\(/u, label: ".createServer(" }, - { pattern: /\.listen\s*\(\s*(?:port|\d)/u, label: ".listen(" }, + // No leading `\.`: `createServer` imported by name (`import { createServer } from + // "node:http"`) and called bare is the same listener as `http.createServer(...)`, and the + // dotted-only pattern missed it. + { pattern: /\bcreateServer\s*\(/u, label: "createServer(" }, + // No constraint on the argument: `.listen(process.env.PORT)`, `.listen({ port })` and + // `.listen(PORT)` (an uppercase identifier) all bind a server, and none starts with a + // literal "port" or a digit the way `.listen(\s*(?:port|\d)` required. + { pattern: /\.listen\s*\(/u, label: ".listen(" }, { pattern: /from ["']node:net["']/u, label: 'import from "node:net"' }, { pattern: /require\(["']node:net["']\)/u, label: 'require("node:net")' }, ]; diff --git a/scripts/__tests__/cli-rules-frontmatter.test.js b/scripts/__tests__/cli-rules-frontmatter.test.js new file mode 100644 index 000000000..adf737fb9 --- /dev/null +++ b/scripts/__tests__/cli-rules-frontmatter.test.js @@ -0,0 +1,44 @@ +const assert = require("node:assert/strict"); +const fs = require("node:fs"); +const path = require("node:path"); +const test = require("node:test"); +const yaml = require("js-yaml"); + +const root = path.resolve(__dirname, "../.."); +const rulesDir = path.join(root, "cli/.claude/rules"); + +function ruleFiles(dir) { + return fs.readdirSync(dir, { withFileTypes: true }).flatMap((entry) => { + const full = path.join(dir, entry.name); + if (entry.isDirectory()) return ruleFiles(full); + return entry.name.endsWith(".md") ? [full] : []; + }); +} + +/** The YAML block between the opening and closing `---`, or null when a file has none. */ +function frontmatterOf(file) { + const text = fs.readFileSync(file, "utf8"); + const match = /^---\r?\n([\s\S]*?)\r?\n---\r?\n/u.exec(text); + return match ? yaml.load(match[1]) : null; +} + +// A rule is loaded only for the files its `paths` name. One with no frontmatter, or an empty +// `paths`, is never loaded, and nothing reported it: `validate-yaml` globs `*.yml` and reads +// a whole file, so a rule's YAML block reached no gate at all. +test("every CLI rule opens with frontmatter whose `paths` names at least one glob", () => { + const files = ruleFiles(rulesDir); + assert.ok(files.length > 0, "no rule found under cli/.claude/rules"); + + for (const file of files) { + const rel = path.relative(root, file); + const frontmatter = frontmatterOf(file); + assert.ok(frontmatter && typeof frontmatter === "object", `${rel}: no YAML frontmatter`); + assert.ok( + Array.isArray(frontmatter.paths) && frontmatter.paths.length > 0, + `${rel}: \`paths\` must be a non-empty list` + ); + for (const glob of frontmatter.paths) { + assert.equal(typeof glob, "string", `${rel}: every entry of \`paths\` is a glob string`); + } + } +}); diff --git a/scripts/__tests__/cli-type-honesty.test.js b/scripts/__tests__/cli-type-honesty.test.js new file mode 100644 index 000000000..9505aef4b --- /dev/null +++ b/scripts/__tests__/cli-type-honesty.test.js @@ -0,0 +1,65 @@ +const assert = require("node:assert/strict"); +const { spawnSync } = require("node:child_process"); +const fs = require("node:fs"); +const os = require("node:os"); +const path = require("node:path"); +const test = require("node:test"); + +const GUARD = path.resolve(__dirname, "..", "check-cli-type-honesty.mjs"); + +/** + * The guard reads process.cwd(), so every case is a whole miniature package under a temp + * directory. Both cli/src and cli/tests must exist: a missing one throws ENOENT, which exits + * non-zero with no breach and would pass an exit-code-only assertion for the wrong reason. + */ +const ALLOWED_CAST_FILE = "cli/src/contexts/translate/application/translate-source.ts"; +const A_CAST = "const parsed = raw as unknown as SourceMarketplace;"; + +function plant(dir, files) { + for (const [relative, contents] of Object.entries(files)) { + const full = path.join(dir, relative); + fs.mkdirSync(path.dirname(full), { recursive: true }); + fs.writeFileSync(full, `${contents}\n`); + } +} + +function runOn(files) { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "aidd-type-honesty-")); + try { + plant(dir, { [ALLOWED_CAST_FILE]: A_CAST, "cli/tests/.keep.ts": "export const kept = 1;", ...files }); + const result = spawnSync(process.execPath, [GUARD], { cwd: dir, encoding: "utf8" }); + return { status: result.status, output: `${result.stdout}${result.stderr}` }; + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } +} + +test("a tree whose only cast is the one CASTS_ALLOWED excuses passes", () => { + const clean = runOn({ "cli/src/honest.ts": "export const one: number = 1;" }); + + assert.equal(clean.status, 0, clean.output); + assert.match(clean.output, /No type is widened/); +}); + +test("a value widened through unknown in src is named and fails", () => { + const breach = runOn({ "cli/src/widened.ts": "export const x = raw as unknown as Thing;" }); + + assert.equal(breach.status, 1); + assert.match(breach.output, /cli\/src\/widened\.ts widens a type/); +}); + +test("a compiler directive in src is named, and the same directive in tests is not", () => { + const inSource = runOn({ "cli/src/silenced.ts": "// @ts-expect-error deliberate\nexport const x = 1;" }); + const inTests = runOn({ "cli/tests/silenced.ts": "// @ts-expect-error proves it does not compile\nexport const x = 1;" }); + + assert.equal(inSource.status, 1); + assert.match(inSource.output, /cli\/src\/silenced\.ts widens a type/); + assert.equal(inTests.status, 0, inTests.output); +}); + +test("an allowance nobody spends is named, so a fixed cast drops its entry", () => { + const stale = runOn({ [ALLOWED_CAST_FILE]: "export const parsed: SourceMarketplace = build();" }); + + assert.equal(stale.status, 1); + assert.match(stale.output, /translate-source\.ts no longer casts - drop its CASTS_ALLOWED entry/); +}); diff --git a/scripts/__tests__/comments-name-files-that-exist.test.js b/scripts/__tests__/comments-name-files-that-exist.test.js index e6d87e072..7b4a27547 100644 --- a/scripts/__tests__/comments-name-files-that-exist.test.js +++ b/scripts/__tests__/comments-name-files-that-exist.test.js @@ -3,47 +3,27 @@ const cp = require("node:child_process"); const fs = require("node:fs"); const path = require("node:path"); const { describe, it } = require("node:test"); - -const ROOT = path.resolve(__dirname, "../.."); +const { REPO_ROOT: ROOT, repositoryPathExists } = require("../lib/repository-path.cjs"); /** - * A doc comment that names a source file is a promise the reader can open it. - * - * The CLI pivot deleted 25 files under `plugins/aidd-telemetry/skills/*` and moved the read - * path into cli/. Thirteen comments went on naming those files in the present tense — - * "Mirrors the plugin's own session-anchor.cjs", "see that file's own doc comment for the - * measurements this is not free to re-derive" — so a reader following them found nothing, - * and worse, read an ongoing parity obligation into a second implementation that no longer - * exists. This is the guard that stops it coming back. + * A doc comment that names a source file is a promise the reader can open it. A comment left + * naming a deleted file sends a reader nowhere and, worse, reads as an ongoing obligation to + * an implementation that no longer exists. * * Only backticked tokens that look like a source file are checked: a token naming a runtime - * path (`.aidd/config.json`, `~/.codex/config.toml`) names something written at runtime, not - * a file in this repository, and is none of this test's business. + * path names something written at runtime, not a file in this repository. */ /** Where a mention is deliberate history rather than a dangling pointer: the file is named * as gone, or named by a test asserting it is gone. Listed one by one rather than inferred * from nearby words like "deleted", so adding one is a decision somebody makes on purpose. */ const NAMED_AS_HISTORY = Object.freeze({ - "cli/src/application/display/telemetry-check-display.ts": ["diagnose.cjs"], - "cli/src/domain/models/telemetry-claim.ts": ["diagnose.cjs"], - "cli/src/infrastructure/adapters/hook-trust-reader-adapter.ts": ["hook-trust.cjs"], - "cli/src/infrastructure/adapters/person-identity-adapter.ts": ["identity.cjs"], - "cli/src/domain/models/session-anchor.ts": ["session-anchor.cjs"], - "cli/tests/e2e/telemetry-check.e2e.test.ts": ["telemetry-check.cjs"], - "cli/tests/e2e/telemetry-identity.e2e.test.ts": ["telemetry-identity.cjs"], - "cli/tests/e2e/telemetry-lifecycle.e2e.test.ts": ["telemetry-switch.cjs"], - "cli/tests/e2e/telemetry-on-runs-privacy.e2e.test.ts": [ - "journal-privacy.cjs", - "aidd-telemetry-switch-gitignore.test.js", - ], - "cli/tests/infrastructure/adapters/telemetry-sink-location.unit.test.ts": ["sink.cjs"], + "cli/src/presentation/display/telemetry-check-display.ts": ["diagnose.cjs"], + "cli/src/contexts/telemetry/domain/telemetry-claim.ts": ["diagnose.cjs"], + "cli/src/contexts/telemetry/infrastructure/hook-trust-reader-adapter.ts": ["hook-trust.cjs"], + "cli/src/contexts/telemetry/infrastructure/person-identity-adapter.ts": ["identity.cjs"], + "cli/src/contexts/telemetry/domain/session-anchor.ts": ["session-anchor.cjs"], "scripts/__tests__/aidd-telemetry-cost-skill.test.js": ["telemetry-report.cjs"], - "scripts/__tests__/plugin-install-shape.test.js": [ - "telemetry-switch.cjs", - "telemetry-identity.cjs", - "telemetry-check.cjs", - ], "scripts/__tests__/telemetry-where-things-live.test.js": [ "scripts/telemetry-check.cjs", "telemetry-report.cjs", @@ -53,26 +33,62 @@ const NAMED_AS_HISTORY = Object.freeze({ /** Named inside a fixture or a runtime path a test builds, never a file of this repository. */ const NOT_A_REPOSITORY_FILE = Object.freeze({ + // Illustrations inside a rule's own probe: each names a file deliberately absent, which is + // what the probe is for — a guard that only ever sees real paths never proves it can see a + // dead one. + "cli/tests/architecture/referenced-paths.arch.test.ts": [ + "kernel/gone.ts", + ], + "scripts/__tests__/cli-type-honesty.test.js": [ + "cli/src/honest.ts", + "cli/src/widened.ts", + "cli/src/silenced.ts", + "cli/tests/silenced.ts", + "cli/tests/.keep.ts", + ], + "cli/tests/contexts/framework/application/doctor-use-case.unit.test.ts": [ + "@.claude/rules/test.md", + ], // A seam artefact one plugin writes into a reader's own project and another reads back — // named here as the shape of that seam, never as a file this repository holds. "docs/ARCHITECTURE.md": ["INSTALL.md"], "docs/CATALOG.md": ["INSTALL.md"], - "cli/tests/application/use-cases/doctor-use-case.unit.test.ts": ["@.claude/rules/test.md"], - "cli/tests/e2e/telemetry-plugin-standalone.e2e.test.ts": [ - "dist/cli.js", - "aidd_docs/tasks/2026_08/2026_08_21_probe-task/notes.md", + // The build artefact every e2e run refuses to share, named as what is avoided. + "cli/tests/e2e/helpers.ts": ["dist/cli.js"], + "cli/tests/e2e/global-setup.ts": ["dist/cli.js"], + // A sample written path fed to a payload or a fixture: illustrative, not a claim that + // `cli/src/index.ts` exists. Tightening the basename fallback for a rooted token (below) + // newly reaches these; the file the token names is out of this pass's scope, and these + // four never intended to name a real one in the first place. + "cli/tests/contexts/telemetry/infrastructure/run-journal-task-declared.integration.test.ts": [ + "cli/src/index.ts", + ], + "cli/tests/contexts/telemetry/infrastructure/run-journal-reader-adapter.integration.test.ts": [ + "cli/src/index.ts", ], + "cli/tests/contexts/telemetry/domain/cost-report.unit.test.ts": ["cli/src/index.ts"], + "cli/tests/contexts/telemetry/domain/task-identity.unit.test.ts": ["cli/src/index.ts"], }); const SOURCE_FILE_TOKEN = /^[\w./@-]+\.(?:ts|cjs|js|md)$/u; const BACKTICKED = /`([^`\n]+)`/gu; +/** A `cli/src/...` or `cli/tests/...` path is unambiguous even outside backticks — nothing + * else in prose is spelled that way by accident. Unlike `SOURCE_FILE_TOKEN`, which screens a + * token already isolated by backticks, this has to isolate the token itself, so it requires + * the prefix a bare mention needs to read as a path at all. */ +const BARE_SOURCE_TOKEN = /\bcli\/(?:src|tests)\/[\w./-]+\.(?:ts|cjs|js|md)\b/gu; + function trackedFiles() { return cp.execSync("git ls-files", { cwd: ROOT, encoding: "utf8" }).trim().split("\n"); } function findFiles(command) { - return cp.execSync(command, { cwd: ROOT, encoding: "utf8" }).trim().split(/\r?\n/).filter(Boolean); + return cp + .execSync(command, { cwd: ROOT, encoding: "utf8" }) + .trim() + .split(/\r?\n/) + .filter(Boolean); } function scannedFiles() { @@ -81,18 +97,13 @@ function scannedFiles() { "find cli/src cli/tests plugins scripts -type f " + "\\( -name '*.ts' -o -name '*.cjs' -o -name '*.js' \\) -not -path '*/node_modules/*'" ), - // docs/ too, and its markdown alone. A durable doc naming a file makes the same promise - // a comment does, and it was the one place nothing kept it: the architecture doc named - // the context plugin's session hook with a cjs extension for a file that has always been - // js. Markdown anywhere else is deliberately out - a skill's own asset and a fixture - // template name illustrative paths on purpose, and scanning those produced 17 findings of - // which none was a fault. This comment itself is why the names above are spelled out in - // prose rather than quoted: a quoted example would be a finding. + // docs/ too, and its markdown alone: a durable doc naming a file makes the same promise a + // comment does. Markdown anywhere else is deliberately out - a skill's own asset and a + // fixture template name illustrative paths on purpose. // // Two calls and not one command joined by `;`: `execSync` runs through `cmd.exe` on - // Windows, where `;` separates nothing and the second `find` was passed to the first as - // an argument. Green on macOS, red on the Windows job, which is exactly what that job is - // there for. + // Windows, where `;` separates nothing and the second `find` is passed to the first as an + // argument. ...findFiles("find docs -type f -name '*.md'"), ]; } @@ -104,15 +115,28 @@ function allowed(file, token) { ); } +/** A token spelled out from the repository root: it claims to be the whole path, not a + * shorthand, so the basename fallback below must not excuse it. Mirrors the prefix + * `BARE_SOURCE_TOKEN` requires — the one shape a stale mention can take while still reading + * as unambiguous. */ +const ROOTED_TOKEN = /^cli\/(?:src|tests)\//u; + /** Every way a token could legitimately name something real: the exact tracked path, a path - * relative to the file doing the naming, one relative to `cli/`, or a bare basename that - * belongs to some tracked file. The last is deliberately generous — a comment saying - * `repo.cjs` names a real file without spelling out where it sits. */ + * relative to the file doing the naming, one relative to `cli/`, or — for a token not itself + * rooted at `cli/src/` or `cli/tests/` — a bare basename belonging to some tracked file. + * + * That last check is generous on purpose, since a comment often names a real file by less + * than its full path. It must not run for a token already spelled as a full repository path, + * which would pass on any unrelated tracked file that happens to share its basename. */ function namesSomethingReal(token, file, tracked, basenames) { if (tracked.has(token)) return true; const relativeToNamer = path.posix.normalize(path.posix.join(path.posix.dirname(file), token)); if (tracked.has(relativeToNamer)) return true; if (tracked.has(path.posix.normalize(path.posix.join("cli", token)))) return true; + // Shared with script-tests-name-cli-files-that-exist.test.js: a real file this repository + // holds, whether or not `git ls-files` has caught up with it yet. + if (repositoryPathExists(token)) return true; + if (ROOTED_TOKEN.test(token)) return false; return basenames.has(path.basename(token)); } @@ -127,12 +151,8 @@ function hookJsFiles(tracked) { } /** - * The same rule as below, for a mention that carries no backticks. - * - * Comments in the hooks named journal.js, record.js, host.js, codex.js and index.js; - * every one of those files is .cjs, and the backtick rule below saw none of - * them — some sat in parentheses, one in a test's own name. The hooks directory is the one - * place a narrow rule is safe: it ships exactly one `.js` file, so any other such name + * The same rule as below, for a mention that carries no backticks. The hooks directory is the + * one place a narrow rule is safe: it ships exactly one `.js` file, so any other such name * anywhere in it, or in the tests that describe it, is a `.cjs` written wrong. */ describe("a comment about the hooks names .cjs where the file is .cjs", () => { @@ -151,8 +171,7 @@ describe("a comment about the hooks names .cjs where the file is .cjs", () => { for (const file of scanned) { const text = fs.readFileSync(path.join(ROOT, file), "utf8"); // `[\w.-]+`, not `[\w-]+`: a filename can carry dots of its own, and capturing only - // the last segment read aidd-telemetry-journal.test.js as test.js and flagged a - // file that exists. + // the last segment reads a dotted test filename as `test.js` and flags a real file. for (const match of text.matchAll(/([\w.-]+)\.js\b/gu)) { const named = `${match[1]}.js`; // A path inside a fixture or an assertion about somebody else's project file is not @@ -178,13 +197,18 @@ describe("a comment about the hooks names .cjs where the file is .cjs", () => { }); }); +/** This file's own path: excluded from the scan below, because the two allowlists it defines + * carry, as literal string values, the very tokens this rule exists to flag. A catalog entry + * naming the path it excuses is not this file claiming the path is real. */ +const SELF = "scripts/__tests__/comments-name-files-that-exist.test.js"; + describe("a comment that names a source file names one that exists", () => { it("names no file the repository does not hold, outside the mentions listed as history", () => { const tracked = new Set(trackedFiles()); const basenames = new Set([...tracked].map((file) => path.basename(file))); const dangling = []; - for (const file of scannedFiles()) { + for (const file of scannedFiles().filter((candidate) => candidate !== SELF)) { const text = fs.readFileSync(path.join(ROOT, file), "utf8"); for (const match of text.matchAll(BACKTICKED)) { const token = match[1].trim(); @@ -193,6 +217,12 @@ describe("a comment that names a source file names one that exists", () => { if (allowed(file, token)) continue; dangling.push(`${file} names \`${token}\`, which no tracked file matches`); } + for (const match of text.matchAll(BARE_SOURCE_TOKEN)) { + const token = match[0]; + if (namesSomethingReal(token, file, tracked, basenames)) continue; + if (allowed(file, token)) continue; + dangling.push(`${file} names ${token} (no backticks), which no tracked file matches`); + } } assert.deepEqual( @@ -223,13 +253,23 @@ describe("a comment that names a source file names one that exists", () => { it("lists no allowance for a file that stopped naming it", () => { const unused = []; - for (const list of [NAMED_AS_HISTORY, NOT_A_REPOSITORY_FILE]) { - for (const [file, tokens] of Object.entries(list)) { - const text = fs.readFileSync(path.join(ROOT, file), "utf8"); - for (const token of tokens) { - if (!text.includes(`\`${token}\``)) { - unused.push(`${file} no longer names \`${token}\` - drop it from the list`); - } + for (const [file, tokens] of Object.entries(NAMED_AS_HISTORY)) { + const text = fs.readFileSync(path.join(ROOT, file), "utf8"); + for (const token of tokens) { + if (!text.includes(`\`${token}\``)) { + unused.push(`${file} no longer names \`${token}\` - drop it from the list`); + } + } + } + + // NOT_A_REPOSITORY_FILE allows tokens that need not be backtick-wrapped: a fixture's + // illustrative path sits inside a string literal, not behind a documentation backtick. + // Checked by plain substring instead of the backtick wrapper NAMED_AS_HISTORY needs. + for (const [file, tokens] of Object.entries(NOT_A_REPOSITORY_FILE)) { + const text = fs.readFileSync(path.join(ROOT, file), "utf8"); + for (const token of tokens) { + if (!text.includes(token)) { + unused.push(`${file} no longer names ${token} - drop it from the list`); } } } diff --git a/scripts/__tests__/dev-sync.test.js b/scripts/__tests__/dev-sync.test.js index 0a63d9daf..01a42e0ea 100644 --- a/scripts/__tests__/dev-sync.test.js +++ b/scripts/__tests__/dev-sync.test.js @@ -12,16 +12,10 @@ function executable(path, body) { chmodSync(path, 0o755); } -/** What the stubs below are put in front of. - * - * On POSIX this stays the strict pair the isolation depends on: nothing but the stubs and - * the platform's own utilities, so a real `opencode` or `claude` installed on the machine - * running these tests can never answer in their place. - * - * Windows has neither directory, and the shell utilities `dev-sync.sh` calls — `find`, - * `cp`, `basename` — live wherever Git for Windows put them, which only that runner's own - * `PATH` names. So there it keeps the inherited `PATH` and merely puts the stubs first, - * which shadows anything real by the same mechanism. */ +/** On POSIX, nothing but the stubs and the platform's own utilities, so a real `opencode` or + * `claude` installed on the machine running these tests can never answer in their place. + * Windows has neither directory, and the utilities `dev-sync.sh` calls live wherever Git for + * Windows put them, so there the inherited `PATH` is kept with the stubs merely first. */ const BASE_PATH = process.platform === "win32" ? (process.env.PATH ?? "") : ["/usr/bin", "/bin"].join(delimiter); diff --git a/scripts/__tests__/doc-duplication.test.js b/scripts/__tests__/doc-duplication.test.js new file mode 100644 index 000000000..a4d341d13 --- /dev/null +++ b/scripts/__tests__/doc-duplication.test.js @@ -0,0 +1,167 @@ +const assert = require("node:assert/strict"); +const cp = require("node:child_process"); +const fs = require("node:fs"); +const os = require("node:os"); +const path = require("node:path"); +const { describe, it } = require("node:test"); + +const { + findDuplicates, + sentencesOf, + staleBaseline, +} = require("../check-doc-duplication.js"); + +const script = path.resolve(__dirname, "../check-doc-duplication.js"); + +/** Twelve words exactly: the floor, so the eleven-word twin below differs by one word only. */ +const TWELVE = "The manifest lists every plugin this repository ships to its users today."; +const ELEVEN = "The manifest lists every plugin this repository ships to its users."; +const NORMALISED = "the manifest lists every plugin this repository ships to its users today"; + +describe("sentencesOf", () => { + it("keeps a sentence of twelve words and drops one of eleven", () => { + assert.deepEqual(sentencesOf(`${TWELVE}\n\n${ELEVEN}\n`), [NORMALISED]); + }); + + it("normalises a link, a backtick, case and whitespace to the same sentence", () => { + const dressed = + "The `manifest` lists every [plugin](../plugins/README.md) this Repository ships to its users today."; + + assert.deepEqual(sentencesOf(dressed), [NORMALISED]); + }); + + it("reads nothing out of a code fence, a table row or a heading", () => { + assert.deepEqual(sentencesOf(["```md", TWELVE, "```", ""].join("\n")), []); + assert.deepEqual(sentencesOf(["```mermaid", TWELVE, "```", ""].join("\n")), []); + assert.deepEqual(sentencesOf(`| ${TWELVE} |\n`), []); + assert.deepEqual(sentencesOf(`## ${TWELVE}\n`), []); + assert.deepEqual(sentencesOf(`---\ntitle: ${TWELVE}\n---\n`), []); + }); +}); + +describe("findDuplicates", () => { + it("reports a sentence two files carry, naming both", () => { + const found = findDuplicates( + { "docs/a.md": `${TWELVE}\n`, "docs/b.md": `Intro.\n\n${TWELVE}\n` }, + {} + ); + + assert.deepEqual(found, [{ sentence: NORMALISED, files: ["docs/a.md", "docs/b.md"] }]); + }); + + it("reports nothing for a sentence one word under the floor", () => { + assert.deepEqual( + findDuplicates({ "docs/a.md": `${ELEVEN}\n`, "docs/b.md": `${ELEVEN}\n` }, {}), + [] + ); + }); + + it("matches across a link, a backtick, case and whitespace", () => { + const dressed = + "The `manifest` lists every [plugin](../plugins/README.md) this Repository ships to its users today."; + + assert.deepEqual(findDuplicates({ "docs/a.md": TWELVE, "docs/b.md": dressed }, {}), [ + { sentence: NORMALISED, files: ["docs/a.md", "docs/b.md"] }, + ]); + }); + + it("reports nothing when the second copy sits in a fence, a table row or a heading", () => { + const fenced = ["```md", TWELVE, "```"].join("\n"); + + assert.deepEqual(findDuplicates({ "docs/a.md": TWELVE, "docs/b.md": fenced }, {}), []); + assert.deepEqual(findDuplicates({ "docs/a.md": TWELVE, "docs/b.md": `| ${TWELVE} |` }, {}), []); + assert.deepEqual(findDuplicates({ "docs/a.md": TWELVE, "docs/b.md": `## ${TWELVE}` }, {}), []); + }); + + it("silences the files a baseline entry lists and no other", () => { + const files = { + "docs/a.md": TWELVE, + "docs/b.md": TWELVE, + "docs/c.md": TWELVE, + "docs/d.md": TWELVE, + }; + const baseline = { + [NORMALISED]: { "docs/a.md": "a reason", "docs/b.md": "a reason" }, + }; + + assert.deepEqual(findDuplicates(files, baseline), [ + { sentence: NORMALISED, files: ["docs/c.md", "docs/d.md"] }, + ]); + }); + + it("reports nothing once a baseline entry covers every carrier", () => { + const baseline = { + [NORMALISED]: { "docs/a.md": "a reason", "docs/b.md": "a reason" }, + }; + + assert.deepEqual(findDuplicates({ "docs/a.md": TWELVE, "docs/b.md": TWELVE }, baseline), []); + }); +}); + +describe("staleBaseline", () => { + it("reports an entry whose sentence left one of the files it lists", () => { + const baseline = { + [NORMALISED]: { "docs/a.md": "a reason", "docs/b.md": "a reason" }, + }; + + const stale = staleBaseline({ "docs/a.md": TWELVE, "docs/b.md": "Something else.\n" }, baseline); + + assert.equal(stale.length, 1); + assert.match(stale[0], /docs\/b\.md/u); + assert.match(stale[0], /drop it from BASELINE/u); + }); + + it("reports nothing while every listed file still carries the sentence", () => { + const baseline = { + [NORMALISED]: { "docs/a.md": "a reason", "docs/b.md": "a reason" }, + }; + + assert.deepEqual(staleBaseline({ "docs/a.md": TWELVE, "docs/b.md": TWELVE }, baseline), []); + }); +}); + +describe("the command line", () => { + /** spawnSync with an argument array, never a shell string: a Windows shell splits a quoted + * path on its spaces and the run reports a missing script instead of a duplicate. */ + function runIn(cwd) { + return cp.spawnSync(process.execPath, [script], { cwd, encoding: "utf8" }); + } + + it("exits 1 on a planted duplicate and 0 once one copy points at the other", () => { + const cwd = fs.mkdtempSync(path.join(os.tmpdir(), "aidd-doc-dup-")); + + try { + fs.mkdirSync(path.join(cwd, "docs"), { recursive: true }); + fs.writeFileSync(path.join(cwd, "docs", "a.md"), `# A\n\n${TWELVE}\n`); + fs.writeFileSync(path.join(cwd, "docs", "b.md"), `# B\n\n${TWELVE}\n`); + + const failed = runIn(cwd); + assert.equal(failed.status, 1, failed.stdout + failed.stderr); + assert.match(failed.stdout + failed.stderr, /docs\/b\.md/u); + assert.match(failed.stdout + failed.stderr, /keep it in one home/u); + + fs.writeFileSync(path.join(cwd, "docs", "b.md"), "# B\n\nWhat it lists: [A](a.md).\n"); + + const passed = runIn(cwd); + assert.equal(passed.status, 0, passed.stdout + passed.stderr); + assert.match(passed.stdout, /2 files/u); + } finally { + fs.rmSync(cwd, { recursive: true, force: true }); + } + }); +}); + +describe("this repository", () => { + it("carries no sentence twice outside BASELINE", () => { + const { duplicates, stale, scannedFiles } = require("../check-doc-duplication.js").scanRepository( + path.resolve(__dirname, "../..") + ); + + assert.ok(scannedFiles > 20, `expected the scan to reach the banks and the docs, got ${scannedFiles}`); + assert.deepEqual(stale, []); + assert.deepEqual( + duplicates.map((d) => `${d.sentence} :: ${d.files.join(", ")}`), + [] + ); + }); +}); diff --git a/scripts/__tests__/opencode-plugin.test.js b/scripts/__tests__/opencode-plugin.test.js index d8570fcf4..321d3a18b 100644 --- a/scripts/__tests__/opencode-plugin.test.js +++ b/scripts/__tests__/opencode-plugin.test.js @@ -6,15 +6,17 @@ const path = require("node:path"); const { pathToFileURL } = require("node:url"); const test = require("node:test"); -const PLUGIN_SOURCE = path.resolve(__dirname, "../../plugins/aidd-telemetry/hooks/opencode-plugin.js"); +const PLUGIN_SOURCE = path.resolve( + __dirname, + "../../plugins/aidd-telemetry/hooks/opencode-plugin.js" +); const CLEAN_ENV = Object.fromEntries( - Object.entries(process.env).filter(([k]) => !k.startsWith("GIT_")), + Object.entries(process.env).filter(([k]) => !k.startsWith("GIT_")) ); -// Removed when the file finishes, not left behind: a suite that seeds a repository per run -// and never sweeps fills the machine's temp volume until mkdtemp itself fails with ENOSPC, -// which is how this was found - 1878 abandoned directories, 37 GB. +// Removed when the file finishes: a suite that seeds a repository per run and never sweeps +// fills the machine's temp volume until mkdtemp itself fails with ENOSPC. const tempDirs = []; test.after(() => { for (const dir of tempDirs) fs.rmSync(dir, { recursive: true, force: true }); @@ -38,31 +40,26 @@ function makeInstalledRepo() { fs.mkdirSync(path.join(repo, ".aidd"), { recursive: true }); fs.writeFileSync( path.join(repo, ".aidd", "config.json"), - JSON.stringify({ telemetry: { enabled: true, endpoint: "http://127.0.0.1:4318" } }), + JSON.stringify({ telemetry: { enabled: true, endpoint: "http://127.0.0.1:4318" } }) ); const pluginDir = path.join(repo, ".opencode", "plugin"); fs.mkdirSync(pluginDir, { recursive: true }); const hooksSrc = path.dirname(PLUGIN_SOURCE); for (const entry of fs.readdirSync(hooksSrc, { withFileTypes: true })) { if (entry.name === "hooks.json") continue; - fs.cpSync(path.join(hooksSrc, entry.name), path.join(pluginDir, entry.name), { recursive: true }); + fs.cpSync(path.join(hooksSrc, entry.name), path.join(pluginDir, entry.name), { + recursive: true, + }); } // A byte-identical `.mjs` twin, for these tests alone. // - // An install carries the plugin as `.js`, and that is forced rather than chosen: OpenCode - // auto-discovers `{plugin,plugins}/*.{ts,js}` and nothing else, so an `.mjs` would simply - // never be found. It loads the file with its own runtime, which does not consult Node's - // `type` field — measured against a real OpenCode session, which journals its - // session_start. + // An install carries the plugin as `.js`, forced rather than chosen: OpenCode auto-discovers + // `{plugin,plugins}/*.{ts,js}` and nothing else, and loads the file with its own runtime, + // which does not consult Node's `type` field. // - // Plain Node does consult that field, and there is none to consult: neither this - // repository's root `package.json` nor `hooks/` declares one, so Node reaches the file as - // typeless, finds ESM syntax, and reparses — with a warning, and only on a Node new enough - // to do it at all. An earlier version of this comment said the measurement was taken "with - // `hooks/`'s `\"type\": \"commonjs\"` marker in place"; there is no such file, and every - // sibling in that directory is `.cjs`, which needs no marker. Naming the extension - // explicitly is what these tests do instead, and the extension is the only thing that - // differs from what ships. + // Plain Node does consult it, and there is none to consult: nothing up this tree declares + // one, so Node reaches the file as typeless, finds ESM syntax, and reparses. Naming the + // extension explicitly is what these tests do instead, and it is the only difference. const esmTwin = path.join(pluginDir, "opencode-plugin.mjs"); fs.copyFileSync(path.join(pluginDir, "opencode-plugin.js"), esmTwin); return { repo, pluginDir, esmTwin }; @@ -80,18 +77,16 @@ function readRunLines(repo) { .readFileSync(path.join(dir, f), "utf8") .split("\n") .filter(Boolean) - .map((l) => JSON.parse(l)), + .map((l) => JSON.parse(l)) ); } test("opencode-plugin.js: runJournal spawns journal.cjs by an absolute filesystem path, not a file:// URL string", async () => { - // Regression test for a real bug found only by running a live OpenCode session - // (see measurements.md, Phase 7): `spawnSync("node", [new URL(...)])` stringifies - // the URL to "file:///..." - node's CLI does not accept that as a script path, it - // resolves it as a bare module specifier relative to its own cwd and dies with - // MODULE_NOT_FOUND. journal.cjs silently never ran; no error surfaced anywhere - // because journal.cjs's own "exit 0 no matter what" contract hid the spawn failure. - const { repo, pluginDir, esmTwin } = makeInstalledRepo(); + // `spawnSync("node", [new URL(...)])` stringifies the URL to "file:///...", which node's + // CLI resolves as a bare module specifier against its own cwd and dies with + // MODULE_NOT_FOUND. journal.cjs then never runs, and its "exit 0 no matter what" contract + // hides the spawn failure entirely. + const { repo, esmTwin } = makeInstalledRepo(); const mod = await import(pathToFileURL(esmTwin).href); const hooks = await mod.AiddTelemetry({ directory: repo }); @@ -110,7 +105,7 @@ test("opencode-plugin.js: runJournal spawns journal.cjs by an absolute filesyste }); test("opencode-plugin.js: session.idle writes turn_end for the session session.created named", async () => { - const { repo, pluginDir, esmTwin } = makeInstalledRepo(); + const { repo, esmTwin } = makeInstalledRepo(); const mod = await import(pathToFileURL(esmTwin).href); const hooks = await mod.AiddTelemetry({ directory: repo }); @@ -127,6 +122,19 @@ test("opencode-plugin.js: session.idle writes turn_end for the session session.c const lines = readRunLines(repo); assert.deepEqual( lines.map((l) => l.type), - ["session_start", "turn_end"], + ["session_start", "turn_end"] ); }); + +test("opencode-plugin.js: an event whose own shape breaks journal call resolution never reaches OpenCode as a thrown error", async () => { + const { repo, esmTwin } = makeInstalledRepo(); + const mod = await import(pathToFileURL(esmTwin).href); + + const hooks = await mod.AiddTelemetry({ directory: repo }); + + // `event: null` is not a shape any fixture or the SDK's own types describe - exactly the + // kind of malformed input the plugin's own event handler must swallow rather than throw + // into OpenCode's in-process event loop. + await assert.doesNotReject(hooks.event({ event: null })); + assert.deepEqual(readRunLines(repo), [], "a swallowed error must write no journal line either"); +}); diff --git a/scripts/__tests__/plugin-install-shape.test.js b/scripts/__tests__/plugin-install-shape.test.js index 88d3038e5..5371ce4ef 100644 --- a/scripts/__tests__/plugin-install-shape.test.js +++ b/scripts/__tests__/plugin-install-shape.test.js @@ -1,80 +1,9 @@ const assert = require("node:assert/strict"); const fs = require("node:fs"); -const os = require("node:os"); const path = require("node:path"); -const { execFileSync, spawnSync } = require("node:child_process"); -const { after, describe, it } = require("node:test"); +const { describe, it } = require("node:test"); -const PLUGIN_DIR = path.resolve(__dirname, "../../plugins/aidd-telemetry"); -const SKILLS_DIR = path.join(PLUGIN_DIR, "skills"); -const HOOKS_DIR = path.join(PLUGIN_DIR, "hooks"); - -// A minimal PATH for spawned scripts, containing only git's own directory - never -// "/usr/bin:/bin", which doesn't hold git on Windows and uses ":" as a separator, not -// win32's ";". "where"/"which" differ by platform; either answers with the same thing, -// which is all a minimal PATH here needs (hooks/lib/repo.cjs shells out to git). -const GIT_DIR = path.dirname( - execFileSync(process.platform === "win32" ? "where" : "which", ["git"], { encoding: "utf8" }) - .trim() - .split(/\r?\n/u)[0], -); - -// Read from each script's own usage banner: no argv at all for a script known to need -// none. `telemetry-switch.cjs` and `telemetry-identity.cjs` are gone as of phase 3 - -// 00-init ships no script of its own any more, and calls `aidd telemetry` instead; -// `telemetry-check.cjs` is gone as of phase 5, the same move for 02-check. This is empty -// on purpose: no skill in this plugin ships a script today, and "ships no skill scripts" -// below is what pins that rather than leaving it silently unexercised. -const KNOWN_INVOCATIONS = {}; - -const STACK_FRAME = /\n\s*at .+:\d+:\d+/u; -const tempDirs = []; - -function makeTempDir(prefix) { - const dir = fs.mkdtempSync(path.join(os.tmpdir(), prefix)); - tempDirs.push(dir); - return dir; -} - -after(() => { - for (const dir of tempDirs) fs.rmSync(dir, { recursive: true, force: true }); -}); - -/** What the flat translation route delivers: `skills/`, and nothing beside it - no - * `hooks/`, no repository, no plugin manifest. */ -function buildFlatShape() { - const root = makeTempDir("aidd-install-shape-flat-"); - fs.cpSync(SKILLS_DIR, path.join(root, "skills"), { recursive: true }); - return root; -} - -/** - * The native route, reconstructed rather than observed: driving - * cli/src/domain/models/plugin-content-translator.ts from this node:test JS file turned out - * impractical. Its constructor takes a TypeScript parameter property, which - * `node --experimental-strip-types` refuses ("not supported in strip-only mode"), and past - * that its relative imports use a `.js` extension that only resolves against tsup's - * compiled output, not against the sibling `.ts` sources - so there is no build-free way to - * import it here. - * - * The shape below instead follows the native layout declared for every native-mode tool - * today - checked in cli/src/domain/tools/ai/{claude,codex,copilot,cursor}.ts, against - * cli/src/domain/models/plugin-content-translator.ts's own `manifestDir` rule - * (`parentDirOf(hooksRelativePath) || "hooks"`). Claude, Codex and Copilot take the default - * `hooksRelativePath` of `hooks/hooks.json`; Cursor overrides it to `hooks.json`, whose - * parent is `""`, which is falsy and falls back to the same `"hooks"` default. So for all - * four, a hook script (as opposed to the manifest itself) installs under `hooks/`, a - * sibling of `skills/` directly under the plugin root - the same relationship the plugin's - * own source tree already has, just with `.claude/plugins/aidd-telemetry/` (or the - * matching prefix for another tool) prepended in front of both. - */ -function buildNativeShape() { - const root = makeTempDir("aidd-install-shape-native-"); - const pluginRoot = path.join(root, ".claude", "plugins", "aidd-telemetry"); - fs.cpSync(SKILLS_DIR, path.join(pluginRoot, "skills"), { recursive: true }); - fs.cpSync(HOOKS_DIR, path.join(pluginRoot, "hooks"), { recursive: true }); - return pluginRoot; -} +const SKILLS_DIR = path.resolve(__dirname, "../../plugins/aidd-telemetry/skills"); // Walks each skill's scripts directory one level deep, so a skill's `scripts/lib/` // internals (only ever require()d, never run directly) are left for the scripts that @@ -94,143 +23,14 @@ function discoverScripts(skillsRoot) { return found.sort(); } -// A minimal, stripped environment, the same direction the neighbouring telemetry-check -// tests take: a real CLAUDE_CODE_SESSION_ID or GIT_* var this file happens to be running -// under must not leak into a script meant to be exercised in isolation. -function hermeticEnv(home) { - const { AIDD_RUNS_DIR: _r, CLAUDE_CODE_SESSION_ID: _c, CODEX_THREAD_ID: _t, ...rest } = process.env; - // process.env's OS-cased PATH key (often "Path" on Windows) must be filtered out too, or - // it sits beside PATH below unfiltered, undoing the minimal PATH this function exists for. - const withoutGit = Object.fromEntries( - Object.entries(rest).filter(([key]) => !key.startsWith("GIT_") && !/^path$/iu.test(key)), - ); - return { ...withoutGit, HOME: home, PATH: GIT_DIR }; -} - -function runScript(scriptPath, args, cwd) { - const home = makeTempDir("aidd-install-shape-home-"); - return spawnSync(process.execPath, [scriptPath, ...args], { - cwd, - encoding: "utf8", - env: hermeticEnv(home), - }); -} - -/** What a person would check: it started. A `MODULE_NOT_FOUND` or any stack trace on - * stderr means it didn't, and a usage message on a non-zero exit still means it did. */ -function assertStarted(result, label) { - assert.equal(result.error, undefined, `${label}: could not be spawned (${result.error})`); - assert.doesNotMatch( - result.stderr, - /Cannot find module|MODULE_NOT_FOUND/u, - `${label} could not load:\n${result.stderr}` - ); - assert.doesNotMatch(result.stderr, STACK_FRAME, `${label} crashed:\n${result.stderr}`); - assert.ok(`${result.stdout}${result.stderr}`.trim().length > 0, `${label} printed nothing`); -} - -function describeShape(name, buildShape) { - describe(`every skill script, run from a copy shaped like ${name}`, () => { - const skillsRoot = path.join(buildShape(), "skills"); - const scripts = discoverScripts(skillsRoot); - - // The actual invariant today: 00-init (phase 3), 01-cost (phase 1) and 02-check - // (phase 5) each moved their script to `aidd`, and none of the three left one behind. - // Pinned explicitly rather than left to the loop below finding nothing to iterate, - // which would pass the same way whether the walk found zero scripts or never ran. - it("ships no skill scripts, now that every skill calls the CLI instead", () => { - assert.deepEqual(scripts, []); - }); - - for (const relativeScript of scripts) { - const scriptPath = path.join(skillsRoot, relativeScript); - const basename = path.basename(relativeScript); - const args = KNOWN_INVOCATIONS[basename] ?? []; - const invokedWithKnownArgs = basename in KNOWN_INVOCATIONS; - - it(`${relativeScript} starts and prints its own output`, () => { - const result = runScript(scriptPath, args, path.dirname(skillsRoot)); - - assertStarted(result, relativeScript); - if (invokedWithKnownArgs) { - assert.equal(result.status, 0, `${relativeScript} exited ${result.status}:\n${result.stderr}`); - assert.equal(result.stderr, "", `${relativeScript} wrote to stderr:\n${result.stderr}`); - } - }); - } - }); -} - /** - * What the four hyphen-flat contracts actually deliver (claude, cursor, codex, copilot - - * `genericFlatSkillPath` in cli/src/domain/formats/flat-paths.ts): every immediate child of - * `skills/` renamed `-`, each carrying its own subtree intact. - * - * The rename is per child, so a script that reaches a sibling outside its own skill folder - * by a relative path resolves to a name that no longer exists. `buildFlatShape` above copies - * `skills/` verbatim and cannot see that; this shape can, and it is why nothing under - * `skills/` is shared between skills. + * A skill in this plugin ships no script of its own, every one of them calling `aidd` + * instead. A harness that built the install shape four ways and ran each skill's scripts + * under it proved nothing once there were no scripts left to find, since the shape changes + * only where `skills/` sits and never what is inside it. */ -function buildHyphenFlatShape() { - const root = makeTempDir("aidd-install-shape-hyphen-flat-"); - const skills = path.join(root, "skills"); - fs.mkdirSync(skills, { recursive: true }); - for (const entry of fs.readdirSync(SKILLS_DIR)) { - fs.cpSync(path.join(SKILLS_DIR, entry), path.join(skills, `aidd-telemetry-${entry}`), { - recursive: true, - }); - } - return root; -} - -describeShape("what the flat translation route delivers (skills/ alone, no hooks/)", buildFlatShape); -describeShape( - "what the hyphen-flat route delivers (each child of skills/ renamed -)", - buildHyphenFlatShape -); -describeShape("what a native install delivers (skills/ beside hooks/, under the plugin root)", buildNativeShape); - -/** - * Both shapes again, this time inside a host project that declares `"type": "module"`. - * - * Node decides a `.js` file's module system from the nearest package.json walking up, so a - * project-scope install - `.claude/plugins/`, `.github/plugins/`, `.codex/plugins/`, all - * inside the project - puts every script this plugin ships under the host's own declaration. - * In an ESM project that made each one die at its first `require` with "require is not - * defined in ES module scope", before running a single line of its own. Measured on a real - * install, not imagined. - * - * Each skill folder carries its own `package.json` declaring `"type": "commonjs"`, and - * `hooks/package.json` declares it for the hooks, so the walk stops before reaching the - * host's own declaration. The marker sits inside each skill rather than beside them because - * the hyphen-flat route renames every immediate child of `skills/`, so a marker at that - * level would not stay where the scripts under it can reach it. - * - * The marker in `hooks/` sits beside one genuine ESM module, `opencode-plugin.js`, and does - * not disturb it: OpenCode loads that file with its own runtime, which does not consult - * Node's `type` field. Measured, after a first attempt got this wrong by changing two things - * at once - renaming it `.mjs` broke OpenCode, and the marker was blamed. OpenCode - * auto-discovers `{plugin,plugins}/*.{ts,js}`, so the extension was the whole cause; with - * the name left alone and the marker in place, a real session journals normally. - */ -function buildEsmHostShape(inner) { - return () => { - const host = makeTempDir("aidd-install-shape-esm-host-"); - fs.writeFileSync( - path.join(host, "package.json"), - `${JSON.stringify({ name: "host-project", type: "module" }, null, 2)}\n` - ); - const pluginRoot = path.join(host, "plugin"); - fs.cpSync(inner(), pluginRoot, { recursive: true }); - return pluginRoot; - }; -} - -describeShape( - "the flat route, inside a host project that declares type: module", - buildEsmHostShape(buildFlatShape) -); -describeShape( - "a native install, inside a host project that declares type: module", - buildEsmHostShape(buildNativeShape) -); +describe("the plugin ships no skill scripts, now that every skill calls the CLI instead", () => { + it("no skill's scripts/ directory holds a .cjs file", () => { + assert.deepEqual(discoverScripts(SKILLS_DIR), []); + }); +}); diff --git a/scripts/__tests__/release-covers-every-plugin.test.js b/scripts/__tests__/release-covers-every-plugin.test.js new file mode 100644 index 000000000..213379df7 --- /dev/null +++ b/scripts/__tests__/release-covers-every-plugin.test.js @@ -0,0 +1,32 @@ +const assert = require("node:assert/strict"); +const fs = require("node:fs"); +const path = require("node:path"); +const test = require("node:test"); +const yaml = require("js-yaml"); + +const root = path.resolve(__dirname, "../.."); + +const marketplacePlugins = () => + JSON.parse(fs.readFileSync(path.join(root, ".claude-plugin/marketplace.json"), "utf8")) + .plugins.map((plugin) => plugin.name) + .sort(); + +const workflow = () => yaml.load(fs.readFileSync(path.join(root, ".github/workflows/ci.yml"), "utf8")); + +// A plugin missing from the matrix is tagged by release-please and gets no archive, in +// silence: a job that skips is indistinguishable from a job that has nothing to do. +test("build-plugin builds an archive for every plugin the marketplace lists", () => { + const matrix = [...workflow().jobs["build-plugin"].strategy.matrix.plugin].sort(); + + assert.deepEqual(matrix, marketplacePlugins()); +}); + +test("release-please versions every plugin the marketplace lists", () => { + const config = JSON.parse(fs.readFileSync(path.join(root, "release-please-config.json"), "utf8")); + const versioned = Object.keys(config.packages) + .filter((p) => p.startsWith("plugins/")) + .map((p) => p.slice("plugins/".length)) + .sort(); + + assert.deepEqual(versioned, marketplacePlugins()); +}); diff --git a/scripts/__tests__/script-tests-name-cli-files-that-exist.test.js b/scripts/__tests__/script-tests-name-cli-files-that-exist.test.js new file mode 100644 index 000000000..4dcf9ea77 --- /dev/null +++ b/scripts/__tests__/script-tests-name-cli-files-that-exist.test.js @@ -0,0 +1,24 @@ +const assert = require("node:assert/strict"); +const fs = require("node:fs"); +const path = require("node:path"); +const test = require("node:test"); +const { repositoryPathExists } = require("../lib/repository-path.cjs"); + +// A test under scripts/__tests__ names a CLI source or fixture file by a literal relative +// path, and the CLI moves its files without reading this directory - so a rename there is +// found only when this suite runs red on an unrelated branch. `cli/tests/` names a fixture +// the way `cli/src/` names a source file, and both are covered here. +test("every cli/src or cli/tests path a script test names by literal resolves on disk", () => { + const literal = /["'`]\.\.\/\.\.\/(cli\/(?:src|tests)\/[^"'`\s]+)["'`]/gu; + const dead = []; + + for (const entry of fs.readdirSync(__dirname)) { + if (!entry.endsWith(".js")) continue; + const source = fs.readFileSync(path.join(__dirname, entry), "utf8"); + for (const match of source.matchAll(literal)) { + if (!repositoryPathExists(match[1])) dead.push(`${entry} names ../../${match[1]}`); + } + } + + assert.deepEqual(dead, []); +}); diff --git a/scripts/__tests__/skill-argument-hints.test.js b/scripts/__tests__/skill-argument-hints.test.js new file mode 100644 index 000000000..90902e92f --- /dev/null +++ b/scripts/__tests__/skill-argument-hints.test.js @@ -0,0 +1,66 @@ +const assert = require("node:assert/strict"); +const { spawnSync } = require("node:child_process"); +const fs = require("node:fs"); +const os = require("node:os"); +const path = require("node:path"); +const test = require("node:test"); + +const GUARD = path.resolve(__dirname, "..", "check-skill-argument-hints.mjs"); +const SKILL = "plugins/aidd-probe/skills/01-thing/SKILL.md"; + +function frontmatter(lines) { + return ["---", "name: 01-thing", ...lines, "---", "", "# Thing"].join("\n"); +} + +function plant(dir, files) { + for (const [relative, contents] of Object.entries(files)) { + const full = path.join(dir, relative); + fs.mkdirSync(path.dirname(full), { recursive: true }); + fs.writeFileSync(full, `${contents}\n`); + } +} + +function runOn(files) { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "aidd-argument-hints-")); + try { + plant(dir, files); + const result = spawnSync(process.execPath, [GUARD], { cwd: dir, encoding: "utf8" }); + return { status: result.status, output: `${result.stdout}${result.stderr}` }; + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } +} + +test("a skill naming what the user brings passes", () => { + const clean = runOn({ [SKILL]: frontmatter(["argument-hint: a file path or a ticket"]) }); + + assert.equal(clean.status, 0, clean.output); + assert.match(clean.output, /Every skill names what the user brings/); +}); + +test("a skill with no argument-hint is named and fails", () => { + const breach = runOn({ [SKILL]: frontmatter(["description: does a thing"]) }); + + assert.equal(breach.status, 1); + assert.match(breach.output, /skills\/01-thing\/SKILL\.md: no argument-hint/); +}); + +test("an argument-hint repeating the action slugs is named and fails", () => { + const breach = runOn({ + [SKILL]: frontmatter(["argument-hint: do | undo"]), + "plugins/aidd-probe/skills/01-thing/actions/01-do.md": "## Input\n\nA path.", + "plugins/aidd-probe/skills/01-thing/actions/02-undo.md": "## Input\n\nA path.", + }); + + assert.equal(breach.status, 1); + assert.match(breach.output, /argument-hint repeats the action slugs/); +}); + +test("a skill whose actions take no input needs no argument-hint", () => { + const clean = runOn({ + [SKILL]: frontmatter(["description: takes nothing"]), + "plugins/aidd-probe/skills/01-thing/actions/01-do.md": "## Process\n\nRun it.", + }); + + assert.equal(clean.status, 0, clean.output); +}); diff --git a/scripts/__tests__/smoke-real-plugin-cache-path-parity.test.js b/scripts/__tests__/smoke-real-plugin-cache-path-parity.test.js new file mode 100644 index 000000000..1f0c0dd94 --- /dev/null +++ b/scripts/__tests__/smoke-real-plugin-cache-path-parity.test.js @@ -0,0 +1,50 @@ +const assert = require("node:assert/strict"); +const fs = require("node:fs"); +const path = require("node:path"); +const { describe, it } = require("node:test"); + +const ROOT = path.resolve(__dirname, "../.."); + +/** + * `smoke-real.sh` cannot call into the built CLI to read `NativeActivation.pluginCacheDir` + * back out, so it names the cache paths as bash literals — a second home of the same fact + * each profile declares. Unpinned, the profile's path could drift and every "the cache is + * gone" assertion in the script would watch the wrong directory in silence. + */ + +function pluginCacheDirSegments(profileFile) { + const text = fs.readFileSync(path.join(ROOT, "cli/src/contexts/tools/domain/profiles", profileFile), "utf8"); + const match = text.match(/pluginCacheDir:\s*\(h\)\s*=>\s*join\(h,\s*([^)]+)\)/u); + assert.ok( + match, + `${profileFile} no longer declares pluginCacheDir the way this guard expects` + ); + return match[1] + .split(",") + .map((segment) => segment.trim().replace(/^"|"$/gu, "")) + .join("/"); +} + +function smokeRealScript() { + return fs.readFileSync(path.join(ROOT, "cli/scripts/smoke-real.sh"), "utf8"); +} + +describe("smoke-real.sh names the same cache path each profile declares", () => { + it("claude's literal path matches claude/profile.ts's own pluginCacheDir", () => { + const expected = pluginCacheDirSegments("claude/profile.ts"); + assert.equal(expected, ".claude/plugins/cache"); + assert.ok( + smokeRealScript().includes(expected), + `smoke-real.sh does not contain the literal path segment '${expected}' claude/profile.ts declares` + ); + }); + + it("codex's literal path matches codex/profile.ts's own pluginCacheDir", () => { + const expected = pluginCacheDirSegments("codex/profile.ts"); + assert.equal(expected, ".codex/plugins/cache"); + assert.ok( + smokeRealScript().includes(expected), + `smoke-real.sh does not contain the literal path segment '${expected}' codex/profile.ts declares` + ); + }); +}); diff --git a/scripts/__tests__/smoke-real-user-scope-tool-list-parity.test.js b/scripts/__tests__/smoke-real-user-scope-tool-list-parity.test.js new file mode 100644 index 000000000..3f58f7be8 --- /dev/null +++ b/scripts/__tests__/smoke-real-user-scope-tool-list-parity.test.js @@ -0,0 +1,56 @@ +const assert = require("node:assert/strict"); +const fs = require("node:fs"); +const path = require("node:path"); +const { describe, it } = require("node:test"); + +const ROOT = path.resolve(__dirname, "../.."); + +/** + * `setup --scope user` refuses an AI tool declaring no machine-wide activation, and + * `smoke-real.sh` cannot call into the built CLI to ask which those are, so its + * `user_ai_list` names the answer as a bash literal — a second home of the same fact. + * + * The failure mode is quiet in both directions: a tool gaining machine-wide activation + * leaves the script measuring one tool fewer than the CLI supports, and one losing it turns + * every `--scope user` phase into an unexpected exit 1. + */ + +function aiToolIds() { + const text = fs.readFileSync(path.join(ROOT, "cli/src/kernel/tool.ts"), "utf8"); + const match = text.match(/export const AI_TOOL_IDS:[^=]*=\s*\[([^\]]*)\]/u); + assert.ok(match, "kernel/tool.ts no longer declares AI_TOOL_IDS the way this guard expects"); + return match[1] + .split(",") + .map((id) => id.trim().replace(/^"|"$/gu, "")) + .filter(Boolean); +} + +function declaresUserScopeActivation(toolId) { + const profile = path.join(ROOT, "cli/src/contexts/tools/domain/profiles", toolId, "profile.ts"); + const text = fs.readFileSync(profile, "utf8"); + return /nativeActivation:\s*\{/u.test(text) || /installScope:\s*"user"/u.test(text); +} + +function scriptUserAiList() { + const text = fs.readFileSync(path.join(ROOT, "cli/scripts/smoke-real.sh"), "utf8"); + const match = text.match(/user_ai_list\(\)\s*\{[\s\S]*?for t in ([^;]+);/u); + assert.ok(match, "smoke-real.sh no longer declares user_ai_list the way this guard expects"); + return match[1].trim().split(/\s+/u); +} + +describe("smoke-real.sh drives --scope user with the tools the profiles support", () => { + it("names every AI tool whose profile declares machine-wide activation, and no other", () => { + const supported = aiToolIds().filter(declaresUserScopeActivation); + assert.deepEqual([...supported].sort(), ["claude", "codex", "copilot", "cursor"]); + assert.deepEqual([...scriptUserAiList()].sort(), [...supported].sort()); + }); + + it("leaves out the AI tool that declares neither, which setup --scope user refuses", () => { + const unsupported = aiToolIds().filter((id) => !declaresUserScopeActivation(id)); + assert.deepEqual(unsupported, ["opencode"]); + assert.ok( + !scriptUserAiList().includes("opencode"), + "smoke-real.sh's user_ai_list names opencode, which setup --scope user refuses outright" + ); + }); +}); diff --git a/scripts/__tests__/smoke-scripts-inline-node-quoting.test.js b/scripts/__tests__/smoke-scripts-inline-node-quoting.test.js new file mode 100644 index 000000000..042019e9d --- /dev/null +++ b/scripts/__tests__/smoke-scripts-inline-node-quoting.test.js @@ -0,0 +1,37 @@ +const assert = require("node:assert/strict"); +const fs = require("node:fs"); +const path = require("node:path"); +const test = require("node:test"); + +const root = path.resolve(__dirname, "../.."); +const SCRIPTS = ["cli/scripts/smoke-tools.sh", "cli/scripts/smoke-real.sh"]; + +/** Each `node -e '...'` block, as the text between its opening and closing quote. */ +function inlineNodeBlocks(script) { + const blocks = []; + const opener = /node -e '/g; + let match = opener.exec(script); + while (match !== null) { + const start = match.index + match[0].length; + const end = script.indexOf("' ", start); + blocks.push({ line: script.slice(0, start).split("\n").length, body: script.slice(start, end) }); + match = opener.exec(script); + } + return blocks; +} + +// `bash -n` accepts a script whose single quotes still pair up across lines, so an +// apostrophe in a comment inside a `node -e '...'` block passes the syntax check and +// fails at run time, in the one phase gated on a binary the CI never has. A JavaScript +// comment or string inside such a block therefore carries no single quote at all. +test("no single quote survives inside an inline node block of a smoke script", () => { + for (const rel of SCRIPTS) { + const script = fs.readFileSync(path.join(root, rel), "utf8"); + for (const block of inlineNodeBlocks(script)) { + assert.ok( + !block.body.includes("'"), + `${rel}:${block.line}: a single quote inside a node -e block ends the bash string early` + ); + } + } +}); diff --git a/scripts/__tests__/source-stays-text.test.js b/scripts/__tests__/source-stays-text.test.js index 1da665071..43905af2c 100644 --- a/scripts/__tests__/source-stays-text.test.js +++ b/scripts/__tests__/source-stays-text.test.js @@ -7,19 +7,12 @@ const { describe, it } = require("node:test"); const ROOT = path.resolve(__dirname, "../.."); /** - * A source file must stay readable by the tools people actually use on it. + * A source file must stay readable by the tools people actually use on it. A raw NUL byte - + * a sound separator for a composite key - makes `file` report `data` and every `grep` over + * that file return nothing at all: no output, exit 1, indistinguishable from a genuine + * absence, which is how a reviewer concludes a symbol is missing from the file defining it. * - * `cost-report.ts` carried two raw NUL bytes as the separator of a composite key. The - * technique is sound - a byte that cannot occur in any component makes the key unambiguous - - * but written as raw bytes rather than the `\u0000` escape it made `file` report `data`, and - * every `grep` over those 1072 lines return nothing at all, silently. Not "no match with a - * warning": no output, exit 1, indistinguishable from a genuine absence. - * - * That is how a reviewer concludes a symbol is missing from the one file that defines it. - * It happened, on this repository, while reading that exact file. - * - * The escape compiles to the identical string, so nothing about the key changes - only - * whether a person can search the file that builds it. + * The `\u0000` escape compiles to the identical string, so only searchability changes. */ const TEXT_EXTENSIONS = new Set([ ".ts", diff --git a/scripts/__tests__/telemetry-cli-required.test.js b/scripts/__tests__/telemetry-cli-required.test.js index 713c895a5..6c35144f7 100644 --- a/scripts/__tests__/telemetry-cli-required.test.js +++ b/scripts/__tests__/telemetry-cli-required.test.js @@ -7,10 +7,8 @@ const ROOT = path.resolve(__dirname, "../.."); const PLUGIN = path.join(ROOT, "plugins/aidd-telemetry"); /** - * The three places this plugin requires `aidd` to answer, each the first numbered step of - * its own action file. A fourth skill, or a fourth wording, is exactly what this guards - * against: phase 1 wrote the rule once, phase 3 reused it, and nothing before this pinned - * all three together. + * The three places this plugin requires `aidd` to answer, each the first numbered step of its + * own action file. A fourth skill, or a fourth wording, is what this guards against. */ const LOCATING_ACTIONS = [ "skills/00-init/actions/01-check.md", diff --git a/scripts/__tests__/telemetry-where-things-live.test.js b/scripts/__tests__/telemetry-where-things-live.test.js index 136f845b0..3db677bf0 100644 --- a/scripts/__tests__/telemetry-where-things-live.test.js +++ b/scripts/__tests__/telemetry-where-things-live.test.js @@ -10,33 +10,14 @@ require("../sweep-stale-test-dirs.cjs").sweepStaleTestDirs(); const ROOT = path.resolve(__dirname, "../.."); -/** Where the figures land is pinned in cli/tests/infrastructure/adapters/ - * telemetry-sink-location.unit.test.ts, since the sink that writes them now lives only in the - * CLI. What is left here is about the plugin's own shape: what each skill carries, and that - * nothing reaches across. */ +/** Where the figures land is pinned in + * cli/tests/contexts/telemetry/infrastructure/telemetry-sink-location.unit.test.ts, since + * the sink that writes them now lives only in the CLI. What is left here is about the + * plugin's own shape: what each skill carries, and that nothing reaches across. */ describe("a library a skill needs is carried by that skill, identically", () => { - const SKILLS = path.join(ROOT, "plugins/aidd-telemetry/skills"); - it("no skill reaches outside its own folder to require code", () => { - const offenders = []; - const walk = (dir) => { - for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { - const full = path.join(dir, entry.name); - if (entry.isDirectory()) { - walk(full); - } else if (entry.name.endsWith(".js")) { - for (const m of fs.readFileSync(full, "utf8").matchAll(/require\("(\.\.[^"]*)"\)/gu)) { - const resolved = path.resolve(path.dirname(full), m[1]); - if (!resolved.startsWith(path.join(SKILLS, path.relative(SKILLS, full).split(path.sep)[0]))) { - offenders.push(`${path.relative(SKILLS, full)} -> ${m[1]}`); - } - } - } - } - }; - walk(SKILLS); - assert.deepEqual(offenders, []); - }); + // No boundary walk over skill scripts: `plugin-install-shape.test.js` already pins that a + // skill carries none, and a check with nothing to walk cannot fail on any input. /** No `package.json` marker anywhere: every CommonJS file this plugin ships is named * `.cjs`, which Node reads as CommonJS whatever the host project declares. A marker is a @@ -66,14 +47,11 @@ describe("a library a skill needs is carried by that skill, identically", () => }); /** - * Every script path this repository *names* must exist. - * - * `plugin-install-shape.test.js` walks the scripts that exist and runs them; it cannot see a - * reference to one that does not. `check-markdown-links.js` walks `[text](target)` links; a - * command inside a fenced block is invisible to it. Between those two walks sits a gap that - * has now swallowed the same defect twice: the plugin README told people to run - * `telemetry-report.cjs` for two phases after it was deleted, and `cli-ci.yml`'s Windows job - * executed it and `telemetry-switch.cjs` — red before anyone looked. + * Every script path this repository names must exist. `plugin-install-shape.test.js` walks the + * scripts that exist and cannot see a reference to one that does not; `check-markdown-links.js` + * walks markdown links and cannot see a command inside a fenced block. Between those two, the + * README went on naming `telemetry-report.cjs` and a workflow went on running + * `telemetry-switch.cjs` after both were deleted. * * This inverts the walk: start from what is written down, and require the file. */ @@ -82,15 +60,12 @@ describe("a script path this repository names is a script that exists", () => { const PLUGIN_DIR = path.join(ROOT_DIR, "plugins/aidd-telemetry"); const SEARCHED = ["plugins/aidd-telemetry", "docs", ".github/workflows", "README.md"]; - // Only the two forms that have actually broken. A bare fragment in prose ("hooks/journal.cjs" - // describing a layout) is not a reference anyone runs, and asserting it would make this - // guard cry wolf until someone deletes it. - // - // plugins/…/x.cjs repo-rooted, what cli-ci.yml executes - // /…/x.cjs the README's own form, where is the installed plugin root - // `scripts` is deliberately absent: it is both a repo directory and the conventional - // subdirectory inside every skill, so `scripts/telemetry-check.cjs` in a skill's own - // markdown is relative to that skill and resolves nowhere from here. + // Only the two forms anyone actually runs - repo-rooted, and the README's `/…` form. + // A bare fragment in prose describing a layout is not a reference, and asserting it would + // make this guard cry wolf. `scripts` is deliberately absent: it is both a repository + // directory and the conventional subdirectory inside every skill, so + // `scripts/telemetry-check.cjs` in a skill's own markdown is relative to that skill and + // resolves nowhere from here. const REPO_ROOTED = /(?:^|[\s"'`(])((?:plugins|cli|docs)\/[\w./-]+\.(?:cjs|mjs))/gmu; const PLUGIN_ROOTED = /\/([\w./-]+\.(?:cjs|mjs))/gmu; diff --git a/scripts/__tests__/tests-leave-git-alone.test.js b/scripts/__tests__/tests-leave-git-alone.test.js index 6c02093e3..deab8c869 100644 --- a/scripts/__tests__/tests-leave-git-alone.test.js +++ b/scripts/__tests__/tests-leave-git-alone.test.js @@ -13,18 +13,12 @@ const { const GUARD = path.resolve(__dirname, "..", "check-tests-leave-git-alone.js"); /** - * The guard that would have caught a test writing into the repository's own hooks. + * The guard that catches a test writing into the repository's own hooks. A suite's own + * `git init` inheriting an exported `GIT_DIR` lands its stubs in the real `.git/hooks` with + * every test green, and `.git/hooks` is in no history. * - * It happened: on 2026-09-03 the trailer-repair suite's `git init` inherited this - * repository's exported `GIT_DIR`, so its stub `prepare-commit-msg` and stub delegate - * landed in the real `.git/hooks`, replacing an install that had stood since 22 August. - * Every test passed. `aidd telemetry check` reported it hours later, by which point the - * original was gone — `.git/hooks` is in no history. - * - * `CLEAN_ENV` in that one suite fixes that one suite. This is the invariant instead, and it - * is deliberately narrower than "tests must strip GIT_*": some tests query the real - * repository on purpose (`git ls-files` over the tree). Reading it is fine. Changing it is - * never fine. + * Deliberately narrower than "tests must strip GIT_*": some tests query the real repository + * on purpose. Reading it is fine, changing it is never fine. */ function withDir(run) { @@ -36,7 +30,6 @@ function withDir(run) { } } -// ── the snapshot ─────────────────────────────────────────────────────────── test("a directory that does not exist snapshots as absent, never as empty", () => { withDir((dir) => { @@ -58,7 +51,6 @@ test("a snapshot carries every file's size and mode, not only its name", () => { }); }); -// ── what counts as a change ──────────────────────────────────────────────── test("an untouched directory reports no change", () => { withDir((dir) => { @@ -83,7 +75,7 @@ test("a file whose content changed is reported, even at the same size", () => { }); }); -// This is the exact shape of the leak: a real hook replaced by a shorter stub. +// The exact shape of the leak: a real hook replaced by a shorter stub. test("a hook replaced by a stub is reported as changed", () => { withDir((dir) => { const at = path.join(dir, "prepare-commit-msg"); @@ -158,7 +150,6 @@ test("a directory that was absent and now exists is a change, not a fresh baseli }); }); -// ── the guard, run for real ──────────────────────────────────────────────── test("a command that leaves the watched directory alone passes the guard through", () => { withDir((dir) => { diff --git a/scripts/__tests__/update-memory.test.js b/scripts/__tests__/update-memory.test.js index dfcc04e25..2041424ea 100644 --- a/scripts/__tests__/update-memory.test.js +++ b/scripts/__tests__/update-memory.test.js @@ -217,9 +217,8 @@ test("AGENTS.md gets markdown links, resolvable from the repository root", () => assert.doesNotMatch(content, /\(\.\.\//u); }); -// A link resolves against the file holding it, so a nested context file has to -// climb back out. Hardcoding one level made every root-level link escape the -// repository, which the link check catches as a broken local path. +// A link resolves against the file holding it, so a nested context file climbs back out. +// Hardcoding one level makes every root-level link escape the repository. test("a nested context file prefixes its links with the climb back out", () => { const content = run({ context: `${OPEN}\n${CLOSE}\n`, diff --git a/scripts/__tests__/validate-json.test.js b/scripts/__tests__/validate-json.test.js new file mode 100644 index 000000000..48a293d7d --- /dev/null +++ b/scripts/__tests__/validate-json.test.js @@ -0,0 +1,125 @@ +const assert = require("node:assert/strict"); +const { spawnSync } = require("node:child_process"); +const fs = require("node:fs"); +const os = require("node:os"); +const path = require("node:path"); +const { pathToFileURL } = require("node:url"); +const { describe, it } = require("node:test"); + +const script = path.resolve(__dirname, "../validate-json.mjs"); +// A bare absolute path is not an import specifier on Windows, where it reads as a `d:` scheme. +const scriptUrl = pathToFileURL(script).href; + +async function validator(root, loadSchema) { + const { createValidator } = await import(scriptUrl); + return createValidator({ root, loadSchema }); +} + +const offline = async () => { + throw new Error("offline"); +}; + +function tree(files) { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "aidd-validate-json-")); + for (const [file, content] of Object.entries(files)) { + const target = path.join(root, ...file.split("/")); + fs.mkdirSync(path.dirname(target), { recursive: true }); + fs.writeFileSync(target, typeof content === "string" ? content : `${JSON.stringify(content, null, 2)}\n`); + } + return root; +} + +const manifest = (skills) => ({ + name: "sample", + version: "1.0.0", + description: "a sample", + repository: "https://example.invalid/repo", + homepage: "https://example.invalid", + license: "MIT", + author: { name: "someone" }, + skills, +}); + +describe("validate-json", () => { + it("routes a plugin manifest, a marketplace and a settings file to their schema, and a plain file to none", async () => { + const { schemaFor } = await import(scriptUrl); + assert.equal(schemaFor("plugins/x/.claude-plugin/plugin.json").type, "pluginManifest"); + assert.equal(schemaFor(".claude-plugin/marketplace.json").type, "marketplace"); + assert.equal(schemaFor(".claude/settings.local.json").type, "claudeSettings"); + assert.equal(schemaFor(path.join("plugins", "x", ".claude-plugin", "plugin.json")).type, "pluginManifest"); + assert.equal(schemaFor("package.json"), null); + }); + + it("falls back to the local rules when the schema cannot be fetched, and names a skill path that is not there", async () => { + const root = tree({ + "plugins/sample/.claude-plugin/plugin.json": manifest(["./skills/present", "./skills/absent"]), + "plugins/sample/skills/present/SKILL.md": "# present\n", + }); + try { + const v = await validator(root, offline); + await v.validate(path.join("plugins", "sample", ".claude-plugin", "plugin.json")); + assert.equal(v.warnings.length, 1); + assert.match(v.warnings[0], /using local fallback \(offline\)/u); + assert.deepEqual( + v.errors.map((e) => e.split(": ").slice(1).join(": ")), + ["skill path does not exist: ./skills/absent"] + ); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } + }); + + it("applies the fetched schema when there is one, and reports what it rejects", async () => { + const root = tree({ "plugins/sample/.claude-plugin/plugin.json": { name: 7 } }); + try { + const schema = { type: "object", properties: { name: { type: "string" } }, required: ["name"] }; + const v = await validator(root, async () => schema); + await v.validate(path.join("plugins", "sample", ".claude-plugin", "plugin.json")); + assert.deepEqual(v.warnings, []); + assert.equal(v.errors.length, 1); + assert.match(v.errors[0], /\/name must be string/u); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } + }); + + it("names a marketplace plugin listed twice and a source that is not there", async () => { + const plugin = { name: "dup", version: "1.0.0", source: "./plugins/dup", description: "d", strict: true, recommended: false }; + const root = tree({ + ".claude-plugin/marketplace.json": { name: "m", version: "1.0.0", description: "d", owner: { name: "o" }, plugins: [plugin, plugin] }, + }); + try { + const v = await validator(root, offline); + await v.validate(path.join(".claude-plugin", "marketplace.json")); + const messages = v.errors.map((e) => e.split(": ").slice(1).join(": ")); + assert.ok(messages.includes("duplicate plugin name: dup"), messages.join("\n")); + assert.equal(messages.filter((m) => m.startsWith("plugins[0].source does not exist")).length, 1); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } + }); + + it("passes the repository's own manifests through the local fallback, so an offline runner agrees with an online one", async () => { + const root = path.resolve(__dirname, "../.."); + const v = await validator(root, offline); + await v.validate(path.join(".claude-plugin", "marketplace.json")); + for (const plugin of fs.readdirSync(path.join(root, "plugins"))) { + await v.validate(path.join("plugins", plugin, ".claude-plugin", "plugin.json")); + } + assert.deepEqual(v.errors, []); + }); + + it("fails the CLI on a file that is not JSON, naming it, and passes a plain valid one", () => { + const root = tree({ "broken.json": "{ not json", "fine.json": { ok: true } }); + try { + const broken = spawnSync(process.execPath, [script, "broken.json"], { cwd: root, encoding: "utf8" }); + assert.equal(broken.status, 1); + assert.match(broken.stderr, /broken\.json: invalid JSON/u); + const fine = spawnSync(process.execPath, [script, "fine.json"], { cwd: root, encoding: "utf8" }); + assert.equal(fine.status, 0, fine.stderr); + assert.match(fine.stdout, /passed for 1 file/u); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } + }); +}); diff --git a/scripts/__tests__/validate-yaml.test.js b/scripts/__tests__/validate-yaml.test.js new file mode 100644 index 000000000..131e1d5e0 --- /dev/null +++ b/scripts/__tests__/validate-yaml.test.js @@ -0,0 +1,28 @@ +const assert = require("node:assert/strict"); +const { spawnSync } = require("node:child_process"); +const fs = require("node:fs"); +const os = require("node:os"); +const path = require("node:path"); +const { describe, it } = require("node:test"); + +const script = path.resolve(__dirname, "../validate-yaml.mjs"); + +describe("validate-yaml", () => { + it("fails on a file YAML cannot load, naming it, and passes a valid multi-document one", () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "aidd-validate-yaml-")); + try { + fs.writeFileSync(path.join(root, "broken.yml"), "key: [unclosed\n"); + fs.writeFileSync(path.join(root, "fine.yml"), "a: 1\n---\nb: [1, 2]\n"); + + const broken = spawnSync(process.execPath, [script, "broken.yml"], { cwd: root, encoding: "utf8" }); + assert.equal(broken.status, 1); + assert.match(broken.stderr, /broken\.yml: /u); + + const fine = spawnSync(process.execPath, [script, "--", "fine.yml"], { cwd: root, encoding: "utf8" }); + assert.equal(fine.status, 0, fine.stderr); + assert.match(fine.stdout, /passed for 1 file/u); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } + }); +}); diff --git a/scripts/__tests__/workflows-pin-global-installs.test.js b/scripts/__tests__/workflows-pin-global-installs.test.js new file mode 100644 index 000000000..226c3e715 --- /dev/null +++ b/scripts/__tests__/workflows-pin-global-installs.test.js @@ -0,0 +1,127 @@ +const assert = require("node:assert/strict"); +const fs = require("node:fs"); +const path = require("node:path"); +const test = require("node:test"); +const yaml = require("js-yaml"); + +const root = path.resolve(__dirname, "../.."); +const workflowsDir = path.join(root, ".github/workflows"); + +// The one way to declare a deliberate exception. A comment on the workflow line cannot +// carry it: a `run:` written as a plain scalar loses its `# …` tail to the YAML parser +// before this suite ever sees the script, so a marker there would be invisible here. +const ALLOWLIST = [ + { + file: ".github/workflows/cli-ci.yml", + pattern: /npm install -g @anthropic-ai\/claude-code\b/, + reason: + "Deliberately unpinned: the identifier-join probe exists to catch drift against " + + "whatever claude-code currently ships, not a version this repo controls. Pinning " + + "it would defeat the point of the probe.", + }, +]; + +/** Every `run:` step's script across every job in one workflow file, each tagged with + * the file it came from — comments in the YAML never reach here, `js-yaml` already + * dropped them, so a prose mention of `npm install -g` in a comment cannot false-positive. */ +function collectRunScripts(file) { + const doc = yaml.load(fs.readFileSync(file, "utf8")); + const scripts = []; + for (const job of Object.values(doc.jobs ?? {})) { + for (const step of job.steps ?? []) { + if (typeof step.run === "string") scripts.push(step.run); + } + } + return scripts; +} + +/** Whether a package spec names no version at all, or names `@latest` explicitly. + * A local tarball/path is not a registry spec — always pinned to whatever this run + * just built — so it is never a violation regardless of what this returns. */ +function isUnpinnedSpec(spec) { + if (spec.startsWith(".") || spec.startsWith("/") || spec.endsWith(".tgz")) return false; + + // Split off the version, respecting the leading "@" of a scoped package name: + // "@scope/name@version" has its pin after the *second* "@", "name@version" after + // the first, and "name" (or "@scope/name") alone has none. + const scoped = spec.startsWith("@"); + const rest = scoped ? spec.slice(1) : spec; + const versionSplit = rest.indexOf("@"); + const version = versionSplit === -1 ? undefined : rest.slice(versionSplit + 1); + + return version === undefined || version === "latest"; +} + +/** A `npm install -g`/`--global` (or `npm i`) invocation's package spec is unpinned + * when it names no version at all, or names `@latest` explicitly — a global install + * always hits the registry, so a bare name silently tracks whatever the registry + * currently serves just as much as an explicit `@latest` does. `npx` normally resolves + * a project's own dependency (or is meant to float, e.g. a one-off diagnostic) so a bare + * `npx ` is not itself a violation — only an explicit `@latest` on an `npx` line is. */ +function findUnpinnedGlobalInstalls(script) { + const findings = []; + + const npmInstallRe = /^.*\bnpm\s+i(?:nstall)?\s+(?:-g|--global)\s+.*$/gm; + let match; + while ((match = npmInstallRe.exec(script)) !== null) { + const line = match[0]; + const specMatch = line.match(/(?:-g|--global)\s+(\S+)/); + if (specMatch === undefined || specMatch === null) continue; + if (isUnpinnedSpec(specMatch[1])) findings.push({ line, spec: specMatch[1] }); + } + + const npxRe = /^.*\bnpx\s+.*$/gm; + while ((match = npxRe.exec(script)) !== null) { + const line = match[0]; + if (/\S+@latest\b/.test(line)) findings.push({ line, spec: "@latest" }); + } + + return findings; +} + +test("no workflow installs a global npm package unpinned (bare or @latest)", () => { + const files = fs + .readdirSync(workflowsDir) + .filter((name) => name.endsWith(".yml") || name.endsWith(".yaml")); + + const violations = []; + for (const name of files) { + // Reported and allowlisted in POSIX form on every platform: a Windows checkout would + // otherwise spell the same file with backslashes and match no allowlist entry. + const relPath = path.posix.join(".github/workflows", name); + const absPath = path.join(workflowsDir, name); + const scripts = collectRunScripts(absPath); + + for (const script of scripts) { + for (const finding of findUnpinnedGlobalInstalls(script)) { + const allowed = ALLOWLIST.some( + (entry) => entry.file === relPath && entry.pattern.test(finding.line) + ); + if (!allowed) { + violations.push(`${relPath}: ${finding.line.trim()}`); + } + } + } + } + + assert.deepEqual( + violations, + [], + `unpinned global npm install(s) found (pin an exact version, or declare the ` + + `exception in this suite's ALLOWLIST with its reason):\n${violations.join("\n")}` + ); +}); + +test("every allowlist entry still matches something (a stale entry hides a real regression)", () => { + for (const entry of ALLOWLIST) { + const absPath = path.join(root, entry.file); + const scripts = collectRunScripts(absPath); + const stillMatches = scripts.some((script) => + script.split("\n").some((line) => entry.pattern.test(line)) + ); + assert.ok( + stillMatches, + `allowlist entry for ${entry.file} (${entry.pattern}) no longer matches anything — remove it` + ); + } +}); diff --git a/scripts/check-claude-accepts-build.cjs b/scripts/check-claude-accepts-build.cjs new file mode 100644 index 000000000..41a120c90 --- /dev/null +++ b/scripts/check-claude-accepts-build.cjs @@ -0,0 +1,66 @@ +#!/usr/bin/env node +/** + * Asks Claude Code itself whether it accepts the marketplace `translate --to claude` builds. + * The golden snapshots pin what the build contains; only the host can say it loads. + * + * `claude plugin validate` exits 0 whether validation passed or failed (measured on 2.1.x), + * so the verdict is read from its text, never from its exit code. + * + * Exit codes, same contract as `probe-identifier-join.cjs`: + * 0 the host accepts the build + * 1 the host refuses it + * 2 no verdict — the CLI is not built, the translation failed, or the host said neither + */ +const { spawnSync } = require("node:child_process"); +const fs = require("node:fs"); +const os = require("node:os"); +const path = require("node:path"); + +const REFUSED = 1; +const NO_VERDICT = 2; + +/** What the host's text says: "passed", "failed", or "unknown" when it says neither. */ +function verdict(text) { + if (/Validation failed/.test(text)) return "failed"; + if (/Validation passed/.test(text)) return "passed"; + return "unknown"; +} + +/** Translates `root` for claude under a fresh directory and asks `host` (an argv) about it. */ +function check({ root, cli, host, log }) { + if (!fs.existsSync(cli)) { + log(`NO VERDICT: the CLI is not built at ${cli}\n`); + return NO_VERDICT; + } + const out = fs.mkdtempSync(path.join(os.tmpdir(), "aidd-claude-build-")); + const build = spawnSync(process.execPath, [cli, "translate", root, "--to", "claude", "--out", out], { + encoding: "utf8", + }); + if (build.status !== 0) { + log(`${build.stdout}${build.stderr}\nNO VERDICT: translate exited ${build.status}\n`); + return NO_VERDICT; + } + const [bin, ...leading] = host; + const asked = spawnSync(bin, [...leading, "plugin", "validate", out], { encoding: "utf8" }); + const text = `${asked.stdout ?? ""}${asked.stderr ?? ""}`; + log(text); + const said = verdict(text); + if (said === "passed") return 0; + if (said === "failed") return REFUSED; + log(`\nNO VERDICT: ${host.join(" ")} said neither passed nor failed (exit ${asked.status ?? asked.error})\n`); + return NO_VERDICT; +} + +module.exports = { verdict, check }; + +if (require.main === module) { + const root = path.resolve(__dirname, ".."); + process.exit( + check({ + root, + cli: path.join(root, "cli", "dist", "cli.js"), + host: ["claude"], + log: (text) => process.stdout.write(text), + }), + ); +} diff --git a/scripts/check-cli-layering.mjs b/scripts/check-cli-layering.mjs deleted file mode 100644 index 21dcebfa8..000000000 --- a/scripts/check-cli-layering.mjs +++ /dev/null @@ -1,137 +0,0 @@ -#!/usr/bin/env node -// Enforces the two invariants of cli/.claude/rules/00-architecture/0-hexagonal.md that a -// linter cannot express: dependencies point inward, and the type system is not bypassed. -// -// Biome cannot do the first: its noRestrictedImports matches exact module specifiers, not -// path prefixes, so a rule written against "../../infrastructure" never fires. Measured, -// not assumed - a deliberate violation planted in src/domain went unreported. -// -// Usage: -// node scripts/check-cli-layering.mjs # exit 1 on any breach - -import { readdir, readFile } from "node:fs/promises"; -import path from "node:path"; - -const CLI = path.join(process.cwd(), "cli"); -const SRC = path.join(CLI, "src"); -const TESTS = path.join(CLI, "tests"); - -/** The cast rule holds everywhere; the layering rule is about production layers only. - * A test lives outside them and legitimately wires an adapter to a use-case. */ -const CAST_ROOTS = [SRC, TESTS]; -const LAYERING_ROOTS = [SRC]; - -const IMPORT_PATTERN = /(?:from|import)\s+["']([^"']+)["']/g; - -/** Both spellings of the same lie: `as unknown as T` walks up to `unknown` and back down, - * `as never` walks down to the bottom type, which is assignable to everything. `\bas` is - * what keeps prose out - "was never" has no word boundary before its "as". */ -const WIDENING_CASTS = [/\bas\s+unknown\s+as\b/, /\bas\s+never\b/]; - -function widensAType(source) { - return WIDENING_CASTS.some((pattern) => pattern.test(source)); -} - -/** Layers may only reach inward. `application/commands/` is the composition root's caller: - * it exists to hand `createDeps` to a use-case, which is the one place the wiring happens. */ -const INWARD_ONLY = [ - { - layer: "domain", - forbids: ["application", "infrastructure"], - reason: "the domain is the innermost layer and depends on nothing", - }, - { - layer: "application", - forbids: ["infrastructure"], - exempt: ["application/commands"], - reason: "use-cases depend on ports, never on the adapters that implement them", - }, -]; - -/** Every cast the type system cannot express away, each with the reason it survives. - * Paths are cli-relative. Listed so the debt is visible and shrinking rather than - * silently permitted everywhere - adding a line here is a decision, not a default. */ -const CASTS_ALLOWED = new Map([ - [ - "src/application/use-cases/framework/framework-build-use-case.ts", - "SourceMarketplace carries an index signature beside typed optional members, so no " + - "parsed `Record` can ever satisfy it; narrowing it honestly would " + - "mean rejecting catalogs the builder accepts today", - ], -]); - -async function typescriptFilesUnder(dir) { - const found = []; - for (const entry of await readdir(dir, { withFileTypes: true })) { - const full = path.join(dir, entry.name); - if (entry.isDirectory()) found.push(...(await typescriptFilesUnder(full))); - else if (entry.name.endsWith(".ts")) found.push(full); - } - return found; -} - -/** Keys are written with `/` so the allow-list reads the same on every platform. */ -function relative(from, file) { - return path.relative(from, file).split(path.sep).join("/"); -} - -function importedLayers(source) { - const layers = new Set(); - for (const [, specifier] of source.matchAll(IMPORT_PATTERN)) { - const match = /(?:^|\/)(domain|application|infrastructure)\//.exec(specifier); - if (match) layers.add(match[1]); - } - return layers; -} - -function layeringBreach(relativePath, source) { - for (const { layer, forbids, exempt, reason } of INWARD_ONLY) { - if (!relativePath.startsWith(`${layer}/`)) continue; - if (exempt?.some((prefix) => relativePath.startsWith(`${prefix}/`))) continue; - const reached = [...importedLayers(source)].filter((imported) => forbids.includes(imported)); - if (reached.length > 0) { - return `${relativePath} imports ${reached.join(", ")} - ${reason}`; - } - } - return null; -} - -function castBreach(cliPath, source) { - if (!widensAType(source) || CASTS_ALLOWED.has(cliPath)) return null; - return `cli/${cliPath} widens a type through \`as unknown as\` or \`as never\` - build the value with the type it claims`; -} - -const breaches = []; -const spentAllowances = new Set(); - -for (const root of CAST_ROOTS) { - for (const file of await typescriptFilesUnder(root)) { - const cliPath = relative(CLI, file); - const source = await readFile(file, "utf-8"); - if (widensAType(source)) spentAllowances.add(cliPath); - const breach = castBreach(cliPath, source); - if (breach) breaches.push(` ${breach}`); - } -} - -for (const root of LAYERING_ROOTS) { - for (const file of await typescriptFilesUnder(root)) { - const breach = layeringBreach(relative(root, file), await readFile(file, "utf-8")); - if (breach) breaches.push(` ${breach}`); - } -} - -// An allowance nobody spends is stale: the cast it excused is gone, so the line goes too. -for (const cliPath of CASTS_ALLOWED.keys()) { - if (!spentAllowances.has(cliPath)) { - breaches.push(` cli/${cliPath} no longer casts - drop its CASTS_ALLOWED entry`); - } -} - -if (breaches.length > 0) { - console.error(`cli layering breaches:\n${breaches.join("\n")}`); - console.error("Contract: cli/.claude/rules/00-architecture/0-hexagonal.md"); - process.exit(1); -} - -console.log("Dependencies point inward, and no type is widened through unknown or never."); diff --git a/scripts/check-cli-type-honesty.mjs b/scripts/check-cli-type-honesty.mjs new file mode 100644 index 000000000..d62f37e29 --- /dev/null +++ b/scripts/check-cli-type-honesty.mjs @@ -0,0 +1,97 @@ +#!/usr/bin/env node +// Enforces the one invariant of cli/.claude/rules/00-architecture/0-hexagonal.md that a +// linter cannot express: no value is widened away from the type it claims to hold. +// Dependency direction belongs to biome, whose per-layer `noRestrictedImports` overrides +// match the resolved path — a hand-picked prefix here goes stale the moment a layer moves. + +import { readdir, readFile } from "node:fs/promises"; +import path from "node:path"; + +const CLI = path.join(process.cwd(), "cli"); +const SRC = path.join(CLI, "src"); +const TESTS = path.join(CLI, "tests"); + +/** + * `\bas` keeps prose out - "was never" has no word boundary before its "as". `any` needs the + * negative lookahead on top: it is also an English word, and a real cast's `any` is a + * complete type, so what follows it is punctuation, never another word. + */ +const WIDENING_ANYWHERE = [ + /\bas\s+unknown\s+as\b/, + /\bas\s+never\b/, + /\bas\s+any\b(?!\s*[a-zA-Z])/, +]; + +/** + * Checked in `src/` only: a test whose whole point is that a shape does not compile has no + * other way to assert it, and that is a compiler assertion rather than a widened value. + * Production code has no "prove this doesn't compile" to make. + */ +const WIDENING_SRC_ONLY = [/@ts-expect-error\b/, /@ts-ignore\b/]; + +/** Cli-relative paths, each with the reason the cast survives. Listed so adding one is a + * decision rather than a default. */ +const CASTS_ALLOWED = new Map([ + [ + "src/contexts/translate/application/translate-source.ts", + "SourceMarketplace carries an index signature beside typed optional members, so no " + + "parsed `Record` can ever satisfy it; narrowing it honestly would " + + "mean rejecting catalogs the builder accepts today", + ], +]); + +async function typescriptFilesUnder(dir) { + const found = []; + for (const entry of await readdir(dir, { withFileTypes: true })) { + const full = path.join(dir, entry.name); + if (entry.isDirectory()) found.push(...(await typescriptFilesUnder(full))); + else if (entry.name.endsWith(".ts")) found.push(full); + } + return found; +} + +/** Keys are written with `/` so the allow-list reads the same on every platform. */ +function relative(from, file) { + return path.relative(from, file).split(path.sep).join("/"); +} + +function widensAType(source, patterns) { + return patterns.some((pattern) => pattern.test(source)); +} + +function castBreach(cliPath, source, patterns) { + if (!widensAType(source, patterns) || CASTS_ALLOWED.has(cliPath)) return null; + return ( + `cli/${cliPath} widens a type through \`as unknown as\`, \`as any\`, \`as never\`, ` + + "`@ts-expect-error` or `@ts-ignore` - build the value with the type it claims" + ); +} + +const breaches = []; +const spentAllowances = new Set(); + +for (const root of [SRC, TESTS]) { + const patterns = root === SRC ? [...WIDENING_ANYWHERE, ...WIDENING_SRC_ONLY] : WIDENING_ANYWHERE; + for (const file of await typescriptFilesUnder(root)) { + const cliPath = relative(CLI, file); + const source = await readFile(file, "utf-8"); + if (widensAType(source, patterns)) spentAllowances.add(cliPath); + const breach = castBreach(cliPath, source, patterns); + if (breach) breaches.push(` ${breach}`); + } +} + +// An allowance nobody spends is stale: the cast it excused is gone, so the line goes too. +for (const cliPath of CASTS_ALLOWED.keys()) { + if (!spentAllowances.has(cliPath)) { + breaches.push(` cli/${cliPath} no longer casts - drop its CASTS_ALLOWED entry`); + } +} + +if (breaches.length > 0) { + console.error(`cli type-honesty breaches:\n${breaches.join("\n")}`); + console.error("Contract: cli/.claude/rules/00-architecture/0-hexagonal.md"); + process.exit(1); +} + +console.log("No type is widened through unknown, any or never, and no directive silences the compiler outside a test proving something does not compile."); diff --git a/scripts/check-context-imports.js b/scripts/check-context-imports.js index 7dca7100f..9ee4a4b0e 100644 --- a/scripts/check-context-imports.js +++ b/scripts/check-context-imports.js @@ -1,20 +1,9 @@ #!/usr/bin/env node /** - * check-context-imports.js - Fails when an `@import` in an AI context file sits - * inside an HTML block, where the context loader skips it. - * - * Any line whose first non-space character is `<` is treated as opening an HTML - * block that runs until the next blank line. Imports inside one are ignored - * exactly like those in a fenced code block, so the file looks correct and loads - * nothing, and nothing reports it. - * - * That rule is deliberately stricter than CommonMark, which closes a comment on - * its own line. Context loaders are not all CommonMark, and a blank line after - * the opening line costs nothing and holds under either reading. - * - * Usage: - * node scripts/check-context-imports.js every context file - * node scripts/check-context-imports.js CLAUDE.md ... only those files + * Fails when an `@import` sits inside an HTML block, where a context loader skips it: the + * file looks correct, loads nothing, and nothing reports it. A block opens on any line + * starting with `<` and runs to the next blank line — stricter than CommonMark, because + * not every context loader is a CommonMark parser. */ const fs = require("node:fs"); @@ -22,12 +11,11 @@ const path = require("node:path"); const ROOT = path.resolve(__dirname, ".."); -// The files a context loader reads. Anywhere in the tree, not just the root: -// a monorepo package carries its own. +// Anywhere in the tree, not just the root: a monorepo package carries its own. const CONTEXT_FILENAMES = ["CLAUDE.md", "AGENTS.md", "copilot-instructions.md"]; -// Directories to never walk into, matched by name at any depth. Same set as -// scripts/check-markdown-links.js, which walks the same tree. +// Matched by name at any depth. Same set as scripts/check-markdown-links.js, which walks +// the same tree. const SKIPPED_DIRS = new Set([".git", "node_modules", "worktrees", ".specstory"]); // Snapshots of older framework versions, kept on the old shape as test input. @@ -56,10 +44,7 @@ function collectContextFiles(dir = ROOT, found = []) { return found; } -/** - * Imports that a context loader will skip, with the line that hid them. - * Code blocks are skipped: the broken shape is quoted in documentation. - */ +/** Code blocks are skipped: the broken shape is quoted in documentation. */ function findHiddenImports(content) { const hidden = []; let fence = null; diff --git a/scripts/check-context-reference-form.js b/scripts/check-context-reference-form.js index 006356ff5..d04a5c330 100644 --- a/scripts/check-context-reference-form.js +++ b/scripts/check-context-reference-form.js @@ -1,23 +1,11 @@ #!/usr/bin/env node /** - * check-context-reference-form.js - Fails when a context file's memory block - * carries a reference in a form its own tool cannot resolve. + * Fails when a context file's memory block carries a reference its own tool cannot resolve: + * `CLAUDE.md` takes `@aidd_docs/…` imports, while `AGENTS.md` and the copilot instructions + * take markdown links, where an `@` line is inert text that loads nothing and reports nothing. * - * `CLAUDE.md` takes `@aidd_docs/…` because Claude Code resolves that import. - * `AGENTS.md`, and the copilot instructions file, take markdown links because the - * tools reading them do not: an `@` line there is inert text that loads nothing - * and reports nothing. - * - * `plugins/aidd-context/hooks/update_memory.js` already writes the right form. - * What was missing is anything that notices a file drifting back to the wrong - * one — through an older copy of that hook still cached on a machine, or a stale - * edit swept into an unrelated commit. Both have happened. - * - * The expected form is read from the hook's own `TARGET_FILES`, never restated + * The expected form is read from `update_memory.js`'s own `TARGET_FILES`, never restated * here: a second copy of that table could disagree with the one that writes. - * - * Usage: - * node scripts/check-context-reference-form.js */ const fs = require("node:fs"); @@ -33,10 +21,8 @@ const TARGET_ENTRY = /\{\s*path:\s*"([^"]+)"\s*,\s*syntax:\s*"(at|link)"\s*\}/gu const AT_REFERENCE = /^@(\S+)$/u; const LINK_REFERENCE = /^\[[^\]]+\]\([^)]+\)$/u; -/** The hook's own table of which file takes which form. Throws rather than - * returning nothing: a table that cannot be read makes every comparison below - * vacuous, and a check that passes because it compared against nothing is worse - * than no check. */ +/** Throws rather than returning nothing: a check that passes because it compared against + * nothing is worse than no check. */ function readDeclaredTargets(hookSource) { const targets = [...hookSource.matchAll(TARGET_ENTRY)].map(([, file, syntax]) => ({ path: file, @@ -51,19 +37,17 @@ function readDeclaredTargets(hookSource) { return targets; } -/** The form of one line, or `null` when the line is not a reference at all — - * prose, an html comment and the read-on-demand list all share the block. */ +/** `null` when the line is not a reference at all — prose, an html comment and the + * read-on-demand list all share the block. */ function referenceForm(line) { if (AT_REFERENCE.test(line)) return "at"; if (LINK_REFERENCE.test(line)) return "link"; return null; } -/** Every reference in the memory block whose form is not the declared one. - * - * A file with no block, and one whose markers are unpaired, both yield nothing: - * `update_memory.js` reports the unpaired case itself, and two voices for one - * fault help nobody. */ +/** A file with no block, and one whose markers are unpaired, both yield nothing: + * `update_memory.js` reports the unpaired case itself, and two voices for one fault + * help nobody. */ function referenceFormProblems(content, expected) { const lines = content.split("\n"); const opensAt = lines.findIndex((line) => line.includes(BLOCK_OPEN)); @@ -86,8 +70,8 @@ function readFileIfPresent(relPath) { return fs.existsSync(full) ? fs.readFileSync(full, "utf8") : null; } -/** A declared file the repository does not have is skipped: the hook writes to - * whichever of them exist, and a project carrying only one is a normal state. */ +/** A declared file the repository does not have is skipped: the hook writes to whichever + * of them exist, and a project carrying only one is a normal state. */ function checkFiles(targets, io = { readFileIfPresent }) { const problems = []; for (const target of targets) { diff --git a/scripts/check-doc-duplication.js b/scripts/check-doc-duplication.js new file mode 100644 index 000000000..fcb98a0a0 --- /dev/null +++ b/scripts/check-doc-duplication.js @@ -0,0 +1,202 @@ +#!/usr/bin/env node +/** + * Fails when two documents carry the same sentence. A fact copied into a second page stops + * being one fact: the copies drift, and a reader who finds the stale one has no way to tell. + * + * A sentence, not a paragraph or a hash of the file: a paragraph moves a word and stops + * matching, and a whole-file measure never names what to fix. Twelve words is the floor + * because shorter lines are shared phrasing rather than a shared fact. + * + * Repetition inside one page is deliberate as often as not, so only cross-file carriers count. + */ + +const fs = require("node:fs"); +const path = require("node:path"); + +/** + * Sentences two documents are allowed to share, keyed by the normalised sentence, listing + * every file allowed to carry it with the reason it is there. The only exception this guard + * accepts, so an entry costs a decision somebody can read back. + */ +const BASELINE = Object.freeze({}); + +/** Only prose this repository writes and can keep in one home. */ +const SCANNED_DIRECTORIES = ["docs", "aidd_docs/memory", "cli/aidd_docs/memory"]; +const SCANNED_FILES = ["README.md", "cli/README.md"]; +const SCANNED_GLOBS = ["plugins/*/README.md"]; + +/** Generated on every commit by lefthook, so a duplicate here is the generator's, not a page's. */ +const GENERATED = new Set(["docs/prompts-documentation.md"]); + +const MINIMUM_WORDS = 12; + +const FRONTMATTER = /^---\r?\n[\s\S]*?\r?\n---\r?\n/u; +/** Mermaid arrives fenced like any other block, so one fence rule covers both. */ +const FENCE = /^(?:```|~~~)/u; +const MARKDOWN_LINK = /!?\[([^\]]*)\]\([^)]*\)/gu; +const INLINE_CODE = /`([^`]*)`/gu; +const LIST_OR_QUOTE_MARKER = /^\s*(?:[>*+-]\s+|\d+[.)]\s+)/u; +const SENTENCE_END = /(?<=[.!?])\s+/u; + +/** + * A line break ends a sentence as surely as a period does: this repository's prose is mostly + * bullets and pointers that carry no terminator, and joining them would hide every duplicate + * inside a paragraph-sized blob. + */ +function sentencesOf(markdown) { + const body = markdown.replace(FRONTMATTER, ""); + const found = new Set(); + let inFence = false; + + for (const raw of body.split(/\r?\n/)) { + if (FENCE.test(raw.trim())) { + inFence = !inFence; + continue; + } + if (inFence) continue; + + const line = raw.trim(); + if (line.startsWith("#") || line.startsWith("|")) continue; + + for (const piece of line.split(SENTENCE_END)) { + const sentence = normalise(piece); + if (sentence.split(" ").filter(Boolean).length >= MINIMUM_WORDS) found.add(sentence); + } + } + + return [...found]; +} + +function normalise(piece) { + return piece + .replace(LIST_OR_QUOTE_MARKER, "") + .replace(MARKDOWN_LINK, "$1") + .replace(INLINE_CODE, "$1") + .replace(/\s+/gu, " ") + .trim() + .toLowerCase() + .replace(/[.!?]+$/u, ""); +} + +/** Which files carry each sentence, in the order the caller handed the files over. */ +function carriersOf(filesToText) { + const carriers = new Map(); + + for (const [file, text] of Object.entries(filesToText)) { + for (const sentence of sentencesOf(text)) { + if (!carriers.has(sentence)) carriers.set(sentence, []); + carriers.get(sentence).push(file); + } + } + + return carriers; +} + +function findDuplicates(filesToText, baseline = BASELINE) { + const duplicates = []; + + for (const [sentence, files] of carriersOf(filesToText)) { + const allowed = baseline[sentence] ?? {}; + const reported = files.filter((file) => !(file in allowed)); + if (reported.length > 1) duplicates.push({ sentence, files: reported }); + } + + return duplicates; +} + +/** An allowance outlives its reason the moment one listed file stops carrying the sentence. */ +function staleBaseline(filesToText, baseline = BASELINE) { + const carriers = carriersOf(filesToText); + const stale = []; + + for (const [sentence, allowed] of Object.entries(baseline)) { + const files = carriers.get(sentence) ?? []; + for (const file of Object.keys(allowed)) { + if (!files.includes(file)) { + stale.push( + `${file} no longer carries "${sentence}" - drop it from BASELINE` + ); + } + } + } + + return stale; +} + +function markdownUnder(root, directory) { + const absolute = path.resolve(root, directory); + if (!fs.existsSync(absolute)) return []; + + return fs + .readdirSync(absolute, { withFileTypes: true, recursive: true }) + .filter((entry) => entry.isFile() && entry.name.endsWith(".md")) + .map((entry) => path.relative(root, path.join(entry.parentPath ?? entry.path, entry.name))); +} + +/** One shape only, a named file one directory down: a glob engine would be a dependency for + * a single pattern. */ +function matchingGlob(root, glob) { + const [parent, , name] = glob.split("/"); + const absolute = path.resolve(root, parent); + if (!fs.existsSync(absolute)) return []; + + return fs + .readdirSync(absolute, { withFileTypes: true }) + .filter((entry) => entry.isDirectory() && fs.existsSync(path.join(absolute, entry.name, name))) + .map((entry) => path.posix.join(parent, entry.name, name)); +} + +function scannedFiles(root) { + const found = [ + ...SCANNED_FILES.filter((file) => fs.existsSync(path.resolve(root, file))), + ...SCANNED_GLOBS.flatMap((glob) => matchingGlob(root, glob)), + ...SCANNED_DIRECTORIES.flatMap((directory) => markdownUnder(root, directory)), + ].map((file) => file.split(path.sep).join("/")); + + // CATALOG.md is regenerated from the tree it indexes, so its lines are an output. + return found.filter((file) => !GENERATED.has(file) && path.basename(file) !== "CATALOG.md"); +} + +function scanRepository(root = process.cwd()) { + const files = scannedFiles(root); + const filesToText = Object.fromEntries( + files.map((file) => [file, fs.readFileSync(path.resolve(root, file), "utf8")]) + ); + + return { + scannedFiles: files.length, + duplicates: findDuplicates(filesToText), + stale: staleBaseline(filesToText), + }; +} + +function run(root = process.cwd(), logger = console.error, successLogger = console.log) { + const { scannedFiles: count, duplicates, stale } = scanRepository(root); + + if (duplicates.length === 0 && stale.length === 0) { + successLogger(`✅ Doc duplication: 0 duplicated sentence(s) in ${count} files`); + return 0; + } + + if (duplicates.length > 0) { + logger(`❌ ${duplicates.length} sentence(s) carried by more than one document`); + for (const { sentence, files } of duplicates) { + logger(` "${sentence}"`); + for (const file of files) logger(` ${file}`); + } + logger("keep it in one home and point the others at it"); + } + + if (stale.length > 0) { + logger(`❌ ${stale.length} BASELINE entry(ies) no longer describing the tree`); + for (const line of stale) logger(` ${line}`); + } + + return 1; +} + +module.exports = { BASELINE, findDuplicates, scanRepository, sentencesOf, staleBaseline, run }; + +if (require.main === module) { + process.exit(run()); +} diff --git a/scripts/check-markdown-links.js b/scripts/check-markdown-links.js index e5d6ca1aa..ede08ebca 100644 --- a/scripts/check-markdown-links.js +++ b/scripts/check-markdown-links.js @@ -17,7 +17,7 @@ Ignored / excluded forms: - Anchor-only links such as #usage - mailto: and tel: links - HTML angle-bracket links and HTML attributes - - .git and node_modules directories + - .git and node_modules directories, plus .stryker-tmp and .e2e-build, which copy the tree - Runtime variables, glob patterns, and bare words - cli/tests/fixtures/** (synthetic mock trees) and cli/aidd_docs/tasks/** (historical task records), always, on top of any --ignore given @@ -35,13 +35,10 @@ Examples: | Reader reference | See [explore skill](plugins/aidd-context/skills/11-explore/SKILL.md). | `; -const SKIPPED_DIRS = new Set([".git", "node_modules", "worktrees", ".specstory"]); +const SKIPPED_DIRS = new Set([".git", "node_modules", "worktrees", ".specstory", ".stryker-tmp", ".e2e-build"]); const SKIPPED_DIR_PREFIXES = [".tmp-check-markdown-links-"]; -// Always-ignored, not just a CLI --ignore convenience: cli/tests/fixtures/** -// holds synthetic mock trees that intentionally don't materialize every file -// they reference, and cli/aidd_docs/tasks/** is a historical record whose -// @path references and inline rewrite-rule examples are expected to drift as -// the codebase evolves after the fact. +// Always ignored, never a --ignore convenience: both trees hold targets meant not to +// resolve — synthetic mock references, and a historical record left as it was written. const DEFAULT_IGNORES = ["cli/tests/fixtures", "cli/aidd_docs/tasks"]; const MARKDOWN_EXTENSIONS = new Set([".md", ".mdx"]); function normalizePathForDisplay(filePath) { @@ -241,6 +238,49 @@ function stripAnchor(target) { return hashIndex === -1 ? target : target.slice(0, hashIndex); } +function anchorOf(target) { + const hashIndex = target.indexOf("#"); + return hashIndex === -1 ? "" : target.slice(hashIndex + 1); +} + +/** GitHub's rule, reproduced: nothing is collapsed, which is why a leading emoji leaves + * the hyphen it was separated by and an `&` leaves two. */ +function headingSlug(text) { + return text + .replace(/\[([^\]]*)\]\([^)]*\)/gu, "$1") + .toLowerCase() + .replace(/[^\p{L}\p{N} _-]/gu, "") + .replaceAll(" ", "-"); +} + +/** A duplicate heading takes GitHub's `-1`, `-2` suffix, so both the bare slug and the + * numbered one resolve. */ +function headingSlugs(absolutePath) { + const slugs = new Set(); + const seen = new Map(); + let inFence = false; + + for (const line of fs.readFileSync(absolutePath, "utf8").split("\n")) { + if (/^\s*(```|~~~)/u.test(line)) { + inFence = !inFence; + continue; + } + if (inFence) continue; + + const heading = line.match(/^ {0,3}#{1,6}\s+(.*?)\s*#*\s*$/u); + if (!heading) continue; + + const base = headingSlug(heading[1]); + if (!base) continue; + + const count = seen.get(base) ?? 0; + seen.set(base, count + 1); + slugs.add(count === 0 ? base : `${base}-${count}`); + } + + return slugs; +} + function safeDecodeUri(target) { try { return decodeURI(target); @@ -272,10 +312,8 @@ function resolveLocalPath(target, sourceFile) { if (!fs.existsSync(absolute) && fs.existsSync(generatedTemplateAbsolute)) { return { absolute: generatedTemplateAbsolute }; } - // A *-template.md scaffold links to files emitted next to the generated - // output at runtime (e.g. ./plan.md, ./phase-1.md), which never exist in - // the repo. A dot-relative target that resolves nowhere is an intentional - // placeholder for that generated sibling, not a broken link. + // A *-template.md scaffold links to siblings emitted at runtime, so a dot-relative + // target resolving nowhere is a placeholder rather than a broken link. if (!fs.existsSync(absolute) && /-template\.md$/u.test(sourceRelative) && /^\.\.?\//u.test(decoded)) { return { ignored: true }; } @@ -320,6 +358,15 @@ function problemForTarget(target, sourceFile) { return { raw: target, reason: "local-path-not-found" }; } + // A fragment is checked only once the file itself resolves, or a missing file reports + // twice for one fix. `#L119` is GitHub's line fragment: it answers to no heading. + const anchor = /^L\d+(-L\d+)?$/u.test(anchorOf(validationTarget)) ? "" : anchorOf(validationTarget); + if (anchor && MARKDOWN_EXTENSIONS.has(path.extname(resolved.absolute).toLowerCase())) { + if (!headingSlugs(resolved.absolute).has(safeDecodeUri(anchor).toLowerCase())) { + return { raw: target, reason: "anchor-not-found" }; + } + } + return null; } @@ -388,6 +435,8 @@ function formatIssue(problem) { return `${link} (template path not found in framework source)`; case "local-path-not-found": return `${link} (local path not found)`; + case "anchor-not-found": + return `${link} (no heading in the target file answers that fragment)`; default: return `${link} (file not found)`; } diff --git a/scripts/check-referenced-paths.js b/scripts/check-referenced-paths.js new file mode 100644 index 000000000..035e32e12 --- /dev/null +++ b/scripts/check-referenced-paths.js @@ -0,0 +1,115 @@ +#!/usr/bin/env node +/** + * Fails when this repository's own prose names a path that does not exist. The link checker + * resolves markdown links; nothing resolved a path written in backticks. + * + * A reference is anchored on a real top-level entry rather than on "looks like a path": + * widening it to any `a/b` token reports MIME types, context-relative fragments and shell + * fragments, and a gate nobody trusts is a gate nobody reads. + * + * Always whole-tree — the hook's glob decides only whether this runs, never what it reads — + * because a path dies when the file it names is deleted, in a commit that touches no page. + * `cli/` carries its own ratchet (cli/tests/architecture/referenced-paths.arch.test.ts). + */ + +const fs = require("node:fs"); +const path = require("node:path"); + +const ROOT = path.resolve(__dirname, ".."); + +/** Prose this repository owns and can keep true. */ +const SCANNED = ["docs", "aidd_docs/memory"]; + +/** Read from disk: a new directory joins the check by existing, and a deleted one stops + * being an anchor rather than becoming a false positive. */ +function topLevelEntries() { + return new Set(fs.readdirSync(ROOT).filter((entry) => entry !== ".git")); +} + +const BACKTICKED = /`([^`\n]+)`/gu; + +function referencedPaths(content, entries = topLevelEntries()) { + const found = []; + const lines = content.split("\n"); + + for (const [index, line] of lines.entries()) { + for (const match of line.matchAll(BACKTICKED)) { + const token = match[1].trim(); + // A command, a placeholder, a version range or a glob is not a path to resolve. + if (/[\s<>*$|…]/u.test(token)) continue; + if (token.startsWith(">") || token.startsWith("=")) continue; + + // Files only: a bare directory is usually a shape rather than a location, and + // directories drift far less than the files inside them. + if (!path.extname(token)) continue; + + const head = token.split("/")[0]; + if (!entries.has(head)) continue; + + found.push({ target: token, line: index + 1 }); + } + } + + return found; +} + +function deadReferences(files) { + const entries = topLevelEntries(); + const dead = []; + + for (const file of files) { + const content = fs.readFileSync(file, "utf8"); + for (const { target, line } of referencedPaths(content, entries)) { + if (!fs.existsSync(path.resolve(ROOT, target))) { + dead.push({ file, line, target }); + } + } + } + + return dead; +} + +function markdownUnder(dir) { + const absolute = path.resolve(ROOT, dir); + if (!fs.existsSync(absolute)) return []; + + return fs + .readdirSync(absolute, { withFileTypes: true, recursive: true }) + .filter((entry) => entry.isFile() && entry.name.endsWith(".md")) + .map((entry) => path.join(entry.parentPath ?? entry.path, entry.name)); +} + +function scannedFiles() { + const rootMarkdown = fs + .readdirSync(ROOT, { withFileTypes: true }) + .filter((entry) => entry.isFile() && entry.name.endsWith(".md")) + .map((entry) => path.join(ROOT, entry.name)); + + return [...rootMarkdown, ...SCANNED.flatMap(markdownUnder)]; +} + +function scanRepository() { + const files = scannedFiles(); + return { scannedFiles: files.length, dead: deadReferences(files) }; +} + +function run(logger = console.error, successLogger = console.log) { + const { scannedFiles: count, dead } = scanRepository(); + + if (dead.length === 0) { + successLogger(`✅ Referenced paths: 0 dead in ${count} files`); + return 0; + } + + logger(`❌ ${dead.length} referenced path(s) naming nothing on disk`); + for (const { file, line, target } of dead) { + logger(` ${path.relative(ROOT, file)}:${line} ${target}`); + } + return 1; +} + +module.exports = { deadReferences, referencedPaths, scanRepository, run }; + +if (require.main === module) { + process.exit(run()); +} diff --git a/scripts/check-skill-argument-hints.mjs b/scripts/check-skill-argument-hints.mjs index 0932fe77f..258f1b136 100644 --- a/scripts/check-skill-argument-hints.mjs +++ b/scripts/check-skill-argument-hints.mjs @@ -1,10 +1,6 @@ #!/usr/bin/env node -// Checks SKILL.md frontmatter against R4 of the skill contract: -// argument-hint names what the user brings, never the action slugs, always present. -// The contract lives in -// plugins/aidd-context/skills/04-skill-generate/references/skill-authoring.md -// Usage: -// node scripts/check-skill-argument-hints.mjs # exit 1 on any breach +// R4 of the skill contract: argument-hint names what the user brings, never the action +// slugs, and is always present. import { readdir, readFile } from "node:fs/promises"; import path from "node:path"; @@ -82,7 +78,8 @@ for (const dir of await skillDirs()) { const content = await readFile(skillPath, "utf8").catch(() => null); if (content === null) continue; - const relative = path.relative(ROOT, skillPath); + // Named the way the contract and every other guard name a path, whatever the host separator. + const relative = path.relative(ROOT, skillPath).split(path.sep).join("/"); const hint = argumentHint(content); const actionFiles = await collectMarkdownFiles(path.join(dir, "actions")); diff --git a/scripts/check-tests-leave-git-alone.js b/scripts/check-tests-leave-git-alone.js index 0f53abaca..04f0bcc1e 100644 --- a/scripts/check-tests-leave-git-alone.js +++ b/scripts/check-tests-leave-git-alone.js @@ -1,25 +1,16 @@ #!/usr/bin/env node /** - * check-tests-leave-git-alone.js - Runs a command and fails when it changed the - * repository's own git hooks. + * Runs a command and fails when it changed the repository's own git hooks. Git exports + * `GIT_DIR` into everything it spawns, so a suite's own `git init` can land its stubs in the + * real `.git/hooks` with every test still green — and `.git` is in no history, so there is + * nothing to restore from. Narrower on purpose than "a test must strip `GIT_*`": reading the + * real repository is fine, changing it never is. * - * On 2026-09-03 the trailer-repair suite's `git init` inherited this repository's exported - * `GIT_DIR`, so its stub `prepare-commit-msg` and stub delegate landed in the real - * `.git/hooks`, replacing an install that had stood since 22 August. Every test passed. - * `aidd telemetry check` reported it hours later, by which point the original was gone: - * `.git/hooks` is in no history, so there was nothing to restore it from. - * - * Stripping `GIT_*` in that one suite fixes that one suite. This is the invariant instead, - * and it is deliberately narrower than "a test must strip `GIT_*`" — some tests query the - * real repository on purpose, walking the tree with `git ls-files`. Reading it is fine. - * Changing it never is. - * - * The command's own exit code is passed through untouched: a red suite must stay red with - * its own code, or this becomes a way to lose test failures. + * The command's own exit code is passed through untouched, or this becomes a way to lose + * test failures. * * Usage: - * node scripts/check-tests-leave-git-alone.js -- node --test 'scripts/__tests__/*.test.js' - * node scripts/check-tests-leave-git-alone.js --watch -- + * node scripts/check-tests-leave-git-alone.js [--watch ]... -- */ const { spawnSync, execFileSync } = require("node:child_process"); @@ -32,9 +23,8 @@ const ROOT = path.resolve(__dirname, ".."); const USAGE_EXIT = 2; const CHANGED_EXIT = 1; -/** The hooks directory git actually runs from, resolved the way git resolves it — a - * worktree's `.git` is a file, and `core.hooksPath` moves the directory outright, so - * neither can be assumed to be `/.git/hooks`. */ +/** Resolved the way git resolves it: a worktree's `.git` is a file, and `core.hooksPath` + * moves the directory outright, so neither is `/.git/hooks`. */ function repositoryHooksDir() { try { return execFileSync("git", ["rev-parse", "--path-format=absolute", "--git-path", "hooks"], { @@ -47,10 +37,7 @@ function repositoryHooksDir() { } /** - * Every entry directly in `dir`, with what it takes to notice a change: the content hash, - * the size, the mode, and a symlink's target. - * - * `null` — never an empty object — when the directory does not exist. A directory that + * `null` — never an empty object — when the directory does not exist: a directory that * appears where there was none is a change, and an absent-reads-as-empty snapshot would * call that nothing. */ @@ -73,13 +60,26 @@ function snapshotDirectory(dir) { shot[entry.name] = { directory: true }; continue; } - const stat = fs.statSync(full); + // One open, then size, mode and bytes through that same descriptor: asking the path + // twice leaves a window in which the entry can change between the two answers. + let stat; + let bytes; + let fd; + try { + fd = fs.openSync(full, "r"); + stat = fs.fstatSync(fd); + bytes = fs.readFileSync(fd); + } catch (err) { + if (err.code === "ENOENT") continue; + throw err; + } finally { + if (fd !== undefined) fs.closeSync(fd); + } shot[entry.name] = { size: stat.size, mode: stat.mode & 0o777, - // The hash, not the size alone: the leak that prompted this replaced a hook with a - // stub, and two different scripts can be the same length. - hash: createHash("sha256").update(fs.readFileSync(full)).digest("hex").slice(0, 16), + // The hash, not the size alone: a hook replaced by a stub can be the same length. + hash: createHash("sha256").update(bytes).digest("hex").slice(0, 16), }; } return shot; @@ -98,7 +98,6 @@ function describeEntry(name, before, after) { return null; } -/** What changed between two snapshots, as lines a person can act on. */ function describeChanges(before, after) { if (before === null && after === null) return []; if (before === null) return [`the watched directory appeared: ${Object.keys(after).join(", ")}`]; diff --git a/scripts/dev-setup.sh b/scripts/dev-setup.sh index eb1e8f55e..9f98da08d 100755 --- a/scripts/dev-setup.sh +++ b/scripts/dev-setup.sh @@ -1,10 +1,7 @@ #!/usr/bin/env bash -# dev-setup.sh - confirm, then install every plugin into Claude and Codex by delegating to -# dev-sync.sh (which builds this checkout into each tool's native tree, registers the -# marketplace against it, and installs - current versions, no bump). This mutates your -# GLOBAL (user-scope) config, so it asks to confirm first. Bypass with `-y` / `--yes` / -# `YES=1`; a non-interactive shell skips rather than hangs. Called by `make setup`. For -# iterating after edits use `make reload`. +# Confirms, then delegates to dev-sync.sh. It mutates the GLOBAL (user-scope) Claude and +# Codex config, so it asks first; `-y` / `--yes` / `YES=1` bypasses, and a non-interactive +# shell skips rather than hangs. set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" @@ -18,7 +15,6 @@ if [ "$HAVE_CODEX" = 0 ] && [ "$HAVE_CLAUDE" = 0 ]; then echo "Neither CLI found - nothing to install."; exit 0 fi -# Confirm before touching the global config (skip with -y / --yes / YES=1). if [ "${YES:-}" != "1" ] && [ "${1:-}" != "-y" ] && [ "${1:-}" != "--yes" ]; then echo "About to register '$MKT' and install its plugins into your GLOBAL Claude/Codex config (user scope)." if [ -t 0 ]; then @@ -32,5 +28,4 @@ if [ "${YES:-}" != "1" ] && [ "${1:-}" != "-y" ] && [ "${1:-}" != "--yes" ]; the fi fi -# dev-sync builds each tool's native tree, registers the marketplace against it, and installs. exec "$SCRIPT_DIR/dev-sync.sh" all diff --git a/scripts/dev-sync.sh b/scripts/dev-sync.sh index 78376ca7f..71a0390a3 100755 --- a/scripts/dev-sync.sh +++ b/scripts/dev-sync.sh @@ -1,26 +1,19 @@ #!/usr/bin/env bash -# dev-sync.sh - (re)install every plugin into Claude, Codex, and OpenCode -# from THIS checkout. Claude installs from the raw repo (already native Claude format). Codex -# installs from a native tree built by the aidd CLI (which maps Claude syntax -> Codex, -# e.g. agents -> TOML), so what you run locally matches what ships at release. +# (Re)install every plugin into Claude, Codex and OpenCode from THIS checkout, named by +# argument or `all`. Claude reads the raw repo, already native; Codex installs from a tree +# the aidd CLI builds (agents -> TOML), so what runs locally matches what ships. # -# scripts/dev-sync.sh aidd-refine # install one plugin (still builds the whole tree) -# scripts/dev-sync.sh aidd-refine aidd-pm # several -# scripts/dev-sync.sh all # every plugin (default) +# NOT live: the install copies built files, so an edit needs a re-run. Idempotent; a tool +# whose CLI is absent is skipped, and the first run needs network. A managed OpenCode host +# exposes `aidd-opencode-reload` instead, and that helper decides which checkout may load. # -# NOT live: the local install copies built files into each tool's cache/config, so re-run -# after an edit. Idempotent; each tool is skipped if its CLI is absent. Needs network the -# first time (npx fetches the CLI). A managed OpenCode host may expose the fixed-purpose -# `aidd-opencode-reload` helper instead; that helper decides which trusted checkout can load. -# -# Codex caveat: the CLI emits codex-agents/*.toml but the .codex-plugin manifest does not -# declare them, and Codex only loads agents from ~/.codex/agents/ - so after install we copy -# the built agent TOML there. Drop this copy once the Codex build wires agents into the manifest. +# Codex caveat: the .codex-plugin manifest does not declare codex-agents/*.toml, and Codex +# loads agents only from ~/.codex/agents/, so the built TOML is copied there after install. set -euo pipefail shopt -s nullglob SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -FW="${FW:-$(dirname "$SCRIPT_DIR")}" # repo root = parent of scripts/ = the local clone +FW="${FW:-$(dirname "$SCRIPT_DIR")}" MKT="${MKT:-aidd-framework}" AIDD_CLI_VERSION="${AIDD_CLI_VERSION:-latest}" # override to pin if a release regresses the build BUILD="${BUILD:-$HOME/.cache/aidd-framework-dev}" # per-tool native trees the marketplaces point at @@ -34,7 +27,7 @@ HAVE_CLAUDE=0; command -v claude >/dev/null 2>&1 && HAVE_CLAUDE=1 HAVE_OPENCODE=0; command -v opencode >/dev/null 2>&1 && HAVE_OPENCODE=1 HAVE_MANAGED_OPENCODE=0; command -v aidd-opencode-reload >/dev/null 2>&1 && HAVE_MANAGED_OPENCODE=1 -build_tool() { # tool -> $BUILD/$tool ; returns non-zero on build failure +build_tool() { local tool="$1" rm -rf "$BUILD/$tool"; mkdir -p "$BUILD/$tool" local mode=() @@ -56,17 +49,15 @@ sync_opencode_skills() { done } -register_marketplace() { # tool +register_marketplace() { case "$1" in # Codex needs the built tree (the raw repo is Claude-syntax; Codex wants TOML/.codex-plugin). codex) codex plugin marketplace remove "$MKT" >/dev/null 2>&1 || true codex plugin marketplace add "$BUILD/codex" >/dev/null 2>&1 ;; - # Claude reads the raw repo directly - it IS native Claude format. The CLI's claude build - # currently emits an invalid agents manifest ("./agents" dir vs the required file list), - # so building for Claude would only break the install. Scope every op to user: a bare - # `marketplace remove` strips the declaration from EVERY scope, which would wipe the repo's - # project-scoped dogfooding config (.claude/settings.json). + # No build for Claude: the CLI's claude build emits an invalid agents manifest, and the + # raw repo is already native. Scope every op to user - a bare `marketplace remove` strips + # the declaration from EVERY scope, wiping the repo's own project-scoped config. claude) claude plugin marketplace remove "$MKT" --scope user >/dev/null 2>&1 || true claude plugin marketplace add "$FW" --scope user >/dev/null 2>&1 ;; @@ -97,11 +88,9 @@ sync_one() { rm -rf "$CLAUDE_CACHE/$MKT/$name" if claude plugin install "$name@$MKT" --scope user >/dev/null 2>&1; then printf ' claude:ok' - # Claude loads agents ONLY from the installed installPath, and `claude plugin install` - # copies them there implicitly - which fails silently (still prints ok) if the copy is - # skipped, dropping executor/checker. Pin it like the Codex net above: force-sync every - # declared agent into the freshly installed version dir and report the count so a miss - # is never silent. Drop this once the install is a trusted source of bundled agents. + # Claude loads agents ONLY from the installed installPath, and `plugin install` copies + # them there implicitly - skipping the copy still prints ok. Force-sync them and report + # the count, so a miss is never silent. if [ -d "$FW/plugins/$name/agents" ]; then local dest src n=0 fixed=0 dest="$(ls -d "$CLAUDE_CACHE/$MKT/$name"/*/ 2>/dev/null | head -1)" diff --git a/scripts/doctor.sh b/scripts/doctor.sh index b75be9fa8..9ccdbf35b 100755 --- a/scripts/doctor.sh +++ b/scripts/doctor.sh @@ -1,9 +1,6 @@ #!/usr/bin/env bash -# aidd-framework doctor -# -# Diagnostic preflight for users (install the marketplace) and contributors -# (work on the marketplace). Prints OK / WARN / FAIL per check and a final -# verdict. +# Diagnostic preflight for users installing the marketplace and for contributors working +# on it. Prints OK / WARN / FAIL per check, then a verdict. set -uo pipefail @@ -16,8 +13,6 @@ MODE="${1:-all}" # all | user | contributor print_section() { printf "\n\033[1m%s\033[0m\n" "$1"; } -# --- user-mode checks -------------------------------------------------------- - if [ "$MODE" = "all" ] || [ "$MODE" = "user" ]; then print_section "Claude Code" if command -v claude >/dev/null 2>&1; then @@ -38,6 +33,11 @@ if [ "$MODE" = "all" ] || [ "$MODE" = "user" ]; then else warn "gh CLI not found (https://cli.github.com/) - required for plugins that interact with GitHub" fi + if command -v jq >/dev/null 2>&1; then + ok "jq $(jq --version)" + else + warn "jq not found (brew install jq) - the skills that pipe gh output through it will fail" + fi print_section "Network" if curl -sf -o /dev/null --max-time 5 https://api.github.com; then @@ -52,17 +52,19 @@ if [ "$MODE" = "all" ] || [ "$MODE" = "user" ]; then fi fi -# --- contributor-mode checks ------------------------------------------------- - if [ "$MODE" = "all" ] || [ "$MODE" = "contributor" ]; then print_section "Node + pnpm" if command -v node >/dev/null 2>&1; then nv=$(node --version | tr -d 'v') nv_major=${nv%%.*} - if [ "$nv_major" -ge 20 ]; then - ok "node v$nv (>= 20)" + nv_rest=${nv#*.} + nv_minor=${nv_rest%%.*} + # The floor every package.json in this repository declares (`engines.node: ">=22.12"`), + # minor included: 22.0 is a Node 22 that `pnpm install` still refuses. + if [ "$nv_major" -gt 22 ] || { [ "$nv_major" -eq 22 ] && [ "$nv_minor" -ge 12 ]; }; then + ok "node v$nv (>= 22.12)" else - fail "node v$nv (need >= 20)" + fail "node v$nv (need >= 22.12)" fi else fail "node not found (https://nodejs.org/)" @@ -74,21 +76,6 @@ if [ "$MODE" = "all" ] || [ "$MODE" = "contributor" ]; then fi print_section "Hook tooling" - if command -v jq >/dev/null 2>&1; then - ok "jq $(jq --version)" - else - fail "jq not found (brew install jq) - required by the json-validity hook" - fi - if command -v python3 >/dev/null 2>&1; then - ok "python3 $(python3 --version 2>&1 | awk '{print $2}')" - else - warn "python3 not found - yaml-validity hook will skip" - fi - if command -v pipx >/dev/null 2>&1; then - ok "pipx $(pipx --version)" - else - warn "pipx not found (brew install pipx) - JSON schema validation will be skipped" - fi if [ -f lefthook.yml ]; then if pnpm exec lefthook version >/dev/null 2>&1; then ok "lefthook installed via pnpm" @@ -98,8 +85,6 @@ if [ "$MODE" = "all" ] || [ "$MODE" = "contributor" ]; then fi fi -# --- verdict ---------------------------------------------------------------- - print_section "Verdict" if [ "$FAIL" -eq 0 ]; then printf " \033[32mAll critical checks passed.\033[0m\n" diff --git a/scripts/generate-star-history.mjs b/scripts/generate-star-history.mjs index a97ba2d12..a35883f03 100644 --- a/scripts/generate-star-history.mjs +++ b/scripts/generate-star-history.mjs @@ -1,16 +1,11 @@ #!/usr/bin/env node -// Renders the repository's star history as an SVG on stdout, from the GitHub -// stargazers API. Since June 30 2026 that API only answers a repository's own -// admins and collaborators, which killed the third-party chart services the -// README used to embed. Running it here, with the repository's own credentials, -// is the only way left to keep a live chart. +// Renders the repository's star history as an SVG on stdout. The stargazers API answers +// only a repository's own admins and collaborators, which killed the third-party chart +// services, so a live chart has to be rendered here with the repository's own credentials. // -// GH_TOKEN=$(gh auth token) node scripts/generate-star-history.mjs > star-history.svg -// -// The output is deterministic: identical star data yields identical bytes, so a -// scheduled run that finds no new star produces no commit. Nothing renders the -// current date, and no